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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ If a cross-repo behavior changed but upstream is not updated yet, document the g

- The browse UI is a **thin client**: fetch catalog JSON from the active instance, render rows client-side, build RSS links from each entry's `path`.
- Do not reintroduce `bin/data-update`, `src/data/configs.json`, or a `html2rss-configs` gem dependency in this repo.
- Wire shape v1 is defined in `html2rss-web` request specs and OpenAPI (`catalog_version`, `parameters.schema`, `parameters.defaults`).
- Wire shape v2 is defined in `html2rss-web` request specs and OpenAPI (`catalog_version: 2`, required `last_result`, `meta.starters`). The browse client supports **`[2]` only** and fails closed on v1.
- When the instance is unreachable or returns `404` with `catalog_disabled`, show an error state — no static fallback list.
- **Wire parsing only in** `src/components/feed-directory/adapters/catalog-api.ts`. Domain modules must not parse API envelopes or wire rows.
- See `CONTEXT.md` for glossary (`FeedDirectoryEntry`, catalog seam, instance persistence contract).
Expand Down
20 changes: 17 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,30 @@ Normalized domain type for one catalog row after wire parsing. Required fields o
| `title`, `summary`, `topics` | Directory metadata from YAML |
| `channelUrl`, `language` | Channel metadata |
| `parameterSchema`, `parameterDefaults` | Dynamic feed parameters |
| `lastResult` | Instance last-known scrape outcome (see below) |

## LastResult

Required ambient signal from catalog_version **2**. Closed set of `state` values only — do not invent green/yellow/red domain enums; map state to UI chrome in `ui/`.

| `state` | Meaning | Browse UX |
| --------- | ------------------------------------------------------------ | -------------------------------------------- |
| `ok` | Last directory-defaults scrape succeeded | Ambient “Last scrape ok” indicator |
| `empty` | Last scrape returned no items | Demote in sort; warn before subscribe; badge |
| `error` | Last scrape failed | Demote in sort; warn before subscribe; badge |
| `unknown` | Never scraped with directory defaults on this process (cold) | Neutral — no badge; sorts with non-failing |

Wire fields: `code` (string \| null), `at` (ISO timestamp \| null). Missing or invalid `last_result` on a row fails closed (row dropped).

## Catalog seam

The boundary between the instance API and domain logic:

- **Wire:** `GET /api/v1/configs` envelope (`success`, `data.configs`, `meta.catalog_version`)
- **Adapter:** `adapters/catalog-api.ts` — fetch, envelope validation, row validation, version gate (supported: `[1]`)
- **Wire:** `GET /api/v1/configs` envelope (`success`, `data.configs`, `meta.catalog_version`, `meta.starters`)
- **Adapter:** `adapters/catalog-api.ts` — fetch, envelope validation, row validation, version gate (supported: **`[2]` only**; v1 fail closed)
- **Domain:** `FeedDirectoryEntry[]` consumed by filters, OPML build, and render

Wire parsing must stay in `adapters/catalog-api.ts` only.
Wire parsing must stay in `adapters/catalog-api.ts` only. `meta.starters` is parsed for forward compatibility; browse does not render a featured strip today.

## Instance persistence contract

Expand Down
77 changes: 72 additions & 5 deletions src/components/feed-directory/adapters/catalog-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,63 @@ const validEnvelope = {
channel: { url: 'https://www.anthropic.com/news', language: 'en' },
directory: { title: 'Anthropic — News', summary: 'Announcements.', topics: ['news'] },
parameters: { schema: {}, defaults: {} },
last_result: { state: 'ok', code: null, at: '2026-08-29T08:00:00Z' },
},
{
id: 'bbc.co.uk/available_episodes',
path: '/bbc.co.uk/available_episodes.rss',
channel: { url: 'https://www.bbc.co.uk/programmes/%<id>s/episodes/player', language: 'en-GB' },
directory: { title: 'BBC Sounds — Programme episodes', summary: 'Episodes.', topics: ['media'] },
parameters: { schema: { id: { type: 'string' } }, defaults: { id: 'b006wkfp' } },
last_result: { state: 'unknown', code: null, at: null },
},
{
id: 'example.com/broken-scrape',
path: '/example.com/broken-scrape.rss',
channel: { url: 'https://example.com/broken', language: 'en' },
directory: { title: 'Broken', summary: '', topics: [] },
parameters: { schema: {}, defaults: {} },
last_result: { state: 'error', code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:00:00Z' },
},
{
id: 'example.com/empty-scrape',
path: '/example.com/empty-scrape.rss',
channel: { url: 'https://example.com/empty', language: 'en' },
directory: { title: 'Empty', summary: '', topics: [] },
parameters: { schema: {}, defaults: {} },
last_result: { state: 'empty', code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:30:00Z' },
},
{ id: 'broken' },
{
id: 'missing.last/result',
path: '/missing.last/result.rss',
channel: { url: 'https://missing.example/', language: 'en' },
directory: { title: 'Missing last_result', summary: '', topics: [] },
parameters: { schema: {}, defaults: {} },
},
{
id: 'invalid.last/result',
path: '/invalid.last/result.rss',
channel: { url: 'https://invalid.example/', language: 'en' },
directory: { title: 'Invalid last_result', summary: '', topics: [] },
parameters: { schema: {}, defaults: {} },
last_result: { state: 'green', code: null, at: null },
},
],
},
meta: { total: 2, catalog_version: 1 },
meta: {
total: 4,
catalog_version: 2,
starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'],
},
};

function mockFetch(response: Partial<Response> & Pick<Response, 'status'>): typeof fetch {
return (async () => response) as typeof fetch;
}

describe('fetchCatalogResponse', () => {
it('maps valid envelope rows and drops invalid ones', async () => {
it('maps valid v2 envelope rows and drops invalid ones', async () => {
const fetchImpl = mockFetch({
ok: true,
status: 200,
Expand All @@ -45,16 +82,32 @@ describe('fetchCatalogResponse', () => {

const { entries, meta } = await fetchCatalogResponse('https://example.test/', fetchImpl);

expect(entries).toHaveLength(2);
expect(entries).toHaveLength(4);
expect(entries[0]).toMatchObject({
id: 'anthropic.com/news',
siteKey: 'anthropic.com',
title: 'Anthropic — News',
topics: ['news'],
language: 'en',
lastResult: { state: 'ok', code: null, at: '2026-08-29T08:00:00Z' },
});
expect(entries[1]?.parameterDefaults).toEqual({ id: 'b006wkfp' });
expect(meta).toEqual({ total: 2, catalogVersion: 1 });
expect(entries[1]?.lastResult).toEqual({ state: 'unknown', code: null, at: null });
expect(entries[2]?.lastResult).toEqual({
state: 'error',
code: 'EXTRACTION_EMPTY',
at: '2026-08-29T09:00:00Z',
});
expect(entries[3]?.lastResult).toEqual({
state: 'empty',
code: 'EXTRACTION_EMPTY',
at: '2026-08-29T09:30:00Z',
});
expect(meta).toEqual({
total: 4,
catalogVersion: 2,
starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'],
});
});

it('throws disabled on 404', async () => {
Expand All @@ -75,13 +128,27 @@ describe('fetchCatalogResponse', () => {
);
});

it('throws unsupported version for catalog_version 1 (fail closed)', async () => {
const fetchImpl = mockFetch({
ok: true,
status: 200,
json: async () => ({
...validEnvelope,
meta: { total: 3, catalog_version: 1, starters: [] },
}),
} as Response);
await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf(
CatalogUnsupportedVersionError
);
});

it('throws unsupported version when catalog_version is not supported', async () => {
const fetchImpl = mockFetch({
ok: true,
status: 200,
json: async () => ({
...validEnvelope,
meta: { total: 2, catalog_version: 99 },
meta: { total: 3, catalog_version: 99, starters: [] },
}),
} as Response);
await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf(
Expand Down
43 changes: 36 additions & 7 deletions src/components/feed-directory/adapters/catalog-api.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { siteKeyFromId } from '../domain/entry';
import type { CatalogLoadError, FeedDirectoryEntry } from '../domain/types';
import { isLastResultState } from '../domain/last-result';
import type { CatalogLoadError, FeedDirectoryEntry, LastResult } from '../domain/types';

const SUPPORTED_CATALOG_VERSIONS = [1] as const;
const SUPPORTED_CATALOG_VERSIONS = [2] as const;

interface CatalogWireEntry {
id?: unknown;
path?: unknown;
channel?: { url?: unknown; language?: unknown };
directory?: { title?: unknown; summary?: unknown; topics?: unknown };
parameters?: { schema?: unknown; defaults?: unknown };
last_result?: unknown;
}

interface CatalogEnvelope {
success?: unknown;
data?: { configs?: unknown };
meta?: { total?: unknown; catalog_version?: unknown };
meta?: { total?: unknown; catalog_version?: unknown; starters?: unknown };
}

export class CatalogDisabledError extends Error {
Expand Down Expand Up @@ -80,6 +82,24 @@ function parseParameterDefaults(value: unknown): Readonly<Record<string, string>
return defaults;
}

/** Fail closed: missing or invalid last_result rejects the row. */
function parseLastResult(value: unknown): LastResult | null {
if (!isRecord(value)) return null;
if (!isLastResultState(value.state)) return null;

const code = value.code;
if (!(code === null || typeof code === 'string')) return null;

const at = value.at;
if (!(at === null || typeof at === 'string')) return null;

return {
state: value.state,
code: code === null || code.trim() === '' ? null : code,
at: at === null || at.trim() === '' ? null : at,
};
}

function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] {
if (!Array.isArray(configs)) return [];

Expand All @@ -90,7 +110,8 @@ function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] {
const id = asString(wire.id);
const path = asString(wire.path);
const channelUrl = asString(wire.channel?.url);
if (!id || !path || !channelUrl) continue;
const lastResult = parseLastResult(wire.last_result);
if (!id || !path || !channelUrl || !lastResult) continue;

entries.push({
id,
Expand All @@ -103,6 +124,7 @@ function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] {
language: asString(wire.channel?.language) ?? '',
parameterSchema: parseParameterSchema(wire.parameters?.schema),
parameterDefaults: parseParameterDefaults(wire.parameters?.defaults),
lastResult,
});
}

Expand All @@ -120,9 +142,15 @@ function parseCatalogVersion(meta: CatalogEnvelope['meta']): number {
return version;
}

export interface CatalogMeta {
total: number;
catalogVersion: number;
starters: readonly string[];
}

function parseCatalogEnvelope(payload: unknown): {
entries: FeedDirectoryEntry[];
meta: { total: number; catalogVersion: number };
meta: CatalogMeta;
} {
if (!isRecord(payload)) {
throw new CatalogInvalidEnvelopeError();
Expand All @@ -137,17 +165,18 @@ function parseCatalogEnvelope(payload: unknown): {
const catalogVersion = parseCatalogVersion(envelope.meta);
const totalRaw = envelope.meta?.total;
const total = typeof totalRaw === 'number' && Number.isFinite(totalRaw) ? totalRaw : entries.length;
const starters = parseStringArray(envelope.meta?.starters);

return {
entries,
meta: { total, catalogVersion },
meta: { total, catalogVersion, starters },
};
}

export async function fetchCatalogResponse(
instanceUrl: string,
fetchImpl: typeof fetch = fetch
): Promise<{ entries: FeedDirectoryEntry[]; meta: { total: number; catalogVersion: number } }> {
): Promise<{ entries: FeedDirectoryEntry[]; meta: CatalogMeta }> {
const catalogUrl = new URL('/api/v1/configs', instanceUrl).toString();

let response: Response;
Expand Down
42 changes: 39 additions & 3 deletions src/components/feed-directory/app/FeedDirectoryApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
} from '../adapters/browser-storage';
import { downloadOpml } from '../adapters/browser-download';
import { buildFeedUrl } from '../domain/feed-url';
import { isFailingLastResult } from '../domain/last-result';
import { buildOpmlDocument } from '../domain/opml';
import { normalizeFilterLanguage } from '../domain/language';
import { debounce } from '../lib/debounce';
import type { FeedDirectoryEntry } from '../domain/types';
import { renderFeedDirectory } from '../ui/render';
import {
applyFilterPatch,
Expand Down Expand Up @@ -182,6 +184,15 @@ export class FeedDirectoryApp {
this.render();
break;
}
case 'open-feed': {
const entryId = actionEl.dataset.entryId;
const entry = this.findEntry(entryId);
if (!entry) return;
if (isFailingLastResult(entry.lastResult) && !this.confirmFailingSubscribe(entry)) {
event.preventDefault();
}
break;
}
case 'copy-feed':
void this.copyFeed(actionEl.dataset.entryId);
break;
Expand All @@ -193,6 +204,19 @@ export class FeedDirectoryApp {
}
}

private findEntry(entryId: string | undefined): FeedDirectoryEntry | undefined {
if (!entryId) return undefined;
return this.state.entries.find((item) => item.id === entryId);
}

private confirmFailingSubscribe(entry: FeedDirectoryEntry): boolean {
const detail =
entry.lastResult.state === 'empty'
? 'The last known scrape on this instance returned no items.'
: 'The last known scrape on this instance failed.';
return window.confirm(`${detail} Feeds that recently failed often fail again. Subscribe anyway?`);
}

private async applyInstance(): Promise<void> {
const normalized = normalizeInstanceUrl(this.state.instanceDraft);
if (!normalized) {
Expand All @@ -217,9 +241,9 @@ export class FeedDirectoryApp {
}

private async copyFeed(entryId: string | undefined): Promise<void> {
if (!entryId) return;
const entry = this.state.entries.find((item) => item.id === entryId);
if (!entry) return;
const entry = this.findEntry(entryId);
if (!entry || !entryId) return;
if (isFailingLastResult(entry.lastResult) && !this.confirmFailingSubscribe(entry)) return;

const url = buildFeedUrl(this.state.instanceUrl, entry, this.state.parametersById[entryId] ?? {});
try {
Expand All @@ -242,6 +266,18 @@ export class FeedDirectoryApp {
private exportOpml(): void {
const { filteredEntries } = selectPagedEntries(this.state);
if (filteredEntries.length === 0) return;

const failingCount = filteredEntries.filter((entry) => isFailingLastResult(entry.lastResult)).length;
if (failingCount > 0) {
const detail =
failingCount === 1
? '1 feed in this export had an empty or failed last scrape on this instance.'
: `${failingCount} feeds in this export had an empty or failed last scrape on this instance.`;
if (!window.confirm(`${detail} Feeds that recently failed often fail again. Export anyway?`)) {
return;
}
}

const opml = buildOpmlDocument(this.state.instanceUrl, filteredEntries, this.state.parametersById);
downloadOpml(opml);
}
Expand Down
1 change: 1 addition & 0 deletions src/components/feed-directory/app/directory-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const entry = (id: string, title: string): FeedDirectoryEntry => ({
language: '',
parameterSchema: {},
parameterDefaults: {},
lastResult: { state: 'unknown', code: null, at: null },
});

describe('applyFilterPatch', () => {
Expand Down
Loading
Loading