diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7bb9467 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,80 @@ +name: Validate and deploy Discover site + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: discover-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install test packages + run: npm ci + + - name: Generate schema references + run: | + npm run write:schema + npm run write:openapi + + - name: Test feed and page code + run: npm test + + - name: Type-check schema code + run: npm run typecheck + + - name: Validate public feeds + run: npm run validate:feed -- feed-live.json feed-dev.json + + - name: Validate public source registry + run: npm run validate:sources + + - name: Prepare static site + run: | + mkdir _site + cp index.html schema.html openapi.json feed.schema.json sources.json CNAME .nojekyll feed-live.json feed-dev.json _site/ + cp -R assets _site/assets + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: _site + + deploy: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + needs: validate + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Deploy GitHub Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2752eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.DS_Store diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..27dfcd8 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +discover.dwithease.com diff --git a/README.md b/README.md new file mode 100644 index 0000000..6eef72f --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# DWithEase Discover + +DWithEase Discover is the DWithEase news engine for ecommerce and Salesforce B2C Commerce (SFCC) updates. It brings commerce news, technical articles, release notes, and useful tools into one light public page. + +## 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) +- [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) +- [Source registry](https://discover.dwithease.com/sources.json) +- [DWithEase](https://dwithease.com/) + +## Editorial sources + +See [SOURCES.md](SOURCES.md) for the full source list and curation rules. + +## Feed v2 + +Both public endpoints use the v2 JSON format: + +```json +{ + "version": 2, + "locale": "en", + "updatedAt": "2026-09-01T12:00:00Z", + "items": [] +} +``` + +Each item has a stable slug ID, a type, an HTTPS URL, source details, a publication date, tags, and a CTA. Editorial items use `article` or `news`. Promotions also include a dated campaign and one or more placements. + +The development feed contains the curation corpus. The live feed remains empty until that corpus is approved. Validate both files before publication: + +```sh +npm run validate:feed -- feed-live.json feed-dev.json +``` + +## UX authoring guidance + +Use the preferred length when possible. The raw maximum counts the original JSON string before trimming. + +| Field | Preferred length | Raw maximum | +| --- | ---: | ---: | +| Title | 70 characters | 120 characters | +| Summary | 150 characters | 280 characters | +| CTA label | 24 characters | 32 characters | +| Image alt text | 100 characters | 160 characters | + +The page does not clamp text. Write complete copy that fits within these limits. diff --git a/SOURCES.md b/SOURCES.md new file mode 100644 index 0000000..3b15668 --- /dev/null +++ b/SOURCES.md @@ -0,0 +1,48 @@ +# Discover feed sources + +This file records the inputs for `feed-dev.json`. + +`sources.json` maps each canonical source URL to a checked-in icon under `assets/sources/`. The page uses the local fallback icon when a feed source has no exact URL match. + +## Curation rules + +- Fetch public HTML, RSS, or Atom with a normal HTTP request. +- Use small regular expressions to read titles, links, dates, and summaries. +- Keep up to 5 recent commerce items from each editorial source. +- Keep links to source pages. Do not copy full article text. +- Remove duplicate URLs and duplicate item IDs. +- Sort editorial items by `publishedAt`, newest first. +- Review every item before it moves to `feed-live.json`. + +## Editorial sources + +| Source | Source URL | Method | Cycle result on 2026-09-01 | +| --- | --- | --- | --- | +| Salesforce News | https://www.salesforce.com/news/topics/commerce/ | HTML | Working. The old `/stories/category/commerce/feed/` URL returned HTTP 404. The HTML topic page is the fallback. | +| Salesforce Commerce Blog | https://www.salesforce.com/blog/category/ecommerce/ | HTML or RSS | Working. The commerce RSS feed also returned HTTP 200. | +| Salesforce Developers Blog | https://developer.salesforce.com/blogs/feed | RSS | Working. Apply an SFCC, B2C Commerce, PWA Kit, or composable storefront term filter. | +| PWA Kit Releases | https://github.com/SalesforceCommerceCloud/pwa-kit/releases.atom | Atom | Working. The current Atom window contains nightly builds. The dev feed keeps 1 nightly item for testing. Exclude nightly builds from live curation unless they add useful release detail. | +| Rhino Inquisitor | https://rhino-inquisitor.com/archive/ | HTML | Working. `/feed/` returned a small HTML page instead of a usable XML feed. Use the archive page. | +| Agentic Storefront | https://agenticstorefront.com/categories/agentic-commerce/ | HTML | Working. | +| ForkPoint | https://forkpoint.com/articles/ | HTML | Working. Article pages do not expose a reliable publication date. The dev feed uses the shared HTTP `Last-Modified` date as a temporary value. Set real dates before live use. | +| Salesforce Ben | https://www.salesforceben.com/category/commerce/ | HTML | Partial. The category page shell does not expose a full item list in the first HTML response. Use commerce search results and article pages as the fallback. | +| Digital Commerce 360 | https://www.digitalcommerce360.com/topic/technology/ | HTML | Working. Apply ecommerce, agentic commerce, storefront, search, retail, or platform term filters. | + +## Product sources + +| Product | URL | Feed use | +| --- | --- | --- | +| Agentic Storefront Audit | https://audit.agenticstorefront.com/ | Promotion | +| CatalogSpark | https://catalogspark.com/ | Promotion | +| RetailPace | https://retailpace.com/ | Promotion | +| IntentFusion | https://intentfusion.com/ | Promotion | + +## Current dev corpus + +`feed-dev.json` contains: + +- 38 editorial items. +- 4 promotion items. +- 42 items in total. + +`feed-live.json` remains a valid empty v2 feed during dev curation. diff --git a/assets/discover-fallback.png b/assets/discover-fallback.png new file mode 100644 index 0000000..991003f Binary files /dev/null and b/assets/discover-fallback.png differ diff --git a/assets/discover-page.js b/assets/discover-page.js new file mode 100644 index 0000000..5cd3a3b --- /dev/null +++ b/assets/discover-page.js @@ -0,0 +1,237 @@ +import { + buildCatalog, + buildSourceRegistry, + isHttpsUrl, + selectFeedName, +} from './feed-model.js'; + +const FALLBACK_IMAGE = 'assets/discover-fallback.png'; +const FALLBACK_SOURCE_ICON = 'assets/sources/source-fallback.svg'; + +function node(document, tagName, className, text) { + const element = document.createElement(tagName); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +function safeHttps(value) { + return isHttpsUrl(value) ? value : ''; +} + +function actionLink(document, href, label, className = 'button') { + const link = node(document, 'a', className, label); + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + return link; +} + +function feedImage(document, source, className, alt = '') { + const image = node(document, 'img', className); + image.src = source || FALLBACK_IMAGE; + image.alt = alt; + image.loading = 'lazy'; + image.addEventListener('error', () => { + if (!image.src.endsWith(FALLBACK_IMAGE)) image.src = FALLBACK_IMAGE; + }); + return image; +} + +function sourceIcon(document, source, sources) { + const image = node(document, 'img', 'source-icon'); + image.src = sources.get(source.url)?.icon || FALLBACK_SOURCE_ICON; + image.dataset.sourceUrl = source.url; + image.alt = ''; + image.setAttribute('width', '28'); + image.setAttribute('height', '28'); + image.addEventListener('error', () => { + if (!image.src.endsWith(FALLBACK_SOURCE_ICON)) image.src = FALLBACK_SOURCE_ICON; + }); + return image; +} + +function applySourceIcons(root, sources) { + for (const image of root.querySelectorAll('.source-icon')) { + const icon = sources.get(image.dataset.sourceUrl)?.icon; + if (icon) image.src = icon; + } +} + +function formattedDate(value) { + return new Intl.DateTimeFormat('en', { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }).format(new Date(value)); +} + +function feedItemCard(document, item, sources) { + const card = node(document, 'article', `discover-card feed-card ${item.type}-card`); + card.dataset.itemId = item.id; + + if (item.image?.src) { + card.append(feedImage(document, item.image.src, 'feed-image', item.image.alt)); + } + + const body = node(document, 'div', 'card-body'); + if (item.type === 'promotion') { + body.append(node(document, 'p', 'eyebrow', 'Featured')); + } + + const metadata = node(document, 'p', 'card-meta'); + const source = node(document, 'span', 'card-source-group'); + source.append(sourceIcon(document, item.source, sources)); + source.append(node(document, 'span', 'card-source', item.source.name)); + metadata.append(source); + metadata.append(node(document, 'span', 'card-date', formattedDate(item.publishedAt))); + body.append(metadata); + body.append(node(document, 'h3', 'card-title', item.title)); + body.append(node(document, 'p', 'card-copy', item.summary)); + + const tags = node(document, 'div', 'tag-list'); + item.tags.forEach((tag) => tags.append(node(document, 'span', 'tag', tag))); + body.append(tags); + + const href = safeHttps(item.url); + if (href) { + const actions = node(document, 'div', 'card-actions'); + actions.append(actionLink( + document, + href, + item.cta.label, + item.type === 'promotion' ? 'button' : 'text-link', + )); + body.append(actions); + } + + card.append(body); + return card; +} + +function section(document, title, items, renderItem) { + const wrapper = node(document, 'section', 'discover-section'); + wrapper.append(node(document, 'h2', 'section-title', title)); + wrapper.lastElementChild.dataset.sectionTitle = ''; + + const grid = node(document, 'div', 'card-grid'); + items.forEach((item, index) => grid.append(renderItem(item, index))); + wrapper.append(grid); + return wrapper; +} + +function emptyState(document) { + const state = node(document, 'section', 'state-card empty-state'); + state.setAttribute('role', 'status'); + state.dataset.testid = 'discover-empty'; + state.append(node(document, 'span', 'state-icon', 'D')); + + const copy = node(document, 'div'); + copy.append(node(document, 'h2', 'state-title', 'Nothing new right now')); + copy.append(node( + document, + 'p', + 'state-copy', + 'There are no articles or tools to discover right now.', + )); + state.append(copy); + return state; +} + +export function renderCatalog(root, catalog, sources = new Map()) { + const { ownerDocument: document } = root; + root.replaceChildren(); + + if (catalog.promotions.length) { + root.append(section( + document, + 'Featured tools', + catalog.promotions, + (item) => feedItemCard(document, item, sources), + )); + } + if (catalog.editorial.length) { + root.append(section( + document, + 'Latest from commerce', + catalog.editorial, + (item) => feedItemCard(document, item, sources), + )); + } + + if (!root.childElementCount) root.append(emptyState(document)); +} + +export function renderError(root, retry) { + const { ownerDocument: document } = root; + const state = node(document, 'section', 'state-card error-state'); + state.setAttribute('role', 'alert'); + const copy = node(document, 'div'); + copy.append(node(document, 'h2', 'state-title', 'Discover could not load')); + copy.append(node(document, 'p', 'state-copy', 'Check your connection and try again.')); + + const button = node(document, 'button', 'button', 'Try again'); + button.type = 'button'; + button.addEventListener('click', retry); + state.append(copy, button); + root.replaceChildren(state); +} + +function renderLoading(root) { + const { ownerDocument: document } = root; + const loading = node(document, 'div', 'loading-grid'); + loading.setAttribute('aria-label', 'Loading Discover'); + loading.setAttribute('aria-live', 'polite'); + loading.append(node(document, 'div', 'loading-card')); + loading.append(node(document, 'div', 'loading-card')); + root.replaceChildren(loading); +} + +async function loadSourceRegistry(fetchImpl, pageUrl) { + try { + const response = await fetchImpl('sources.json', { cache: 'no-store' }); + if (!response.ok) throw new Error(`Source request failed: ${response.status}`); + const registryUrl = response.url || new URL('sources.json', pageUrl).href; + return buildSourceRegistry(await response.json(), registryUrl, pageUrl); + } catch { + return new Map(); + } +} + +export async function startDiscoverPage({ + root, + search = '', + fetchImpl = fetch, + now = Date.now(), + pageUrl = root.ownerDocument.baseURI, +}) { + const channel = selectFeedName(search); + const feedUrl = `feed-${channel}.json`; + root.dataset.feed = channel; + renderLoading(root); + + try { + const feedPromise = fetchImpl(feedUrl, { cache: 'no-store' }); + const sourcesPromise = loadSourceRegistry(fetchImpl, pageUrl); + const feedResponse = await feedPromise; + if (!feedResponse.ok) throw new Error(`Feed request failed: ${feedResponse.status}`); + + const feed = await feedResponse.json(); + renderCatalog(root, buildCatalog(feed, now)); + void sourcesPromise.then((sources) => applySourceIcons(root, sources)); + } catch { + renderError(root, () => startDiscoverPage({ + root, + search, + fetchImpl, + now: Date.now(), + pageUrl, + })); + } +} + +if (typeof document !== 'undefined') { + const root = document.getElementById('discover-content'); + if (root) startDiscoverPage({ root, search: window.location.search }); +} diff --git a/assets/discover.css b/assets/discover.css new file mode 100644 index 0000000..9ca4d7d --- /dev/null +++ b/assets/discover.css @@ -0,0 +1,536 @@ +@font-face { + font-family: "Mulish"; + src: url("fonts/mulish-700.woff2") format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "Mulish"; + src: url("fonts/mulish-800.woff2") format("woff2"); + font-style: normal; + font-weight: 800; + font-display: swap; +} + +@font-face { + font-family: "Public Sans"; + src: url("fonts/public-sans-400.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Public Sans"; + src: url("fonts/public-sans-600.woff2") format("woff2"); + font-style: normal; + font-weight: 600; + font-display: swap; +} + +:root { + color-scheme: light; + --accent: #0879aa; + --accent-hover: #076d99; + --accent-deep: #0b5f85; + --accent-tint: #e7f6fc; + --ink: #14181f; + --muted: rgba(20, 24, 31, 0.64); + --canvas: #f6f9fc; + --paper: #fff; + --line: #e3eaf2; + --surface: #f2f6fa; + --shadow-soft: 0 1px 2px rgba(16, 32, 52, 0.04), 0 10px 30px rgba(16, 32, 52, 0.055); + --shadow-lift: 0 3px 8px rgba(16, 32, 52, 0.07), 0 20px 48px rgba(16, 32, 52, 0.10); + --bloom: radial-gradient(54vw 38vw at 5% -8%, rgba(17, 161, 224, 0.15), transparent 62%), radial-gradient(42vw 32vw at 98% 2%, rgba(98, 195, 151, 0.09), transparent 64%); +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: var(--canvas); +} + +body { + min-height: 100vh; + margin: 0; + color: var(--ink); + background-color: var(--canvas); + background-image: var(--bloom); + background-attachment: fixed; + font-family: "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a, +button { + font: inherit; +} + +a { + color: inherit; +} + +a:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.page-shell { + width: min(1120px, calc(100% - 48px)); + margin: 0 auto; + padding: 20px 0 52px; +} + +.site-header { + position: sticky; + z-index: 10; + top: 14px; + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 10px 12px 10px 18px; + border: 1px solid rgba(227, 234, 242, 0.92); + border-radius: 18px; + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px); +} + +.brand-link { + display: inline-flex; + border-radius: 12px; +} + +.brand-logo { + display: block; + width: clamp(168px, 18vw, 204px); + height: auto; +} + +.site-link, +.button { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + padding: 8px 18px; + color: #fff; + background: var(--accent); + box-shadow: 0 2px 8px rgba(17, 161, 224, 0.24); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.4; + text-decoration: none; + cursor: pointer; + transition: background-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.site-link:hover, +.button:hover { + background: var(--accent-hover); + box-shadow: 0 4px 16px rgba(17, 161, 224, 0.38); + transform: translateY(-1px); +} + +.page-header { + max-width: 800px; + padding: clamp(52px, 8vw, 88px) 0 42px; +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent-deep); + font-size: clamp(0.6875rem, 0.66rem + 0.12vw, 0.8125rem); + font-weight: 600; + line-height: 1.4; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3 { + font-family: "Mulish", system-ui, -apple-system, "Segoe UI", sans-serif; +} + +h1 { + margin: 0; + font-size: clamp(2.5rem, 1.7rem + 2.7vw, 4rem); + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.02em; +} + +.page-intro { + max-width: 640px; + margin: 16px 0 0; + color: var(--muted); + font-size: clamp(1rem, 0.96rem + 0.18vw, 1.125rem); + line-height: 1.6; +} + +.discover-content { + min-height: 260px; +} + +.discover-section { + margin-bottom: 40px; +} + +.section-title { + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 18px; + font-size: 1.0625rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.section-title::after { + min-width: 24px; + height: 1px; + flex: 1; + background: var(--line); + content: ""; +} + +.card-grid, +.loading-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.discover-card, +.state-card { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--paper); + box-shadow: var(--shadow-soft); +} + +.discover-card { + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.discover-card:hover { + border-color: rgba(17, 161, 224, 0.34); + box-shadow: var(--shadow-lift); + transform: translateY(-2px); +} + +.feed-card { + display: flex; + min-height: 260px; + flex-direction: column; +} + +.feed-card .card-body { + padding: 20px; +} + +.promotion-card { + border-color: rgba(17, 161, 224, 0.24); + background: linear-gradient(145deg, var(--paper) 68%, var(--accent-tint)); +} + +.feed-image { + width: 100%; + height: 184px; + border-bottom: 1px solid var(--line); + background: var(--surface); + object-fit: cover; +} + +.card-meta { + display: flex; + min-width: 0; + max-width: 100%; + align-items: center; + flex-wrap: wrap; + gap: 6px 10px; + margin: 0 0 10px; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.4; +} + +.card-source { + color: var(--accent-deep); + font-weight: 600; +} + +.card-source-group { + display: inline-flex; + min-width: 0; + max-width: 100%; + align-items: center; + gap: 7px; +} + +.source-icon { + width: 28px; + height: 28px; + flex: 0 0 28px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--paper); + object-fit: contain; +} + +.card-date::before { + margin-right: 10px; + color: rgba(20, 24, 31, 0.28); + content: "•"; +} + +.tag-list { + display: flex; + min-width: 0; + max-width: 100%; + flex-wrap: wrap; + gap: 7px; + margin-top: 16px; +} + +.tag { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 9px; + color: var(--accent-deep); + background: var(--surface); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.2; +} + +.card-body { + display: flex; + min-width: 0; + max-width: 100%; + flex: 1; + flex-direction: column; +} + +.card-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + line-height: 1.5; + letter-spacing: -0.01em; +} + +.card-copy { + margin: 6px 0 0; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.card-actions { + display: flex; + min-width: 0; + max-width: 100%; + justify-content: flex-end; + margin-top: auto; + padding-top: 16px; +} + +.card-source-group, +.card-source, +.card-date, +.card-title, +.card-copy, +.tag, +.card-actions .button, +.card-actions .text-link { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; +} + +.card-actions .text-link { + display: inline-block; +} + +.text-link { + border-radius: 999px; + padding: 7px 12px; + color: var(--accent-deep); + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; +} + +.text-link:hover { + background: var(--accent-tint); + color: var(--accent-hover); +} + +.state-card { + display: flex; + min-height: 150px; + align-items: center; + justify-content: center; + gap: 18px; + padding: 28px; + border-style: dashed; + text-align: left; +} + +.state-icon { + display: grid; + width: 48px; + height: 48px; + flex: 0 0 auto; + place-items: center; + border-radius: 14px; + color: #fff; + background: linear-gradient(145deg, var(--accent-deep), var(--accent)); + font-family: "Mulish", sans-serif; + font-size: 1.25rem; + font-weight: 800; +} + +.state-title { + margin: 0; + font-size: 1.125rem; + line-height: 1.2; +} + +.state-copy { + margin: 7px 0 0; + color: var(--muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.error-state { + justify-content: space-between; +} + +.loading-card { + min-height: 154px; + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(100deg, var(--paper) 20%, var(--surface) 40%, var(--paper) 60%); + background-size: 220% 100%; + box-shadow: var(--shadow-soft); + animation: shimmer 1.4s ease-in-out infinite; +} + +.site-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 12px; + padding-top: 24px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.8125rem; + line-height: 1.5; +} + +.site-footer a { + border-radius: 6px; + color: var(--accent-deep); + font-weight: 600; + text-decoration: none; +} + +.site-footer a:hover { + text-decoration: underline; +} + +.footer-links { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 18px; +} + +@keyframes shimmer { + to { + background-position: -220% 0; + } +} + +@media (max-width: 840px) { + .page-shell { + width: min(100% - 32px, 680px); + } + + .card-grid, + .loading-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .page-shell { + width: min(100% - 24px, 520px); + padding-top: 16px; + } + + .site-header { + top: 8px; + gap: 14px; + min-height: 56px; + padding: 8px 8px 8px 12px; + border-radius: 15px; + } + + .brand-logo { + width: 168px; + } + + .site-link { + min-height: 36px; + padding: 7px 14px; + font-size: 0.8125rem; + } + + .page-header { + padding: 46px 0 34px; + } + + .state-card, + .error-state { + align-items: flex-start; + flex-direction: column; + } + + .site-footer { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/assets/dwithease-logo-on-dark.svg b/assets/dwithease-logo-on-dark.svg new file mode 100644 index 0000000..1ed0baa --- /dev/null +++ b/assets/dwithease-logo-on-dark.svg @@ -0,0 +1,50 @@ + + + + +DWithEase_Logo + + + + diff --git a/assets/dwithease-logo.svg b/assets/dwithease-logo.svg new file mode 100644 index 0000000..a4717d9 --- /dev/null +++ b/assets/dwithease-logo.svg @@ -0,0 +1,50 @@ + + + + +DWithEase_Logo + + + + diff --git a/assets/feed-model.js b/assets/feed-model.js new file mode 100644 index 0000000..6552f76 --- /dev/null +++ b/assets/feed-model.js @@ -0,0 +1,171 @@ +const PRODUCT_PLACEMENTS = new Set(['discover', 'task-end', 'popup']); +const STRICT_END = '(?![\\s\\S])'; +const SLUG_PATTERN = new RegExp(`^[a-z0-9]+(?:-[a-z0-9]+)*${STRICT_END}`); +const LOCALE_PATTERN = new RegExp(`^[a-z]{2}(?:-[A-Z]{2})?${STRICT_END}`); +const DATE_TIME_PATTERN = new RegExp('^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29' + + '|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))' + + 'T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))' + + STRICT_END); +const RFC3986_PERCENT_ENCODED = '%[0-9A-Fa-f]{2}'; +const DNS_LABEL = '(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'; +const DNS_NAME = `(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|${STRICT_END}))${DNS_LABEL}(?:\\.${DNS_LABEL})*\\.?`; +const IPV4_DECIMAL_OCTET = '(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])'; +const IPV4_ADDRESS = `(?:${IPV4_DECIMAL_OCTET}\\.){3}${IPV4_DECIMAL_OCTET}`; +const IPV6_HEXTET = '[0-9A-Fa-f]{1,4}'; +const IPV6_LOW_32_BITS = `(?:${IPV6_HEXTET}:${IPV6_HEXTET}|${IPV4_ADDRESS})`; +const IPV6_ADDRESS = `(?:(?:${IPV6_HEXTET}:){6}${IPV6_LOW_32_BITS}` + + `|::(?:${IPV6_HEXTET}:){5}${IPV6_LOW_32_BITS}` + + `|(?:${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){4}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,1}${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){3}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,2}${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){2}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,3}${IPV6_HEXTET})?::${IPV6_HEXTET}:${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,4}${IPV6_HEXTET})?::${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,5}${IPV6_HEXTET})?::${IPV6_HEXTET}` + + `|(?:(?:${IPV6_HEXTET}:){0,6}${IPV6_HEXTET})?::)`; +const RFC3986_HOST = `(?:${IPV4_ADDRESS}|\\[${IPV6_ADDRESS}\\]|${DNS_NAME})`; +const RFC3986_PORT = '(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])'; +const RFC3986_AUTHORITY = `${RFC3986_HOST}(?::${RFC3986_PORT})?`; +const RFC3986_PATH_CHARACTER = "[A-Za-z0-9._~!$&'()*+,;=:@-]"; +const RFC3986_QUERY_CHARACTER = "[A-Za-z0-9._~!$&'()*+,;=:@/?-]"; +const RFC3986_HTTPS_PATTERN = new RegExp(`^https://${RFC3986_AUTHORITY}` + + `(?:/(?:${RFC3986_PATH_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)*` + + `(?:\\?(?:${RFC3986_QUERY_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)?` + + `(?:#(?:${RFC3986_QUERY_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)?${STRICT_END}`); +const SOURCE_ICON_PATTERN = /^assets\/sources\/[a-z0-9]+(?:-[a-z0-9]+)*\.(?:ico|png|svg|webp)$/; + +function exactObject(value, requiredKeys, optionalKeys = []) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const allowedKeys = new Set([...requiredKeys, ...optionalKeys]); + const keys = Object.keys(value); + return requiredKeys.every((key) => Object.hasOwn(value, key)) + && keys.every((key) => allowedKeys.has(key)); +} + +function limitedText(value, maxLength) { + return typeof value === 'string' && [...value].length <= maxLength; +} + +function requiredText(value, maxLength) { + return limitedText(value, maxLength) && value.trim().length > 0; +} + +function safeId(value) { + return limitedText(value, 120) && SLUG_PATTERN.test(value); +} + +function dateTime(value) { + return typeof value === 'string' + && DATE_TIME_PATTERN.test(value) + && Number.isFinite(Date.parse(value)); +} + +export function isHttpsUrl(value) { + if (typeof value !== 'string' || !RFC3986_HTTPS_PATTERN.test(value)) return false; + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function validImage(image) { + if (image === undefined) return true; + if (!exactObject(image, ['src', 'alt']) || !limitedText(image.alt, 160)) return false; + return isHttpsUrl(image.src) + || (typeof image.src === 'string' && image.src.startsWith('assets/')); +} + +function validV2Item(item) { + if (!item || !['article', 'news', 'promotion'].includes(item.type)) return false; + const requiredKeys = [ + 'id', + 'type', + 'title', + 'summary', + 'url', + 'source', + 'publishedAt', + 'tags', + 'cta', + ]; + if (item.type === 'promotion') requiredKeys.push('campaign'); + if (!exactObject(item, requiredKeys, ['image']) || !safeId(item.id)) return false; + if (!requiredText(item.title, 120) || !requiredText(item.summary, 280)) return false; + if (!isHttpsUrl(item.url) || !dateTime(item.publishedAt)) return false; + if (!exactObject(item.source, ['name', 'url'])) return false; + if (!requiredText(item.source.name, 80) || !isHttpsUrl(item.source.url)) return false; + if (!exactObject(item.cta, ['label']) || !requiredText(item.cta.label, 32)) return false; + if (!Array.isArray(item.tags) || item.tags.length === 0 || item.tags.length > 8) return false; + if (!item.tags.every((tag) => safeId(tag)) || new Set(item.tags).size !== item.tags.length) return false; + if (!validImage(item.image)) return false; + if (item.type !== 'promotion') return true; + + const { campaign } = item; + if (!exactObject(campaign, ['id', 'startsAt', 'endsAt', 'placements'])) return false; + if (!safeId(campaign.id)) return false; + if (!dateTime(campaign.startsAt) || !dateTime(campaign.endsAt)) return false; + if (Date.parse(campaign.endsAt) <= Date.parse(campaign.startsAt)) return false; + if (!Array.isArray(campaign.placements) + || campaign.placements.length === 0 + || campaign.placements.length > 3) return false; + 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' + || !LOCALE_PATTERN.test(raw.locale) + || !dateTime(raw.updatedAt)) return false; + if (!Array.isArray(raw.items) || !raw.items.every((item) => validV2Item(item))) return false; + return new Set(raw.items.map(({ id }) => id)).size === raw.items.length; +} + +export function buildSourceRegistry(raw, registryUrl, pageUrl = registryUrl) { + if (!exactObject(raw, ['sources']) || !Array.isArray(raw.sources)) { + throw new TypeError('Invalid source registry'); + } + + const pageAddress = new URL(pageUrl); + const registryAddress = new URL(registryUrl, pageAddress); + if (!['http:', 'https:'].includes(pageAddress.protocol) + || registryAddress.origin !== pageAddress.origin) { + throw new TypeError('Source registry must use the page origin'); + } + + const sources = new Map(); + for (const source of raw.sources) { + if (!exactObject(source, ['name', 'url', 'icon']) + || !requiredText(source.name, 80) + || !isHttpsUrl(source.url) + || typeof source.icon !== 'string' + || !SOURCE_ICON_PATTERN.test(source.icon) + || sources.has(source.url)) { + throw new TypeError('Invalid source registry entry'); + } + + const icon = new URL(source.icon, registryAddress); + if (icon.origin !== pageAddress.origin) { + throw new TypeError('Source icon must use the page origin'); + } + sources.set(source.url, { ...source, icon: icon.href }); + } + + return sources; +} + +export function selectFeedName(search = '') { + return new URLSearchParams(search).get('feed') === 'dev' ? 'dev' : 'live'; +} + +export function buildCatalog(raw, now = Date.now()) { + const items = validV2Feed(raw) ? raw.items : []; + const promotions = items.filter((item) => item.type === 'promotion' + && item.campaign.placements.includes('discover') + && Date.parse(item.campaign.startsAt) <= now + && now < Date.parse(item.campaign.endsAt)); + const editorial = items.filter((item) => item.type === 'article' || item.type === 'news') + .sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt)); + + return { promotions, editorial }; +} diff --git a/assets/fonts/LICENSE-Public-Sans.md b/assets/fonts/LICENSE-Public-Sans.md new file mode 100644 index 0000000..4a5f700 --- /dev/null +++ b/assets/fonts/LICENSE-Public-Sans.md @@ -0,0 +1,115 @@ +This repository is a fork of Libre Franklin: https://github.com/impallari/Libre-Franklin + +## License for USWDS’s Modified Version + +This repository contains both the original font software for Libre Franklin (the “Original Version”) and font software modifications made by the General Services Administration (GSA). This repository combines the Original Version and these GSA modifications into a piece of font software called Public Sans, which is a “Modified Version” of Libre Franklin. + +As a work of the United States Government, the font software modifications made by GSA are not subject to copyright within the United States. Additionally, GSA waives copyright and related rights in its font software modifications worldwide through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). + +The Original Version (as defined in the SIL Open Font License, Version 1.1) remains subject to copyright under the SIL Open Font License, Version 1.1. + +This Modified Version (Public Sans) contains both software under the SIL Open Font License, Version 1.1 and software modifications by GSA released as CC0. As a work of the United States Government, the software modifications made by GSA are not subject to copyright within the United States. Additionally, GSA waives copyright and related rights in its software modifications worldwide through the [CC0 1.0 Universal Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/). It is a “joint work” made of the original software and modifications combined into a single work. + +**In practice, users of this Modified Version (Public Sans) should use Public Sans according to the terms of the SIL Open Font License, Version 1.1, below.** This is because this font is a combination of work subject to copyright and work not subject to copyright, so the more restrictive requirements apply to using the combined work. + +## License of project USWDS’s Modified Version is based on + +- Libre Franklin is licensed under the SIL Open Font License, Version 1.1 () +- To view the copyright and specific terms and conditions of Libre Franklin, please refer to [OFL.txt](https://github.com/impallari/Libre-Franklin/blob/master/OFL.txt) + +## SIL Open Font License, Version 1.1 + +Copyright 2015 The Public Sans Project Authors (https://github.com/uswds/public-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at http://scripts.sil.org/OFL + +``` +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. +``` diff --git a/assets/fonts/OFL-Mulish.txt b/assets/fonts/OFL-Mulish.txt new file mode 100644 index 0000000..c8c54ed --- /dev/null +++ b/assets/fonts/OFL-Mulish.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Mulish Project Authors (https://github.com/googlefonts/mulish) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/mulish-700.woff2 b/assets/fonts/mulish-700.woff2 new file mode 100644 index 0000000..07e2c88 Binary files /dev/null and b/assets/fonts/mulish-700.woff2 differ diff --git a/assets/fonts/mulish-800.woff2 b/assets/fonts/mulish-800.woff2 new file mode 100644 index 0000000..56c32cc Binary files /dev/null and b/assets/fonts/mulish-800.woff2 differ diff --git a/assets/fonts/public-sans-400.woff2 b/assets/fonts/public-sans-400.woff2 new file mode 100644 index 0000000..e303dab Binary files /dev/null and b/assets/fonts/public-sans-400.woff2 differ diff --git a/assets/fonts/public-sans-600.woff2 b/assets/fonts/public-sans-600.woff2 new file mode 100644 index 0000000..86089d1 Binary files /dev/null and b/assets/fonts/public-sans-600.woff2 differ diff --git a/assets/sources/agentic-storefront-audit.svg b/assets/sources/agentic-storefront-audit.svg new file mode 100644 index 0000000..2bf280f --- /dev/null +++ b/assets/sources/agentic-storefront-audit.svg @@ -0,0 +1,4 @@ + + + A + diff --git a/assets/sources/agentic-storefront.svg b/assets/sources/agentic-storefront.svg new file mode 100644 index 0000000..96da5f6 --- /dev/null +++ b/assets/sources/agentic-storefront.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/sources/catalogspark.ico b/assets/sources/catalogspark.ico new file mode 100644 index 0000000..1fb8da6 Binary files /dev/null and b/assets/sources/catalogspark.ico differ diff --git a/assets/sources/digital-commerce-360.ico b/assets/sources/digital-commerce-360.ico new file mode 100644 index 0000000..603cb40 Binary files /dev/null and b/assets/sources/digital-commerce-360.ico differ diff --git a/assets/sources/forkpoint.svg b/assets/sources/forkpoint.svg new file mode 100644 index 0000000..a67889a --- /dev/null +++ b/assets/sources/forkpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/sources/github.svg b/assets/sources/github.svg new file mode 100644 index 0000000..bf1081a --- /dev/null +++ b/assets/sources/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/sources/intentfusion.ico b/assets/sources/intentfusion.ico new file mode 100644 index 0000000..ccb67db Binary files /dev/null and b/assets/sources/intentfusion.ico differ diff --git a/assets/sources/retailpace.ico b/assets/sources/retailpace.ico new file mode 100644 index 0000000..4fb27b4 Binary files /dev/null and b/assets/sources/retailpace.ico differ diff --git a/assets/sources/rhino-inquisitor.svg b/assets/sources/rhino-inquisitor.svg new file mode 100644 index 0000000..20557a8 --- /dev/null +++ b/assets/sources/rhino-inquisitor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/sources/salesforce-ben.png b/assets/sources/salesforce-ben.png new file mode 100644 index 0000000..2e53c18 Binary files /dev/null and b/assets/sources/salesforce-ben.png differ diff --git a/assets/sources/salesforce.ico b/assets/sources/salesforce.ico new file mode 100644 index 0000000..a15a8f3 Binary files /dev/null and b/assets/sources/salesforce.ico differ diff --git a/assets/sources/source-fallback.svg b/assets/sources/source-fallback.svg new file mode 100644 index 0000000..a563be9 --- /dev/null +++ b/assets/sources/source-fallback.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/feed-dev.json b/feed-dev.json new file mode 100644 index 0000000..a8a18f7 --- /dev/null +++ b/feed-dev.json @@ -0,0 +1,703 @@ +{ + "version": 2, + "locale": "en", + "updatedAt": "2026-09-01T12:50:31.306Z", + "items": [ + { + "id": "promotion-agentic-storefront-audit", + "type": "promotion", + "title": "See How AI Shopping Agents Read Your Store", + "summary": "Run a free storefront audit across 199 checks for AI discoverability, product data, protocols, and agent-ready checkout.", + "url": "https://audit.agenticstorefront.com/", + "source": { + "name": "Agentic Storefront Audit", + "url": "https://audit.agenticstorefront.com/" + }, + "publishedAt": "2026-09-01T00:00:00Z", + "tags": ["agentic-commerce", "audit", "ecommerce"], + "cta": { + "label": "Run free audit" + }, + "campaign": { + "id": "agentic-storefront-audit-2026", + "startsAt": "2026-09-01T00:00:00Z", + "endsAt": "2027-09-01T00:00:00Z", + "placements": ["discover", "task-end", "popup"] + } + }, + { + "id": "promotion-catalogspark", + "type": "promotion", + "title": "Turn Product Data Into Channel-Ready Catalogs", + "summary": "Use AI to extract, enrich, translate, version, and publish product data across Salesforce Commerce Cloud, Shopify, Magento, and more.", + "url": "https://catalogspark.com/", + "source": { + "name": "CatalogSpark", + "url": "https://catalogspark.com/" + }, + "publishedAt": "2026-09-01T00:00:00Z", + "tags": ["product-data", "ai", "ecommerce"], + "cta": { + "label": "Explore CatalogSpark" + }, + "campaign": { + "id": "catalogspark-2026", + "startsAt": "2026-09-01T00:00:00Z", + "endsAt": "2027-09-01T00:00:00Z", + "placements": ["discover", "task-end", "popup"] + } + }, + { + "id": "promotion-retailpace", + "type": "promotion", + "title": "See Store Problems Before Shoppers Do", + "summary": "Bring commerce data into one monitoring hub with proactive alerts and dashboards built for Salesforce Commerce Cloud operations.", + "url": "https://retailpace.com/", + "source": { + "name": "RetailPace", + "url": "https://retailpace.com/" + }, + "publishedAt": "2026-09-01T00:00:00Z", + "tags": ["monitoring", "sfcc", "ecommerce"], + "cta": { + "label": "Explore RetailPace" + }, + "campaign": { + "id": "retailpace-2026", + "startsAt": "2026-09-01T00:00:00Z", + "endsAt": "2027-09-01T00:00:00Z", + "placements": ["discover", "task-end", "popup"] + } + }, + { + "id": "promotion-intentfusion", + "type": "promotion", + "title": "Help Every Shopper Find the Right Product", + "summary": "Add intent-aware search, recommendations, and AI chat to your ecommerce catalog with native Salesforce Commerce Cloud support.", + "url": "https://intentfusion.com/", + "source": { + "name": "IntentFusion", + "url": "https://intentfusion.com/" + }, + "publishedAt": "2026-09-01T00:00:00Z", + "tags": ["search", "ai", "ecommerce"], + "cta": { + "label": "Explore IntentFusion" + }, + "campaign": { + "id": "intentfusion-2026", + "startsAt": "2026-09-01T00:00:00Z", + "endsAt": "2027-09-01T00:00:00Z", + "placements": ["discover", "task-end", "popup"] + } + }, + { + "id": "pwa-kit-v3-21-nightly-2026-09-01", + "type": "news", + "title": "PWA Kit v3.21.0 Nightly Build 2026-09-01", + "summary": "The latest PWA Kit nightly build provides an early integration signal for teams that track upcoming composable storefront changes.", + "url": "https://github.com/SalesforceCommerceCloud/pwa-kit/releases/tag/v3.21.0-nightly-20260901080519", + "source": { + "name": "PWA Kit Releases", + "url": "https://github.com/SalesforceCommerceCloud/pwa-kit/releases.atom" + }, + "publishedAt": "2026-09-01T08:08:59Z", + "tags": ["pwa-kit", "release", "nightly"], + "cta": { + "label": "View release" + } + }, + { + "id": "salesforce-blog-b2c-commerce-august-26-release", + "type": "article", + "title": "B2C Commerce August Release: Custom Apps and Omnichannel Innovations", + "summary": "The August B2C Commerce release adds custom Business Manager workspaces, anomaly detection, catalog unification, developer tools, and AI-ready product features.", + "url": "https://www.salesforce.com/blog/b2c-commerce-august-26-release/", + "source": { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/" + }, + "publishedAt": "2026-08-26T00:00:00Z", + "tags": ["sfcc", "release-notes", "business-manager"], + "cta": { + "label": "Read article" + } + }, + { + "id": "rhino-b2c-commerce-26-9-release", + "type": "article", + "title": "B2C Commerce 26.9: Consent, Workspaces, and SLAS Deadlines", + "summary": "A technical review of Commerce Apps workspaces, shopper consent, tax items, SCAPI changes, and upcoming SLAS enforcement dates in B2C Commerce 26.9.", + "url": "https://rhino-inquisitor.com/b2c-commerce-cloud-26-9-release/", + "source": { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/" + }, + "publishedAt": "2026-08-20T00:00:00Z", + "tags": ["sfcc", "release-notes", "slas"], + "cta": { + "label": "Read article" + } + }, + { + "id": "rhino-sfcc-github-repo-access", + "type": "article", + "title": "SFCC GitHub Repo Access: Getting (and Keeping) It", + "summary": "A guide to obtaining and retaining access to Salesforce B2C Commerce repositories on GitHub.", + "url": "https://rhino-inquisitor.com/sfcc-github-repo-access/", + "source": { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["sfcc", "github", "developer-tools"], + "cta": { + "label": "Read article" + } + }, + { + "id": "forkpoint-leading-brands-embrace-sfcc", + "type": "article", + "title": "7 Reasons Why Leading Brands Embrace Salesforce Commerce Cloud", + "summary": "A review of why enterprise brands choose Salesforce Commerce Cloud for scale, personalization, integration, and digital growth.", + "url": "https://forkpoint.com/articles/7-reasons-why-leading-brands-embrace-sfcc/", + "source": { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["sfcc", "ecommerce", "strategy"], + "cta": { + "label": "Read article" + } + }, + { + "id": "forkpoint-ai-smart-revolution-commerce", + "type": "article", + "title": "AI – The Smart Revolution in Commerce", + "summary": "An overview of how AI improves personalization, inventory planning, customer service, and operational decisions in commerce.", + "url": "https://forkpoint.com/articles/ai-the-smart-revolution-in-commerce/", + "source": { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["ecommerce", "ai", "retail"], + "cta": { + "label": "Read article" + } + }, + { + "id": "forkpoint-ai-better-developers", + "type": "article", + "title": "How AI Is Making Us Better Developers", + "summary": "ForkPoint shares how AI supports coding, testing, documentation, debugging, and developer onboarding without replacing engineering judgment.", + "url": "https://forkpoint.com/articles/how-ai-is-making-us-better-developers/", + "source": { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["ai", "development", "engineering"], + "cta": { + "label": "Read article" + } + }, + { + "id": "forkpoint-invisible-commerce-2026", + "type": "article", + "title": "Invisible Commerce: What Brands Must Know in 2026", + "summary": "A guide to the new shopping layer where AI assistants shape product discovery before shoppers reach a storefront.", + "url": "https://forkpoint.com/articles/invisible-commerce-what-brands-must-know-in-2026/", + "source": { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["agentic-commerce", "ai-visibility", "ecommerce"], + "cta": { + "label": "Read article" + } + }, + { + "id": "forkpoint-ecommerce-ai-search", + "type": "article", + "title": "Why eCommerce Brands Are Falling in Love with AI Search", + "summary": "A look at how AI search can reduce lost revenue by understanding shopper intent beyond exact keywords.", + "url": "https://forkpoint.com/articles/why-ecommerce-brands-are-falling-in-love-with-ai-search/", + "source": { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/" + }, + "publishedAt": "2026-08-12T00:00:00Z", + "tags": ["search", "ai", "ecommerce"], + "cta": { + "label": "Read article" + } + }, + { + "id": "dc360-ahold-delhaize-pear-commerce", + "type": "news", + "title": "Ahold Delhaize USA Adds Pear Commerce to Connect CPG Content to Online Grocery Carts", + "summary": "Ahold Delhaize USA is using Pear Commerce to connect recipes, product pages, and brand content directly to online grocery carts.", + "url": "https://www.digitalcommerce360.com/2026/08/06/ahold-delhaize-usa-adds-pear-commerce-to-connect-cpg-content-to-online-grocery-carts/", + "source": { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/" + }, + "publishedAt": "2026-08-06T00:00:00Z", + "tags": ["ecommerce", "retail-tech", "grocery"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforce-blog-grow-revenue-hub-ai", + "type": "article", + "title": "How To Grow a Revenue Hub With AI Agents", + "summary": "A practical look at guided shopping, commerce automation, connected data, and small first steps for AI-powered ecommerce.", + "url": "https://www.salesforce.com/blog/small-business/grow-a-revenue-hub-with-ai/", + "source": { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/" + }, + "publishedAt": "2026-08-05T00:00:00Z", + "tags": ["ecommerce", "ai", "agentic-commerce"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforceben-agentforce-for-architects", + "type": "article", + "title": "Introducing Agentforce for Architects: Guidance On Demand", + "summary": "A review of Salesforce's architecture assistant, including guidance on the Commerce Cloud move from x86 to ARM.", + "url": "https://www.salesforceben.com/introducing-agentforce-for-architects-guidance-on-demand/", + "source": { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/" + }, + "publishedAt": "2026-08-05T00:00:00Z", + "tags": ["salesforce", "architecture", "commerce-cloud"], + "cta": { + "label": "Read article" + } + }, + { + "id": "rhino-sfcc-price-books", + "type": "article", + "title": "How SFCC Price Books Actually Work", + "summary": "A developer-focused explanation of Salesforce B2C Commerce price book behavior and how pricing resolves in real storefronts.", + "url": "https://rhino-inquisitor.com/how-sfcc-price-books-actually-work/", + "source": { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/" + }, + "publishedAt": "2026-08-05T00:00:00Z", + "tags": ["sfcc", "pricing", "developer-guide"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-blog-b2b-commerce-july-26", + "type": "article", + "title": "Smarter Merchandising and Platform Extensibility: New Features for B2B Commerce in July", + "summary": "Salesforce reviews July B2B Commerce improvements for merchandising, storefront discovery, and platform extensibility.", + "url": "https://www.salesforce.com/blog/b2b-commerce-innovations-july-26/", + "source": { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/" + }, + "publishedAt": "2026-08-03T00:00:00Z", + "tags": ["b2b-commerce", "merchandising", "salesforce"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-blog-b2c-commerce-july-26", + "type": "article", + "title": "Unlock Intent-Driven Shopping: Inside the July 2026 B2C Commerce Release", + "summary": "The July B2C Commerce release focuses on intent-aware shopping, storefront search, and tools that help teams respond to natural-language product discovery.", + "url": "https://www.salesforce.com/blog/b2c-commerce-july-26-release/", + "source": { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/" + }, + "publishedAt": "2026-07-29T00:00:00Z", + "tags": ["sfcc", "release-notes", "search"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-news-agentic-search-growth", + "type": "news", + "title": "Shopping's New First Step: Agentic Search Grows 200% as Purchase Journeys Start in AI Chats", + "summary": "Salesforce reports that agentic search grew 200% year over year as shoppers moved product discovery into AI chats and commerce teams increased AI adoption.", + "url": "https://www.salesforce.com/news/stories/agentic-search-growth/", + "source": { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/" + }, + "publishedAt": "2026-07-28T00:00:00Z", + "tags": ["commerce", "agentic-commerce", "research"], + "cta": { + "label": "Read news" + } + }, + { + "id": "rhino-storefront-next-pwa-kit-migration", + "type": "article", + "title": "Storefront Next: Architecture and the PWA Kit Migration", + "summary": "An architectural guide to Storefront Next and the migration path from existing PWA Kit storefronts.", + "url": "https://rhino-inquisitor.com/storefront-next-architecture-and-migration-from-pwa-kit/", + "source": { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/" + }, + "publishedAt": "2026-07-27T00:00:00Z", + "tags": ["sfcc", "storefront-next", "pwa-kit"], + "cta": { + "label": "Read article" + } + }, + { + "id": "rhino-b2c-commerce-26-8-release", + "type": "article", + "title": "B2C Commerce 26.8: Unification, Anomalies, and Deadlines", + "summary": "A detailed review of B2C Commerce 26.8 changes, including unified operations, anomaly detection, and dates that implementation teams must track.", + "url": "https://rhino-inquisitor.com/b2c-commerce-cloud-26-8-release/", + "source": { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/" + }, + "publishedAt": "2026-07-23T00:00:00Z", + "tags": ["sfcc", "release-notes", "commerce-cloud"], + "cta": { + "label": "Read article" + } + }, + { + "id": "agentic-storefront-chatbot-tracking-study", + "type": "article", + "title": "17 of 20 AI Chatbots Leak Data to Third Parties. Here's Why Merchants Should Care", + "summary": "A UC Davis study found broad third-party tracking across popular AI chatbots. This article turns the findings into a practical privacy checklist for commerce teams.", + "url": "https://agenticstorefront.com/blog/ai-chatbot-tracking-study-what-brands-must-know/", + "source": { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/" + }, + "publishedAt": "2026-07-23T00:00:00Z", + "tags": ["agentic-commerce", "privacy", "chatbots"], + "cta": { + "label": "Read article" + } + }, + { + "id": "agentic-storefront-ai-visibility-q4", + "type": "article", + "title": "Q4 Is Coming. AI Visibility Is the Most Underpriced Channel in Your Media Plan", + "summary": "A case for treating AI visibility as a measurable acquisition channel before paid media costs rise for the holiday season.", + "url": "https://agenticstorefront.com/blog/ai-visibility-most-underpriced-channel-q4/", + "source": { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/" + }, + "publishedAt": "2026-07-23T00:00:00Z", + "tags": ["agentic-commerce", "geo", "marketing"], + "cta": { + "label": "Read article" + } + }, + { + "id": "dc360-esw-agentic-commerce-microsoft", + "type": "news", + "title": "ESW Launches Agentic Commerce Integration, Partnering With Microsoft Copilot", + "summary": "ESW launches agentic commerce infrastructure for AI product discovery, secure checkout, payments, and post-purchase services.", + "url": "https://www.digitalcommerce360.com/2026/07/22/esw-agentic-commerce-integration-microsoft-copilot/", + "source": { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/" + }, + "publishedAt": "2026-07-22T00:00:00Z", + "tags": ["agentic-commerce", "ecommerce", "microsoft"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforce-blog-agentic-commerce-search", + "type": "article", + "title": "Beyond Keywords: How Agentic Commerce Search Understands True Shopper Intent", + "summary": "Salesforce explains how agentic commerce search moves beyond short keyword queries to interpret shopper intent and improve product discovery.", + "url": "https://www.salesforce.com/blog/new-agentic-commerce-search-capabilities/", + "source": { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/" + }, + "publishedAt": "2026-07-21T00:00:00Z", + "tags": ["search", "agentic-commerce", "ecommerce"], + "cta": { + "label": "Read article" + } + }, + { + "id": "agentic-storefront-openai-product-feed", + "type": "article", + "title": "OpenAI's Product Feed: How Your Catalog Gets Into ChatGPT — and What It's Worth", + "summary": "A field-by-field guide to the product feed behind ChatGPT shopping, Instant Checkout, and sponsored placements.", + "url": "https://agenticstorefront.com/blog/openai-product-feed-chatgpt-shopping-explained/", + "source": { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/" + }, + "publishedAt": "2026-07-16T00:00:00Z", + "tags": ["agentic-commerce", "product-feeds", "openai"], + "cta": { + "label": "Read article" + } + }, + { + "id": "agentic-storefront-we-know-enough", + "type": "article", + "title": "\"We Know Enough\" — The Most Expensive Sentence in AI Visibility", + "summary": "An argument for measuring how AI systems describe and recommend a brand instead of relying on assumptions about visibility.", + "url": "https://agenticstorefront.com/blog/we-know-enough-ai-visibility/", + "source": { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/" + }, + "publishedAt": "2026-07-15T00:00:00Z", + "tags": ["ai-visibility", "geo", "strategy"], + "cta": { + "label": "Read article" + } + }, + { + "id": "agentic-storefront-catch-me-if-you-can", + "type": "article", + "title": "The \"Catch Me If You Can\" Strategy: Why E-Commerce Leaders Don't Need to Understand Every AI Model", + "summary": "A strategy note for commerce leaders who need to prepare for AI-driven buying without tracking every model change.", + "url": "https://agenticstorefront.com/blog/catch-me-if-you-can-agentic-commerce/", + "source": { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/" + }, + "publishedAt": "2026-07-14T00:00:00Z", + "tags": ["agentic-commerce", "ecommerce", "strategy"], + "cta": { + "label": "Read article" + } + }, + { + "id": "dc360-square-chatgpt-claude", + "type": "news", + "title": "Square Launches Agentic Commerce Integrations With ChatGPT and Claude", + "summary": "Square adds integrations that help sellers expose products and commerce actions through ChatGPT and Claude.", + "url": "https://www.digitalcommerce360.com/2026/07/01/square-agentic-commerce-integrations-chatgpt-claude/", + "source": { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/" + }, + "publishedAt": "2026-07-01T00:00:00Z", + "tags": ["agentic-commerce", "ecommerce", "payments"], + "cta": { + "label": "Read news" + } + }, + { + "id": "dc360-newegg-conversational-ai", + "type": "news", + "title": "Newegg Adds Conversational AI Assistant for Shopping", + "summary": "Newegg introduces a conversational shopping assistant for product discovery and guided ecommerce decisions.", + "url": "https://www.digitalcommerce360.com/2026/06/29/newegg-adds-conversational-ai-assistant-for-shopping/", + "source": { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/" + }, + "publishedAt": "2026-06-29T00:00:00Z", + "tags": ["ecommerce", "ai", "search"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforce-news-agentforce-commerce-release", + "type": "news", + "title": "As AI Agents Transform Commerce, Salesforce Unleashes Its Biggest Agentforce Commerce Release Yet", + "summary": "Salesforce details a major Agentforce Commerce release focused on AI-led shopping, unified commerce, and faster storefront work.", + "url": "https://www.salesforce.com/news/stories/agentforce-commerce-announcement/", + "source": { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/" + }, + "publishedAt": "2026-06-24T00:00:00Z", + "tags": ["commerce", "agentforce", "salesforce"], + "cta": { + "label": "Read news" + } + }, + { + "id": "dc360-ecommerce-trends-2026", + "type": "news", + "title": "10 Ecommerce Trends That Are Defining 2026 for Online Retailers", + "summary": "A review of 2026 ecommerce trends across agentic commerce, AI referrals, platform features, omnichannel retail, and resale.", + "url": "https://www.digitalcommerce360.com/2026/06/18/ecommerce-trends-shaping-2026/", + "source": { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/" + }, + "publishedAt": "2026-06-18T00:00:00Z", + "tags": ["ecommerce", "trends", "agentic-commerce"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforceben-connections-2026-guide", + "type": "article", + "title": "Complete Guide to Salesforce Connections 2026", + "summary": "A practical guide to Salesforce Connections 2026 for marketing and Commerce Cloud professionals.", + "url": "https://www.salesforceben.com/salesforce-connections/", + "source": { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/" + }, + "publishedAt": "2026-05-07T00:00:00Z", + "tags": ["salesforce", "events", "commerce-cloud"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-developers-b2c-cli-sandbox-cloning", + "type": "article", + "title": "Introducing the B2C Commerce CLI and On-Demand Sandbox Cloning", + "summary": "Salesforce introduces a supported B2C CLI, SDK, agent skills, and on-demand sandbox cloning for Commerce Cloud developers and admins.", + "url": "https://developer.salesforce.com/blogs/2026/04/introducing-the-b2c-commerce-cli-and-on-demand-sandbox-cloning", + "source": { + "name": "Salesforce Developers Blog", + "url": "https://developer.salesforce.com/blogs/feed" + }, + "publishedAt": "2026-04-28T15:00:06.000Z", + "tags": ["sfcc", "developer-tools", "cli"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforceben-commerce-marketing-cloud-shrink", + "type": "news", + "title": "Salesforce Commerce Cloud and Marketing Cloud Shrink for the First Time in Years", + "summary": "Salesforce Ben examines the first year-over-year decline reported for Agentforce Marketing and Commerce in Salesforce's Q4 FY26 results.", + "url": "https://www.salesforceben.com/salesforce-commerce-cloud-and-marketing-cloud-shrink-for-the-first-time-in-years/", + "source": { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/" + }, + "publishedAt": "2026-02-26T00:00:00Z", + "tags": ["commerce-cloud", "salesforce", "business"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforceben-salesforce-acquires-cimulate", + "type": "news", + "title": "Salesforce Acquires Cimulate in First Acquisition of 2026", + "summary": "Salesforce's acquisition of Cimulate adds intent-aware product discovery technology to Agentforce Commerce.", + "url": "https://www.salesforceben.com/salesforce-acquires-cimulate-in-first-major-acquisition-of-2026/", + "source": { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/" + }, + "publishedAt": "2026-02-11T00:00:00Z", + "tags": ["agentforce", "commerce-cloud", "acquisitions"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-news-google-ucp-support", + "type": "news", + "title": "Salesforce Announces Support for Google's Universal Commerce Protocol", + "summary": "Salesforce announces support for Google's Universal Commerce Protocol for product discovery and checkout inside conversational AI.", + "url": "https://www.salesforce.com/news/stories/google-universal-commerce-protocol-support-announcement/", + "source": { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/" + }, + "publishedAt": "2026-01-14T00:00:00Z", + "tags": ["commerce", "ucp", "agentic-commerce"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforce-news-holiday-shopping-2025", + "type": "news", + "title": "Holiday Season Rakes in Record $1.29T for Retailers, Salesforce Data Shows", + "summary": "Salesforce reviews the 2025 holiday season and reports record global online sales shaped by AI-assisted shopping.", + "url": "https://www.salesforce.com/news/stories/2025-holiday-shopping-data/", + "source": { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/" + }, + "publishedAt": "2026-01-08T00:00:00Z", + "tags": ["ecommerce", "retail", "research"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforce-developers-pwa-kit-mcp-server", + "type": "article", + "title": "Build Composable Storefronts Smarter and Faster with the PWA Kit MCP Server", + "summary": "A hands-on guide to using the PWA Kit MCP Server to create and customize composable storefronts with an AI coding agent.", + "url": "https://developer.salesforce.com/blogs/2025/12/build-composable-storefronts-smarter-and-faster-with-the-pwa-kit-mcp-server", + "source": { + "name": "Salesforce Developers Blog", + "url": "https://developer.salesforce.com/blogs/feed" + }, + "publishedAt": "2025-12-23T16:00:41.000Z", + "tags": ["pwa-kit", "mcp", "developer-tools"], + "cta": { + "label": "Read article" + } + }, + { + "id": "salesforce-news-cyber-week-2025", + "type": "news", + "title": "Salesforce Data: AI and Agents Propel Cyber Week to Record $336.6B in Global Spend", + "summary": "Salesforce reports record Cyber Week spending and measures how AI and shopping agents influenced retail demand.", + "url": "https://www.salesforce.com/news/press-releases/2025/12/05/cyber-week-ai-agents-sales/", + "source": { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/" + }, + "publishedAt": "2025-12-05T00:00:00Z", + "tags": ["ecommerce", "retail", "ai"], + "cta": { + "label": "Read news" + } + }, + { + "id": "salesforceben-agentforce-commerce-unveiled", + "type": "news", + "title": "Agentforce Commerce Unveiled With Guided Shopping, Agent-Assisted Clienteling, and More", + "summary": "An overview of Agentforce Commerce features for guided shopping, clienteling, catalog syndication, and conversational checkout.", + "url": "https://www.salesforceben.com/agentforce-commerce-unveiled-with-guided-shopping-agent-assisted-clienteling-and-more/", + "source": { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/" + }, + "publishedAt": "2025-11-18T00:00:00Z", + "tags": ["agentforce", "commerce-cloud", "agentic-commerce"], + "cta": { + "label": "Read article" + } + } + ] +} diff --git a/feed-live.json b/feed-live.json new file mode 100644 index 0000000..7a9b6ca --- /dev/null +++ b/feed-live.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "locale": "en", + "updatedAt": "2026-09-01T12:50:31.306Z", + "items": [] +} diff --git a/feed.schema.json b/feed.schema.json new file mode 100644 index 0000000..2d54a8d --- /dev/null +++ b/feed.schema.json @@ -0,0 +1,328 @@ +{ + "$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])" + }, + "updatedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + "source": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + "publishedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "tags": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "uniqueItems": true + }, + "image": { + "type": "object", + "properties": { + "src": { + "anyOf": [ + { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + { + "type": "string", + "pattern": "^assets\\/" + } + ] + }, + "alt": { + "type": "string", + "maxLength": 160 + } + }, + "required": [ + "src", + "alt" + ], + "additionalProperties": false + }, + "cta": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "\\S" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "article", + "news" + ] + } + }, + "required": [ + "id", + "title", + "summary", + "url", + "source", + "publishedAt", + "tags", + "cta", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + "source": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + "publishedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "tags": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "uniqueItems": true + }, + "image": { + "type": "object", + "properties": { + "src": { + "anyOf": [ + { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + { + "type": "string", + "pattern": "^assets\\/" + } + ] + }, + "alt": { + "type": "string", + "maxLength": 160 + } + }, + "required": [ + "src", + "alt" + ], + "additionalProperties": false + }, + "cta": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "\\S" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "const": "promotion" + }, + "campaign": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "startsAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "placements": { + "minItems": 1, + "maxItems": 3, + "type": "array", + "items": { + "type": "string", + "enum": [ + "discover", + "task-end", + "popup" + ] + } + } + }, + "required": [ + "id", + "startsAt", + "endsAt", + "placements" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "title", + "summary", + "url", + "source", + "publishedAt", + "tags", + "cta", + "type", + "campaign" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "version", + "locale", + "updatedAt", + "items" + ], + "additionalProperties": false, + "$id": "https://discover.dwithease.com/feed.schema.json", + "title": "DWithEase Discover Feed" +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..846f787 --- /dev/null +++ b/index.html @@ -0,0 +1,46 @@ + + + + + + + + Discover · DWithEase + + + + + +
+ + +
+ + +
+
+
+
+
+
+
+ + +
+ + diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..0b646c2 --- /dev/null +++ b/openapi.json @@ -0,0 +1,391 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "DWithEase Discover Feed API", + "version": "2.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": [ + { + "name": "Feeds", + "description": "Read-only JSON feeds for Discover clients." + } + ], + "paths": { + "/feed-live.json": { + "get": { + "operationId": "getLiveDiscoverFeed", + "summary": "Get the live Discover feed", + "description": "Returns approved editorial items and active or scheduled product promotions.", + "tags": [ + "Feeds" + ], + "responses": { + "200": { + "description": "The public Discover feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverFeed" + } + } + } + } + } + } + }, + "/feed-dev.json": { + "get": { + "operationId": "getDevelopmentDiscoverFeed", + "summary": "Get the development Discover feed", + "description": "Returns approved content, promotions, and unapproved gather candidates for review.", + "tags": [ + "Feeds" + ], + "responses": { + "200": { + "description": "The development Discover feed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverFeed" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "DiscoverFeed": { + "title": "Discover Feed", + "description": "The complete version 2 Discover document.", + "type": "object", + "properties": { + "version": { + "type": "number", + "const": 2 + }, + "locale": { + "type": "string", + "pattern": "^[a-z]{2}(?:-[A-Z]{2})?(?![\\s\\S])" + }, + "updatedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + "source": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + "publishedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "tags": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "uniqueItems": true + }, + "image": { + "type": "object", + "properties": { + "src": { + "anyOf": [ + { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + { + "type": "string", + "pattern": "^assets\\/" + } + ] + }, + "alt": { + "type": "string", + "maxLength": 160 + } + }, + "required": [ + "src", + "alt" + ], + "additionalProperties": false + }, + "cta": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "\\S" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": [ + "article", + "news" + ] + } + }, + "required": [ + "id", + "title", + "summary", + "url", + "source", + "publishedAt", + "tags", + "cta", + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + "source": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "\\S" + }, + "url": { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + } + }, + "required": [ + "name", + "url" + ], + "additionalProperties": false + }, + "publishedAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "tags": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "uniqueItems": true + }, + "image": { + "type": "object", + "properties": { + "src": { + "anyOf": [ + { + "pattern": "^https://(?:(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|\\[(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)\\]|(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|(?![\\s\\S])))(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?)(?::(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*(?:\\?(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?:#(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*)?(?![\\s\\S])", + "type": "string", + "format": "uri" + }, + { + "type": "string", + "pattern": "^assets\\/" + } + ] + }, + "alt": { + "type": "string", + "maxLength": 160 + } + }, + "required": [ + "src", + "alt" + ], + "additionalProperties": false + }, + "cta": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "\\S" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "type": { + "type": "string", + "const": "promotion" + }, + "campaign": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*(?![\\s\\S])" + }, + "startsAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "endsAt": { + "type": "string", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))(?![\\s\\S])", + "format": "date-time" + }, + "placements": { + "minItems": 1, + "maxItems": 3, + "type": "array", + "items": { + "type": "string", + "enum": [ + "discover", + "task-end", + "popup" + ] + } + } + }, + "required": [ + "id", + "startsAt", + "endsAt", + "placements" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "title", + "summary", + "url", + "source", + "publishedAt", + "tags", + "cta", + "type", + "campaign" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "version", + "locale", + "updatedAt", + "items" + ], + "additionalProperties": false + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1eef788 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,753 @@ +{ + "name": "dwithease-discover", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dwithease-discover", + "version": "1.0.0", + "dependencies": { + "zod": "4.5.4" + }, + "devDependencies": { + "@types/node": "26.4.0", + "linkedom": "^0.18.13", + "playwright-core": "1.62.1", + "typescript": "7.0.2", + "yaml": "^2.8.3" + } + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..308b331 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "dwithease-discover", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.js test/*.test.ts", + "typecheck": "tsc --noEmit", + "validate:feed": "node scripts/validate-feed.ts", + "validate:sources": "node scripts/validate-sources.js", + "write:schema": "node scripts/write-json-schema.ts", + "write:openapi": "node scripts/write-openapi.ts" + }, + "devDependencies": { + "@types/node": "26.4.0", + "linkedom": "^0.18.13", + "playwright-core": "1.62.1", + "typescript": "7.0.2", + "yaml": "^2.8.3" + }, + "dependencies": { + "zod": "4.5.4" + } +} diff --git a/schema.html b/schema.html new file mode 100644 index 0000000..b403ddc --- /dev/null +++ b/schema.html @@ -0,0 +1,245 @@ + + + + + + + + Discover Feed Reference · DWithEase + + + + +
+ + + + + + diff --git a/scripts/feed-schema.ts b/scripts/feed-schema.ts new file mode 100644 index 0000000..8b4249d --- /dev/null +++ b/scripts/feed-schema.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; + +const STRICT_END = '(?![\\s\\S])'; +const DATE_TIME_PATTERN = '^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29' + + '|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))' + + 'T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))' + + STRICT_END; + +const SlugSchema = z.string() + .min(1) + .max(120) + .regex(new RegExp(`^[a-z0-9]+(?:-[a-z0-9]+)*${STRICT_END}`)); + +const RFC3986_PERCENT_ENCODED = '%[0-9A-Fa-f]{2}'; +const DNS_LABEL = '(?![Xx][Nn]--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'; +const DNS_NAME = `(?![0-9]+(?:\\.[0-9]+)*\\.?(?=[:/?#]|${STRICT_END}))${DNS_LABEL}(?:\\.${DNS_LABEL})*\\.?`; +const IPV4_DECIMAL_OCTET = '(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])'; +const IPV4_ADDRESS = `(?:${IPV4_DECIMAL_OCTET}\\.){3}${IPV4_DECIMAL_OCTET}`; +const IPV6_HEXTET = '[0-9A-Fa-f]{1,4}'; +const IPV6_LOW_32_BITS = `(?:${IPV6_HEXTET}:${IPV6_HEXTET}|${IPV4_ADDRESS})`; +const IPV6_ADDRESS = `(?:(?:${IPV6_HEXTET}:){6}${IPV6_LOW_32_BITS}` + + `|::(?:${IPV6_HEXTET}:){5}${IPV6_LOW_32_BITS}` + + `|(?:${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){4}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,1}${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){3}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,2}${IPV6_HEXTET})?::(?:${IPV6_HEXTET}:){2}${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,3}${IPV6_HEXTET})?::${IPV6_HEXTET}:${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,4}${IPV6_HEXTET})?::${IPV6_LOW_32_BITS}` + + `|(?:(?:${IPV6_HEXTET}:){0,5}${IPV6_HEXTET})?::${IPV6_HEXTET}` + + `|(?:(?:${IPV6_HEXTET}:){0,6}${IPV6_HEXTET})?::)`; +const RFC3986_HOST = `(?:${IPV4_ADDRESS}|\\[${IPV6_ADDRESS}\\]|${DNS_NAME})`; +const RFC3986_PORT = '(?:[0-9]{1,4}|[0-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])'; +const RFC3986_AUTHORITY = `${RFC3986_HOST}(?::${RFC3986_PORT})?`; +const RFC3986_PATH_CHARACTER = "[A-Za-z0-9._~!$&'()*+,;=:@-]"; +const RFC3986_QUERY_CHARACTER = "[A-Za-z0-9._~!$&'()*+,;=:@/?-]"; +const HTTPS_URL_PATTERN = `^https://${RFC3986_AUTHORITY}` + + `(?:/(?:${RFC3986_PATH_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)*` + + `(?:\\?(?:${RFC3986_QUERY_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)?` + + `(?:#(?:${RFC3986_QUERY_CHARACTER}|${RFC3986_PERCENT_ENCODED})*)?${STRICT_END}`; + +const HttpsUrlSchema = z.string() + .regex(new RegExp(HTTPS_URL_PATTERN)) + .pipe(z.url({ protocol: new RegExp(`^https${STRICT_END}`) })) + .meta({ pattern: HTTPS_URL_PATTERN }); + +const DateTimeSchema = z.string() + .regex(new RegExp(DATE_TIME_PATTERN)) + .meta({ format: 'date-time' }); + +const TagsSchema = z.array(SlugSchema) + .min(1) + .max(8) + .refine((tags) => new Set(tags).size === tags.length, { + message: 'Tags must be unique', + }) + .meta({ uniqueItems: true }); + +const SourceSchema = z.strictObject({ + name: z.string().max(80).regex(/\S/).trim().min(1), + url: HttpsUrlSchema, +}); + +const ImageSchema = z.strictObject({ + src: z.union([ + HttpsUrlSchema, + z.string().regex(/^assets\//), + ]), + alt: z.string().max(160).trim(), +}); + +const CtaSchema = z.strictObject({ + label: z.string().max(32).regex(/\S/).trim().min(1), +}); + +const CommonItemShape = { + id: SlugSchema, + title: z.string().max(120).regex(/\S/).trim().min(1), + summary: z.string().max(280).regex(/\S/).trim().min(1), + url: HttpsUrlSchema, + source: SourceSchema, + publishedAt: DateTimeSchema, + tags: TagsSchema, + image: ImageSchema.optional(), + cta: CtaSchema, +}; + +const EditorialItemSchema = z.strictObject({ + ...CommonItemShape, + type: z.enum(['article', 'news']), +}); + +const CampaignSchema = z.strictObject({ + id: SlugSchema, + startsAt: DateTimeSchema, + endsAt: DateTimeSchema, + placements: z.array(z.enum(['discover', 'task-end', 'popup'])).min(1).max(3), +}).superRefine((campaign, context) => { + if (Date.parse(campaign.endsAt) <= Date.parse(campaign.startsAt)) { + context.addIssue({ + code: 'custom', + path: ['endsAt'], + message: 'Campaign end must be after its start', + }); + } +}); + +const PromotionItemSchema = z.strictObject({ + ...CommonItemShape, + type: z.literal('promotion'), + campaign: CampaignSchema, +}); + +export const FeedItemSchema = z.discriminatedUnion('type', [ + EditorialItemSchema, + PromotionItemSchema, +]); + +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), +}).superRefine((feed, context) => { + const itemIds = new Set(); + + feed.items.forEach((item, index) => { + if (itemIds.has(item.id)) { + context.addIssue({ + code: 'custom', + path: ['items', index, 'id'], + message: `Duplicate item ID: ${item.id}`, + }); + } + itemIds.add(item.id); + }); +}); + +export type Feed = z.infer; +export type FeedItem = z.infer; + +export type FeedValidationResult = + | { success: true; data: Feed } + | { success: false; errors: Array<{ path: string; message: string }> }; + +export function validateFeed(input: unknown): FeedValidationResult { + const result = FeedSchema.safeParse(input); + + if (result.success) { + return { success: true, data: result.data }; + } + + return { + success: false, + errors: result.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }; +} diff --git a/scripts/validate-feed.ts b/scripts/validate-feed.ts new file mode 100644 index 0000000..c81a559 --- /dev/null +++ b/scripts/validate-feed.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; + +import { validateFeed } from './feed-schema.ts'; + +const paths = process.argv.slice(2); + +if (paths.length === 0) { + console.error('Usage: npm run validate:feed -- [feed.json]'); + process.exitCode = 2; +} else { + let failed = false; + + for (const path of paths) { + try { + const input: unknown = JSON.parse(await readFile(path, 'utf8')); + const result = validateFeed(input); + + if (result.success) { + console.log(`${basename(path)}: valid`); + continue; + } + + failed = true; + for (const error of result.errors) { + console.error(`${basename(path)}: ${error.path}: ${error.message}`); + } + } catch (error) { + failed = true; + const message = error instanceof Error ? error.message : String(error); + console.error(`${basename(path)}: ${message}`); + } + } + + if (failed) { + process.exitCode = 1; + } +} diff --git a/scripts/validate-sources.js b/scripts/validate-sources.js new file mode 100644 index 0000000..1e59b51 --- /dev/null +++ b/scripts/validate-sources.js @@ -0,0 +1,45 @@ +import { access, readFile } from 'node:fs/promises'; +import { basename, dirname, resolve } from 'node:path'; + +import { buildSourceRegistry } from '../assets/feed-model.js'; + +const [registryPath = 'sources.json', ...requestedFeedPaths] = process.argv.slice(2); +const feedPaths = requestedFeedPaths.length + ? requestedFeedPaths + : ['feed-live.json', 'feed-dev.json']; +const pageUrl = 'https://discover.dwithease.com/'; + +try { + const rawRegistry = JSON.parse(await readFile(registryPath, 'utf8')); + const registryUrl = new URL(basename(registryPath), pageUrl).href; + const registry = buildSourceRegistry(rawRegistry, registryUrl, pageUrl); + const feedSources = new Map(); + + for (const feedPath of feedPaths) { + const feed = JSON.parse(await readFile(feedPath, 'utf8')); + if (!Array.isArray(feed.items)) throw new TypeError(`${basename(feedPath)} has no items array`); + for (const { source } of feed.items) { + if (feedSources.has(source.url) && feedSources.get(source.url) !== source.name) { + throw new TypeError(`Source name mismatch for ${source.url}`); + } + feedSources.set(source.url, source.name); + } + } + + if (registry.size !== feedSources.size) { + throw new TypeError('Source registry does not match the public feeds'); + } + + for (const source of rawRegistry.sources) { + if (feedSources.get(source.url) !== source.name) { + throw new TypeError(`Missing source registry entry for ${source.url}`); + } + await access(resolve(dirname(registryPath), source.icon)); + } + + console.log(`${basename(registryPath)}: valid`); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`${basename(registryPath)}: ${message}`); + process.exitCode = 1; +} diff --git a/scripts/write-json-schema.ts b/scripts/write-json-schema.ts new file mode 100644 index 0000000..31ea2f9 --- /dev/null +++ b/scripts/write-json-schema.ts @@ -0,0 +1,15 @@ +import { writeFile } from 'node:fs/promises'; + +import { z } from 'zod'; + +import { FeedSchema } from './feed-schema.ts'; + +const outputPath = process.argv[2] ?? 'feed.schema.json'; +const schema = { + ...z.toJSONSchema(FeedSchema, { target: 'draft-2020-12' }), + $id: 'https://discover.dwithease.com/feed.schema.json', + title: 'DWithEase Discover Feed', +}; + +await writeFile(outputPath, `${JSON.stringify(schema, null, 2)}\n`); +console.log(`${outputPath}: written`); diff --git a/scripts/write-openapi.ts b/scripts/write-openapi.ts new file mode 100644 index 0000000..efda425 --- /dev/null +++ b/scripts/write-openapi.ts @@ -0,0 +1,72 @@ +import { writeFile } from 'node:fs/promises'; + +import { z } from 'zod'; + +import { FeedSchema } from './feed-schema.ts'; + +const outputPath = process.argv[2] ?? 'openapi.json'; +const { $schema: _jsonSchemaDialect, ...discoverFeedSchema } = z.toJSONSchema(FeedSchema, { + target: 'draft-2020-12', +}); + +const feedResponse = (description: string) => ({ + description, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/DiscoverFeed', + }, + }, + }, +}); + +const document = { + openapi: '3.1.1', + info: { + title: 'DWithEase Discover Feed API', + version: '2.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: [ + { + name: 'Feeds', + description: 'Read-only JSON feeds for Discover clients.', + }, + ], + paths: { + '/feed-live.json': { + get: { + operationId: 'getLiveDiscoverFeed', + summary: 'Get the live Discover feed', + description: 'Returns approved editorial items and active or scheduled product promotions.', + tags: ['Feeds'], + responses: { + '200': feedResponse('The public Discover feed.'), + }, + }, + }, + '/feed-dev.json': { + get: { + operationId: 'getDevelopmentDiscoverFeed', + summary: 'Get the development Discover feed', + description: 'Returns approved content, promotions, and unapproved gather candidates for review.', + tags: ['Feeds'], + responses: { + '200': feedResponse('The development Discover feed.'), + }, + }, + }, + }, + components: { + schemas: { + DiscoverFeed: { + title: 'Discover Feed', + description: 'The complete version 2 Discover document.', + ...discoverFeedSchema, + }, + }, + }, +}; + +await writeFile(outputPath, `${JSON.stringify(document, null, 2)}\n`); +console.log(`${outputPath}: written`); diff --git a/sources.json b/sources.json new file mode 100644 index 0000000..f20ebc3 --- /dev/null +++ b/sources.json @@ -0,0 +1,69 @@ +{ + "sources": [ + { + "name": "Agentic Storefront", + "url": "https://agenticstorefront.com/categories/agentic-commerce/", + "icon": "assets/sources/agentic-storefront.svg" + }, + { + "name": "Agentic Storefront Audit", + "url": "https://audit.agenticstorefront.com/", + "icon": "assets/sources/agentic-storefront-audit.svg" + }, + { + "name": "CatalogSpark", + "url": "https://catalogspark.com/", + "icon": "assets/sources/catalogspark.ico" + }, + { + "name": "Salesforce Developers Blog", + "url": "https://developer.salesforce.com/blogs/feed", + "icon": "assets/sources/salesforce.ico" + }, + { + "name": "ForkPoint", + "url": "https://forkpoint.com/articles/", + "icon": "assets/sources/forkpoint.svg" + }, + { + "name": "PWA Kit Releases", + "url": "https://github.com/SalesforceCommerceCloud/pwa-kit/releases.atom", + "icon": "assets/sources/github.svg" + }, + { + "name": "IntentFusion", + "url": "https://intentfusion.com/", + "icon": "assets/sources/intentfusion.ico" + }, + { + "name": "RetailPace", + "url": "https://retailpace.com/", + "icon": "assets/sources/retailpace.ico" + }, + { + "name": "Rhino Inquisitor", + "url": "https://rhino-inquisitor.com/archive/", + "icon": "assets/sources/rhino-inquisitor.svg" + }, + { + "name": "Digital Commerce 360", + "url": "https://www.digitalcommerce360.com/topic/technology/", + "icon": "assets/sources/digital-commerce-360.ico" + }, + { + "name": "Salesforce Commerce Blog", + "url": "https://www.salesforce.com/blog/category/ecommerce/", + "icon": "assets/sources/salesforce.ico" + }, + { + "name": "Salesforce News", + "url": "https://www.salesforce.com/news/topics/commerce/", + "icon": "assets/sources/salesforce.ico" + }, + { + "name": "Salesforce Ben", + "url": "https://www.salesforceben.com/category/commerce/", + "icon": "assets/sources/salesforce-ben.png" + } + ] +} diff --git a/test/browser-page.test.js b/test/browser-page.test.js new file mode 100644 index 0000000..2fcedf7 --- /dev/null +++ b/test/browser-page.test.js @@ -0,0 +1,358 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, resolve, sep } from 'node:path'; + +import { chromium } from 'playwright-core'; + +const SITE_ROOT = process.cwd(); +const LONG_TEXT = { + title: 'T'.repeat(120), + summary: 'S'.repeat(280), + source: 'N'.repeat(80), + tag: 't'.repeat(120), + cta: 'C'.repeat(32), +}; +const COMMON_ITEM = { + title: LONG_TEXT.title, + summary: LONG_TEXT.summary, + source: { + name: LONG_TEXT.source, + url: 'https://example.com/', + }, + publishedAt: '2026-09-01T00:00:00Z', + tags: [LONG_TEXT.tag], + cta: { + label: LONG_TEXT.cta, + }, +}; +const TEST_FEED = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [ + { + ...COMMON_ITEM, + id: 'long-promotion', + type: 'promotion', + url: 'https://example.com/promotion', + campaign: { + id: 'long-promotion-campaign', + startsAt: '2020-01-01T00:00:00Z', + endsAt: '2099-01-01T00:00:00Z', + placements: ['discover'], + }, + }, + { + ...COMMON_ITEM, + id: 'long-article', + type: 'article', + url: 'https://example.com/article', + }, + ], +}; + +const CONTENT_TYPES = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', +}; + +function chromeExecutable() { + const candidates = [ + process.env.CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + ].filter(Boolean); + const executable = candidates.find((candidate) => existsSync(candidate)); + assert.ok(executable, 'Chrome is required for computed-style tests'); + return executable; +} + +async function staticResponse(request, response) { + const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname); + const relativePath = pathname === '/' ? 'index.html' : pathname.slice(1); + const filePath = resolve(SITE_ROOT, relativePath); + if (filePath !== SITE_ROOT && !filePath.startsWith(`${SITE_ROOT}${sep}`)) { + response.writeHead(403).end(); + return; + } + + try { + const body = await readFile(filePath); + response.writeHead(200, { + 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream', + }); + response.end(body); + } catch { + response.writeHead(404).end(); + } +} + +function listen(server) { + return new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(0, '127.0.0.1', () => resolveListen()); + }); +} + +function close(server) { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }); +} + +function colorChannels(color) { + return color.match(/[\d.]+/g).slice(0, 3).map(Number); +} + +function relativeLuminance(color) { + const [red, green, blue] = colorChannels(color).map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(first, second) { + const luminances = [relativeLuminance(first), relativeLuminance(second)] + .sort((left, right) => right - left); + return (luminances[0] + 0.05) / (luminances[1] + 0.05); +} + +let browser; +let server; +let siteUrl; + +test.before(async () => { + server = createServer((request, response) => { + staticResponse(request, response); + }); + await listen(server); + const address = server.address(); + siteUrl = `http://127.0.0.1:${address.port}/`; + browser = await chromium.launch({ + executablePath: chromeExecutable(), + headless: true, + args: ['--no-sandbox'], + }); +}); + +test.after(async () => { + await browser?.close(); + if (server) await close(server); +}); + +async function openDiscoverPage(context) { + const browserContext = await browser.newContext({ + colorScheme: 'dark', + reducedMotion: 'reduce', + viewport: { width: 320, height: 900 }, + }); + context.after(() => browserContext.close()); + const page = await browserContext.newPage(); + await page.route('**/feed-live.json', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(TEST_FEED), + })); + await page.route('**/sources.json', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sources: [{ + name: 'Example', + url: 'https://example.com/', + icon: 'assets/sources/github.svg', + }], + }), + })); + await page.goto(siteUrl, { waitUntil: 'networkidle' }); + await page.locator('[data-item-id="long-promotion"]').waitFor(); + return page; +} + +test('computes the light theme under a dark system preference', async (context) => { + const page = await openDiscoverPage(context); + const computed = await page.evaluate(() => ({ + colorScheme: getComputedStyle(document.documentElement).colorScheme, + background: getComputedStyle(document.body).backgroundColor, + })); + + assert.equal(computed.colorScheme, 'light'); + assert.ok(relativeLuminance(computed.background) >= 0.9); +}); + +test('renders same-origin source icons at 28 by 28 pixels', async (context) => { + const page = await openDiscoverPage(context); + const icons = await page.locator('.source-icon').evaluateAll((elements) => elements.map((element) => { + const bounds = element.getBoundingClientRect(); + return { + width: bounds.width, + height: bounds.height, + source: element.currentSrc, + }; + })); + + assert.equal(icons.length, 2); + for (const icon of icons) { + assert.equal(icon.width, 28); + assert.equal(icon.height, 28); + assert.equal(new URL(icon.source).origin, new URL(siteUrl).origin); + assert.match(icon.source, /\/assets\/sources\/github\.svg$/); + } +}); + +test('renders feed text before a delayed source registry', async (context) => { + const browserContext = await browser.newContext({ viewport: { width: 320, height: 900 } }); + context.after(() => browserContext.close()); + const page = await browserContext.newPage(); + let releaseSourceRoute; + const sourceRoute = new Promise((resolveRoute) => { releaseSourceRoute = resolveRoute; }); + + await page.route('**/feed-live.json', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(TEST_FEED), + })); + await page.route('**/sources.json', (route) => releaseSourceRoute(route)); + await page.goto(siteUrl, { waitUntil: 'domcontentloaded' }); + + const card = page.locator('[data-item-id="long-promotion"]'); + await card.waitFor({ timeout: 1_000 }); + assert.match(await card.textContent(), new RegExp(LONG_TEXT.title)); + assert.match( + await card.locator('.source-icon').getAttribute('src'), + /assets\/sources\/source-fallback\.svg$/, + ); + + const route = await sourceRoute; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + sources: [{ + name: 'Example', + url: 'https://example.com/', + icon: 'assets/sources/github.svg', + }], + }), + }); + await page.waitForFunction(() => document.querySelector('.source-icon') + ?.src.endsWith('/assets/sources/github.svg')); + assert.match(await card.textContent(), new RegExp(LONG_TEXT.title)); +}); + +test('shows the OpenAPI fallback when the Scalar CDN fails', async (context) => { + const browserContext = await browser.newContext({ + colorScheme: 'dark', + viewport: { width: 320, height: 900 }, + }); + context.after(() => browserContext.close()); + const page = await browserContext.newPage(); + await page.route('https://cdn.jsdelivr.net/**', (route) => route.abort('blockedbyclient')); + + await page.goto(`${siteUrl}schema.html`, { waitUntil: 'domcontentloaded' }); + + const fallback = page.locator('[data-testid="scalar-fallback"]'); + await fallback.waitFor({ timeout: 1_000 }); + assert.match(await fallback.textContent(), /interactive reference could not load/i); + const link = fallback.getByRole('link', { name: 'Open the OpenAPI document' }); + assert.equal(await link.getAttribute('href'), 'openapi.json'); + const colors = await fallback.evaluate((element) => ({ + foreground: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + })); + assert.ok(relativeLuminance(colors.background) >= 0.9); + assert.ok(contrastRatio(colors.foreground, colors.background) >= 4.5); +}); + +test('computes WCAG AA contrast for normal and hovered action text', async (context) => { + const page = await openDiscoverPage(context); + const button = page.locator('.promotion-card .button'); + const normal = await button.evaluate((element) => ({ + color: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + })); + + await button.hover(); + await page.waitForFunction((previous) => ( + getComputedStyle(document.querySelector('.promotion-card .button')).backgroundColor + !== previous + ), normal.background); + const hovered = await button.evaluate((element) => ({ + color: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + })); + + assert.ok(contrastRatio(normal.color, normal.background) >= 4.5); + assert.ok(contrastRatio(hovered.color, hovered.background) >= 4.5); +}); + +test('wraps all accepted feed text without overflow or clamping', async (context) => { + const page = await openDiscoverPage(context); + const selectors = { + '.card-source': 2, + '.card-title': 2, + '.card-copy': 2, + '.tag': 2, + '.card-actions .button': 1, + '.card-actions .text-link': 1, + }; + + for (const [selector, expectedCount] of Object.entries(selectors)) { + const metrics = await page.locator(selector).evaluateAll((elements) => elements.map((element) => { + const style = getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + const cardBounds = element.closest('.feed-card').getBoundingClientRect(); + return { + overflowWrap: style.overflowWrap, + lineClamp: style.webkitLineClamp, + textOverflow: style.textOverflow, + whiteSpace: style.whiteSpace, + overflowX: style.overflowX, + overflowY: style.overflowY, + horizontalOverflow: element.scrollWidth - element.clientWidth, + verticalOverflow: element.scrollHeight - element.clientHeight, + leftUnderflow: cardBounds.left - bounds.left, + rightOverflow: bounds.right - cardBounds.right, + }; + })); + + assert.equal(metrics.length, expectedCount); + for (const metric of metrics) { + assert.equal(metric.overflowWrap, 'anywhere'); + assert.equal(metric.lineClamp, 'none'); + assert.equal(metric.textOverflow, 'clip'); + assert.equal(metric.whiteSpace, 'normal'); + assert.notEqual(metric.overflowX, 'hidden'); + assert.notEqual(metric.overflowY, 'hidden'); + assert.ok(metric.horizontalOverflow <= 1); + assert.ok(metric.verticalOverflow <= 1); + assert.ok(metric.leftUnderflow <= 1); + assert.ok(metric.rightOverflow <= 1); + } + } + + const pageOverflow = await page.evaluate(() => ({ + horizontal: document.documentElement.scrollWidth - document.documentElement.clientWidth, + cards: [...document.querySelectorAll('.feed-card')].map((card) => ({ + horizontal: card.scrollWidth - card.clientWidth, + vertical: card.scrollHeight - card.clientHeight, + })), + })); + assert.ok(pageOverflow.horizontal <= 1); + for (const card of pageOverflow.cards) { + assert.ok(card.horizontal <= 1); + assert.ok(card.vertical <= 1); + } +}); diff --git a/test/discover-page.test.js b/test/discover-page.test.js new file mode 100644 index 0000000..2b91efd --- /dev/null +++ b/test/discover-page.test.js @@ -0,0 +1,214 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; + +import { + renderCatalog, + renderError, + startDiscoverPage, +} from '../assets/discover-page.js'; + +function createRoot() { + const { document } = parseHTML(` + +
+ `); + return document.getElementById('discover-content'); +} + +const V2_PROMOTION = { + id: 'catalogspark-2026', + type: 'promotion', + title: 'Make product data ready', + summary: 'Prepare product data for every commerce channel.', + url: 'https://catalogspark.com/', + source: { + name: 'CatalogSpark', + url: 'https://catalogspark.com/', + }, + publishedAt: '2026-09-01T00:00:00Z', + tags: ['product-data'], + cta: { + label: 'Explore CatalogSpark', + }, + campaign: { + id: 'catalogspark-2026', + startsAt: '2026-09-01T00:00:00Z', + endsAt: '2026-10-01T00:00:00Z', + placements: ['discover'], + }, +}; + +test('renders v2 promotions and editorial items with useful metadata', () => { + const root = createRoot(); + const unsafeTitle = ''; + const article = { + id: 'commerce-news', + type: 'article', + title: unsafeTitle, + summary: 'A useful commerce update.', + url: 'https://example.com/article', + source: { + name: 'Commerce Source', + url: 'https://example.com/', + }, + publishedAt: '2026-08-30T00:00:00Z', + tags: ['sfcc', 'commerce'], + cta: { + label: 'Read article', + }, + }; + + const sources = new Map([[ + 'https://example.com/', + { icon: 'https://discover.example/assets/sources/example.svg' }, + ]]); + renderCatalog(root, { + promotions: [V2_PROMOTION], + editorial: [article], + }, sources); + + assert.deepEqual( + [...root.querySelectorAll('[data-section-title]')].map((node) => node.textContent), + ['Featured tools', 'Latest from commerce'], + ); + assert.equal(root.querySelector('[data-item-id="commerce-news"] h3').textContent, unsafeTitle); + assert.equal(root.querySelector('[data-item-id="commerce-news"] h3 img'), null); + assert.match(root.querySelector('[data-item-id="commerce-news"]').textContent, /Commerce Source/); + assert.match(root.querySelector('[data-item-id="commerce-news"]').textContent, /Aug 30, 2026/); + assert.equal( + root.querySelector('[data-item-id="commerce-news"] .source-icon').getAttribute('src'), + 'https://discover.example/assets/sources/example.svg', + ); + assert.equal( + root.querySelector('[data-item-id="catalogspark-2026"] .source-icon').getAttribute('src'), + 'assets/sources/source-fallback.svg', + ); + assert.equal( + root.querySelector('[data-item-id="commerce-news"] .source-icon').getAttribute('width'), + '28', + ); + assert.deepEqual( + [...root.querySelectorAll('[data-item-id="commerce-news"] .tag')].map((node) => node.textContent), + ['sfcc', 'commerce'], + ); + assert.equal( + root.querySelector('[data-item-id="catalogspark-2026"] a').getAttribute('href'), + 'https://catalogspark.com/', + ); +}); + +test('renders the Discover empty state', () => { + const root = createRoot(); + + renderCatalog(root, { promotions: [], editorial: [] }); + + assert.match(root.textContent, /There are no articles or tools to discover right now/); + assert.equal(root.querySelector('[data-testid="discover-empty"]').getAttribute('role'), 'status'); +}); + +test('does not render actions for raw non-RFC URLs', () => { + const root = createRoot(); + + for (const url of [ + 'https://catalogspark.com/\t', + 'https://catalogspark.com\\audit', + 'https://user@catalogspark.com/audit', + 'https://a@b@catalogspark.com/audit', + 'https://catalogspark.com:65536/audit', + ]) { + renderCatalog(root, { + promotions: [{ ...V2_PROMOTION, url }], + editorial: [], + }); + + assert.equal(root.querySelector('[data-item-id="catalogspark-2026"] a'), null); + } +}); + +test('renders a working retry action after a feed error', () => { + const root = createRoot(); + let retries = 0; + + renderError(root, () => { retries += 1; }); + root.querySelector('button').click(); + + assert.match(root.textContent, /Discover could not load/); + assert.equal(root.querySelector('.error-state').getAttribute('role'), 'alert'); + assert.equal(retries, 1); +}); + +test('loads only the selected v2 feed', async () => { + const root = createRoot(); + const requests = []; + const fetchImpl = async (url) => { + requests.push(url); + if (url === 'sources.json') { + return { + ok: true, + url: 'https://discover.example/sources.json', + json: async () => ({ + sources: [{ + name: 'CatalogSpark', + url: 'https://catalogspark.com/', + icon: 'assets/sources/catalogspark.ico', + }], + }), + }; + } + return { + ok: true, + json: async () => ({ + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [V2_PROMOTION], + }), + }; + }; + + await startDiscoverPage({ + root, + search: '?feed=dev', + fetchImpl, + now: Date.parse('2026-09-10T12:00:00Z'), + pageUrl: 'https://discover.example/', + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(requests, ['feed-dev.json', 'sources.json']); + assert.ok(root.querySelector('[data-item-id="catalogspark-2026"]')); + assert.equal( + root.querySelector('[data-item-id="catalogspark-2026"] .source-icon').getAttribute('src'), + 'https://discover.example/assets/sources/catalogspark.ico', + ); +}); + +test('keeps feed text when the source registry fails', async () => { + const root = createRoot(); + const fetchImpl = async (url) => { + if (url === 'sources.json') throw new Error('Source registry unavailable'); + return { + ok: true, + json: async () => ({ + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [V2_PROMOTION], + }), + }; + }; + + await startDiscoverPage({ + root, + fetchImpl, + now: Date.parse('2026-09-10T12:00:00Z'), + pageUrl: 'https://discover.example/', + }); + + assert.match(root.textContent, /Make product data ready/); + assert.equal( + root.querySelector('.source-icon').getAttribute('src'), + 'assets/sources/source-fallback.svg', + ); +}); diff --git a/test/feed-model.test.js b/test/feed-model.test.js new file mode 100644 index 0000000..ad396cb --- /dev/null +++ b/test/feed-model.test.js @@ -0,0 +1,427 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + buildCatalog, + isHttpsUrl, + selectFeedName, +} from '../assets/feed-model.js'; +import { validateFeed } from '../scripts/feed-schema.ts'; + +const NOW = Date.parse('2026-09-10T12:00:00Z'); + +const V2_PROMOTION = { + id: 'catalogspark-2026', + type: 'promotion', + title: 'Make product data ready', + summary: 'Prepare product data for every commerce channel.', + url: 'https://catalogspark.com/', + source: { + name: 'CatalogSpark', + url: 'https://catalogspark.com/', + }, + publishedAt: '2026-09-01T02:30:00+02:30', + tags: ['product-data'], + cta: { + label: 'Explore CatalogSpark', + }, + campaign: { + id: 'catalogspark-2026', + startsAt: '2026-09-01T02:30:00+02:30', + endsAt: '2026-10-01T02:30:00+02:30', + placements: ['discover'], + }, +}; + +test('selects the live feed unless the query asks for the dev feed', () => { + assert.equal(selectFeedName(''), 'live'); + assert.equal(selectFeedName('?feed=dev'), 'dev'); + assert.equal(selectFeedName('?feed=other'), 'live'); +}); + +test('builds the v2 catalog with schema-valid offsets and sorted editorial items', () => { + const olderArticle = { + id: 'older-article', + type: 'article', + title: 'Older article', + summary: 'An older commerce article.', + url: 'https://example.com/older', + source: { + name: 'Example', + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T02:30:00+02:30', + tags: ['commerce'], + cta: { + label: 'Read article', + }, + }; + const newerNews = { + ...olderArticle, + id: 'newer-news', + type: 'news', + title: 'Newer news', + url: 'https://example.com/newer', + publishedAt: '2026-08-10T08:00:00-04:00', + }; + + const catalog = buildCatalog({ + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [olderArticle, V2_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', () => { + const validFeed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [V2_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 }] }, + ]; + + for (const feed of invalidFeeds) { + assert.equal(validateFeed(feed).success, false); + assert.deepEqual(buildCatalog(feed, NOW), { promotions: [], editorial: [] }); + } +}); + +test('enforces raw v2 text limits in Zod and the browser catalog', () => { + const article = { + id: 'raw-text-limits', + type: 'article', + title: 'T'.repeat(120), + summary: 'S'.repeat(280), + url: 'https://example.com/raw-text-limits', + source: { + name: 'N'.repeat(80), + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['commerce'], + image: { + src: 'assets/example.png', + alt: 'A'.repeat(160), + }, + cta: { + label: 'C'.repeat(32), + }, + }; + const validFeed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [article], + }; + + assert.equal(validateFeed(validFeed).success, true); + assert.deepEqual( + buildCatalog(validFeed, NOW).editorial.map(({ id }) => id), + ['raw-text-limits'], + ); + + const overLimitItems = [ + { ...article, title: ` ${article.title}` }, + { ...article, summary: ` ${article.summary}` }, + { ...article, source: { ...article.source, name: ` ${article.source.name}` } }, + { ...article, cta: { label: ` ${article.cta.label}` } }, + { ...article, image: { ...article.image, alt: ` ${article.image.alt}` } }, + ]; + + for (const item of overLimitItems) { + const feed = { ...validFeed, items: [item] }; + assert.equal(validateFeed(feed).success, false); + assert.deepEqual(buildCatalog(feed, NOW).editorial, []); + } +}); + +test('counts Unicode code points for browser text limits', () => { + const article = { + id: 'unicode-text-limits', + type: 'article', + title: '😀'.repeat(120), + summary: '😀'.repeat(280), + url: 'https://example.com/unicode-text-limits', + source: { + name: '😀'.repeat(80), + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['commerce'], + image: { + src: 'assets/example.png', + alt: '😀'.repeat(160), + }, + cta: { + label: '😀'.repeat(32), + }, + }; + const feed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [article], + }; + + assert.equal(validateFeed(feed).success, true); + assert.deepEqual( + buildCatalog(feed, NOW).editorial.map(({ id }) => id), + ['unicode-text-limits'], + ); +}); + +test('requires raw RFC 3986 lowercase HTTPS URLs across feed validators', () => { + const article = { + id: 'lowercase-https', + type: 'article', + title: 'Lowercase HTTPS article', + summary: 'Every remote URL starts with the required lowercase prefix.', + url: 'https://example.com/article', + source: { + name: 'Example', + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['commerce'], + image: { + src: 'https://example.com/image.png', + alt: 'Example commerce article', + }, + cta: { + label: 'Read article', + }, + }; + const validFeed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [article], + }; + + assert.equal(validateFeed(validFeed).success, true); + assert.deepEqual( + buildCatalog(validFeed, NOW).editorial.map(({ id }) => id), + ['lowercase-https'], + ); + + for (const url of [ + 'https://example.com:8443/article', + 'https://[2001:db8::1]:443/article', + ]) { + const feed = { ...validFeed, items: [{ ...article, url }] }; + assert.equal(validateFeed(feed).success, true); + assert.deepEqual( + buildCatalog(feed, NOW).editorial.map(({ id }) => id), + ['lowercase-https'], + ); + } + + const invalidItems = [ + { ...article, url: 'HTTPS://example.com/article' }, + { ...article, source: { ...article.source, url: 'HTTPS://example.com/' } }, + { ...article, image: { ...article.image, src: 'HTTPS://example.com/image.png' } }, + { ...article, url: 'https://example.com/article\t' }, + { ...article, source: { ...article.source, url: 'https://example.com/\n' } }, + { ...article, image: { ...article.image, src: 'https://example.com/image.png ' } }, + { ...article, url: 'https://example.com\\article' }, + { ...article, source: { ...article.source, url: 'https://example.com/%zz' } }, + { ...article, image: { ...article.image, src: 'https://example.com/image{1}.png' } }, + { ...article, url: 'https://例え.テスト/article' }, + { ...article, url: 'https://example.com/#first#second' }, + { ...article, url: 'https://user@example.com/article' }, + { ...article, url: 'https://a@b@example.com/article' }, + { ...article, source: { ...article.source, url: 'https://example.com:/' } }, + { ...article, image: { ...article.image, src: 'https://example.com:65536/image.png' } }, + ]; + + for (const item of invalidItems) { + const feed = { ...validFeed, items: [item] }; + assert.equal(validateFeed(feed).success, false); + assert.deepEqual(buildCatalog(feed, NOW).editorial, []); + } +}); + +test('keeps web host validation aligned across feed contracts', () => { + const article = { + id: 'host-validation', + type: 'article', + title: 'Host validation', + summary: 'Checks the shared web host contract.', + url: 'https://example.com/', + source: { + name: 'Example', + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['commerce'], + cta: { + label: 'Read article', + }, + }; + const jsonSchema = JSON.parse(readFileSync('feed.schema.json', 'utf8')); + const openapi = JSON.parse(readFileSync('openapi.json', 'utf8')); + const contractPatterns = { + jsonSchema: new RegExp(jsonSchema.properties.items.items.oneOf[0].properties.url.pattern), + openapi: new RegExp(openapi.components.schemas.DiscoverFeed.properties.items.items.oneOf[0].properties.url.pattern), + }; + const cases = [ + ['https://example.com/path', true], + ['https://plain-ascii.example/path', true], + ['https://sub-domain.example.com:8443/path', true], + ['https://localhost:3000/path', true], + ['https://127.0.0.1/path', true], + ['https://[2001:db8::1]:443/path', true], + ['https://[::ffff:192.0.2.128]/path', true], + ['https://[v1.foo]/', false], + ['https://%23/', false], + ['https://256.0.0.1/', false], + ['https://[2001:::1]/', false], + ['https://-example.com/', false], + ['https://example-.com/', false], + ['https://xn--a/', false], + ['https://xn--bcher-kva.example/', false], + ['https://www.xn--a.example/', false], + ['https://XN--a.example/', false], + ]; + + for (const [url, accepted] of cases) { + const feed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [{ ...article, url }], + }; + assert.equal(validateFeed(feed).success, accepted, `${url} Zod`); + assert.equal(isHttpsUrl(url), accepted, `${url} browser`); + for (const [contract, pattern] of Object.entries(contractPatterns)) { + assert.equal(pattern.test(url), accepted, `${url} ${contract}`); + } + } +}); + +test('rejects final line breaks across regex-backed public fields', () => { + const article = { + id: 'strict-end', + type: 'article', + title: 'Strict end checks', + summary: 'Rejects raw line breaks at contract boundaries.', + url: 'https://example.com/article', + source: { + name: 'Example', + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['commerce'], + cta: { + label: 'Read article', + }, + }; + const validFeed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [article], + }; + const jsonSchema = JSON.parse(readFileSync('feed.schema.json', 'utf8')); + const openapi = JSON.parse(readFileSync('openapi.json', 'utf8')); + const jsonArticle = jsonSchema.properties.items.items.oneOf[0]; + const openapiArticle = openapi.components.schemas.DiscoverFeed.properties.items.items.oneOf[0]; + const fields = [ + { + name: 'URL', + value: article.url, + makeFeed: (value) => ({ ...validFeed, items: [{ ...article, url: value }] }), + patterns: [jsonArticle.properties.url.pattern, openapiArticle.properties.url.pattern], + }, + { + name: 'slug', + value: article.id, + makeFeed: (value) => ({ ...validFeed, items: [{ ...article, id: value }] }), + patterns: [jsonArticle.properties.id.pattern, openapiArticle.properties.id.pattern], + }, + { + name: 'locale', + value: validFeed.locale, + makeFeed: (value) => ({ ...validFeed, locale: value }), + patterns: [jsonSchema.properties.locale.pattern, openapi.components.schemas.DiscoverFeed.properties.locale.pattern], + }, + { + name: 'date-time', + value: article.publishedAt, + makeFeed: (value) => ({ ...validFeed, items: [{ ...article, publishedAt: value }] }), + patterns: [jsonArticle.properties.publishedAt.pattern, openapiArticle.properties.publishedAt.pattern], + }, + ]; + + for (const lineBreak of ['\n', '\r\n']) { + for (const field of fields) { + const value = `${field.value}${lineBreak}`; + const feed = field.makeFeed(value); + const caseName = `${field.name} with ${JSON.stringify(lineBreak)}`; + assert.equal(validateFeed(feed).success, false, `${caseName} Zod`); + assert.deepEqual(buildCatalog(feed, NOW).editorial, [], `${caseName} browser`); + for (const pattern of field.patterns) { + assert.equal(new RegExp(pattern).test(value), false, `${caseName} public contract`); + } + } + } +}); + +test('renders schema-valid reserved-word slugs', () => { + const article = { + id: 'constructor', + type: 'article', + title: 'Reserved slug article', + summary: 'Reserved JavaScript names remain valid feed slugs.', + url: 'https://example.com/constructor', + source: { + name: 'Example', + url: 'https://example.com/', + }, + publishedAt: '2026-08-01T00:00:00Z', + tags: ['prototype'], + cta: { + label: 'Read article', + }, + }; + const promotion = { + ...V2_PROMOTION, + id: 'prototype', + tags: ['constructor'], + campaign: { + ...V2_PROMOTION.campaign, + id: 'constructor', + }, + }; + const feed = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T00:00:00Z', + items: [article, promotion], + }; + + assert.equal(validateFeed(feed).success, true); + const catalog = buildCatalog(feed, NOW); + assert.deepEqual(catalog.editorial.map(({ id }) => id), ['constructor']); + assert.deepEqual(catalog.promotions.map(({ id }) => id), ['prototype']); +}); + +test('returns an empty v2 catalog for invalid payloads', () => { + assert.deepEqual(buildCatalog({ items: [] }, NOW), { + promotions: [], + editorial: [], + }); +}); diff --git a/test/feed-schema.test.ts b/test/feed-schema.test.ts new file mode 100644 index 0000000..aedfea8 --- /dev/null +++ b/test/feed-schema.test.ts @@ -0,0 +1,167 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { validateFeed } from '../scripts/feed-schema.ts'; + +const ARTICLE = { + id: 'b2c-commerce-cli', + type: 'article', + title: 'Introducing the B2C Commerce CLI', + summary: 'Use supported tools for common B2C Commerce development tasks.', + url: 'https://developer.salesforce.com/blogs/2026/04/introducing-the-b2c-commerce-cli', + source: { + name: 'Salesforce Developers Blog', + url: 'https://developer.salesforce.com/blogs', + }, + publishedAt: '2026-04-08T09:00:00Z', + tags: ['salesforce', 'b2c-commerce'], + image: { + src: 'https://developer.salesforce.com/example.png', + alt: 'B2C Commerce command-line interface', + }, + cta: { + label: 'Read article', + }, +}; + +const PROMOTION = { + id: 'agentic-storefront-audit', + type: 'promotion', + title: 'Check your agentic storefront', + summary: 'Run 199 checks and get a storefront readiness report.', + url: 'https://audit.agenticstorefront.com/', + source: { + name: 'Agentic Storefront', + url: 'https://agenticstorefront.com/', + }, + publishedAt: '2026-09-01T00:00:00Z', + tags: ['agentic-commerce', 'storefront-audit'], + cta: { + label: 'Run free audit', + }, + campaign: { + id: 'agentic-storefront-audit-2026', + startsAt: '2026-09-01T00:00:00Z', + endsAt: '2026-10-01T00:00:00Z', + placements: ['discover', 'task-end'], + }, +}; + +const VALID_FEED = { + version: 2, + locale: 'en', + updatedAt: '2026-09-01T12:00:00Z', + items: [ARTICLE, PROMOTION], +}; + +test('accepts the version 2 article and promotion contract', () => { + const result = validateFeed(VALID_FEED); + + assert.equal(result.success, true); +}); + +test('rejects unsafe URLs, duplicate IDs, and invalid campaign dates', () => { + const result = validateFeed({ + version: 2, + locale: 'en', + updatedAt: '2026-09-01T12:00:00Z', + items: [ + ARTICLE, + { + ...PROMOTION, + id: ARTICLE.id, + url: 'http://audit.agenticstorefront.com/', + campaign: { + ...PROMOTION.campaign, + endsAt: PROMOTION.campaign.startsAt, + }, + }, + ], + }); + + assert.equal(result.success, false); + assert.deepEqual( + result.errors.map(({ path }) => path), + ['items.1.url', 'items.1.campaign.endsAt', 'items.1.id'], + ); +}); + +test('checks a valid feed file from the command line', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'discover-feed-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const feedPath = join(directory, 'valid.json'); + await writeFile(feedPath, JSON.stringify(VALID_FEED)); + + const result = spawnSync(process.execPath, ['scripts/validate-feed.ts', feedPath], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /valid\.json: valid/); +}); + +test('reports bad feed fields from the command line', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'discover-feed-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const feedPath = join(directory, 'invalid.json'); + await writeFile(feedPath, JSON.stringify({ ...VALID_FEED, updatedAt: 'not-a-date' })); + + const result = spawnSync(process.execPath, ['scripts/validate-feed.ts', feedPath], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /invalid\.json: updatedAt: Invalid string/); +}); + +test('writes the public JSON Schema from the Zod contract', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'discover-schema-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const schemaPath = join(directory, 'feed.schema.json'); + + const result = spawnSync(process.execPath, ['scripts/write-json-schema.ts', schemaPath], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema'); + assert.equal(schema.$id, 'https://discover.dwithease.com/feed.schema.json'); + assert.equal(schema.title, 'DWithEase Discover Feed'); + assert.equal(schema.properties.version.const, 2); + const article = schema.properties.items.items.oneOf[0]; + const urlPattern = new RegExp(article.properties.url.pattern); + assert.equal(article.properties.url.format, 'uri'); + assert.equal(urlPattern.test('https://example.com/path?item=1#details'), true); + assert.equal(urlPattern.test('https://example.com:8443/path'), true); + assert.equal(urlPattern.test('https://[2001:db8::1]:443/path'), true); + assert.equal(urlPattern.test('https://example.com\\path'), false); + assert.equal(urlPattern.test('https://example.com/%zz'), false); + assert.equal(urlPattern.test('https://例え.テスト/path'), false); + assert.equal(urlPattern.test('https://user@example.com/path'), false); + assert.equal(urlPattern.test('https://a@b@example.com/path'), false); + assert.equal(urlPattern.test('https://example.com:/path'), false); + assert.equal(urlPattern.test('https://example.com:65536/path'), false); + assert.equal(article.properties.source.properties.url.pattern, article.properties.url.pattern); + assert.equal(article.properties.tags.uniqueItems, true); + assert.equal(article.properties.title.maxLength, 120); + assert.equal(article.properties.summary.maxLength, 280); + assert.equal(article.properties.source.properties.name.maxLength, 80); + assert.equal(article.properties.cta.properties.label.maxLength, 32); + assert.equal(article.properties.image.properties.alt.maxLength, 160); + assert.equal(article.properties.title.pattern, '\\S'); + assert.equal(article.properties.summary.pattern, '\\S'); + assert.equal(article.properties.source.properties.name.pattern, '\\S'); + assert.equal(article.properties.cta.properties.label.pattern, '\\S'); + assert.deepEqual( + article.properties.image.properties.src.anyOf.map(({ pattern }: { pattern?: string }) => pattern), + [article.properties.url.pattern, '^assets\\/'], + ); +}); diff --git a/test/schema-docs.test.js b/test/schema-docs.test.js new file mode 100644 index 0000000..b3eb276 --- /dev/null +++ b/test/schema-docs.test.js @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseHTML } from 'linkedom'; + +import { validateFeed } from '../scripts/feed-schema.ts'; + +test('writes an OpenAPI 3.1 contract for both Discover feeds', async (context) => { + const directory = await mkdtemp(join(tmpdir(), 'discover-openapi-')); + context.after(() => rm(directory, { recursive: true, force: true })); + const outputPath = join(directory, 'openapi.json'); + + const result = spawnSync(process.execPath, ['scripts/write-openapi.ts', outputPath], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + const document = JSON.parse(await readFile(outputPath, 'utf8')); + assert.equal(document.openapi, '3.1.1'); + assert.equal(document.info.title, 'DWithEase Discover Feed API'); + assert.equal(document.servers, undefined); + assert.equal( + document.paths['/feed-live.json'].get.responses['200'].content['application/json'].schema.$ref, + '#/components/schemas/DiscoverFeed', + ); + assert.equal( + document.paths['/feed-dev.json'].get.responses['200'].content['application/json'].schema.$ref, + '#/components/schemas/DiscoverFeed', + ); + assert.equal(document.components.schemas.DiscoverFeed.properties.version.const, 2); + const article = document.components.schemas.DiscoverFeed.properties.items.items.oneOf[0]; + const urlPattern = new RegExp(article.properties.url.pattern); + assert.equal(article.properties.url.format, 'uri'); + assert.equal(urlPattern.test('https://example.com/path?item=1#details'), true); + assert.equal(urlPattern.test('https://example.com:8443/path'), true); + assert.equal(urlPattern.test('https://[2001:db8::1]:443/path'), true); + assert.equal(urlPattern.test('https://example.com\\path'), false); + assert.equal(urlPattern.test('https://example.com/%zz'), false); + assert.equal(urlPattern.test('https://例え.テスト/path'), false); + assert.equal(urlPattern.test('https://user@example.com/path'), false); + assert.equal(urlPattern.test('https://a@b@example.com/path'), false); + assert.equal(urlPattern.test('https://example.com:/path'), false); + assert.equal(urlPattern.test('https://example.com:65536/path'), false); + assert.equal(article.properties.source.properties.url.pattern, article.properties.url.pattern); + assert.equal(article.properties.title.maxLength, 120); + assert.equal(article.properties.summary.maxLength, 280); + assert.equal(article.properties.source.properties.name.maxLength, 80); + assert.equal(article.properties.cta.properties.label.maxLength, 32); + assert.equal(article.properties.image.properties.alt.maxLength, 160); + assert.equal(article.properties.title.pattern, '\\S'); + assert.equal(article.properties.summary.pattern, '\\S'); + assert.equal(article.properties.source.properties.name.pattern, '\\S'); + assert.equal(article.properties.cta.properties.label.pattern, '\\S'); + assert.deepEqual( + article.properties.image.properties.src.anyOf.map(({ pattern }) => pattern), + [article.properties.url.pattern, '^assets\\/'], + ); +}); + +test('publishes both documented endpoints as v2 feeds', async () => { + const live = JSON.parse(await readFile('feed-live.json', 'utf8')); + const development = JSON.parse(await readFile('feed-dev.json', 'utf8')); + + assert.equal(validateFeed(live).success, true); + assert.equal(validateFeed(development).success, true); + assert.deepEqual(live.items, []); + assert.ok(development.items.length > 0); +}); + +test('configures the pinned Scalar viewer for the generated contract', async () => { + const html = await readFile('schema.html', 'utf8'); + const { document, window } = parseHTML(html); + const externalScript = document.querySelector('script[src]'); + const setupScript = document.querySelector('script:not([src])'); + let call; + const Scalar = { + createApiReference(selector, config) { + call = { selector, config }; + }, + }; + + assert.equal(document.title, 'Discover Feed Reference · DWithEase'); + assert.ok(document.querySelector('#app')); + assert.equal( + externalScript?.getAttribute('src'), + 'https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.67.0', + ); + window.Scalar = Scalar; + Function('window', 'document', setupScript?.textContent ?? '')(window, document); + assert.equal(call?.selector, '#app'); + assert.equal(call?.config.url, 'openapi.json'); + assert.equal(call?.config.showDeveloperTools, 'never'); + assert.equal(call?.config.forceDarkModeState, 'light'); + assert.equal(call?.config.hideDarkModeToggle, true); + assert.equal(call?.config.hideClientButton, true); + assert.equal(call?.config.hideTestRequestButton, true); + assert.equal(call?.config.hiddenClients, true); + assert.equal(call?.config.documentDownloadType, 'none'); + assert.equal(call?.config.withDefaultFonts, false); + assert.equal(call?.config.telemetry, false); + assert.deepEqual(call?.config.agent, { disabled: true }); + assert.deepEqual(call?.config.mcp, { disabled: true }); + assert.equal(call?.config.modelsSectionLabel, 'Schemas'); +}); + +test('links the public Discover footer to the Scalar reference', async () => { + const html = await readFile('index.html', 'utf8'); + const { document } = parseHTML(html); + const link = document.querySelector('.site-footer a[href="schema.html"]'); + + assert.equal(link?.textContent, 'Feed reference'); +}); diff --git a/test/source-registry.test.js b/test/source-registry.test.js new file mode 100644 index 0000000..5091d0c --- /dev/null +++ b/test/source-registry.test.js @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { access, readFile } from 'node:fs/promises'; + +import { buildSourceRegistry } from '../assets/feed-model.js'; + +const PAGE_URL = 'https://discover.dwithease.com/'; +const REGISTRY_URL = `${PAGE_URL}sources.json`; + +test('validates the published source registry from the command line', () => { + const result = spawnSync(process.execPath, ['scripts/validate-sources.js'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /sources\.json: valid/); +}); + +test('covers each feed source with a local checked-in icon', async () => { + const rawRegistry = JSON.parse(await readFile('sources.json', 'utf8')); + const feed = JSON.parse(await readFile('feed-dev.json', 'utf8')); + const registry = buildSourceRegistry(rawRegistry, REGISTRY_URL, PAGE_URL); + const feedSources = new Map(feed.items.map(({ source }) => [source.url, source.name])); + + assert.equal(rawRegistry.sources.length, feedSources.size); + assert.deepEqual([...registry.keys()].sort(), [...feedSources.keys()].sort()); + + for (const source of rawRegistry.sources) { + assert.equal(source.name, feedSources.get(source.url)); + assert.equal(registry.get(source.url).icon, new URL(source.icon, REGISTRY_URL).href); + await access(source.icon); + } + + await access('assets/sources/source-fallback.svg'); +}); + +test('resolves safe icon paths from the registry response URL', () => { + const registry = buildSourceRegistry({ + sources: [{ + name: 'Example', + url: 'https://example.com/news/', + icon: 'assets/sources/example.svg', + }], + }, 'https://discover.example/catalog/sources.json', 'https://discover.example/'); + + assert.equal( + registry.get('https://example.com/news/').icon, + 'https://discover.example/catalog/assets/sources/example.svg', + ); +}); + +test('rejects unsafe source registries', () => { + const source = { + name: 'Example', + url: 'https://example.com/news/', + icon: 'assets/sources/example.svg', + }; + + for (const icon of [ + '../example.svg', + '/assets/sources/example.svg', + 'assets/sources/../example.svg', + 'assets/sources/example.svg?remote=1', + 'https://example.com/icon.svg', + ]) { + assert.throws(() => buildSourceRegistry({ + sources: [{ ...source, icon }], + }, REGISTRY_URL, PAGE_URL), /Invalid source registry entry/); + } + + assert.throws(() => buildSourceRegistry({ + sources: [{ ...source, extra: true }], + }, REGISTRY_URL, PAGE_URL), /Invalid source registry entry/); + assert.throws(() => buildSourceRegistry({ + sources: [source, { ...source }], + }, REGISTRY_URL, PAGE_URL), /Invalid source registry entry/); + assert.throws(() => buildSourceRegistry({ sources: [source] }, + 'https://other.example/sources.json', PAGE_URL), /page origin/); +}); diff --git a/test/workflow.test.js b/test/workflow.test.js new file mode 100644 index 0000000..f5cc8db --- /dev/null +++ b/test/workflow.test.js @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +import { parse } from 'yaml'; + +function commands(job) { + return job.steps.flatMap(({ run }) => (typeof run === 'string' + ? run.split('\n').map((command) => command.trim()).filter(Boolean) + : [])); +} + +function actions(job) { + return job.steps.flatMap(({ uses }) => (uses ? [uses] : [])); +} + +test('validates pull requests and deploys only main pushes', async () => { + const workflow = parse(await readFile('.github/workflows/pages.yml', 'utf8')); + + assert.deepEqual(workflow.on.push.branches, ['main']); + assert.deepEqual(workflow.on.pull_request.branches, ['main']); + assert.ok(Object.hasOwn(workflow.on, 'workflow_dispatch')); + assert.deepEqual(workflow.permissions, { contents: 'read' }); + assert.deepEqual(workflow.concurrency, { + group: 'discover-${{ github.event_name }}-${{ github.ref }}', + 'cancel-in-progress': true, + }); + + assert.deepEqual(actions(workflow.jobs.validate), [ + 'actions/checkout@v6', + 'actions/setup-node@v6', + 'actions/upload-pages-artifact@v4', + ]); + assert.deepEqual(commands(workflow.jobs.validate), [ + 'npm ci', + 'npm run write:schema', + 'npm run write:openapi', + 'npm test', + 'npm run typecheck', + 'npm run validate:feed -- feed-live.json feed-dev.json', + 'npm run validate:sources', + 'mkdir _site', + 'cp index.html schema.html openapi.json feed.schema.json sources.json CNAME .nojekyll feed-live.json feed-dev.json _site/', + 'cp -R assets _site/assets', + ]); + + const deploy = workflow.jobs.deploy; + assert.equal(deploy.needs, 'validate'); + assert.equal( + deploy.if, + "${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}", + ); + assert.deepEqual(deploy.permissions, { + contents: 'read', + pages: 'write', + 'id-token': 'write', + }); + assert.deepEqual(actions(deploy), [ + 'actions/configure-pages@v5', + 'actions/deploy-pages@v4', + ]); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d0493af --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "forceConsistentCasingInFileNames": true, + "module": "nodenext", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "strict": true, + "target": "es2024", + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "test/**/*.ts"] +}