diff --git a/AGENTS.md b/AGENTS.md index e0e995ee..68ffee85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/CONTEXT.md b/CONTEXT.md index a9fffad8..81c2ec43 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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`. diff --git a/docs/design-system.md b/docs/design-system.md index 3e21e22e..f0607417 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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 `` 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). diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index cf479769..2b900698 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -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', @@ -63,6 +69,7 @@ describe('App', () => { history.replaceState({}, '', 'http://localhost:3000/#/create'); localStorage.clear(); mockCreateFeed.mockResolvedValue(mockCreatedFeedResult); + mockUseCatalogEntries.mockReturnValue([]); mockUseAccessToken.mockReturnValue({ token: undefined, @@ -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: { @@ -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(); + + 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/%s/episodes/player', + parameterDefaults: { id: 'b006wkfp' }, + }, + ]); + + render(); + + 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/%s/episodes/player', + parameterDefaults: { id: 'b006wkfp' }, + }, + ]); + + render(); + + 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(); + + 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 () => { diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts new file mode 100644 index 00000000..3d08ad0c --- /dev/null +++ b/frontend/src/__tests__/catalog.test.ts @@ -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 & Pick +): 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/%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/%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/%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']); + }); +}); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 6a2c274d..67aac8d8 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -23,6 +23,13 @@ export const server = setupServer( }, }, }); + }), + http.get('/api/v1/configs', () => { + return HttpResponse.json({ + success: true, + data: { configs: [] }, + meta: { total: 0, catalog_version: 1 }, + }); }) ); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index 1a6ef274..9511f158 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -16,6 +16,20 @@ const mockMetadata = { describe('useSession', () => { let fetchMock: SpyInstance; + const emptyCatalogResponse = Response.json({ + success: true, + data: { configs: [] }, + meta: { total: 0, catalog_version: 1 }, + }); + + const mockFetchFor = (metadata: unknown, catalogResponse: Response = emptyCatalogResponse) => { + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/configs')) return catalogResponse.clone(); + return Response.json({ success: true, data: metadata }); + }); + }; + beforeEach(() => { vi.clearAllMocks(); getPersistentStorage().clear(); @@ -33,7 +47,7 @@ describe('useSession', () => { it('coordinates api metadata load and token loading', async () => { localStorage.setItem(ACCESS_TOKEN_KEY, 'session-token'); - fetchMock.mockResolvedValueOnce(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); const { result } = renderHook(() => useSession()); @@ -46,12 +60,13 @@ describe('useSession', () => { expect(result.current.token).toBe('session-token'); expect(result.current.hasToken).toBe(true); expect(result.current.featuredFeeds).toEqual([]); + expect(result.current.catalogEntries).toEqual([]); expect(result.current.metadataError).toBeUndefined(); expect(result.current.feedCreationEnabled).toBe(true); }); it('saves new tokens to persistent storage and does not write sessionStorage', async () => { - fetchMock.mockResolvedValueOnce(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -69,7 +84,7 @@ describe('useSession', () => { it('clears the canonical persistent token copy', async () => { localStorage.setItem(ACCESS_TOKEN_KEY, 'old-token'); - fetchMock.mockResolvedValueOnce(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -85,7 +100,7 @@ describe('useSession', () => { }); it('falls back to in-memory token when persistent storage write is unavailable', async () => { - fetchMock.mockResolvedValueOnce(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); localStorage.setItem.mockImplementationOnce(() => { throw new Error('blocked'); }); @@ -103,7 +118,7 @@ describe('useSession', () => { }); it('loads from in-memory fallback when persistent storage read is unavailable', async () => { - fetchMock.mockResolvedValue(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); localStorage.setItem.mockImplementationOnce(() => { throw new Error('blocked'); }); @@ -128,7 +143,7 @@ describe('useSession', () => { }); it('mayCreate returns needToken when access token is required and missing', async () => { - fetchMock.mockResolvedValueOnce(Response.json({ success: true, data: mockMetadata })); + mockFetchFor(mockMetadata); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -139,18 +154,13 @@ describe('useSession', () => { }); it('mayCreate returns disabled when feed creation is disabled', async () => { - fetchMock.mockResolvedValueOnce( - Response.json({ - success: true, - data: { - ...mockMetadata, - instance: { - ...mockMetadata.instance, - feed_creation: { enabled: false, access_token_required: true }, - }, - }, - }) - ); + mockFetchFor({ + ...mockMetadata, + instance: { + ...mockMetadata.instance, + feed_creation: { enabled: false, access_token_required: true }, + }, + }); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -160,18 +170,13 @@ describe('useSession', () => { }); it('mayCreate returns proceed when token is not required', async () => { - fetchMock.mockResolvedValueOnce( - Response.json({ - success: true, - data: { - ...mockMetadata, - instance: { - ...mockMetadata.instance, - feed_creation: { enabled: true, access_token_required: false }, - }, - }, - }) - ); + mockFetchFor({ + ...mockMetadata, + instance: { + ...mockMetadata.instance, + feed_creation: { enabled: true, access_token_required: false }, + }, + }); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); @@ -180,15 +185,10 @@ describe('useSession', () => { }); it('defaults gate to token-required when metadata feed_creation is absent', async () => { - fetchMock.mockResolvedValueOnce( - Response.json({ - success: true, - data: { - api: mockMetadata.api, - instance: {}, - }, - }) - ); + mockFetchFor({ + api: mockMetadata.api, + instance: {}, + }); const { result } = renderHook(() => useSession()); await waitFor(() => expect(result.current.isLoading).toBe(false)); diff --git a/frontend/src/catalog/findCatalogEntries.ts b/frontend/src/catalog/findCatalogEntries.ts new file mode 100644 index 00000000..80df0b1e --- /dev/null +++ b/frontend/src/catalog/findCatalogEntries.ts @@ -0,0 +1,127 @@ +import { expandCreateUrl } from '../utils/url'; +import type { CatalogEntry } from './types'; + +const FIND_MIN_LENGTH = 2; +const FIND_CAP = 5; + +/** + * Lowercases hostname and strips one leading `www.`. + */ +function hostKey(hostname: string): string { + const lower = hostname.toLowerCase(); + return lower.startsWith('www.') ? lower.slice(4) : lower; +} + +/** + * Strips trailing slash on non-root paths. + */ +function stripTrailingSlash(pathname: string): string { + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + return pathname; +} + +/** + * Host+path+search key for URL equivalence (scheme ignored, hash dropped). + * Channel URLs may include Ruby-style placeholders (`%s`); those are + * replaced with `_` so `URL` can parse. + */ +function equivalenceKey(rawHref: string): string | undefined { + const sanitized = rawHref.replaceAll(/%<[^>]+>/g, '_').replaceAll(/%\{[^}]+\}/g, '_'); + try { + const url = new URL(sanitized); + url.hash = ''; + const host = hostKey(url.hostname); + const path = stripTrailingSlash(url.pathname); + return `${host}${path}${url.search}`; + } catch { + return undefined; + } +} + +function channelHostKey(channelUrl: string): string | undefined { + const match = /^https?:\/\/([^/?#]+)/i.exec(channelUrl); + if (!match?.[1]) return undefined; + return hostKey(match[1]); +} + +function parseExpandedQuery(raw: string): URL | undefined { + const expanded = expandCreateUrl(raw); + if (!('ok' in expanded)) return undefined; + try { + const url = new URL(expanded.ok); + url.hash = ''; + return url; + } catch { + return undefined; + } +} + +function isBareHostQuery(url: URL): boolean { + const path = stripTrailingSlash(url.pathname); + return path === '/' || path === ''; +} + +function isTextMatch(entry: CatalogEntry, needle: string): boolean { + const haystacks = [entry.title, entry.description, entry.id, entry.channelUrl]; + return haystacks.some((value) => value.toLowerCase().includes(needle)); +} + +/** + * Relative feed href with `parameters.defaults` applied as query params. + */ +export function catalogFeedHref(entry: CatalogEntry): string { + const parameters = new URLSearchParams(entry.parameterDefaults); + const query = parameters.toString(); + return query ? `${entry.path}?${query}` : entry.path; +} + +/** + * Finds catalog entries for a create-field query: URL equivalence hits first, + * then case-insensitive substring text hits. Deduped by `id`, capped at 5. + */ +export function findCatalogEntries(query: string, entries: readonly CatalogEntry[]): readonly CatalogEntry[] { + const trimmed = query.trim(); + if (trimmed.length < FIND_MIN_LENGTH) return []; + + const needle = trimmed.toLowerCase(); + const queryUrl = parseExpandedQuery(trimmed); + const queryKey = queryUrl ? equivalenceKey(queryUrl.href) : undefined; + const queryHost = queryUrl ? hostKey(queryUrl.hostname) : undefined; + const isBareHost = queryUrl ? isBareHostQuery(queryUrl) : false; + + const urlHits: CatalogEntry[] = []; + const textHits: CatalogEntry[] = []; + const seen = new Set(); + + const pushUnique = (bucket: CatalogEntry[], entry: CatalogEntry) => { + if (seen.has(entry.id)) return; + seen.add(entry.id); + bucket.push(entry); + }; + + for (const entry of entries) { + let isUrlHit = false; + if (queryUrl && queryHost) { + if (isBareHost) { + isUrlHit = channelHostKey(entry.channelUrl) === queryHost; + } else if (queryKey) { + isUrlHit = equivalenceKey(entry.channelUrl) === queryKey; + } + } + + if (isUrlHit) { + pushUnique(urlHits, entry); + continue; + } + + if (isTextMatch(entry, needle)) { + pushUnique(textHits, entry); + } + } + + urlHits.sort((a, b) => a.id.localeCompare(b.id)); + textHits.sort((a, b) => a.id.localeCompare(b.id)); + return [...urlHits, ...textHits].slice(0, FIND_CAP); +} diff --git a/frontend/src/catalog/index.ts b/frontend/src/catalog/index.ts new file mode 100644 index 00000000..557d7ef4 --- /dev/null +++ b/frontend/src/catalog/index.ts @@ -0,0 +1,4 @@ +export type { CatalogEntry } from './types'; +export { findCatalogEntries, catalogFeedHref } from './findCatalogEntries'; +export { parseCatalogEntries, selectStarterFeeds } from './parseCatalog'; +export { useCatalogEntries } from './useCatalogEntries'; diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts new file mode 100644 index 00000000..9535aa4b --- /dev/null +++ b/frontend/src/catalog/parseCatalog.ts @@ -0,0 +1,74 @@ +import type { CatalogEntry } from './types'; + +interface CatalogWireEntry { + id?: unknown; + path?: unknown; + channel?: { url?: unknown }; + directory?: { title?: unknown; summary?: unknown }; + parameters?: { defaults?: unknown }; +} + +interface CatalogEnvelope { + data?: { configs?: unknown }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function parseParameterDefaults(value: unknown): Readonly> { + if (!isRecord(value)) return {}; + + const defaults: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (typeof raw === 'string') defaults[key] = raw; + } + return defaults; +} + +/** + * Maps a catalog API envelope to domain entries. Invalid rows are dropped. + */ +export function parseCatalogEntries(payload: unknown): CatalogEntry[] { + if (!isRecord(payload)) return []; + const envelope = payload as CatalogEnvelope; + const configs = envelope.data?.configs; + if (!Array.isArray(configs)) return []; + + const entries: CatalogEntry[] = []; + for (const row of configs) { + if (!isRecord(row)) continue; + const wire = row as CatalogWireEntry; + const id = asString(wire.id); + const path = asString(wire.path); + const channelUrl = asString(wire.channel?.url); + if (!id || !path || !channelUrl) continue; + + entries.push({ + id, + path, + title: asString(wire.directory?.title) ?? id, + description: asString(wire.directory?.summary) ?? '', + channelUrl, + parameterDefaults: parseParameterDefaults(wire.parameters?.defaults), + }); + } + + return entries; +} + +const STARTER_FEED_IDS = ['microsoft.com/azure-products', 'phys.org/weekly', 'softwareleadweekly.com/issues']; + +/** + * Picks starter feeds for the creation-disabled surface. + */ +export function selectStarterFeeds(entries: readonly CatalogEntry[]): CatalogEntry[] { + const selected = STARTER_FEED_IDS.map((id) => entries.find((entry) => entry.id === id)).filter( + (entry): entry is CatalogEntry => Boolean(entry) + ); + return selected.length > 0 ? selected : entries.slice(0, 3); +} diff --git a/frontend/src/catalog/types.ts b/frontend/src/catalog/types.ts new file mode 100644 index 00000000..944c77e1 --- /dev/null +++ b/frontend/src/catalog/types.ts @@ -0,0 +1,10 @@ +/** Domain catalog entry used for find and starter selection. */ +export interface CatalogEntry { + id: string; + path: string; + title: string; + description: string; + channelUrl: string; + /** String defaults from wire `parameters.defaults` (empty when none). */ + parameterDefaults: Readonly>; +} diff --git a/frontend/src/catalog/useCatalogEntries.ts b/frontend/src/catalog/useCatalogEntries.ts new file mode 100644 index 00000000..c9c6f471 --- /dev/null +++ b/frontend/src/catalog/useCatalogEntries.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'preact/hooks'; +import type { ApiMetadataRecord } from '../api/contracts'; +import { parseCatalogEntries } from './parseCatalog'; +import type { CatalogEntry } from './types'; + +/** + * Loads catalog entries when the instance catalog is enabled. + */ +export function useCatalogEntries(metadata?: ApiMetadataRecord): CatalogEntry[] { + const [entries, setEntries] = useState([]); + const catalog = metadata?.instance.catalog; + + useEffect(() => { + if (!catalog?.enabled || !catalog.url) { + setEntries([]); + return; + } + + let isCancelled = false; + + const load = async () => { + try { + const response = await fetch(catalog.url, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + if (!isCancelled) setEntries([]); + return; + } + + const payload: unknown = await response.json(); + if (isCancelled) return; + setEntries(parseCatalogEntries(payload)); + } catch { + if (!isCancelled) setEntries([]); + } + }; + + load(); + return () => { + isCancelled = true; + }; + }, [catalog?.enabled, catalog?.url]); + + return entries; +} diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index a7416871..23696cdd 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -35,6 +35,7 @@ export function App() { token, hasToken, metadata, + catalogEntries, featuredFeeds, isLoading: sessionLoading, metadataError, @@ -117,6 +118,7 @@ export function App() { feedFieldErrors={feedFieldErrors} submitDisabled={submitDisabled} feedCreationEnabled={feedCreationEnabled} + catalogEntries={catalogEntries} featuredFeeds={featuredFeeds} tokenDraft={tokenDraft} onFeedSubmit={onFeedSubmit} diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 7ec8c418..0992b0e5 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -1,8 +1,10 @@ -import { useLayoutEffect, useRef } from 'preact/hooks'; -import type { RefObject } from 'preact'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks'; +import type { JSX, RefObject } from 'preact'; import { Bookmarklet } from './Bookmarklet'; import { DominantField } from './DominantField'; import { Notice } from './Notice'; +import type { CatalogEntry } from '../catalog'; +import { catalogFeedHref, findCatalogEntries } from '../catalog'; import type { AppViewModel } from '../feed'; import { COPY } from '../journey/copy'; @@ -25,7 +27,8 @@ interface CreateFeedPanelProperties { feedFieldErrors: FeedFieldErrors; submitDisabled: boolean; feedCreationEnabled: boolean; - featuredFeeds: Array<{ path: string; title: string; description: string }>; + catalogEntries: CatalogEntry[]; + featuredFeeds: CatalogEntry[]; tokenDraft: string; onFeedSubmit: (event: Event) => void; onFeedFieldChange: (key: 'url', value: string) => void; @@ -41,21 +44,122 @@ interface UrlEntrySectionProperties { error: string; isCreating: boolean; feedCreationEnabled: boolean; - featuredFeeds: Array<{ path: string; title: string; description: string }>; + catalogEntries: CatalogEntry[]; + featuredFeeds: CatalogEntry[]; inputRef: RefObject; onInput: (value: string) => void; } +const CATALOG_FIND_LISTBOX_ID = 'catalog-find-hits'; + +function catalogHitOptionId(index: number): string { + return `${CATALOG_FIND_LISTBOX_ID}-option-${index}`; +} + +interface CatalogHitListProperties { + entries: readonly CatalogEntry[]; + ariaLabel: string; + listboxId?: string; + activeIndex?: number; +} + +function CatalogHitList({ entries, ariaLabel, listboxId, activeIndex }: CatalogHitListProperties) { + const isListbox = listboxId !== undefined; + + return ( +
+ {entries.map((entry, index) => { + const isActive = isListbox && activeIndex === index; + const hit = ( + + {entry.title} + {entry.description ? ( + {entry.description} + ) : undefined} + + ); + + if (isListbox) return hit; + + return ( +
+ {hit} +
+ ); + })} +
+ ); +} + function UrlEntrySection({ url, disabled, error, isCreating, feedCreationEnabled, + catalogEntries, featuredFeeds, inputRef, onInput, }: UrlEntrySectionProperties) { + const catalogHits = useMemo(() => findCatalogEntries(url, catalogEntries), [url, catalogEntries]); + const [activeHitIndex, setActiveHitIndex] = useState(undefined); + const hasHits = catalogHits.length > 0; + + useEffect(() => { + setActiveHitIndex(undefined); + }, [catalogHits]); + + const handleUrlKeyDown: JSX.KeyboardEventHandler = (event) => { + if (!hasHits || event.isComposing || event.repeat) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveHitIndex((current) => (current === undefined ? 0 : (current + 1) % catalogHits.length)); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveHitIndex((current) => + current === undefined + ? catalogHits.length - 1 + : (current - 1 + catalogHits.length) % catalogHits.length + ); + return; + } + + if (event.key === 'Escape') { + if (activeHitIndex === undefined) return; + event.preventDefault(); + setActiveHitIndex(undefined); + return; + } + + if (event.key === 'Enter' && activeHitIndex !== undefined) { + const active = catalogHits[activeHitIndex]; + if (!active) return; + + event.preventDefault(); + location.assign(catalogFeedHref(active)); + } + }; + return ( <> '} disabled={disabled} error={error} + aria-controls={hasHits ? CATALOG_FIND_LISTBOX_ID : undefined} + aria-expanded={hasHits} + aria-activedescendant={activeHitIndex === undefined ? undefined : catalogHitOptionId(activeHitIndex)} + onKeyDown={handleUrlKeyDown} onInput={(event) => onInput(event.currentTarget.value)} /> + {hasHits && ( +
+

{COPY.catalogFindHint}

+ +
+ )} + {!feedCreationEnabled && ( <>

{COPY.creationDisabled}

@@ -88,20 +208,7 @@ function UrlEntrySection({ title={COPY.includedFeedsTitle} >

{COPY.includedFeedsIntro}

-
- {featuredFeeds.map((feed) => ( - - ))} -
+

onFeedFieldChange('url', value)} diff --git a/frontend/src/components/DominantField.tsx b/frontend/src/components/DominantField.tsx index 84545432..3020bbd8 100644 --- a/frontend/src/components/DominantField.tsx +++ b/frontend/src/components/DominantField.tsx @@ -18,9 +18,13 @@ interface DominantFieldProperties { actionVariant?: 'default' | 'soft'; onAction?: () => void; onInput?: JSX.GenericEventHandler; + onKeyDown?: JSX.KeyboardEventHandler; inputRef?: Ref; actionRef?: Ref; error?: string; + 'aria-controls'?: string; + 'aria-expanded'?: boolean | 'true' | 'false'; + 'aria-activedescendant'?: string; } const ArrowIcon = () => ( @@ -60,9 +64,13 @@ export function DominantField({ actionVariant = 'default', onAction, onInput, + onKeyDown, inputRef, actionRef, error, + 'aria-controls': ariaControls, + 'aria-expanded': ariaExpanded, + 'aria-activedescendant': ariaActivedescendant, }: DominantFieldProperties) { return (

@@ -83,8 +91,13 @@ export function DominantField({ value={value} readOnly={readOnly} disabled={disabled} + aria-controls={ariaControls} + aria-expanded={ariaExpanded} + aria-activedescendant={ariaActivedescendant} onInput={onInput} onKeyDown={(event) => { + onKeyDown?.(event); + if (event.defaultPrevented) return; if (onAction || event.key !== 'Enter' || event.isComposing || event.repeat) return; event.preventDefault(); diff --git a/frontend/src/hooks/useStarterFeeds.ts b/frontend/src/hooks/useStarterFeeds.ts deleted file mode 100644 index 84ef8c39..00000000 --- a/frontend/src/hooks/useStarterFeeds.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect, useState } from 'preact/hooks'; -import type { ApiMetadataRecord } from '../api/contracts'; - -export interface StarterFeed { - path: string; - title: string; - description: string; -} - -interface CatalogEnvelope { - success?: boolean; - data?: { - configs?: Array<{ - id: string; - path: string; - directory?: { - title?: string; - summary?: string; - }; - }>; - }; -} - -const STARTER_FEED_IDS = ['microsoft.com/azure-products', 'phys.org/weekly', 'softwareleadweekly.com/issues']; - -/** - * Loads starter feeds from the public catalog when feed creation is disabled. - */ -export function useStarterFeeds(metadata?: ApiMetadataRecord, feedCreationEnabled = true) { - const [starterFeeds, setStarterFeeds] = useState([]); - const catalog = metadata?.instance.catalog; - - useEffect(() => { - if (feedCreationEnabled || !catalog?.enabled || !catalog.url) { - setStarterFeeds([]); - return; - } - - let cancelled = false; - - const load = async () => { - try { - const response = await fetch(catalog.url, { headers: { Accept: 'application/json' } }); - if (!response.ok) return; - - const payload = (await response.json()) as CatalogEnvelope; - const configs = payload.data?.configs ?? []; - const selected = STARTER_FEED_IDS.map((id) => configs.find((entry) => entry.id === id)).filter( - (entry): entry is NonNullable => Boolean(entry) - ); - const entries = selected.length > 0 ? selected : configs.slice(0, 3); - - if (cancelled) return; - - setStarterFeeds( - entries.map((entry) => ({ - path: entry.path, - title: entry.directory?.title ?? entry.id, - description: entry.directory?.summary ?? '', - })) - ); - } catch { - if (!cancelled) setStarterFeeds([]); - } - }; - - load(); - return () => { - cancelled = true; - }; - }, [catalog?.enabled, catalog?.url, feedCreationEnabled]); - - return starterFeeds; -} diff --git a/frontend/src/journey/copy.ts b/frontend/src/journey/copy.ts index 4817fc65..d9177d39 100644 --- a/frontend/src/journey/copy.ts +++ b/frontend/src/journey/copy.ts @@ -20,6 +20,8 @@ export const COPY = { includedFeedsTitle: 'Included feeds', includedFeedsIntro: 'Start with a ready-made feed from this instance.', includedFeedsLearnMore: 'Learn how included feeds work.', + catalogFindHint: 'Matching included feeds.', + catalogFindHitsLabel: 'Matching included feeds', dockerSetup: 'Set up your own instance with Docker.', dockerInstall: 'Install from Docker Hub', bookmarkletTitle: 'Bookmarklet', diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index 8b82918c..5d79feba 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -1,5 +1,7 @@ +import { useMemo } from 'preact/hooks'; +import type { CatalogEntry } from '../catalog'; +import { selectStarterFeeds, useCatalogEntries } from '../catalog'; import { useApiMetadata } from '../hooks/useApiMetadata'; -import { useStarterFeeds } from '../hooks/useStarterFeeds'; import { useAccessToken } from './accessToken'; const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; @@ -26,7 +28,11 @@ export function useSession() { const isLoading = tokenLoading || metadataLoading; const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const feedCreationEnabled = feedCreation.enabled; - const featuredFeeds = useStarterFeeds(metadata, feedCreationEnabled); + const catalogEntries = useCatalogEntries(metadata); + const featuredFeeds: CatalogEntry[] = useMemo(() => { + if (feedCreationEnabled) return []; + return selectStarterFeeds(catalogEntries); + }, [catalogEntries, feedCreationEnabled]); const mayCreate = (accessToken?: string): MayCreateResult => { if (!feedCreation.enabled) return 'disabled'; @@ -39,6 +45,7 @@ export function useSession() { token, hasToken, metadata, + catalogEntries, featuredFeeds, isLoading, metadataError, diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 04d15981..29221ede 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -182,6 +182,46 @@ color: var(--text-body); } +.catalog-hit-list { + width: 100%; + max-width: var(--layout-rail-reading); + margin: 0 auto; + text-align: start; +} + +.catalog-hit { + display: grid; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + border: var(--border-width) solid transparent; + border-radius: var(--radius-md); + color: inherit; + text-decoration: none; +} + +.catalog-hit:hover, +.catalog-hit:focus-visible, +.catalog-hit[data-active] { + text-decoration: none; + background: var(--surface-base); +} + +.catalog-hit:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: var(--border-strong); +} + +.catalog-hit[data-active] { + background: var(--surface-elevated); + border-color: var(--border-strong); +} + +.catalog-hit__excerpt { + color: var(--text-muted); + font-size: var(--font-size-00); +} + .dominant-field__action { position: absolute; right: var(--dominant-field-action-right, var(--space-3)); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 232114b5..6393156e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ proxy: { '/api': 'http://localhost:4000', '/rss.xsl': 'http://localhost:4000', + // Feed documents (relative catalogFeedHref); exclude /api so JSON API stays on /api. + '^/(?!api/).+\\.(?:rss|xml|json)$': 'http://localhost:4000', }, }, preview: {