diff --git a/AGENTS.md b/AGENTS.md index 07b31e81..4949b15d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ 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) | Frontend `selectStarterFeeds` when feed creation is disabled; catalog find uses full catalog when enabled | +| Starter feeds (UI) | Frontend `selectStarterFeeds` for empty Create / creation-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 }` | diff --git a/app/web/catalog/merge.rb b/app/web/catalog/merge.rb index d7e6b773..77405782 100644 --- a/app/web/catalog/merge.rb +++ b/app/web/catalog/merge.rb @@ -8,10 +8,12 @@ module Web # Merges embedded catalog entries with local feed configs for the public catalog API. module Catalog module Merge + # Prefer Faraday-stable IGO/gov configs. Keep in lockstep with + # frontend `STARTER_FEED_IDS` in `frontend/src/catalog/parseCatalog.ts`. STARTER_FEED_IDS = %w[ - microsoft.com/azure-products - phys.org/weekly - softwareleadweekly.com/issues + fao.org/newsroom + ftc.gov/press-releases + icrc.org/news ].freeze module_function diff --git a/docs/design-system.md b/docs/design-system.md index f16deba3..ca3a8723 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. 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. +- **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 feeds as a subordinate list under the field — never a second primary task or in-app catalog browser. On an empty Create URL, up to three Feed Directory starters may appear as subordinate chrome (lean: `.ui-eyebrow` + list + demoted `utility-link` escape under the list when creation is enabled; Notice when creation is disabled). Escape and utility-strip share **Browse Feed Directory (N)** (`catalogEntries.length`); hide starters and the under-list escape while typing, when find has hits, or while creating. - **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). @@ -55,7 +55,7 @@ If a page looks like it came from a different product, the change is wrong even | Result | **Feed ready** / **Copy feed URL** | | Retry | **Try again** (button only) | | Preview | **Checking preview** / **Check again** | -| Catalog | **Included feeds** | +| Catalog | **Feed Directory** | | Token | **Access token** | ## Non-Negotiable Surface Rules diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 424b027e..db63d4a6 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -361,17 +361,124 @@ describe('App', () => { expect(mockCreateFeed).not.toHaveBeenCalled(); }); - it('promotes included feeds when feed creation is disabled', async () => { + const faoStarter = { + id: 'fao.org/newsroom', + path: '/fao.org/newsroom.rss', + title: 'FAO Newsroom', + description: 'News and media from the Food and Agriculture Organization.', + channelUrl: 'https://www.fao.org/newsroom', + parameterDefaults: {}, + }; + + it('shows lean included-feed starters on empty create when creation is enabled', async () => { + mockUseCatalogEntries.mockReturnValue([faoStarter]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toHaveAttribute( + 'href', + '/fao.org/newsroom.rss' + ); + }); + const starters = screen.getByRole('status'); + expect(starters.querySelector('.ui-eyebrow')?.textContent).toBe(COPY.feedDirectory); + expect(screen.queryByText(COPY.feedDirectoryIntro)).not.toBeInTheDocument(); + expect(screen.queryByText(COPY.feedDirectoryLearnMore)).not.toBeInTheDocument(); + expect(document.querySelector('.notice')).toBeNull(); + expect(screen.getByRole('list', { name: COPY.feedDirectory })).toBeInTheDocument(); + const directoryLinks = screen.getAllByRole('link', { name: COPY.browseFeedDirectory(1) }); + expect(directoryLinks).toHaveLength(2); + for (const link of directoryLinks) { + expect(link).toHaveAttribute( + 'href', + 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' + ); + expect(link).toHaveClass('utility-link'); + } + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByLabelText(COPY.urlLabel)); + }); + }); + + it('hides included-feed starters when the URL field is non-empty', async () => { + mockUseCatalogEntries.mockReturnValue([faoStarter]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toBeInTheDocument(); + }); + + fireEvent.input(screen.getByLabelText(COPY.urlLabel), { + target: { value: 'example.com/articles' }, + }); + + expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory(1) })).toHaveAttribute( + 'href', + 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' + ); + }); + + it('hides included-feed starters when catalog find has hits', async () => { mockUseCatalogEntries.mockReturnValue([ + faoStarter, { - 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', + 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: {}, }, ]); + mockUseAccessToken.mockReturnValue({ + token: 'session-token', + hasToken: true, + saveToken: mockSaveToken, + clearToken: mockClearToken, + isLoading: false, + error: undefined, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toBeInTheDocument(); + }); + + fireEvent.input(screen.getByLabelText(COPY.urlLabel), { + target: { value: 'www.anthropic.com/news' }, + }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Anthropic — News' })).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: 'FAO Newsroom' })).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: COPY.feedDirectory })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory(2) })).toBeInTheDocument(); + }); + + it('promotes included feeds when feed creation is disabled', async () => { + mockUseCatalogEntries.mockReturnValue([faoStarter]); mockUseApiMetadata.mockReturnValue({ metadata: { @@ -395,13 +502,19 @@ describe('App', () => { render(); await waitFor(() => { - expect(screen.getByText(COPY.includedFeedsTitle)).toBeInTheDocument(); + expect(document.querySelector('.notice__title')?.textContent).toBe(COPY.feedDirectory); }); - expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'FAO Newsroom' })).toHaveAttribute( 'href', - '/microsoft.com/azure-products.rss' + '/fao.org/newsroom.rss' ); expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument(); + expect(screen.getByText(COPY.feedDirectoryIntro)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: COPY.feedDirectoryLearnMore })).toHaveAttribute( + 'href', + 'https://html2rss.github.io/web-application/guides/use-the-feed-directory/' + ); + expect(document.querySelector('.notice')).not.toBeNull(); }); it('suggests included feeds when the URL matches the catalog', async () => { @@ -763,7 +876,7 @@ describe('App', () => { ].map((element) => element.textContent); expect(utilityItems).toEqual([ - COPY.tryIncludedFeeds, + COPY.browseFeedDirectory(), COPY.bookmarkletTitle, COPY.logout, COPY.dockerInstall, @@ -1148,7 +1261,7 @@ describe('App', () => { ...screen.getByLabelText(COPY.utilities).querySelectorAll(':scope .utility-strip__items > a'), ].map((link) => link.textContent); expect(utilityLinks).toEqual([ - COPY.tryIncludedFeeds, + COPY.browseFeedDirectory(), COPY.bookmarkletTitle, COPY.dockerInstall, COPY.openapiSpec, @@ -1159,7 +1272,7 @@ describe('App', () => { 'href', 'https://example.test/openapi.yaml' ); - expect(screen.getByRole('link', { name: COPY.tryIncludedFeeds })).toHaveAttribute( + expect(screen.getByRole('link', { name: COPY.browseFeedDirectory() })).toHaveAttribute( 'href', 'https://html2rss.github.io/feed-directory/#!url=http%3A%2F%2Flocalhost%3A3000%2F' ); diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts index 3d08ad0c..67f88a59 100644 --- a/frontend/src/__tests__/catalog.test.ts +++ b/frontend/src/__tests__/catalog.test.ts @@ -134,11 +134,14 @@ describe('parseCatalogEntries', () => { 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 fao = baseEntry({ id: 'fao.org/newsroom', channelUrl: 'https://fao.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, fao]).map((entry) => entry.id)).toEqual(['fao.org/newsroom']); expect(selectStarterFeeds([other]).map((entry) => entry.id)).toEqual(['other.com/feed']); }); + + it('exports STARTER_FEED_IDS for lockstep assertions', async () => { + const { STARTER_FEED_IDS } = await import('../catalog'); + expect([...STARTER_FEED_IDS]).toEqual(['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news']); + }); }); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index 9511f158..edc2b1b2 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -65,6 +65,30 @@ describe('useSession', () => { expect(result.current.feedCreationEnabled).toBe(true); }); + it('selects starter feeds when feed creation is enabled', async () => { + const fao = { + id: 'fao.org/newsroom', + path: '/fao.org/newsroom.rss', + channel: { url: 'https://www.fao.org/newsroom' }, + directory: { title: 'FAO Newsroom', summary: 'News' }, + parameters: { defaults: {} }, + }; + mockFetchFor( + mockMetadata, + Response.json({ + success: true, + data: { configs: [fao] }, + meta: { total: 1, catalog_version: 1 }, + }) + ); + + const { result } = renderHook(() => useSession()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.feedCreationEnabled).toBe(true); + expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual(['fao.org/newsroom']); + }); + it('saves new tokens to persistent storage and does not write sessionStorage', async () => { mockFetchFor(mockMetadata); diff --git a/frontend/src/catalog/index.ts b/frontend/src/catalog/index.ts index 557d7ef4..44b96659 100644 --- a/frontend/src/catalog/index.ts +++ b/frontend/src/catalog/index.ts @@ -1,4 +1,4 @@ export type { CatalogEntry } from './types'; export { findCatalogEntries, catalogFeedHref } from './findCatalogEntries'; -export { parseCatalogEntries, selectStarterFeeds } from './parseCatalog'; +export { parseCatalogEntries, selectStarterFeeds, STARTER_FEED_IDS } from './parseCatalog'; export { useCatalogEntries } from './useCatalogEntries'; diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts index 9535aa4b..d43423cf 100644 --- a/frontend/src/catalog/parseCatalog.ts +++ b/frontend/src/catalog/parseCatalog.ts @@ -61,10 +61,14 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { return entries; } -const STARTER_FEED_IDS = ['microsoft.com/azure-products', 'phys.org/weekly', 'softwareleadweekly.com/issues']; +/** + * Preferred included-feed starters (empty Create URL / creation-disabled Notice). + * Keep in lockstep with `Html2rss::Web::Catalog::Merge::STARTER_FEED_IDS`. + */ +export const STARTER_FEED_IDS = ['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news'] as const; /** - * Picks starter feeds for the creation-disabled surface. + * Picks up to three starter feeds by preferred id, else the first catalog rows. */ export function selectStarterFeeds(entries: readonly CatalogEntry[]): CatalogEntry[] { const selected = STARTER_FEED_IDS.map((id) => entries.find((entry) => entry.id === id)).filter( diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index f7f338bb..daebf778 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -175,6 +175,7 @@ export function App() { )} - {!feedCreationEnabled && ( - <> -

{COPY.creationDisabled}

- {featuredFeeds.length > 0 && ( - -

{COPY.includedFeedsIntro}

- -

- - {COPY.includedFeedsLearnMore} - -

-
- )} - + {!feedCreationEnabled &&

{COPY.creationDisabled}

} + + {shouldShowStarters && ( + )} ); @@ -482,6 +526,7 @@ export function CreateFeedPanel({ interface UtilityStripProperties { hasAccessToken: boolean; + catalogCount: number; openapiUrl?: string; onClearToken: () => void; onShowBookmarkletHelp: () => void; @@ -489,26 +534,17 @@ interface UtilityStripProperties { export function UtilityStrip({ hasAccessToken, + catalogCount, openapiUrl, onClearToken, onShowBookmarkletHelp, }: UtilityStripProperties) { const normalizedOpenapiUrl = normalizeLocalOriginUrl(openapiUrl); - const includedFeedsHref = (() => { - const directoryUrl = new URL('https://html2rss.github.io/feed-directory/'); - if (globalThis.window === undefined) return directoryUrl.href; - - const instanceUrl = new URL('/', location.origin); - directoryUrl.hash = `!url=${encodeURIComponent(instanceUrl.href)}`; - return directoryUrl.href; - })(); return (
- - {COPY.tryIncludedFeeds} - + {hasAccessToken && (