diff --git a/AGENTS.md b/AGENTS.md index e0381b04..86245d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ What this repo owns: - docs content and navigation under `src/content/docs/` - docs-specific components and styling under `src/components/` -- feed-directory presentation and client behavior (`FeedDirectory.astro`, `feed-directory.js`) +- feed-directory presentation and client behavior (`src/components/feed-directory/`) What this repo does not own: @@ -30,7 +30,7 @@ Before substantial edits, state cross-repo context in your notes: Common contracts: - Feed Directory browse data comes from `{instance}/api/v1/configs` on a running `html2rss-web` instance (see OpenAPI in `html2rss-web`). -- Instance URL persistence: default public instance, `#!url=` hash deep link from the web app, and browser localStorage (see `feed-directory.js`). +- Instance URL persistence: default public instance, `#!url=` hash deep link from the web app, browser localStorage, and filter state in URL query params (`q`, `topic`, `lang`, `sort`, `page`). - Deep link from `html2rss-web`: `https://html2rss.github.io/feed-directory/#!url={encodedInstanceUrl}` must keep working. - Catalog metadata in YAML (`directory.title`, `directory.summary`, `directory.topics`) is authored in `html2rss-configs` only. - Ruby gem docs should match `html2rss` behavior and CLI output. @@ -44,6 +44,20 @@ If a cross-repo behavior changed but upstream is not updated yet, document the g - 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`). - 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). + +### Module layout (`src/components/feed-directory/`) + +| Layer | Path | Role | +| -------- | ----------- | ------------------------------------------------------------------------------------------ | +| adapters | `adapters/` | Catalog API fetch/parse, browser storage, URL filters, OPML download | +| domain | `domain/` | Pure behavior — filters, language, feed URLs, OPML build; no `window` / `document` | +| app | `app/` | State transitions (`directory-state.ts`), view model, event wiring (`FeedDirectoryApp.ts`) | +| ui | `ui/` | HTML rendering from `FeedDirectoryViewModel` | +| lib | `lib/` | Shared utilities (escape, debounce) | + +Entry point: `feed-directory/FeedDirectory.astro` mounts `FeedDirectoryApp` directly. ## Generated Artifacts @@ -58,11 +72,13 @@ Run commands from `html2rss.github.io/`: - `make build` builds production output - `make lint` checks formatting - `make lintfix` applies formatting fixes +- `make test` runs Vitest on feed-directory pure modules +- `make check` runs `lint` and `test` Preferred verification flow for docs/content changes: -1. Run targeted check(s) first (`make lint` or `make build`). -2. Run the broader check set before PR (`make lint` and `make build`). +1. Run targeted check(s) first (`make lint`, `make test`, or `make build`). +2. Run the broader check set before PR (`make lint`, `make test`, and `make build`). 3. For feed-directory UI changes, spot-check against a running instance with catalog enabled (`GET /api/v1/configs` returns entries). ## Docs Authoring Rules diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..dbe04297 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,51 @@ +# Feed Directory glossary + +Terms used across the `src/components/feed-directory/` module tree. + +## Feed Directory + +The browse UI embedded on `/feed-directory/`. It is a thin client: it loads catalog JSON from an active `html2rss-web` instance, renders rows client-side, and builds RSS links from each entry's `path`. + +## FeedDirectoryEntry + +Normalized domain type for one catalog row after wire parsing. Required fields only — no OpenAPI nullability leaks into filters or render code. Produced exclusively by `adapters/catalog-api.ts`. + +| Field | Meaning | +| -------------------------------------- | ---------------------------------------------------- | +| `id` | Config identifier (e.g. `bbc.com/mundo`) | +| `path` | RSS path on the instance (e.g. `/bbc.com/mundo.rss`) | +| `siteKey` | Host key derived from `id` for display and site sort | +| `title`, `summary`, `topics` | Directory metadata from YAML | +| `channelUrl`, `language` | Channel metadata | +| `parameterSchema`, `parameterDefaults` | Dynamic feed parameters | + +## 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]`) +- **Domain:** `FeedDirectoryEntry[]` consumed by filters, OPML build, and render + +Wire parsing must stay in `adapters/catalog-api.ts` only. + +## Instance persistence contract + +| Mechanism | Key / format | Behavior | +| ---------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | +| Default instance | `DEFAULT_INSTANCE_URL` in `adapters/browser-storage.ts` | `https://1.h2r.workers.dev/` | +| Deep link | `#!url={encodedInstanceUrl}` | Read on load, normalized to https/http, persisted, hash stripped | +| localStorage | `html2rss.feedDirectory.instanceUrl` | Stores custom instance when different from default | +| Filter state | URL query params `q`, `topic`, `lang`, `sort`, `page` | Managed by `adapters/browser-location.ts` | + +Deep link from `html2rss-web`: `https://html2rss.github.io/feed-directory/#!url={encodedInstanceUrl}` must keep working. + +## Module layout + +| Layer | Path | Role | +| -------- | ----------- | -------------------------------------------------- | +| adapters | `adapters/` | Browser I/O and catalog API wire translation | +| domain | `domain/` | Pure behavior — no `window` / `document` | +| app | `app/` | Orchestration — state transitions and event wiring | +| ui | `ui/` | HTML string rendering from view model | +| lib | `lib/` | Shared utilities (escape, debounce) | diff --git a/Makefile b/Makefile index 401598b1..2aa0e2b4 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,11 @@ build-full: lint: npm run lint +test: + npm run test + +check: lint test + lintfix: npm run lintfix diff --git a/package-lock.json b/package-lock.json index a5ddb685..4927f193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ }, "devDependencies": { "prettier": "^3.9.6", - "prettier-plugin-astro": "^0.14.0" + "prettier-plugin-astro": "^0.14.0", + "vitest": "^3.2.4" } }, "node_modules/@astrojs/compiler": { @@ -1709,6 +1710,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -2117,6 +2138,395 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@shikijs/core": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", @@ -2227,6 +2637,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "license": "MIT", @@ -2234,6 +2655,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2316,6 +2744,94 @@ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2420,6 +2936,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -2581,6 +3107,16 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ccount": { "version": "2.0.1", "license": "MIT", @@ -2589,6 +3125,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2639,6 +3192,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -2890,6 +3453,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -3233,6 +3806,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expressive-code": { "version": "0.44.1", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz", @@ -3889,6 +4472,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", @@ -4206,6 +4796,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -5529,6 +6126,23 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -6099,6 +6713,52 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, "node_modules/s.color": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/s.color/-/s.color-0.0.15.tgz", @@ -6228,6 +6888,13 @@ "node": ">=20" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6293,6 +6960,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-replace-string": { "version": "2.0.0", "license": "MIT" @@ -6309,6 +6990,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -6380,6 +7074,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyclip": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", @@ -6414,6 +7115,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6852,6 +7583,111 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -6871,6 +7707,188 @@ } } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -6881,6 +7899,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/package.json b/package.json index dbfe04f7..d8e9655d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "astro": "astro", "build:full": "npm run build", "lint": "prettier --check .", - "lintfix": "prettier --write ." + "lintfix": "prettier --write .", + "test": "vitest run" }, "dependencies": { "@astrojs/sitemap": "^3.7.3", @@ -20,7 +21,8 @@ }, "devDependencies": { "prettier": "^3.9.6", - "prettier-plugin-astro": "^0.14.0" + "prettier-plugin-astro": "^0.14.0", + "vitest": "^3.2.4" }, "overrides": { "esbuild": "^0.28.1" diff --git a/src/components/FeedDirectory.astro b/src/components/FeedDirectory.astro deleted file mode 100644 index 14f0f063..00000000 --- a/src/components/FeedDirectory.astro +++ /dev/null @@ -1,622 +0,0 @@ ---- -import { Icon } from "@astrojs/starlight/components"; ---- - -
- - -
-
- -
- - -
-

- Search across - 0 - ready-to-use feeds -

-
- -
-
-

Topics

-
-
- -
-
- - -
- - -
-
- -
-
- Using instance: - 1.h2r.workers.dev - -
- - -
-
- - - -
-
- - - - diff --git a/src/components/catalogClient.js b/src/components/catalogClient.js deleted file mode 100644 index 818fb0a6..00000000 --- a/src/components/catalogClient.js +++ /dev/null @@ -1,59 +0,0 @@ -export class CatalogDisabledError extends Error { - constructor(message = 'Catalog is disabled on this instance.') { - super(message); - this.name = 'CatalogDisabledError'; - } -} - -export class CatalogNetworkError extends Error { - constructor(message = 'Could not reach the instance catalog.') { - super(message); - this.name = 'CatalogNetworkError'; - } -} - -export class CatalogInvalidEnvelopeError extends Error { - constructor(message = 'The instance returned an invalid catalog response.') { - super(message); - this.name = 'CatalogInvalidEnvelopeError'; - } -} - -/** - * @param {string} instanceUrl - * @returns {Promise<{ configs: Array>, meta: Record }>} - */ -export async function fetchCatalog(instanceUrl) { - const catalogUrl = new URL('/api/v1/configs', instanceUrl).toString(); - - let response; - try { - response = await fetch(catalogUrl, { headers: { Accept: 'application/json' } }); - } catch { - throw new CatalogNetworkError(); - } - - if (response.status === 404) { - throw new CatalogDisabledError(); - } - - if (!response.ok) { - throw new CatalogNetworkError(`Catalog request failed with status ${response.status}.`); - } - - let payload; - try { - payload = await response.json(); - } catch { - throw new CatalogInvalidEnvelopeError(); - } - - if (!payload?.success || !Array.isArray(payload?.data?.configs)) { - throw new CatalogInvalidEnvelopeError(); - } - - return { - configs: payload.data.configs, - meta: payload.meta ?? {}, - }; -} diff --git a/src/components/feed-directory.js b/src/components/feed-directory.js deleted file mode 100644 index fa81e6e7..00000000 --- a/src/components/feed-directory.js +++ /dev/null @@ -1,516 +0,0 @@ -import { - CatalogDisabledError, - CatalogInvalidEnvelopeError, - CatalogNetworkError, - fetchCatalog, -} from './catalogClient.js'; -import { - buildFeedUrl, - formatInstanceLabel, - getDefaultInstanceUrl, - normalizeInstanceUrl, - readInitialInstanceUrl, - writeInstanceUrl, -} from './instanceUrl.js'; - -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -function fuzzyMatch(text, query) { - if (!query) return true; - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - let textIndex = 0; - let queryIndex = 0; - while (queryIndex < lowerQuery.length && textIndex < lowerText.length) { - if (lowerQuery[queryIndex] === lowerText[textIndex]) queryIndex++; - textIndex++; - } - return queryIndex === lowerQuery.length; -} - -function escapeHtml(value) { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} - -function escapeXml(value) { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function setCatalogStatus(message, state = 'idle') { - const status = document.querySelector('[data-catalog-status]'); - if (!status) return; - status.textContent = message; - status.dataset.state = state; - status.hidden = !message; -} - -function setInstanceFeedback(message, state) { - const feedback = document.querySelector('[data-instance-feedback]'); - if (!feedback) return; - feedback.textContent = message; - feedback.dataset.state = state; -} - -function updateInstanceSummary(instanceUrl) { - const host = document.querySelector('[data-instance-host]'); - if (host) { - host.textContent = formatInstanceLabel(instanceUrl); - } -} - -function populateTopicFilters(configs) { - const container = document.querySelector('[data-topic-chips]'); - if (!container) return; - - const topics = [...new Set(configs.flatMap((entry) => entry.directory?.topics || []))].sort(); - container.innerHTML = topics - .map( - (topic) => - `` - ) - .join(''); -} - -function populateLanguageFilter(configs) { - const select = document.querySelector('[data-language-filter]'); - if (!select) return; - - const languages = [...new Set(configs.map((entry) => entry.channel?.language).filter(Boolean))].sort(); - select.innerHTML = - '' + - languages - .map((language) => ``) - .join(''); -} - -function renderParameterForm(entry, index) { - const schema = entry.parameters?.schema || {}; - const defaults = entry.parameters?.defaults || {}; - const keys = Object.keys(schema); - if (keys.length === 0) return ''; - - const fields = keys - .map((key) => { - const defaultValue = defaults[key] ?? ''; - return `
- - -
`; - }) - .join(''); - - return ``; -} - -function renderCatalogRow(entry, index, instanceUrl) { - const title = entry.directory?.title || entry.id; - const summary = entry.directory?.summary || ''; - const topics = (entry.directory?.topics || []).join(' '); - const language = entry.channel?.language || ''; - const feedUrl = buildFeedUrl(instanceUrl, entry, entry.parameters?.defaults || {}); - const sourceUrl = entry.channel?.url || ''; - const searchable = `${entry.id} ${title} ${summary} ${sourceUrl} ${topics} ${language}`; - const hasParameters = Object.keys(entry.parameters?.schema || {}).length > 0; - const [domain, ...nameParts] = entry.id.split('/'); - const name = nameParts.join('/'); - - return `
-
-
-

${escapeHtml(title)}

-
- RSS -
-
-
- ${summary ? `

${escapeHtml(summary)}

` : ''} -
- Advanced -
- ${sourceUrl ? `View source` : ''} - ${language ? `${escapeHtml(language)}` : ''} - ${hasParameters ? `` : ''} - -
-
-
-
- ${renderParameterForm(entry, index)} -
`; -} - -function updateFeedLinks(instanceUrl) { - document.querySelectorAll('[data-entry-id]').forEach((item) => { - const feedLink = item.querySelector('[data-feed-url]'); - if (!feedLink) return; - - const params = {}; - item.querySelectorAll('[data-param-key]').forEach((input) => { - if (input.value) params[input.dataset.paramKey] = input.value; - }); - - const entry = { - id: item.dataset.entryId, - path: `/${item.dataset.entryId}.rss`, - }; - feedLink.href = buildFeedUrl(instanceUrl, entry, params); - }); -} - -function updateSearchState(feedItems, query, selectedTopics = [], selectedLanguage = '') { - let visibleCount = 0; - feedItems.forEach((item) => { - const searchableText = item.dataset.searchable?.toLowerCase() || ''; - const matchesSearch = fuzzyMatch(searchableText, query); - const itemTopics = (item.dataset.topics || '').split(/\s+/).filter(Boolean); - const matchesTopics = - selectedTopics.length === 0 || selectedTopics.some((topic) => itemTopics.includes(topic)); - const itemLanguage = item.dataset.language || ''; - const matchesLanguage = !selectedLanguage || itemLanguage === selectedLanguage; - const matches = matchesSearch && matchesTopics && matchesLanguage; - item.hidden = !matches; - if (matches) visibleCount++; - }); - - const resultCount = document.querySelector('[data-result-count]'); - const resultLabel = document.querySelector('[data-result-label]'); - const emptyState = document.querySelector('[data-empty-state]'); - const emptyCopy = document.querySelector('[data-empty-copy]'); - const feedList = document.querySelector('[data-feed-list]'); - const hasActiveFilters = Boolean(query.trim()) || selectedTopics.length > 0 || Boolean(selectedLanguage); - - if (resultCount) resultCount.textContent = String(visibleCount); - if (resultLabel) { - resultLabel.textContent = hasActiveFilters - ? visibleCount === 1 - ? 'matching feed' - : 'matching feeds' - : visibleCount === 1 - ? 'ready-to-use feed' - : 'ready-to-use feeds'; - } - - if (emptyState && emptyCopy && feedList) { - const hasNoResults = visibleCount === 0; - emptyState.hidden = !hasNoResults; - feedList.hidden = hasNoResults; - if (hasNoResults) { - emptyCopy.textContent = hasActiveFilters - ? 'No configurations match the current search and filters. Try clearing a topic or language filter.' - : 'Try a different domain or feed name, or contribute a new configuration.'; - } - } -} - -function getSelectedTopics() { - return Array.from(document.querySelectorAll('[data-topic-filter][aria-pressed="true"]')).map( - (button) => button.dataset.topicFilter - ); -} - -function getSelectedLanguage() { - const languageFilter = document.querySelector('[data-language-filter]'); - return languageFilter?.value || ''; -} - -function setupFilters(searchInput, feedItems) { - const applyFilters = debounce(() => { - updateSearchState( - feedItems, - (searchInput?.value || '').toLowerCase(), - getSelectedTopics(), - getSelectedLanguage() - ); - }, 120); - - if (searchInput) searchInput.addEventListener('input', applyFilters); - - document.addEventListener('click', (event) => { - const button = event.target.closest('[data-topic-filter]'); - if (!button) return; - const pressed = button.getAttribute('aria-pressed') === 'true'; - button.setAttribute('aria-pressed', String(!pressed)); - applyFilters(); - }); - - const languageFilter = document.querySelector('[data-language-filter]'); - if (languageFilter) languageFilter.addEventListener('change', applyFilters); - - applyFilters(); -} - -function buildOpmlDocument(visibleItems) { - const outlines = visibleItems - .map((item) => { - const feedLink = item.querySelector('[data-feed-url]'); - const title = item.querySelector('.feed-title')?.textContent?.trim() || item.dataset.entryId; - const xmlUrl = feedLink?.href; - if (!xmlUrl || xmlUrl === '#' || xmlUrl.endsWith('/#')) return null; - - const htmlUrl = item.querySelector('.meta-link[title]')?.getAttribute('href') || ''; - const htmlAttr = htmlUrl ? ` htmlUrl="${escapeXml(htmlUrl)}"` : ''; - return ` `; - }) - .filter(Boolean) - .join('\n'); - - return ` - - - html2rss feeds - - -${outlines} - - -`; -} - -function setupOpmlExport(feedItems) { - const exportButton = document.querySelector('[data-export-opml]'); - if (!exportButton) return; - - exportButton.addEventListener('click', () => { - const visibleItems = feedItems.filter((item) => !item.hidden); - if (visibleItems.length === 0) return; - - const opml = buildOpmlDocument(visibleItems); - const blob = new Blob([opml], { type: 'text/x-opml+xml' }); - const objectUrl = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = 'html2rss-feeds.opml'; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(objectUrl); - }); -} - -function setupInstanceEditor( - defaultInstanceUrl, - getCurrentInstanceUrl, - setCurrentInstanceUrl, - updateFeedUrls, - reloadCatalog -) { - const toggle = document.querySelector('[data-toggle-instance]'); - const editor = document.getElementById('instance-editor'); - const input = document.getElementById('instance-url-input'); - const apply = document.querySelector('[data-apply-instance]'); - if (!toggle || !editor || !input || !apply) return; - - const directory = document.querySelector('[data-feed-directory]'); - if (directory) directory.dataset.enhanced = 'true'; - - const setExpanded = (expanded) => { - editor.hidden = !expanded; - toggle.setAttribute('aria-expanded', String(expanded)); - toggle.textContent = expanded ? 'Close' : 'Change'; - }; - - toggle.addEventListener('click', () => { - const nextExpanded = editor.hidden; - setExpanded(nextExpanded); - if (nextExpanded) { - input.value = getCurrentInstanceUrl(); - setInstanceFeedback('Feed links update when you apply a valid instance URL.', 'idle'); - input.focus(); - input.select(); - } - }); - - const applyInstance = async () => { - const normalized = normalizeInstanceUrl(input.value); - if (!normalized) { - setInstanceFeedback('Enter a valid URL.', 'error'); - return; - } - - setCurrentInstanceUrl(normalized); - input.value = normalized; - updateInstanceSummary(normalized); - writeInstanceUrl(normalized, defaultInstanceUrl); - setExpanded(false); - setInstanceFeedback('Using your custom instance.', 'success'); - await reloadCatalog(normalized); - }; - - setExpanded(false); - apply.addEventListener('click', applyInstance); - input.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - applyInstance(); - } - }); -} - -function setupParameterForms(updateFeedUrls, getCurrentInstanceUrl) { - document.addEventListener('click', (event) => { - const sourceButton = event.target.closest('[data-target]'); - if (!sourceButton) return; - - const form = document.getElementById(sourceButton.dataset.target); - if (!form) return; - - const isExpanded = !form.hidden; - form.hidden = isExpanded; - sourceButton.setAttribute('aria-expanded', String(!isExpanded)); - const label = sourceButton.querySelector('span'); - if (label) label.textContent = isExpanded ? 'Customize' : 'Close'; - - if (!isExpanded) updateFeedUrls(getCurrentInstanceUrl()); - }); -} - -function setupCloseForms() { - document.addEventListener('click', (event) => { - const button = event.target.closest('[data-close-form]'); - if (!button) return; - - const form = button.closest('.parameter-form'); - const toggle = document.querySelector(`[data-target="${form?.id}"]`); - if (!form || !toggle) return; - - form.hidden = true; - toggle.setAttribute('aria-expanded', 'false'); - const label = toggle.querySelector('span'); - if (label) label.textContent = 'Customize'; - }); -} - -function setupParameterInputs(updateFeedUrls, getCurrentInstanceUrl) { - document.addEventListener( - 'input', - debounce((event) => { - if (!event.target.matches('.form-input')) return; - updateFeedUrls(getCurrentInstanceUrl()); - }, 180) - ); -} - -function setupCopyButtons() { - document.addEventListener('click', async (event) => { - const button = event.target.closest('[data-copy-feed]'); - if (!button) return; - - const feedLink = button.closest('[data-entry-id]')?.querySelector('[data-feed-url]'); - if (!feedLink?.href) return; - - try { - await navigator.clipboard.writeText(feedLink.href); - const label = button.querySelector('span'); - button.dataset.copied = 'true'; - if (label) label.textContent = 'Copied'; - window.setTimeout(() => { - button.dataset.copied = 'false'; - if (label) label.textContent = 'Copy link'; - }, 1400); - } catch { - const label = button.querySelector('span'); - if (label) label.textContent = 'Copy failed'; - window.setTimeout(() => { - if (label) label.textContent = 'Copy link'; - }, 1400); - } - }); -} - -async function renderCatalog(instanceUrl) { - const root = document.querySelector('[data-feed-list]'); - const searchInput = document.getElementById('search-input'); - if (!root) return; - - setCatalogStatus('Loading feeds from the instance catalog…', 'loading'); - root.innerHTML = ''; - - try { - const { configs } = await fetchCatalog(instanceUrl); - populateTopicFilters(configs); - populateLanguageFilter(configs); - root.innerHTML = configs.map((entry, index) => renderCatalogRow(entry, index, instanceUrl)).join(''); - - const feedItems = Array.from(root.querySelectorAll('[data-entry-id]')); - document - .querySelector('[data-result-count]') - ?.replaceChildren(document.createTextNode(String(feedItems.length))); - setCatalogStatus('', 'idle'); - setupFilters(searchInput, feedItems); - setupOpmlExport(feedItems); - updateFeedLinks(instanceUrl); - } catch (error) { - root.innerHTML = ''; - document.querySelector('[data-empty-state]')?.setAttribute('hidden', ''); - if (error instanceof CatalogDisabledError) { - setCatalogStatus('This instance has the feed catalog disabled.', 'error'); - } else if (error instanceof CatalogInvalidEnvelopeError) { - setCatalogStatus('The instance returned an unexpected catalog response.', 'error'); - } else if (error instanceof CatalogNetworkError) { - setCatalogStatus('Could not load the feed catalog from this instance.', 'error'); - } else { - setCatalogStatus('Could not load the feed catalog.', 'error'); - } - } -} - -function initializeFeedDirectory() { - const defaultInstanceUrl = getDefaultInstanceUrl(); - let currentInstanceUrl = readInitialInstanceUrl(defaultInstanceUrl); - const instanceInput = document.getElementById('instance-url-input'); - const updateFeedUrls = () => updateFeedLinks(currentInstanceUrl); - const reloadCatalog = async (nextUrl) => { - currentInstanceUrl = nextUrl; - await renderCatalog(currentInstanceUrl); - }; - - updateInstanceSummary(currentInstanceUrl); - if (instanceInput) instanceInput.value = currentInstanceUrl; - - setupInstanceEditor( - defaultInstanceUrl, - () => currentInstanceUrl, - (nextUrl) => { - currentInstanceUrl = nextUrl; - }, - updateFeedUrls, - reloadCatalog - ); - setupParameterForms(updateFeedUrls, () => currentInstanceUrl); - setupCloseForms(); - setupParameterInputs(updateFeedUrls, () => currentInstanceUrl); - setupCopyButtons(); - - renderCatalog(currentInstanceUrl); -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeFeedDirectory); -} else { - initializeFeedDirectory(); -} diff --git a/src/components/feed-directory/FeedDirectory.astro b/src/components/feed-directory/FeedDirectory.astro new file mode 100644 index 00000000..a7ed24c1 --- /dev/null +++ b/src/components/feed-directory/FeedDirectory.astro @@ -0,0 +1,15 @@ +--- +import "./feed-directory.css"; +--- + +
+ + diff --git a/src/components/feed-directory/adapters/browser-download.ts b/src/components/feed-directory/adapters/browser-download.ts new file mode 100644 index 00000000..dad35cbd --- /dev/null +++ b/src/components/feed-directory/adapters/browser-download.ts @@ -0,0 +1,11 @@ +export function downloadOpml(content: string, filename = 'html2rss-feeds.opml'): void { + const blob = new Blob([content], { type: 'text/x-opml+xml' }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} diff --git a/src/components/feed-directory/adapters/browser-location.ts b/src/components/feed-directory/adapters/browser-location.ts new file mode 100644 index 00000000..556e66de --- /dev/null +++ b/src/components/feed-directory/adapters/browser-location.ts @@ -0,0 +1,49 @@ +import type { FilterState, SortKey } from '../domain/types'; +import { DEFAULT_FILTER_STATE } from '../domain/filters'; +import { normalizeFilterLanguage } from '../domain/language'; + +const SORT_KEYS: SortKey[] = ['title', 'site']; + +function parseSort(value: string | null): SortKey { + return SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : DEFAULT_FILTER_STATE.sort; +} + +export function readFiltersFromUrl(): FilterState { + const params = new URLSearchParams(window.location.search); + const page = Number.parseInt(params.get('page') ?? '1', 10); + + return { + query: params.get('q') ?? '', + topics: params.getAll('topic').filter(Boolean), + language: normalizeFilterLanguage(params.get('lang') ?? ''), + sort: parseSort(params.get('sort')), + page: Number.isFinite(page) && page > 0 ? page : 1, + }; +} + +export function writeFiltersToUrl(filters: FilterState): void { + const params = new URLSearchParams(); + + const query = filters.query.trim(); + if (query) params.set('q', query); + + for (const topic of filters.topics) { + params.append('topic', topic); + } + + if (filters.language) params.set('lang', normalizeFilterLanguage(filters.language)); + if (filters.sort !== DEFAULT_FILTER_STATE.sort) params.set('sort', filters.sort); + if (filters.page > 1) params.set('page', String(filters.page)); + + const next = params.toString(); + const url = next ? `${window.location.pathname}?${next}` : window.location.pathname; + + window.history.replaceState({}, '', url); +} + +export function clearFilters(current: FilterState): FilterState { + return { + ...DEFAULT_FILTER_STATE, + sort: current.sort, + }; +} diff --git a/src/components/feed-directory/adapters/browser-storage.ts b/src/components/feed-directory/adapters/browser-storage.ts new file mode 100644 index 00000000..2ad7a69b --- /dev/null +++ b/src/components/feed-directory/adapters/browser-storage.ts @@ -0,0 +1,74 @@ +export const DEFAULT_INSTANCE_URL = 'https://1.h2r.workers.dev/'; + +const STORAGE_KEY = 'html2rss.feedDirectory.instanceUrl'; + +function hashParams(): URLSearchParams { + const hash = window.location.hash || ''; + if (!hash.startsWith('#!')) return new URLSearchParams(); + return new URLSearchParams(hash.slice(2)); +} + +function normalizeParsed(parsed: URL): string | null { + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); +} + +export function getDefaultInstanceUrl(): string { + return DEFAULT_INSTANCE_URL; +} + +export function normalizeInstanceUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + return normalizeParsed(new URL(trimmed)); + } catch { + return null; + } +} + +export function readInitialInstanceUrl(defaultUrl = getDefaultInstanceUrl()): string { + const fromHash = hashParams().get('url'); + if (fromHash) { + try { + const normalized = normalizeParsed(new URL(fromHash)); + if (normalized) { + persistInstanceUrl(normalized, defaultUrl); + return normalized; + } + } catch { + // fall through + } + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored) { + const normalized = normalizeInstanceUrl(stored); + if (normalized) return normalized; + } + } catch { + // ignore storage failures + } + + return defaultUrl; +} + +export function persistInstanceUrl(instanceUrl: string, defaultUrl = getDefaultInstanceUrl()): void { + try { + if (instanceUrl && instanceUrl !== defaultUrl) { + window.localStorage.setItem(STORAGE_KEY, instanceUrl); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + } catch { + // ignore + } + + if (window.location.hash.startsWith('#!')) { + const next = `${window.location.pathname}${window.location.search}`; + window.history.replaceState({}, '', next); + } +} diff --git a/src/components/feed-directory/adapters/catalog-api.test.ts b/src/components/feed-directory/adapters/catalog-api.test.ts new file mode 100644 index 00000000..883d6959 --- /dev/null +++ b/src/components/feed-directory/adapters/catalog-api.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + CatalogDisabledError, + CatalogInvalidEnvelopeError, + CatalogUnsupportedVersionError, + fetchCatalogResponse, + mapCatalogError, +} from './catalog-api'; + +const validEnvelope = { + success: true, + data: { + configs: [ + { + id: 'anthropic.com/news', + path: '/anthropic.com/news.rss', + channel: { url: 'https://www.anthropic.com/news', language: 'en' }, + directory: { title: 'Anthropic — News', summary: 'Announcements.', topics: ['news'] }, + 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', language: 'en-GB' }, + directory: { title: 'BBC Sounds — Programme episodes', summary: 'Episodes.', topics: ['media'] }, + parameters: { schema: { id: { type: 'string' } }, defaults: { id: 'b006wkfp' } }, + }, + { id: 'broken' }, + ], + }, + meta: { total: 2, catalog_version: 1 }, +}; + +function mockFetch(response: Partial & Pick): typeof fetch { + return (async () => response) as typeof fetch; +} + +describe('fetchCatalogResponse', () => { + it('maps valid envelope rows and drops invalid ones', async () => { + const fetchImpl = mockFetch({ + ok: true, + status: 200, + json: async () => validEnvelope, + } as Response); + + const { entries, meta } = await fetchCatalogResponse('https://example.test/', fetchImpl); + + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: 'anthropic.com/news', + siteKey: 'anthropic.com', + title: 'Anthropic — News', + topics: ['news'], + language: 'en', + }); + expect(entries[1]?.parameterDefaults).toEqual({ id: 'b006wkfp' }); + expect(meta).toEqual({ total: 2, catalogVersion: 1 }); + }); + + it('throws disabled on 404', async () => { + const fetchImpl = mockFetch({ ok: false, status: 404 } as Response); + await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf( + CatalogDisabledError + ); + }); + + it('throws invalid on malformed envelope', async () => { + const fetchImpl = mockFetch({ + ok: true, + status: 200, + json: async () => ({ success: false }), + } as Response); + await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf( + CatalogInvalidEnvelopeError + ); + }); + + 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 }, + }), + } as Response); + await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf( + CatalogUnsupportedVersionError + ); + }); +}); + +describe('mapCatalogError', () => { + it('maps unsupported version errors', () => { + expect(mapCatalogError(new CatalogUnsupportedVersionError()).kind).toBe('unsupported_version'); + }); +}); diff --git a/src/components/feed-directory/adapters/catalog-api.ts b/src/components/feed-directory/adapters/catalog-api.ts new file mode 100644 index 00000000..585290b6 --- /dev/null +++ b/src/components/feed-directory/adapters/catalog-api.ts @@ -0,0 +1,195 @@ +import { siteKeyFromId } from '../domain/entry'; +import type { CatalogLoadError, FeedDirectoryEntry } from '../domain/types'; + +const SUPPORTED_CATALOG_VERSIONS = [1] 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 }; +} + +interface CatalogEnvelope { + success?: unknown; + data?: { configs?: unknown }; + meta?: { total?: unknown; catalog_version?: unknown }; +} + +export class CatalogDisabledError extends Error { + constructor(message = 'Catalog is disabled on this instance.') { + super(message); + this.name = 'CatalogDisabledError'; + } +} + +export class CatalogNetworkError extends Error { + constructor(message = 'Could not reach the instance catalog.') { + super(message); + this.name = 'CatalogNetworkError'; + } +} + +export class CatalogInvalidEnvelopeError extends Error { + constructor(message = 'The instance returned an invalid catalog response.') { + super(message); + this.name = 'CatalogInvalidEnvelopeError'; + } +} + +export class CatalogUnsupportedVersionError extends Error { + constructor(message = 'This instance returned an unsupported catalog version.') { + super(message); + this.name = 'CatalogUnsupportedVersionError'; + } +} + +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 parseStringArray(value: unknown): readonly string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); +} + +function parseParameterSchema(value: unknown): Readonly> { + if (!isRecord(value)) return {}; + + const schema: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (!isRecord(raw)) continue; + const type = asString(raw.type); + if (type) schema[key] = { type }; + } + return schema; +} + +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; +} + +function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] { + if (!Array.isArray(configs)) return []; + + const entries: FeedDirectoryEntry[] = []; + 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, + siteKey: siteKeyFromId(id), + title: asString(wire.directory?.title) ?? id, + summary: asString(wire.directory?.summary) ?? '', + topics: parseStringArray(wire.directory?.topics), + channelUrl, + language: asString(wire.channel?.language) ?? '', + parameterSchema: parseParameterSchema(wire.parameters?.schema), + parameterDefaults: parseParameterDefaults(wire.parameters?.defaults), + }); + } + + return entries; +} + +function parseCatalogVersion(meta: CatalogEnvelope['meta']): number { + const version = meta?.catalog_version; + if (typeof version !== 'number' || !Number.isFinite(version)) { + throw new CatalogInvalidEnvelopeError(); + } + if (!SUPPORTED_CATALOG_VERSIONS.includes(version as (typeof SUPPORTED_CATALOG_VERSIONS)[number])) { + throw new CatalogUnsupportedVersionError(); + } + return version; +} + +function parseCatalogEnvelope(payload: unknown): { + entries: FeedDirectoryEntry[]; + meta: { total: number; catalogVersion: number }; +} { + if (!isRecord(payload)) { + throw new CatalogInvalidEnvelopeError(); + } + + const envelope = payload as CatalogEnvelope; + if (envelope.success !== true || !isRecord(envelope.data)) { + throw new CatalogInvalidEnvelopeError(); + } + + const entries = parseCatalogEntries(envelope.data.configs); + const catalogVersion = parseCatalogVersion(envelope.meta); + const totalRaw = envelope.meta?.total; + const total = typeof totalRaw === 'number' && Number.isFinite(totalRaw) ? totalRaw : entries.length; + + return { + entries, + meta: { total, catalogVersion }, + }; +} + +export async function fetchCatalogResponse( + instanceUrl: string, + fetchImpl: typeof fetch = fetch +): Promise<{ entries: FeedDirectoryEntry[]; meta: { total: number; catalogVersion: number } }> { + const catalogUrl = new URL('/api/v1/configs', instanceUrl).toString(); + + let response: Response; + try { + response = await fetchImpl(catalogUrl, { headers: { Accept: 'application/json' } }); + } catch { + throw new CatalogNetworkError(); + } + + if (response.status === 404) { + throw new CatalogDisabledError(); + } + + if (!response.ok) { + throw new CatalogNetworkError(`Catalog request failed with status ${response.status}.`); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new CatalogInvalidEnvelopeError(); + } + + return parseCatalogEnvelope(payload); +} + +export function mapCatalogError(error: unknown): CatalogLoadError { + if (error instanceof CatalogDisabledError) { + return { kind: 'disabled', message: 'This instance has the feed catalog disabled.' }; + } + if (error instanceof CatalogUnsupportedVersionError) { + return { + kind: 'unsupported_version', + message: 'This instance returned an unsupported catalog version.', + }; + } + if (error instanceof CatalogInvalidEnvelopeError) { + return { kind: 'invalid', message: 'The instance returned an unexpected catalog response.' }; + } + if (error instanceof CatalogNetworkError) { + return { kind: 'network', message: 'Could not load the feed catalog from this instance.' }; + } + return { kind: 'unknown', message: 'Could not load the feed catalog.' }; +} diff --git a/src/components/feed-directory/app/FeedDirectoryApp.ts b/src/components/feed-directory/app/FeedDirectoryApp.ts new file mode 100644 index 00000000..52c8d350 --- /dev/null +++ b/src/components/feed-directory/app/FeedDirectoryApp.ts @@ -0,0 +1,248 @@ +import { fetchCatalogResponse, mapCatalogError } from '../adapters/catalog-api'; +import { clearFilters, readFiltersFromUrl, writeFiltersToUrl } from '../adapters/browser-location'; +import { + getDefaultInstanceUrl, + normalizeInstanceUrl, + persistInstanceUrl, + readInitialInstanceUrl, +} from '../adapters/browser-storage'; +import { downloadOpml } from '../adapters/browser-download'; +import { buildFeedUrl } from '../domain/feed-url'; +import { buildOpmlDocument } from '../domain/opml'; +import { normalizeFilterLanguage } from '../domain/language'; +import { debounce } from '../lib/debounce'; +import { renderFeedDirectory } from '../ui/render'; +import { + applyFilterPatch, + catalogErrorState, + catalogReadyState, + initialState, + resetFilters, + selectPagedEntries, + type DirectoryState, +} from './directory-state'; +import { buildViewModel } from './view-model'; + +export class FeedDirectoryApp { + private readonly root: HTMLElement; + private state: DirectoryState; + + private readonly debouncedSearch = debounce((value: string) => { + this.patchFilters({ query: value, page: 1 }); + }, 180); + + constructor(root: HTMLElement) { + this.root = root; + this.state = initialState(readFiltersFromUrl(), readInitialInstanceUrl(getDefaultInstanceUrl())); + } + + start(): void { + this.root.addEventListener('click', (event) => this.onClick(event)); + this.root.addEventListener('input', (event) => this.onInput(event)); + this.root.addEventListener('change', (event) => this.onChange(event)); + this.render(); + void this.loadCatalog(); + } + + private async loadCatalog(nextInstanceUrl = this.state.instanceUrl): Promise { + this.state = { ...this.state, loadState: 'loading', error: null }; + this.render(); + + try { + const { entries, meta } = await fetchCatalogResponse(nextInstanceUrl); + this.state = catalogReadyState( + { + ...this.state, + instanceUrl: nextInstanceUrl, + instanceDraft: nextInstanceUrl, + }, + entries, + meta.total + ); + } catch (caught) { + this.state = catalogErrorState(this.state, mapCatalogError(caught)); + } + + this.render(); + } + + private patchFilters(patch: Parameters[1]): void { + this.state = applyFilterPatch(this.state, patch); + writeFiltersToUrl(this.state.filters); + this.render(); + } + + private currentPagedSelection() { + const paged = selectPagedEntries(this.state); + if (paged.filters.page !== this.state.filters.page) { + this.state = applyFilterPatch(this.state, { page: paged.filters.page }); + writeFiltersToUrl(this.state.filters); + } + return selectPagedEntries(this.state); + } + + private render(): void { + const paged = this.currentPagedSelection(); + this.root.innerHTML = renderFeedDirectory(buildViewModel(this.state, paged)); + this.syncRefs(); + } + + private syncRefs(): void { + const search = this.root.querySelector('[data-ref="search"]'); + if (search && search.value !== this.state.filters.query) { + search.value = this.state.filters.query; + } + } + + private onInput(event: Event): void { + const target = event.target; + if (!(target instanceof HTMLInputElement)) return; + + if (target.dataset.ref === 'search') { + this.debouncedSearch(target.value); + return; + } + + const entryId = target.dataset.entryId; + const paramKey = target.dataset.paramKey; + if (entryId && paramKey) { + const next = { ...(this.state.parametersById[entryId] ?? {}), [paramKey]: target.value }; + this.state = { + ...this.state, + parametersById: { ...this.state.parametersById, [entryId]: next }, + }; + this.render(); + } + + if (target.dataset.ref === 'instance-draft') { + this.state = { ...this.state, instanceDraft: target.value }; + } + } + + private onChange(event: Event): void { + const target = event.target; + if (!(target instanceof HTMLSelectElement)) return; + + if (target.dataset.ref === 'language') { + this.patchFilters({ language: normalizeFilterLanguage(target.value), page: 1 }); + return; + } + + if (target.dataset.ref === 'sort') { + this.patchFilters({ sort: target.value as DirectoryState['filters']['sort'], page: 1 }); + } + } + + private onClick(event: Event): void { + const target = event.target; + if (!(target instanceof Element)) return; + const actionEl = target.closest('[data-action]'); + if (!actionEl) return; + + const action = actionEl.dataset.action; + if (!action) return; + + switch (action) { + case 'toggle-topic': { + const topic = actionEl.dataset.topic; + if (!topic) return; + const selected = new Set(this.state.filters.topics); + if (selected.has(topic)) selected.delete(topic); + else selected.add(topic); + this.patchFilters({ topics: [...selected], page: 1 }); + break; + } + case 'clear-filters': + this.patchFilters(clearFilters(this.state.filters)); + break; + case 'page-prev': + if (this.state.filters.page > 1) this.patchFilters({ page: this.state.filters.page - 1 }); + break; + case 'page-next': + this.patchFilters({ page: this.state.filters.page + 1 }); + break; + case 'toggle-instance': + this.state = { + ...this.state, + instanceEditorOpen: !this.state.instanceEditorOpen, + instanceFeedback: null, + }; + this.render(); + break; + case 'apply-instance': + void this.applyInstance(); + break; + case 'toggle-params': { + const entryId = actionEl.dataset.entryId; + if (!entryId) return; + this.state = { + ...this.state, + expandedEntryId: this.state.expandedEntryId === entryId ? null : entryId, + }; + this.render(); + break; + } + case 'copy-feed': + void this.copyFeed(actionEl.dataset.entryId); + break; + case 'export-opml': + this.exportOpml(); + break; + default: + break; + } + } + + private async applyInstance(): Promise { + const normalized = normalizeInstanceUrl(this.state.instanceDraft); + if (!normalized) { + this.state = { + ...this.state, + instanceFeedback: { message: 'Enter a valid http(s) URL.', tone: 'error' }, + }; + this.render(); + return; + } + + persistInstanceUrl(normalized, getDefaultInstanceUrl()); + this.state = resetFilters( + { + ...this.state, + instanceEditorOpen: false, + instanceFeedback: { message: 'Using your custom instance.', tone: 'success' }, + }, + readFiltersFromUrl() + ); + await this.loadCatalog(normalized); + } + + private async copyFeed(entryId: string | undefined): Promise { + if (!entryId) return; + const entry = this.state.entries.find((item) => item.id === entryId); + if (!entry) return; + + const url = buildFeedUrl(this.state.instanceUrl, entry, this.state.parametersById[entryId] ?? {}); + try { + await navigator.clipboard.writeText(url); + this.state = { ...this.state, copiedEntryId: entryId }; + this.render(); + window.setTimeout(() => { + this.state = { ...this.state, copiedEntryId: null }; + this.render(); + }, 1400); + } catch { + this.state = { + ...this.state, + instanceFeedback: { message: 'Could not copy link.', tone: 'error' }, + }; + this.render(); + } + } + + private exportOpml(): void { + const { filteredEntries } = selectPagedEntries(this.state); + if (filteredEntries.length === 0) return; + const opml = buildOpmlDocument(this.state.instanceUrl, filteredEntries, this.state.parametersById); + downloadOpml(opml); + } +} diff --git a/src/components/feed-directory/app/directory-state.test.ts b/src/components/feed-directory/app/directory-state.test.ts new file mode 100644 index 00000000..19adab01 --- /dev/null +++ b/src/components/feed-directory/app/directory-state.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { applyFilterPatch, initialState, selectPagedEntries } from './directory-state'; +import { DEFAULT_FILTER_STATE, PAGE_SIZE } from '../domain/filters'; +import type { FeedDirectoryEntry } from '../domain/types'; + +const entry = (id: string, title: string): FeedDirectoryEntry => ({ + id, + path: `/${id}.rss`, + siteKey: id.split('/')[0] ?? id, + title, + summary: '', + topics: [], + channelUrl: `https://${id}`, + language: '', + parameterSchema: {}, + parameterDefaults: {}, +}); + +describe('applyFilterPatch', () => { + it('merges filter patches immutably', () => { + const state = initialState(DEFAULT_FILTER_STATE, 'https://example.test/'); + const next = applyFilterPatch(state, { query: 'news', page: 2 }); + expect(next.filters.query).toBe('news'); + expect(next.filters.page).toBe(2); + expect(state.filters.query).toBe(''); + }); +}); + +describe('selectPagedEntries', () => { + it('clamps page without mutating source state', () => { + const entries = Array.from({ length: PAGE_SIZE + 1 }, (_, index) => + entry(`site.example/feed-${index}`, `Feed ${index}`) + ); + const state = { + ...initialState(DEFAULT_FILTER_STATE, 'https://example.test/'), + loadState: 'ready' as const, + entries, + catalogTotal: entries.length, + }; + const paged = selectPagedEntries(applyFilterPatch(state, { page: 99 })); + expect(paged.pageItems).toHaveLength(1); + expect(paged.filters.page).toBe(2); + expect(state.filters.page).toBe(1); + }); +}); diff --git a/src/components/feed-directory/app/directory-state.ts b/src/components/feed-directory/app/directory-state.ts new file mode 100644 index 00000000..48cfb99f --- /dev/null +++ b/src/components/feed-directory/app/directory-state.ts @@ -0,0 +1,122 @@ +import { + DEFAULT_FILTER_STATE, + extractFacets, + filterEntries, + paginateEntries, + sortEntries, +} from '../domain/filters'; +import type { + CatalogFacets, + CatalogLoadError, + FeedDirectoryEntry, + FilterState, + LoadState, +} from '../domain/types'; + +export interface InstanceFeedback { + message: string; + tone: 'idle' | 'error' | 'success'; +} + +export interface DirectoryState { + loadState: LoadState; + entries: FeedDirectoryEntry[]; + facets: CatalogFacets; + catalogTotal: number; + filters: FilterState; + instanceUrl: string; + instanceDraft: string; + instanceEditorOpen: boolean; + instanceFeedback: InstanceFeedback | null; + expandedEntryId: string | null; + parametersById: Record>; + copiedEntryId: string | null; + error: CatalogLoadError | null; +} + +export interface PagedSelection { + filteredEntries: FeedDirectoryEntry[]; + pageItems: FeedDirectoryEntry[]; + filteredTotal: number; + totalPages: number; + filters: FilterState; +} + +export function initialState(filters: FilterState, instanceUrl: string): DirectoryState { + return { + loadState: 'idle', + entries: [], + facets: { topics: [], languages: [] }, + catalogTotal: 0, + filters, + instanceUrl, + instanceDraft: instanceUrl, + instanceEditorOpen: false, + instanceFeedback: null, + expandedEntryId: null, + parametersById: {}, + copiedEntryId: null, + error: null, + }; +} + +export function applyFilterPatch(state: DirectoryState, patch: Partial): DirectoryState { + return { + ...state, + filters: { ...state.filters, ...patch }, + }; +} + +export function selectPagedEntries(state: DirectoryState): PagedSelection { + const filteredEntries = sortEntries(filterEntries(state.entries, state.filters), state.filters.sort); + const { items, totalPages, total } = paginateEntries(filteredEntries, state.filters.page); + + return { + filteredEntries, + pageItems: items, + filteredTotal: total, + totalPages, + filters: { ...state.filters, page: Math.min(Math.max(state.filters.page, 1), totalPages) }, + }; +} + +export function resetCatalogState(state: DirectoryState): DirectoryState { + return { + ...state, + expandedEntryId: null, + parametersById: {}, + }; +} + +export function catalogReadyState( + state: DirectoryState, + entries: FeedDirectoryEntry[], + catalogTotal: number +): DirectoryState { + return { + ...resetCatalogState(state), + loadState: 'ready', + entries, + catalogTotal, + facets: extractFacets(entries), + error: null, + }; +} + +export function catalogErrorState(state: DirectoryState, error: CatalogLoadError): DirectoryState { + return { + ...state, + loadState: 'error', + error, + entries: [], + facets: { topics: [], languages: [] }, + catalogTotal: 0, + }; +} + +export function resetFilters(state: DirectoryState, filters: FilterState): DirectoryState { + return { + ...state, + filters: { ...DEFAULT_FILTER_STATE, ...filters }, + }; +} diff --git a/src/components/feed-directory/app/view-model.ts b/src/components/feed-directory/app/view-model.ts new file mode 100644 index 00000000..253608eb --- /dev/null +++ b/src/components/feed-directory/app/view-model.ts @@ -0,0 +1,49 @@ +import type { PagedSelection, DirectoryState } from './directory-state'; +import type { + CatalogFacets, + CatalogLoadError, + FeedDirectoryEntry, + FilterState, + LoadState, +} from '../domain/types'; +import type { InstanceFeedback } from './directory-state'; + +export interface FeedDirectoryViewModel { + loadState: LoadState; + error: CatalogLoadError | null; + instanceUrl: string; + instanceEditorOpen: boolean; + instanceDraft: string; + instanceFeedback: InstanceFeedback | null; + filters: FilterState; + facets: CatalogFacets; + catalogTotal: number; + catalogEntryCount: number; + filteredTotal: number; + pageItems: FeedDirectoryEntry[]; + totalPages: number; + expandedEntryId: string | null; + parametersById: Record>; + copiedEntryId: string | null; +} + +export function buildViewModel(state: DirectoryState, paged: PagedSelection): FeedDirectoryViewModel { + return { + loadState: state.loadState, + error: state.error, + instanceUrl: state.instanceUrl, + instanceEditorOpen: state.instanceEditorOpen, + instanceDraft: state.instanceDraft, + instanceFeedback: state.instanceFeedback, + filters: paged.filters, + facets: state.facets, + catalogTotal: state.catalogTotal, + catalogEntryCount: state.entries.length, + filteredTotal: paged.filteredTotal, + pageItems: paged.pageItems, + totalPages: paged.totalPages, + expandedEntryId: state.expandedEntryId, + parametersById: state.parametersById, + copiedEntryId: state.copiedEntryId, + }; +} diff --git a/src/components/feed-directory/domain/entry.ts b/src/components/feed-directory/domain/entry.ts new file mode 100644 index 00000000..6fbb679d --- /dev/null +++ b/src/components/feed-directory/domain/entry.ts @@ -0,0 +1,4 @@ +export function siteKeyFromId(entryId: string): string { + const slash = entryId.indexOf('/'); + return slash === -1 ? entryId : entryId.slice(0, slash); +} diff --git a/src/components/feed-directory/domain/feed-url.ts b/src/components/feed-directory/domain/feed-url.ts new file mode 100644 index 00000000..d1dfb715 --- /dev/null +++ b/src/components/feed-directory/domain/feed-url.ts @@ -0,0 +1,22 @@ +import type { FeedDirectoryEntry } from './types'; + +export function buildFeedUrl( + instanceUrl: string, + entry: Pick, + parameters: Record = {} +): string { + const url = new URL(entry.path, instanceUrl); + for (const [key, value] of Object.entries(parameters)) { + if (value) url.searchParams.set(key, value); + } + return url.toString(); +} + +export function formatInstanceLabel(instanceUrl: string): string { + try { + const parsed = new URL(instanceUrl); + return parsed.host + parsed.pathname.replace(/\/$/, ''); + } catch { + return instanceUrl; + } +} diff --git a/src/components/feed-directory/domain/filters.test.ts b/src/components/feed-directory/domain/filters.test.ts new file mode 100644 index 00000000..36e11857 --- /dev/null +++ b/src/components/feed-directory/domain/filters.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_FILTER_STATE, extractFacets, filterEntries, fuzzyMatch, sortEntries } from './filters'; +import type { FeedDirectoryEntry } from './types'; + +const baseEntry = ( + overrides: Partial & Pick +): FeedDirectoryEntry => ({ + path: `/${overrides.id}.rss`, + siteKey: overrides.siteKey ?? overrides.id.split('/')[0] ?? overrides.id, + title: overrides.title ?? overrides.id, + summary: overrides.summary ?? '', + topics: overrides.topics ?? [], + channelUrl: overrides.channelUrl ?? `https://${overrides.id}`, + language: overrides.language ?? '', + parameterSchema: overrides.parameterSchema ?? {}, + parameterDefaults: overrides.parameterDefaults ?? {}, + ...overrides, +}); + +describe('filterEntries', () => { + const mundo = baseEntry({ + id: 'bbc.com/mundo', + siteKey: 'bbc.com', + channelUrl: 'https://www.bbc.com/mundo', + title: 'BBC — Mundo', + summary: 'Spanish-language news from BBC Mundo.', + language: 'es', + topics: ['news'], + }); + const sounds = baseEntry({ + id: 'bbc.co.uk/available_episodes', + siteKey: 'bbc.co.uk', + channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', + title: 'BBC Sounds — Programme episodes', + topics: ['media'], + }); + + it('filters by fuzzy query across searchable text', () => { + const hits = filterEntries([mundo, sounds], { ...DEFAULT_FILTER_STATE, query: 'bbc' }); + expect(hits.map((entry) => entry.id)).toEqual(['bbc.com/mundo', 'bbc.co.uk/available_episodes']); + }); + + it('filters by topic and language', () => { + expect( + filterEntries([mundo, sounds], { ...DEFAULT_FILTER_STATE, topics: ['news'] }).map((entry) => entry.id) + ).toEqual(['bbc.com/mundo']); + expect( + filterEntries([mundo, sounds], { ...DEFAULT_FILTER_STATE, language: 'es' }).map((entry) => entry.id) + ).toEqual(['bbc.com/mundo']); + }); +}); + +describe('sortEntries', () => { + it('sorts by title and site key', () => { + const a = baseEntry({ id: 'z.example/feed', siteKey: 'z.example', title: 'Zulu' }); + const b = baseEntry({ id: 'a.example/feed', siteKey: 'a.example', title: 'Alpha' }); + expect(sortEntries([a, b], 'title').map((entry) => entry.id)).toEqual([ + 'a.example/feed', + 'z.example/feed', + ]); + expect(sortEntries([a, b], 'site').map((entry) => entry.id)).toEqual([ + 'a.example/feed', + 'z.example/feed', + ]); + }); +}); + +describe('extractFacets', () => { + it('collects unique topics and base languages', () => { + const facets = extractFacets([ + baseEntry({ id: 'a.example/one', topics: ['news'], language: 'en-US' }), + baseEntry({ id: 'b.example/two', topics: ['media', 'news'], language: 'de-DE' }), + ]); + expect(facets.topics).toEqual(['media', 'news']); + expect(facets.languages).toEqual(['de', 'en']); + }); +}); + +describe('fuzzyMatch', () => { + it('matches subsequence queries case-insensitively', () => { + expect(fuzzyMatch('Anthropic News', 'anth')).toBe(true); + expect(fuzzyMatch('Anthropic News', 'xyz')).toBe(false); + }); +}); diff --git a/src/components/feed-directory/domain/filters.ts b/src/components/feed-directory/domain/filters.ts new file mode 100644 index 00000000..e7f1cb1b --- /dev/null +++ b/src/components/feed-directory/domain/filters.ts @@ -0,0 +1,115 @@ +import type { CatalogFacets, FeedDirectoryEntry, FilterState, SortKey } from './types'; +import { baseLanguageCode, languageMatches } from './language'; + +export const PAGE_SIZE = 25; + +export const DEFAULT_FILTER_STATE: FilterState = { + query: '', + topics: [], + language: '', + sort: 'title', + page: 1, +}; + +export function buildSearchableText(entry: FeedDirectoryEntry): string { + const topics = entry.topics.join(' '); + const languageBase = baseLanguageCode(entry.language); + return [ + entry.id, + entry.title, + entry.summary, + entry.channelUrl, + entry.language, + languageBase, + topics, + entry.siteKey, + ] + .filter(Boolean) + .join(' '); +} + +export function fuzzyMatch(text: string, query: string): boolean { + if (!query) return true; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let textIndex = 0; + let queryIndex = 0; + while (queryIndex < lowerQuery.length && textIndex < lowerText.length) { + if (lowerQuery[queryIndex] === lowerText[textIndex]) queryIndex += 1; + textIndex += 1; + } + return queryIndex === lowerQuery.length; +} + +export function extractFacets(entries: FeedDirectoryEntry[]): CatalogFacets { + const topics = new Set(); + const languages = new Set(); + + for (const entry of entries) { + for (const topic of entry.topics) { + topics.add(topic); + } + if (entry.language) { + const base = baseLanguageCode(entry.language); + if (base) languages.add(base); + } + } + + return { + topics: [...topics].sort((a, b) => a.localeCompare(b)), + languages: [...languages].sort((a, b) => a.localeCompare(b)), + }; +} + +export function filterEntries(entries: FeedDirectoryEntry[], filters: FilterState): FeedDirectoryEntry[] { + const query = filters.query.trim().toLowerCase(); + + return entries.filter((entry) => { + const searchable = buildSearchableText(entry).toLowerCase(); + if (query && !fuzzyMatch(searchable, query)) return false; + + if (filters.topics.length > 0 && !filters.topics.some((topic) => entry.topics.includes(topic))) { + return false; + } + + if (filters.language && !languageMatches(entry.language, filters.language)) return false; + + return true; + }); +} + +export function sortEntries(entries: FeedDirectoryEntry[], sort: SortKey): FeedDirectoryEntry[] { + const sorted = [...entries]; + sorted.sort((a, b) => { + if (sort === 'site') { + return a.siteKey.localeCompare(b.siteKey); + } + return a.title.localeCompare(b.title); + }); + return sorted; +} + +export function paginateEntries( + items: T[], + page: number, + pageSize = PAGE_SIZE +): { items: T[]; totalPages: number; total: number } { + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(page, 1), totalPages); + const start = (safePage - 1) * pageSize; + return { + items: items.slice(start, start + pageSize), + totalPages, + total, + }; +} + +export function hasActiveFilters(filters: FilterState): boolean { + return ( + Boolean(filters.query.trim()) || + filters.topics.length > 0 || + Boolean(filters.language) || + filters.page > 1 + ); +} diff --git a/src/components/feed-directory/domain/language.test.ts b/src/components/feed-directory/domain/language.test.ts new file mode 100644 index 00000000..dc9b3ceb --- /dev/null +++ b/src/components/feed-directory/domain/language.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { baseLanguageCode, displayLanguage, languageMatches, normalizeFilterLanguage } from './language'; + +describe('baseLanguageCode', () => { + it('collapses BCP 47 tags to base language codes', () => { + expect(baseLanguageCode('en-GB')).toBe('en'); + expect(baseLanguageCode('de_DE')).toBe('de'); + expect(baseLanguageCode('')).toBeNull(); + }); +}); + +describe('languageMatches', () => { + it('matches base language codes only', () => { + expect(languageMatches('en-US', 'en')).toBe(true); + expect(languageMatches('de-DE', 'en')).toBe(false); + expect(languageMatches(undefined, 'en')).toBe(false); + }); +}); + +describe('normalizeFilterLanguage', () => { + it('normalizes filter values to base codes', () => { + expect(normalizeFilterLanguage('en-GB')).toBe('en'); + expect(normalizeFilterLanguage('')).toBe(''); + }); +}); + +describe('displayLanguage', () => { + it('shows base code or em dash placeholder', () => { + expect(displayLanguage('fr-CA')).toBe('fr'); + expect(displayLanguage(undefined)).toBe('—'); + }); +}); diff --git a/src/components/feed-directory/domain/language.ts b/src/components/feed-directory/domain/language.ts new file mode 100644 index 00000000..e6b72342 --- /dev/null +++ b/src/components/feed-directory/domain/language.ts @@ -0,0 +1,29 @@ +/** + * Collapse BCP 47 tags (e.g. de-DE, en-GB) to a base language code for filtering. + */ +export function baseLanguageCode(language: string | null | undefined): string | null { + if (!language) return null; + + const trimmed = language.trim(); + if (!trimmed) return null; + + const base = trimmed.split(/[-_]/)[0]?.toLowerCase(); + return base || null; +} + +export function languageMatches(entryLanguage: string | undefined, filterLanguage: string): boolean { + if (!filterLanguage) return true; + + const entryBase = baseLanguageCode(entryLanguage); + const filterBase = baseLanguageCode(filterLanguage); + + return entryBase !== null && filterBase !== null && entryBase === filterBase; +} + +export function normalizeFilterLanguage(filterLanguage: string): string { + return baseLanguageCode(filterLanguage) ?? ''; +} + +export function displayLanguage(language: string | null | undefined): string { + return baseLanguageCode(language) ?? '—'; +} diff --git a/src/components/feed-directory/domain/opml.test.ts b/src/components/feed-directory/domain/opml.test.ts new file mode 100644 index 00000000..07b29ac2 --- /dev/null +++ b/src/components/feed-directory/domain/opml.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { buildOpmlDocument } from './opml'; +import type { FeedDirectoryEntry } from './types'; + +const entry: FeedDirectoryEntry = { + id: 'anthropic.com/news', + path: '/anthropic.com/news.rss', + siteKey: 'anthropic.com', + title: 'Anthropic — News', + summary: 'Announcements.', + topics: ['news'], + channelUrl: 'https://www.anthropic.com/news', + language: 'en', + parameterSchema: {}, + parameterDefaults: {}, +}; + +describe('buildOpmlDocument', () => { + it('builds OPML with escaped titles and feed URLs', () => { + const xml = buildOpmlDocument('https://instance.example/', [entry], {}); + expect(xml).toContain(''); + expect(xml).toContain('xmlUrl="https://instance.example/anthropic.com/news.rss"'); + expect(xml).toContain('htmlUrl="https://www.anthropic.com/news"'); + expect(xml).toContain('title="Anthropic — News"'); + }); + + it('includes query parameters from overrides', () => { + const parameterized: FeedDirectoryEntry = { + ...entry, + id: 'bbc.co.uk/available_episodes', + path: '/bbc.co.uk/available_episodes.rss', + siteKey: 'bbc.co.uk', + title: 'BBC Sounds', + parameterSchema: { id: { type: 'string' } }, + parameterDefaults: { id: 'b006wkfp' }, + }; + const xml = buildOpmlDocument('https://instance.example/', [parameterized], { + 'bbc.co.uk/available_episodes': { id: 'custom-id' }, + }); + expect(xml).toContain('xmlUrl="https://instance.example/bbc.co.uk/available_episodes.rss?id=custom-id"'); + }); +}); diff --git a/src/components/feed-directory/domain/opml.ts b/src/components/feed-directory/domain/opml.ts new file mode 100644 index 00000000..0b5a8a36 --- /dev/null +++ b/src/components/feed-directory/domain/opml.ts @@ -0,0 +1,28 @@ +import { escapeXml } from '../lib/escape'; +import { buildFeedUrl } from './feed-url'; +import type { FeedDirectoryEntry } from './types'; + +export function buildOpmlDocument( + instanceUrl: string, + entries: FeedDirectoryEntry[], + parametersById: Record> +): string { + const outlines = entries + .map((entry) => { + const xmlUrl = buildFeedUrl(instanceUrl, entry, parametersById[entry.id] ?? {}); + const htmlAttr = entry.channelUrl ? ` htmlUrl="${escapeXml(entry.channelUrl)}"` : ''; + return ` `; + }) + .join('\n'); + + return ` + + + html2rss feeds + + +${outlines} + + +`; +} diff --git a/src/components/feed-directory/domain/types.ts b/src/components/feed-directory/domain/types.ts new file mode 100644 index 00000000..ba106299 --- /dev/null +++ b/src/components/feed-directory/domain/types.ts @@ -0,0 +1,36 @@ +export interface FeedDirectoryEntry { + id: string; + path: string; + siteKey: string; + title: string; + summary: string; + topics: readonly string[]; + channelUrl: string; + language: string; + parameterSchema: Readonly>; + parameterDefaults: Readonly>; +} + +export type SortKey = 'title' | 'site'; + +export interface FilterState { + query: string; + topics: string[]; + language: string; + sort: SortKey; + page: number; +} + +export interface CatalogFacets { + topics: string[]; + languages: string[]; +} + +export type LoadState = 'idle' | 'loading' | 'ready' | 'error'; + +export type CatalogErrorKind = 'disabled' | 'network' | 'invalid' | 'unsupported_version' | 'unknown'; + +export interface CatalogLoadError { + kind: CatalogErrorKind; + message: string; +} diff --git a/src/components/feed-directory/feed-directory.css b/src/components/feed-directory/feed-directory.css new file mode 100644 index 00000000..a4db4bbc --- /dev/null +++ b/src/components/feed-directory/feed-directory.css @@ -0,0 +1,541 @@ +.fd-app { + /* Layout */ + --fd-gap: 0.75rem; + --fd-gap-sm: 0.5rem; + --fd-gap-xs: 0.375rem; + --fd-gap-lg: 1rem; + --fd-margin-block: 1.5rem 2rem; + + /* Shape */ + --fd-radius: 0.75rem; + --fd-radius-sm: calc(var(--fd-radius) - 0.25rem); + --fd-radius-pill: 999px; + + /* Color */ + --fd-border: var(--sl-color-gray-5); + --fd-surface: var(--sl-color-bg); + --fd-surface-raised: var(--sl-color-gray-6, var(--sl-color-bg)); + --fd-text: var(--sl-color-text); + --fd-muted: var(--sl-color-gray-3); + --fd-accent: var(--sl-color-accent); + --fd-accent-soft: var(--sl-color-accent-low); + --fd-link: var(--sl-color-text-accent); + --fd-danger: var(--sl-color-red-high); + --fd-success: var(--sl-color-green-high); + --fd-border-muted: color-mix(in srgb, var(--fd-border) 45%, transparent); + --fd-row-hover: color-mix(in srgb, var(--fd-accent-soft) 35%, transparent); + --fd-shimmer-start: color-mix(in srgb, var(--fd-border) 35%, transparent); + --fd-shimmer-mid: color-mix(in srgb, var(--fd-border) 70%, transparent); + + /* Spacing */ + --fd-pad-panel: 1rem 1.125rem; + --fd-pad-message: 1.25rem 1.125rem; + --fd-pad-cell: 0.875rem 1rem; + --fd-pad-cell-block: 1rem; + --fd-pad-control: 0.625rem 0.75rem; + --fd-pad-btn: 0.45rem 0.8rem; + --fd-pad-btn-compact: 0.3rem 0.55rem; + --fd-pad-chip: 0.3rem 0.7rem; + --fd-pad-badge: 0.12rem 0.45rem; + --fd-pad-lang: 0.1rem 0.35rem; + + /* Layout constants */ + --fd-actions-col: 11.5rem; + --fd-field-inline-min: 10rem; + --fd-summary-max: 52rem; + + /* Focus */ + --fd-focus-ring: 2px solid var(--fd-accent); + --fd-focus-offset: 1px; + + color: var(--fd-text); + margin-block: var(--fd-margin-block); +} + +.fd-app *, +.fd-app *::before, +.fd-app *::after { + box-sizing: border-box; +} + +.fd-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.fd-shell { + display: grid; + gap: var(--fd-gap); +} + +.fd-header, +.fd-panel, +.fd-table-wrap, +.fd-empty, +.fd-loading, +.fd-banner { + border: 1px solid var(--fd-border); + border-radius: var(--fd-radius); + background: var(--fd-surface-raised); +} + +.fd-header { + display: flex; + flex-wrap: wrap; + gap: var(--fd-gap-lg); + align-items: center; + justify-content: space-between; + padding: var(--fd-pad-panel); +} + +.fd-header-copy { + min-width: min(100%, 16rem); +} + +.fd-eyebrow { + margin: 0; + color: var(--fd-muted); + font-size: var(--sl-text-xs); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.fd-lead { + margin: 0.25rem 0 0; + font-size: var(--sl-text-sm); + line-height: 1.5; +} + +.fd-header-actions, +.fd-filter-row, +.fd-inline-field { + display: flex; + flex-wrap: wrap; + gap: var(--fd-gap-sm); + align-items: center; +} + +.fd-action-bar { + display: inline-flex; + flex-wrap: nowrap; + gap: var(--fd-gap-xs); + align-items: center; + justify-content: flex-end; + max-width: 100%; +} + +.fd-panel { + padding: var(--fd-pad-panel); +} + +.fd-toolbar { + display: grid; + gap: var(--fd-gap-lg); +} + +.fd-search-input { + width: 100%; +} + +.fd-filter-block { + display: grid; + gap: 0.875rem; +} + +.fd-filter-group { + display: grid; + gap: var(--fd-gap-sm); +} + +.fd-filter-row { + justify-content: space-between; +} + +.fd-field, +.fd-field-inline { + display: grid; + gap: var(--fd-gap-xs); +} + +.fd-field-inline { + min-width: var(--fd-field-inline-min); +} + +.fd-field-label { + color: var(--fd-muted); + font-size: var(--sl-text-xs); + font-weight: 600; +} + +.fd-input, +.fd-select, +.fd-btn, +.fd-chip { + border: 1px solid var(--fd-border); + font: inherit; +} + +.fd-input, +.fd-select { + width: 100%; + min-width: 0; + padding: var(--fd-pad-control); + border-radius: var(--fd-radius-sm); + background: var(--fd-surface); + color: var(--fd-text); + font-size: var(--sl-text-sm); +} + +.fd-input:focus, +.fd-select:focus, +.fd-btn:focus-visible, +.fd-chip:focus-visible { + outline: var(--fd-focus-ring); + outline-offset: var(--fd-focus-offset); +} + +.fd-chip-row { + display: flex; + flex-wrap: wrap; + gap: var(--fd-gap-xs); +} + +.fd-chip { + appearance: none; + padding: var(--fd-pad-chip); + border-radius: var(--fd-radius-pill); + background: var(--fd-surface); + color: var(--fd-muted); + font-size: var(--sl-text-xs); + cursor: pointer; +} + +.fd-chip.is-active, +.fd-chip[aria-pressed="true"] { + border-color: var(--fd-accent); + background: var(--fd-accent-soft); + color: var(--fd-text); +} + +.fd-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: var(--fd-pad-btn); + border-radius: var(--fd-radius-sm); + background: var(--fd-surface); + color: var(--fd-text); + font-size: var(--sl-text-sm); + line-height: 1.2; + text-decoration: none; + cursor: pointer; +} + +.fd-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.fd-btn-primary { + border-color: var(--fd-accent); + background: var(--fd-accent-soft); +} + +.fd-btn-ghost { + background: transparent; +} + +.fd-btn-compact { + padding: var(--fd-pad-btn-compact); + font-size: var(--sl-text-xs); + white-space: nowrap; +} + +.fd-btn-primary:hover:not(:disabled), +.fd-btn-ghost:hover:not(:disabled), +.fd-chip:hover { + border-color: var(--fd-accent); +} + +.fd-table-wrap { + overflow-x: auto; +} + +.fd-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: var(--sl-text-sm); +} + +.fd-col-feed { + width: auto; +} + +.fd-col-actions, +.fd-col-actions-heading { + width: var(--fd-actions-col); +} + +.fd-table th, +.fd-table td { + padding: var(--fd-pad-cell); + border-bottom: 1px solid var(--fd-border); + text-align: left; + vertical-align: middle; +} + +.fd-cell-feed, +.fd-cell-actions { + vertical-align: top; + padding-block: var(--fd-pad-cell-block); +} + +.fd-cell-actions { + text-align: right; +} + +.fd-table th { + color: var(--fd-muted); + font-size: var(--sl-text-xs); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + background: var(--fd-surface); + padding-block: var(--fd-gap-sm); +} + +.fd-col-actions-heading { + text-align: right; +} + +.fd-table tbody tr:last-child td, +.fd-table tbody tr.fd-row-detail td { + border-bottom: 0; +} + +.fd-row:hover td { + background: var(--fd-row-hover); +} + +.fd-feed-card { + display: grid; + gap: 0.45rem; + min-width: 0; +} + +.fd-feed-title { + margin: 0; + font-size: var(--sl-text-base); + font-weight: 600; + line-height: 1.35; +} + +.fd-feed-summary { + margin: 0; + color: var(--fd-muted); + font-size: var(--sl-text-sm); + line-height: 1.5; + max-width: var(--fd-summary-max); +} + +.fd-feed-meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.55rem; + align-items: center; + margin-top: 0.15rem; +} + +.fd-domain { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--sl-font-system-mono); + font-size: var(--sl-text-xs); +} + +.fd-domain-link { + color: var(--fd-link); + text-decoration: none; +} + +.fd-domain-link:hover { + text-decoration: underline; +} + +.fd-lang { + display: inline-flex; + align-items: center; + padding: var(--fd-pad-lang); + border-radius: var(--fd-radius-sm); + background: var(--fd-border-muted); + font-size: var(--sl-text-xs); + color: var(--fd-muted); + text-transform: lowercase; +} + +.fd-badge { + display: inline-flex; + align-items: center; + padding: var(--fd-pad-badge); + border-radius: var(--fd-radius-pill); + background: var(--fd-surface); + border: 1px solid var(--fd-border); + color: var(--fd-muted); + font-size: var(--sl-text-xs); + line-height: 1.35; + white-space: nowrap; +} + +.fd-detail { + padding: 0.75rem 0 0.25rem; +} + +.fd-detail-title { + margin: 0 0 0.75rem; + font-size: var(--sl-text-sm); + font-weight: 600; +} + +.fd-params { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.fd-pagination { + display: flex; + flex-wrap: wrap; + gap: var(--fd-gap); + align-items: center; + justify-content: center; + padding: var(--fd-pad-cell); + border-top: 1px solid var(--fd-border); +} + +.fd-pagination-label { + color: var(--fd-muted); + font-size: var(--sl-text-sm); +} + +.fd-empty, +.fd-loading, +.fd-banner { + padding: var(--fd-pad-message); +} + +.fd-empty-title { + margin: 0 0 0.35rem; + font-size: var(--sl-text-base); + font-weight: 600; +} + +.fd-muted, +.fd-empty-hint, +.fd-feedback { + margin: 0; + color: var(--fd-muted); + font-size: var(--sl-text-sm); +} + +.fd-feedback-error, +.fd-banner-error { + color: var(--fd-danger); +} + +.fd-feedback-success { + color: var(--fd-success); +} + +.fd-banner-error { + padding: var(--fd-pad-cell); +} + +.fd-skeleton { + border-radius: var(--fd-radius-sm); + background: linear-gradient(90deg, var(--fd-shimmer-start), var(--fd-shimmer-mid), var(--fd-shimmer-start)); + background-size: 200% 100%; + animation: fd-shimmer 1.2s ease-in-out infinite; +} + +.fd-skeleton-toolbar { + height: 2.75rem; + margin-bottom: 0.75rem; +} + +.fd-skeleton-row { + height: 3rem; + margin-bottom: var(--fd-gap-sm); +} + +@keyframes fd-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +@media (max-width: 52rem) { + .fd-header, + .fd-filter-row, + .fd-inline-field { + flex-direction: column; + align-items: stretch; + } + + .fd-action-bar { + flex-wrap: wrap; + justify-content: flex-start; + } + + .fd-cell-actions { + text-align: left; + } + + .fd-table thead { + display: none; + } + + .fd-table, + .fd-table tbody, + .fd-table tr, + .fd-table td { + display: block; + width: 100%; + } + + .fd-table tr.fd-row { + padding: 0.25rem 0; + border-bottom: 1px solid var(--fd-border); + } + + .fd-table td { + padding: var(--fd-gap-sm) 1rem; + border: 0; + } + + .fd-cell-actions { + padding-top: 0; + padding-bottom: var(--fd-pad-cell-block); + } + + .fd-col-actions, + .fd-col-actions-heading { + width: auto; + } +} diff --git a/src/components/feed-directory/lib/debounce.ts b/src/components/feed-directory/lib/debounce.ts new file mode 100644 index 00000000..1004e89c --- /dev/null +++ b/src/components/feed-directory/lib/debounce.ts @@ -0,0 +1,7 @@ +export function debounce void>(func: T, wait: number): T { + let timeout: ReturnType | undefined; + return function debounced(this: ThisParameterType, ...args: Parameters) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + } as T; +} diff --git a/src/components/feed-directory/lib/escape.ts b/src/components/feed-directory/lib/escape.ts new file mode 100644 index 00000000..afecdc15 --- /dev/null +++ b/src/components/feed-directory/lib/escape.ts @@ -0,0 +1,16 @@ +export function escapeHtml(value: unknown): string { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function escapeXml(value: unknown): string { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/src/components/feed-directory/ui/render.ts b/src/components/feed-directory/ui/render.ts new file mode 100644 index 00000000..47828421 --- /dev/null +++ b/src/components/feed-directory/ui/render.ts @@ -0,0 +1,242 @@ +import { escapeHtml } from '../lib/escape'; +import { buildFeedUrl, formatInstanceLabel } from '../domain/feed-url'; +import { hasActiveFilters, PAGE_SIZE } from '../domain/filters'; +import { displayLanguage, normalizeFilterLanguage } from '../domain/language'; +import type { CatalogFacets, FeedDirectoryEntry, FilterState } from '../domain/types'; +import type { FeedDirectoryViewModel } from '../app/view-model'; + +function renderTopicChips(facets: CatalogFacets, selected: string[]): string { + if (facets.topics.length === 0) { + return `

Topics appear after the catalog loads.

`; + } + + return facets.topics + .map((topic) => { + const active = selected.includes(topic); + return ``; + }) + .join(''); +} + +function renderLanguageOptions(facets: CatalogFacets, selected: string): string { + const normalizedSelected = normalizeFilterLanguage(selected); + const options = facets.languages + .map( + (language) => + `` + ) + .join(''); + + return `${options}`; +} + +function renderParameterFields(entry: FeedDirectoryEntry, values: Record): string { + const keys = Object.keys(entry.parameterSchema); + if (keys.length === 0) return ''; + + const fields = keys + .map((key) => { + const value = values[key] ?? entry.parameterDefaults[key] ?? ''; + return ``; + }) + .join(''); + + return `
${fields}
`; +} + +function renderFeedRow(entry: FeedDirectoryEntry, vm: FeedDirectoryViewModel): string { + const language = displayLanguage(entry.language); + const params = vm.parametersById[entry.id] ?? {}; + const feedUrl = buildFeedUrl(vm.instanceUrl, entry, params); + const hasParameters = Object.keys(entry.parameterSchema).length > 0; + const expanded = vm.expandedEntryId === entry.id; + const copied = vm.copiedEntryId === entry.id; + + const topicBadges = + entry.topics.length > 0 + ? entry.topics.map((topic) => `${escapeHtml(topic)}`).join('') + : ''; + + const domainMarkup = entry.channelUrl + ? `${escapeHtml(entry.siteKey)}` + : `${escapeHtml(entry.siteKey)}`; + + return ` + +
+

${escapeHtml(entry.title)}

+ ${entry.summary ? `

${escapeHtml(entry.summary)}

` : ''} +
+ ${domainMarkup} + ${language !== '—' ? `${escapeHtml(language)}` : ''} + ${topicBadges} +
+
+ + +
+ RSS + + ${entry.channelUrl ? `Source` : ''} + ${hasParameters ? `` : ''} +
+ + + ${ + expanded && hasParameters + ? ` + +
+

Customize feed parameters

+ ${renderParameterFields(entry, params)} +
+ + ` + : '' + }`; +} + +function renderPagination(vm: FeedDirectoryViewModel): string { + if (vm.filteredTotal <= PAGE_SIZE) return ''; + + const prevDisabled = vm.filters.page <= 1; + const nextDisabled = vm.filters.page >= vm.totalPages; + + return ``; +} + +function renderFeedTable(vm: FeedDirectoryViewModel): string { + if (vm.loadState === 'loading') { + return `
+
+
+
+
+

Loading feeds from the instance catalog…

+
`; + } + + if (vm.loadState === 'error' && vm.error) { + return ``; + } + + if (vm.loadState !== 'ready') return ''; + + if (vm.catalogEntryCount === 0) { + return `
+

No feeds in this catalog

+

Try another instance or contribute a configuration.

+
`; + } + + if (vm.filteredTotal === 0) { + return `
+

No feeds match your filters

+

Try clearing search text, topics, or language filters.

+ +
`; + } + + const rows = vm.pageItems.map((entry) => renderFeedRow(entry, vm)).join(''); + + return `
+ + + + + + + + + + + + ${rows} +
FeedActions
+
+ ${renderPagination(vm)}`; +} + +export function renderFeedDirectory(vm: FeedDirectoryViewModel): string { + const activeFilters = hasActiveFilters(vm.filters); + const resultLabel = activeFilters + ? `${vm.filteredTotal} matching feed${vm.filteredTotal === 1 ? '' : 's'}` + : `${vm.catalogTotal} ready-to-use feed${vm.catalogTotal === 1 ? '' : 's'}`; + + const feedback = vm.instanceFeedback; + const feedbackClass = feedback?.tone ? ` fd-feedback-${feedback.tone}` : ''; + + return `
+
+
+

Feed Directory

+

${escapeHtml(resultLabel)} from ${escapeHtml(formatInstanceLabel(vm.instanceUrl))}

+
+
+ + +
+
+ + ${ + vm.instanceEditorOpen + ? `
+ +

${escapeHtml(feedback?.message ?? 'Feed links update when you apply a valid instance URL.')}

+
` + : '' + } + +
+ + +
+
+ Topics +
${renderTopicChips(vm.facets, vm.filters.topics)}
+
+ +
+ + + + + ${ + activeFilters + ? `` + : '' + } +
+
+
+ + ${renderFeedTable(vm)} +
`; +} + +export type { FeedDirectoryViewModel }; diff --git a/src/components/instanceUrl.js b/src/components/instanceUrl.js deleted file mode 100644 index 719cab0e..00000000 --- a/src/components/instanceUrl.js +++ /dev/null @@ -1,99 +0,0 @@ -export function getDefaultInstanceUrl() { - return atob('aHR0cHM6Ly8xLmgyci53b3JrZXJzLmRldi8='); -} - -export function getStorageKey() { - return 'html2rss.feedDirectory.instanceUrl'; -} - -export function getHashParams() { - const hash = window.location.hash || ''; - if (!hash.startsWith('#!')) return new URLSearchParams(); - return new URLSearchParams(hash.slice(2)); -} - -export function normalizeParsedInstanceUrl(parsedUrl) { - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - return null; - } - - parsedUrl.search = ''; - parsedUrl.hash = ''; - return parsedUrl.toString(); -} - -export function readInstanceUrlFromHash(defaultInstanceUrl) { - const candidate = getHashParams().get('url'); - if (!candidate) return defaultInstanceUrl; - - try { - return normalizeParsedInstanceUrl(new URL(candidate)) || defaultInstanceUrl; - } catch { - return defaultInstanceUrl; - } -} - -export function readInstanceUrlFromStorage(defaultInstanceUrl) { - try { - const candidate = window.localStorage.getItem(getStorageKey()); - if (!candidate) return defaultInstanceUrl; - return normalizeInstanceUrl(candidate) || defaultInstanceUrl; - } catch { - return defaultInstanceUrl; - } -} - -export function writeInstanceUrl(instanceUrl, defaultInstanceUrl) { - try { - if (instanceUrl && instanceUrl !== defaultInstanceUrl) { - window.localStorage.setItem(getStorageKey(), instanceUrl); - } else { - window.localStorage.removeItem(getStorageKey()); - } - } catch { - // Ignore storage failures and keep the current page usable. - } - - if (window.location.hash.startsWith('#!')) { - const nextUrl = `${window.location.pathname}${window.location.search}`; - window.history.replaceState({}, '', nextUrl); - } -} - -export function readInitialInstanceUrl(defaultInstanceUrl) { - const hashInstanceUrl = readInstanceUrlFromHash(defaultInstanceUrl); - if (hashInstanceUrl !== defaultInstanceUrl) { - writeInstanceUrl(hashInstanceUrl, defaultInstanceUrl); - return hashInstanceUrl; - } - - return readInstanceUrlFromStorage(defaultInstanceUrl); -} - -export function normalizeInstanceUrl(value) { - const trimmed = value.trim(); - if (!trimmed) return null; - - try { - return normalizeParsedInstanceUrl(new URL(trimmed)); - } catch { - return null; - } -} - -export function formatInstanceLabel(instanceUrl) { - try { - const parsedUrl = new URL(instanceUrl); - return parsedUrl.host + parsedUrl.pathname.replace(/\/$/, ''); - } catch { - return instanceUrl; - } -} - -export function buildFeedUrl(instanceUrl, entry, parameters = {}) { - const url = new URL(entry.path, instanceUrl); - Object.entries(parameters).forEach(([key, value]) => { - if (value) url.searchParams.set(key, value); - }); - return url.toString(); -} diff --git a/src/content/docs/feed-directory/index.mdx b/src/content/docs/feed-directory/index.mdx index 60421c8e..8b5944d2 100644 --- a/src/content/docs/feed-directory/index.mdx +++ b/src/content/docs/feed-directory/index.mdx @@ -1,6 +1,7 @@ --- title: "Feed Directory" description: "Browse pre-built configurations to create RSS feeds for various websites." +tableOfContents: false head: - tag: meta attrs: @@ -8,17 +9,17 @@ head: content: noindex --- -import FeedDirectory from "../../../components/FeedDirectory.astro"; +import FeedDirectory from "../../../components/feed-directory/FeedDirectory.astro"; --- -Browse pre-built feed configs by site name. The list loads from the active `html2rss-web` instance (`GET /api/v1/configs`). Use search and topic filters to narrow results; export OPML from Advanced when subscribing to several feeds at once. +Browse pre-built feed configs by site name. The list loads from the active `html2rss-web` instance (`GET /api/v1/configs`). Search, filter by topic or language, and paginate results. Export OPML for the current filter set when subscribing to several feeds at once. Need the main onboarding path first? Start with [Getting Started](/web-application/getting-started/) and create a feed from your own page URL. The directory below is the fallback path when a curated config already covers your site. -Need a different instance? Set the instance URL in Advanced, self-host your own, or find more options on the [community-run wiki](https://github.com/html2rss/html2rss-web/wiki/Instances). +Need a different instance? Use **Change instance** in the directory header, self-host your own, or find more options on the [community-run wiki](https://github.com/html2rss/html2rss-web/wiki/Instances). [🚀 Host Your Own Instance (and share it!)](/web-application/deployment/) diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx index 0ec4945c..e70b6f84 100644 --- a/src/content/docs/index.mdx +++ b/src/content/docs/index.mdx @@ -41,6 +41,6 @@ The canonical path to your first feed: - **Start with Docker**: It is the recommended way to run `html2rss-web`. - **Start with a Page URL**: Automatic generation is the primary workflow. -- **Use Included Configs**: Check the [Feed Directory](/feed-directory/) first; the site you want might already be covered. +- **Feed Directory**: Check the [Feed Directory](/feed-directory/) first; the site you want might already be covered. **Need help?** Continue to the [troubleshooting guide](/troubleshooting/troubleshooting/) or join [GitHub Discussions](https://github.com/orgs/html2rss/discussions). diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..eacd178c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, configDefaults } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + exclude: [...configDefaults.exclude, 'dist/**'], + }, +});