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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ Public feed-directory metadata for embedded and local configs.
| Disabled response | `404` with `{ "error": "catalog_disabled" }` |
| Embedded entries | `Html2rss::Configs::Catalog.entries` — do not re-walk YAML in the handler |
| Local entries | `Catalog::Merge` includes `feeds.yml` feeds only when `directory.title` is set |
| Starter feeds (UI) | `Catalog::Merge.starter_entries` — used by frontend when feed creation is disabled |
| Starter feeds (UI) | Frontend `selectStarterFeeds` when feed creation is disabled; catalog find uses full catalog when enabled |
| Catalog find | `findCatalogEntries` → multi-hit list under create URL; links via `catalogFeedHref` (path + defaults) |
| CORS | Route-scoped on `/api/v1/configs` only (`GET`, `OPTIONS`) |
| Root metadata | `GET /api/v1/` exposes `instance.catalog: { enabled, url }` |
| Contract SSOT | Request specs under `spec/html2rss/web/api/v1_spec.rb` and generated `public/openapi.yaml` |
Expand Down
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ A mechanism that automatically initiates feed creation when a prefilled URL is p
### Create-Time URL Expansion
Single frontend path (`expandCreateUrl`) that normalizes the create URL before Creation IO. Field-error copy for empty/invalid is mapped by Feed Flow from COPY.

### Catalog Find
On create, `findCatalogEntries` matches the typed query against the catalog (URL equivalence after create-time expansion, plus case-insensitive substring on title/description/id/channelUrl). Hits (capped) render as a subordinate included-feeds list under the URL field via `catalogFeedHref` (path + parameter defaults). Create stays primary. Browse stays on the docs Feed Directory.

### CreateEntry remount
Visiting create (including hashbang `#!/…` → `#/…`) bumps `createEntryKey` so the create surface remounts. Remount alone does not auto-submit; auto-submit requires `prefillUrl`.

Expand Down
2 changes: 1 addition & 1 deletion docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ If a page looks like it came from a different product, the change is wrong even

## Journey Grammar (enforced)

- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit.
- **Create:** URL field is the task. Visiting `#/create` or `#!/create` remounts create; hashbang aliases canonicalize to `#/create`. Bare create does not auto-submit. When the typed query finds catalog entries (URL equivalence or text substring), show matching included feeds as a subordinate list under the field — never a second primary task or in-app catalog browser.
- **Token gate:** a native `<dialog>` over the still-mounted, inert URL task (one interactive task). Auth copy is in-field (`tokenError`); ActionFeedback stays on create. Access Token persists until Logout with no storage UI.
- **Result:** primary CTA is **Copy feed URL**. Open feed / JSON / feed-reader are demoted secondary actions and stay available while preview loads. Preview is non-blocking confirmation only.
- **Unmatched result:** `#/result/:token` is valid only with a matching in-memory result. Missing or mismatched tokens recover onto remounted `#/create` (no API rehydrate, no failure chrome, no durable shareable result page).
Expand Down
187 changes: 168 additions & 19 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ vi.mock('../hooks/useApiMetadata', () => ({
useApiMetadata: vi.fn(),
}));

vi.mock('../catalog/useCatalogEntries', () => ({
useCatalogEntries: vi.fn(() => []),
}));

import { useAccessToken } from '../session/accessToken';
import { useApiMetadata } from '../hooks/useApiMetadata';
import { useFeedCreation } from '../feed/useFeedCreation';
import { useCatalogEntries } from '../catalog/useCatalogEntries';

const mockUseAccessToken = useAccessToken as any;
const mockUseApiMetadata = useApiMetadata as any;
const mockUseFeedCreation = useFeedCreation as any;
const mockUseCatalogEntries = useCatalogEntries as any;
const mockCreatedFeedResult = {
feed: {
id: 'feed-123',
Expand Down Expand Up @@ -63,6 +69,7 @@ describe('App', () => {
history.replaceState({}, '', 'http://localhost:3000/#/create');
localStorage.clear();
mockCreateFeed.mockResolvedValue(mockCreatedFeedResult);
mockUseCatalogEntries.mockReturnValue([]);

mockUseAccessToken.mockReturnValue({
token: undefined,
Expand Down Expand Up @@ -340,24 +347,16 @@ describe('App', () => {
});

it('promotes included feeds when feed creation is disabled', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch');
fetchMock.mockResolvedValueOnce(
Response.json({
success: true,
data: {
configs: [
{
id: 'microsoft.com/azure-products',
path: '/microsoft.com/azure-products.rss',
directory: {
title: 'Azure product updates',
summary: 'Follow Microsoft Azure product announcements from your own instance.',
},
},
],
},
})
);
mockUseCatalogEntries.mockReturnValue([
{
id: 'microsoft.com/azure-products',
path: '/microsoft.com/azure-products.rss',
title: 'Azure product updates',
description: 'Follow Microsoft Azure product announcements from your own instance.',
channelUrl: 'https://azure.microsoft.com/updates',
parameterDefaults: {},
},
]);

mockUseApiMetadata.mockReturnValue({
metadata: {
Expand Down Expand Up @@ -388,7 +387,157 @@ describe('App', () => {
'/microsoft.com/azure-products.rss'
);
expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument();
fetchMock.mockRestore();
});

it('suggests included feeds when the URL matches the catalog', async () => {
mockUseCatalogEntries.mockReturnValue([
{
id: 'anthropic.com/news',
path: '/anthropic.com/news.rss',
title: 'Anthropic — News',
description: 'Product and research announcements from Anthropic.',
channelUrl: 'https://www.anthropic.com/news',
parameterDefaults: {},
},
]);

render(<App />);

fireEvent.input(screen.getByLabelText(COPY.urlLabel), {
target: { value: 'www.anthropic.com/news' },
});

await waitFor(() => {
expect(screen.getByRole('option', { name: 'Anthropic — News' })).toHaveAttribute(
'href',
'/anthropic.com/news.rss'
);
});
expect(screen.getByRole('status')).toHaveTextContent(COPY.catalogFindHint);
});

it('lists multiple catalog find hits for a text query including defaults href', async () => {
mockUseCatalogEntries.mockReturnValue([
{
id: 'bbc.com/mundo',
path: '/bbc.com/mundo.rss',
title: 'BBC — Mundo',
description: 'Spanish-language news from BBC Mundo.',
channelUrl: 'https://www.bbc.com/mundo',
parameterDefaults: {},
},
{
id: 'bbc.co.uk/available_episodes',
path: '/bbc.co.uk/available_episodes.rss',
title: 'BBC Sounds — Programme episodes',
description: 'Available episodes for a BBC programme on Sounds.',
channelUrl: 'https://www.bbc.co.uk/programmes/%<id>s/episodes/player',
parameterDefaults: { id: 'b006wkfp' },
},
]);

render(<App />);

fireEvent.input(screen.getByLabelText(COPY.urlLabel), {
target: { value: 'bbc' },
});

await waitFor(() => {
expect(screen.getByRole('option', { name: 'BBC — Mundo' })).toHaveAttribute(
'href',
'/bbc.com/mundo.rss'
);
expect(screen.getByRole('option', { name: 'BBC Sounds — Programme episodes' })).toHaveAttribute(
'href',
'/bbc.co.uk/available_episodes.rss?id=b006wkfp'
);
});

expect(screen.getByRole('option', { name: 'BBC — Mundo' })).toHaveClass('catalog-hit');
expect(screen.getByRole('option', { name: 'BBC — Mundo' })).not.toHaveClass('ui-card');
expect(screen.getByRole('listbox', { name: COPY.catalogFindHitsLabel })).toBeInTheDocument();
});

it('opens the active catalog hit with ArrowDown then Enter without submitting Create', async () => {
const assignSpy = vi.fn();
vi.stubGlobal('location', { ...location, assign: assignSpy });

mockUseCatalogEntries.mockReturnValue([
{
id: 'bbc.com/mundo',
path: '/bbc.com/mundo.rss',
title: 'BBC — Mundo',
description: 'Spanish-language news from BBC Mundo.',
channelUrl: 'https://www.bbc.com/mundo',
parameterDefaults: {},
},
{
id: 'bbc.co.uk/available_episodes',
path: '/bbc.co.uk/available_episodes.rss',
title: 'BBC Sounds — Programme episodes',
description: 'Available episodes for a BBC programme on Sounds.',
channelUrl: 'https://www.bbc.co.uk/programmes/%<id>s/episodes/player',
parameterDefaults: { id: 'b006wkfp' },
},
]);

render(<App />);

const urlField = screen.getByLabelText(COPY.urlLabel);
fireEvent.input(urlField, { target: { value: 'bbc' } });

await waitFor(() => {
expect(screen.getByRole('listbox', { name: COPY.catalogFindHitsLabel })).toBeInTheDocument();
});

fireEvent.keyDown(urlField, { key: 'ArrowDown' });
await waitFor(() => {
expect(urlField).toHaveAttribute('aria-activedescendant', 'catalog-find-hits-option-0');
});

fireEvent.keyDown(urlField, { key: 'Enter' });

expect(assignSpy).toHaveBeenCalledWith('/bbc.co.uk/available_episodes.rss?id=b006wkfp');
expect(mockCreateFeed).not.toHaveBeenCalled();

vi.unstubAllGlobals();
});

it('submits Create with Enter when no catalog hit is active', async () => {
mockUseAccessToken.mockReturnValue({
token: 'saved-token',
hasToken: true,
saveToken: mockSaveToken,
clearToken: mockClearToken,
isLoading: false,
error: undefined,
});

mockUseCatalogEntries.mockReturnValue([
{
id: 'bbc.com/mundo',
path: '/bbc.com/mundo.rss',
title: 'BBC — Mundo',
description: 'Spanish-language news from BBC Mundo.',
channelUrl: 'https://www.bbc.com/mundo',
parameterDefaults: {},
},
]);

render(<App />);

const urlField = screen.getByLabelText(COPY.urlLabel);
fireEvent.input(urlField, { target: { value: 'https://www.bbc.com/mundo' } });

await waitFor(() => {
expect(screen.getByRole('listbox', { name: COPY.catalogFindHitsLabel })).toBeInTheDocument();
});

fireEvent.keyDown(urlField, { key: 'Enter' });

await waitFor(() => {
expect(mockCreateFeed).toHaveBeenCalledWith('https://www.bbc.com/mundo', 'saved-token');
});
});

it('renders the result panel when a feed is available', async () => {
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { catalogFeedHref, findCatalogEntries, parseCatalogEntries, selectStarterFeeds } from '../catalog';
import type { CatalogEntry } from '../catalog';

const baseEntry = (
overrides: Partial<CatalogEntry> & Pick<CatalogEntry, 'id' | 'channelUrl'>
): CatalogEntry => ({
path: `/${overrides.id}.rss`,
title: overrides.title ?? overrides.id,
description: overrides.description ?? '',
parameterDefaults: overrides.parameterDefaults ?? {},
...overrides,
});

describe('findCatalogEntries', () => {
const mundo = baseEntry({
id: 'bbc.com/mundo',
channelUrl: 'https://www.bbc.com/mundo',
title: 'BBC — Mundo',
description: 'Spanish-language news from BBC Mundo.',
});
const sounds = baseEntry({
id: 'bbc.co.uk/available_episodes',
channelUrl: 'https://www.bbc.co.uk/programmes/%<id>s/episodes/player',
title: 'BBC Sounds — Programme episodes',
description: 'Available episodes for a BBC programme on Sounds.',
parameterDefaults: { id: 'b006wkfp' },
});
const anthropic = baseEntry({
id: 'anthropic.com/news',
channelUrl: 'https://www.anthropic.com/news',
title: 'Anthropic — News',
});

it('returns BBC text hits including Sounds with defaults href', () => {
const hits = findCatalogEntries('bbc', [mundo, sounds, anthropic]);
expect(hits.map((entry) => entry.id)).toEqual(['bbc.co.uk/available_episodes', 'bbc.com/mundo']);
expect(findCatalogEntries('BBC', [mundo, sounds]).map((entry) => entry.id)).toEqual([
'bbc.co.uk/available_episodes',
'bbc.com/mundo',
]);
expect(catalogFeedHref(sounds)).toBe('/bbc.co.uk/available_episodes.rss?id=b006wkfp');
expect(catalogFeedHref(mundo)).toBe('/bbc.com/mundo.rss');
});

it('matches URL equivalence across www, scheme, and case', () => {
expect(findCatalogEntries('anthropic.com/news', [anthropic]).map((entry) => entry.id)).toEqual([
'anthropic.com/news',
]);
expect(
findCatalogEntries('https://www.anthropic.com/news/', [anthropic]).map((entry) => entry.id)
).toEqual(['anthropic.com/news']);
expect(
findCatalogEntries('HTTPS://WWW.ANTHROPIC.COM/NEWS', [anthropic]).map((entry) => entry.id)
).toEqual(['anthropic.com/news']);
});

it('matches bare host to all entries on that host key', () => {
expect(findCatalogEntries('bbc.com', [mundo, sounds]).map((entry) => entry.id)).toEqual([
'bbc.com/mundo',
]);
});

it('includes parameterized entries and returns empty below min length', () => {
expect(findCatalogEntries('b', [mundo])).toEqual([]);
expect(findCatalogEntries('available_episodes', [sounds]).map((entry) => entry.id)).toEqual([
'bbc.co.uk/available_episodes',
]);
});

it('caps results at 5 and prefers URL hits before text hits', () => {
const many = Array.from({ length: 8 }, (_, index) =>
baseEntry({
id: `news.example/feed-${index}`,
channelUrl: `https://news.example/feed-${index}`,
title: `News item ${index}`,
})
);
const exact = baseEntry({
id: 'exact.example/path',
channelUrl: 'https://exact.example/path',
title: 'Unrelated',
});
const hits = findCatalogEntries('exact.example/path', [exact, ...many]);
expect(hits[0]?.id).toBe('exact.example/path');
expect(findCatalogEntries('news', many)).toHaveLength(5);
});
});

describe('parseCatalogEntries', () => {
it('maps wire rows with parameterDefaults and drops invalid ones', () => {
const entries = parseCatalogEntries({
data: {
configs: [
{
id: 'anthropic.com/news',
path: '/anthropic.com/news.rss',
channel: { url: 'https://www.anthropic.com/news' },
directory: { title: 'Anthropic — News', summary: 'Announcements.' },
parameters: { schema: {}, defaults: {} },
},
{
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' },
directory: { title: 'BBC Sounds — Programme episodes', summary: 'Episodes.' },
parameters: { schema: { id: { type: 'string' } }, defaults: { id: 'b006wkfp' } },
},
{ id: 'broken' },
],
},
});

expect(entries).toEqual([
{
id: 'anthropic.com/news',
path: '/anthropic.com/news.rss',
title: 'Anthropic — News',
description: 'Announcements.',
channelUrl: 'https://www.anthropic.com/news',
parameterDefaults: {},
},
{
id: 'bbc.co.uk/available_episodes',
path: '/bbc.co.uk/available_episodes.rss',
title: 'BBC Sounds — Programme episodes',
description: 'Episodes.',
channelUrl: 'https://www.bbc.co.uk/programmes/%<id>s/episodes/player',
parameterDefaults: { id: 'b006wkfp' },
},
]);
});
});

describe('selectStarterFeeds', () => {
it('prefers known starter ids then falls back to the first three', () => {
const azure = baseEntry({ id: 'microsoft.com/azure-products', channelUrl: 'https://azure.example' });
const other = baseEntry({ id: 'other.com/feed', channelUrl: 'https://other.example' });
expect(selectStarterFeeds([other, azure]).map((entry) => entry.id)).toEqual([
'microsoft.com/azure-products',
]);
expect(selectStarterFeeds([other]).map((entry) => entry.id)).toEqual(['other.com/feed']);
});
});
Loading
Loading