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 @@ -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 }` |
Expand Down
8 changes: 5 additions & 3 deletions app/web/catalog/merge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions 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. 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 `<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 All @@ -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
Expand Down
137 changes: 125 additions & 12 deletions frontend/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);

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(<App />);

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(<App />);

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: {
Expand All @@ -395,13 +502,19 @@ describe('App', () => {
render(<App />);

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 () => {
Expand Down Expand Up @@ -763,7 +876,7 @@ describe('App', () => {
].map((element) => element.textContent);

expect(utilityItems).toEqual([
COPY.tryIncludedFeeds,
COPY.browseFeedDirectory(),
COPY.bookmarkletTitle,
COPY.logout,
COPY.dockerInstall,
Expand Down Expand Up @@ -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,
Expand All @@ -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'
);
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
24 changes: 24 additions & 0 deletions frontend/src/__tests__/useSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/catalog/index.ts
Original file line number Diff line number Diff line change
@@ -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';
8 changes: 6 additions & 2 deletions frontend/src/catalog/parseCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function App() {
<div class="app-footer__inner">
<UtilityStrip
hasAccessToken={hasToken}
catalogCount={catalogEntries.length}
openapiUrl={metadata?.api.openapi_url}
onClearToken={onClearToken}
onShowBookmarkletHelp={onShowBookmarkletHelp}
Expand Down
Loading
Loading