From c0995881cd46d15151c5e4bd80e7864fa0d6247f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 13:37:27 +0100 Subject: [PATCH 1/2] feat: make feed directory search-first --- src/components/FeedDirectory.astro | 818 +++++++++++++--------- src/components/feed-directory.js | 358 +++++++--- src/content/docs/feed-directory/index.mdx | 16 +- 3 files changed, 759 insertions(+), 433 deletions(-) diff --git a/src/components/FeedDirectory.astro b/src/components/FeedDirectory.astro index f9b88fb6..c4c2a272 100644 --- a/src/components/FeedDirectory.astro +++ b/src/components/FeedDirectory.astro @@ -1,142 +1,186 @@ --- import { configs } from "../data/loadConfigs"; -import { Icon, LinkButton } from "@astrojs/starlight/components"; - -// Simple helper functions -function getFeedUrl( - config: { - domain: string; - name: string; - url_parameters?: Record; - }, - instanceUrl: string, - params: Record = {}, -) { - const baseUrl = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`; - let url = `${baseUrl}${config.domain}/${config.name}.rss`; - - const queryParams = new URLSearchParams(); - Object.keys(config.url_parameters || {}).forEach((key) => { - if (params[key]) queryParams.append(key, params[key]); - }); - - const queryString = queryParams.toString(); - if (queryString) url += `?${queryString}`; - return url; +import { Icon } from "@astrojs/starlight/components"; + +const feedCount = configs.length; + +function formatDefaultParameters(defaultParameters: Record = {}) { + return Object.entries(defaultParameters) + .filter(([, value]) => value) + .map(([key, value]) => `${key}=${value}`) + .join(", "); } -// Don't generate static URLs to avoid exposing instance URL in build const staticFeedUrls = configs.map((config) => ({ ...config, - staticFeedUrl: "#", // Placeholder that will be updated by JavaScript + staticFeedUrl: "#", + defaultSummary: formatDefaultParameters(config.default_parameters), + sourceSummary: config.channel?.url + ?.replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/\/$/, ""), })); --- -
- -
- - - - - +
+
+
+ +
+ + +
+

+ Search across + {feedCount} + ready-to-use feeds +

+
+ +
+
+ Using instance: + 1.h2r.workers.dev + +
+ + +
+
+ + -
+
{ staticFeedUrls.map((config, index) => ( -
-
-
-
- <> - {config.domain} - / - {config.name} - +
+
+

{config.sourceSummary || `${config.domain}/${config.name}`}

+ + +
- {config.channel?.url && ( -
+
+ {config.defaultSummary ? ( +

Defaults: {config.defaultSummary}

+ ) : ( +
+ )} + + {Object.keys(config.url_parameters || {}).length > 0 && ( + + )} -
- {!config.valid_channel_url && Object.keys(config.url_parameters || {}).length > 0 ? ( - ) : ( -
- )} - - - - RSS - - - - - Edit - +
- {!config.valid_channel_url && Object.keys(config.url_parameters || {}).length > 0 && ( + {Object.keys(config.url_parameters || {}).length > 0 && ( ))} +
-
)} -
+ )) }
-
+
- diff --git a/src/components/feed-directory.js b/src/components/feed-directory.js index d7d42f07..d690ffd6 100644 --- a/src/components/feed-directory.js +++ b/src/components/feed-directory.js @@ -1,7 +1,5 @@ // Feed Directory JavaScript functionality -// Simple, focused functions for maintainability -// Simple debounce helper function debounce(func, wait) { let timeout; return function executedFunction(...args) { @@ -14,7 +12,45 @@ function debounce(func, wait) { }; } -// Simple fuzzy search +function getDefaultInstanceUrl() { + return atob('aHR0cHM6Ly8xLmgyci53b3JrZXJzLmRldi8='); +} + +function getHashParams() { + const hash = window.location.hash || ''; + const normalizedHash = hash.startsWith('#!') ? hash.slice(2) : hash.startsWith('#') ? hash.slice(1) : hash; + return new URLSearchParams(normalizedHash); +} + +function readInstanceUrlFromHash(defaultInstanceUrl) { + const candidate = getHashParams().get('url'); + if (!candidate) return defaultInstanceUrl; + + try { + const parsedUrl = new URL(candidate); + parsedUrl.search = ''; + parsedUrl.hash = ''; + return parsedUrl.toString(); + } catch { + return defaultInstanceUrl; + } +} + +function writeInstanceUrlToHash(instanceUrl, defaultInstanceUrl) { + const params = getHashParams(); + if (instanceUrl && instanceUrl !== defaultInstanceUrl) { + params.set('url', instanceUrl); + } else { + params.delete('url'); + } + + const nextHash = params.toString(); + const nextUrl = nextHash + ? `${window.location.pathname}${window.location.search}#!${nextHash}` + : `${window.location.pathname}${window.location.search}`; + window.history.replaceState({}, '', nextUrl); +} + function fuzzyMatch(text, query) { if (!query) return true; const lowerText = text.toLowerCase(); @@ -28,150 +64,284 @@ function fuzzyMatch(text, query) { return queryIndex === lowerQuery.length; } -// Search functionality +function formatInstanceLabel(instanceUrl) { + try { + const parsedUrl = new URL(instanceUrl); + return parsedUrl.host + parsedUrl.pathname.replace(/\/$/, ''); + } catch { + return instanceUrl; + } +} + +function normalizeInstanceUrl(value) { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const parsedUrl = new URL(trimmed); + parsedUrl.search = ''; + parsedUrl.hash = ''; + return parsedUrl.toString(); + } catch { + return null; + } +} + +function updateInstanceSummary(instanceUrl) { + const host = document.querySelector('[data-instance-host]'); + if (host) { + host.textContent = formatInstanceLabel(instanceUrl); + } +} + +function setInstanceFeedback(message, state) { + const feedback = document.querySelector('[data-instance-feedback]'); + if (!feedback) return; + feedback.textContent = message; + feedback.dataset.state = state; +} + +function createUpdateFeedUrlsFunction() { + return function updateFeedUrls(instanceUrl) { + document.querySelectorAll('[data-feed-url]').forEach((link) => { + const item = link.closest('[data-domain]'); + if (!item) return; + + const domain = item.dataset.domain; + const name = item.dataset.name; + if (!domain || !name) return; + + const params = {}; + item.querySelectorAll('[data-param-key]').forEach((input) => { + if (input.value) params[input.dataset.paramKey] = input.value; + }); + + let url = instanceUrl.endsWith('/') ? instanceUrl : `${instanceUrl}/`; + url += `${domain}/${name}.rss`; + + const queryParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => queryParams.append(key, value)); + const queryString = queryParams.toString(); + if (queryString) url += `?${queryString}`; + + link.href = url; + }); + }; +} + +function updateSearchState(feedItems, query) { + let visibleCount = 0; + feedItems.forEach((item) => { + const searchableText = item.dataset.searchable?.toLowerCase() || ''; + const matches = fuzzyMatch(searchableText, query); + 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]'); + + if (resultCount) { + resultCount.textContent = String(visibleCount); + } + + if (resultLabel) { + const hasQuery = Boolean(query.trim()); + if (hasQuery) { + resultLabel.textContent = visibleCount === 1 ? 'matching feed' : 'matching feeds'; + } else { + resultLabel.textContent = 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 = query + ? `No configurations found for "${query}". Try another domain or feed name, check the community wiki, or contribute a new configuration.` + : 'Try a different domain or feed name, or contribute a new configuration.'; + } + } +} + function setupSearch(searchInput, feedItems) { if (!searchInput) return; - searchInput.addEventListener( - 'input', - debounce((e) => { - const query = e.target.value.toLowerCase(); - feedItems.forEach((item) => { - const searchableText = item.dataset.searchable?.toLowerCase() || ''; - item.style.display = fuzzyMatch(searchableText, query) ? 'flex' : 'none'; - }); - }, 150) - ); + const applySearch = debounce((query) => { + updateSearchState(feedItems, query.toLowerCase()); + }, 120); + + searchInput.addEventListener('input', (event) => { + applySearch(event.target.value); + }); + + updateSearchState(feedItems, searchInput.value.toLowerCase()); } -// Instance URL updates -function setupInstanceUrlUpdates(instanceInput, defaultInstanceUrl, updateFeedUrls) { - if (!instanceInput) return; +function setupInstanceEditor( + defaultInstanceUrl, + getCurrentInstanceUrl, + setCurrentInstanceUrl, + updateFeedUrls +) { + 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; - instanceInput.addEventListener( - 'input', - debounce((e) => { - const instanceUrl = e.target.value || defaultInstanceUrl; - updateFeedUrls(instanceUrl); - }, 300) - ); + 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 = () => { + const normalized = normalizeInstanceUrl(input.value); + if (!normalized) { + setInstanceFeedback('Enter a valid URL.', 'error'); + return; + } + + setCurrentInstanceUrl(normalized); + input.value = normalized; + updateInstanceSummary(normalized); + writeInstanceUrlToHash(normalized, defaultInstanceUrl); + updateFeedUrls(normalized); + setExpanded(false); + setInstanceFeedback('Using your custom instance.', 'success'); + }; + + apply.addEventListener('click', applyInstance); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + applyInstance(); + } + }); } -// Parameter forms toggle -function setupParameterForms(updateFeedUrls) { - document.querySelectorAll('.configure-button').forEach((button) => { - button.addEventListener('click', (e) => { - const targetId = e.target.closest('button')?.dataset.target; - const form = document.getElementById(targetId); +function setupParameterForms(updateFeedUrls, getCurrentInstanceUrl) { + document.querySelectorAll('[data-target]').forEach((button) => { + button.addEventListener('click', (event) => { + const sourceButton = event.currentTarget; + const form = document.getElementById(sourceButton.dataset.target); if (!form) return; const isExpanded = !form.hidden; form.hidden = isExpanded; - const button = e.target.closest('button'); - if (button) { - button.setAttribute('aria-expanded', !isExpanded); - const span = button.querySelector('span'); - if (span) { - span.textContent = isExpanded ? 'Configure' : 'Hide'; - } + sourceButton.setAttribute('aria-expanded', String(!isExpanded)); + const label = sourceButton.querySelector('span'); + if (label) { + label.textContent = isExpanded ? 'Customize' : 'Close'; } - if (!isExpanded) updateFeedUrls(); + if (!isExpanded) { + updateFeedUrls(getCurrentInstanceUrl()); + } }); }); } -// Close forms function setupCloseForms() { document.querySelectorAll('[data-close-form]').forEach((button) => { - button.addEventListener('click', (e) => { - const form = e.target.closest('.parameter-form'); + button.addEventListener('click', (event) => { + const form = event.currentTarget.closest('.parameter-form'); const toggle = document.querySelector(`[data-target="${form?.id}"]`); if (!form || !toggle) return; form.hidden = true; toggle.setAttribute('aria-expanded', 'false'); - const span = toggle.querySelector('span'); - if (span) { - span.textContent = 'Configure'; + const label = toggle.querySelector('span'); + if (label) { + label.textContent = 'Customize'; } }); }); } -// Parameter input updates -function setupParameterInputs(updateFeedUrls) { +function setupParameterInputs(updateFeedUrls, getCurrentInstanceUrl) { document.querySelectorAll('.form-input').forEach((input) => { input.addEventListener( 'input', - debounce(() => updateFeedUrls(), 200) + debounce(() => updateFeedUrls(getCurrentInstanceUrl()), 180) ); }); } -// Update feed URLs -function createUpdateFeedUrlsFunction() { - return function updateFeedUrls(instanceUrl) { - document.querySelectorAll('[data-feed-url]').forEach((link) => { - const item = link.closest('[data-domain]'); - if (!item) return; - - const domain = item.dataset.domain; - const name = item.dataset.name; - if (!domain || !name) return; - - // Get parameters - const params = {}; - item.querySelectorAll('[data-param-key]').forEach((input) => { - if (input.value) params[input.dataset.paramKey] = input.value; - }); - - // Build URL - let url = instanceUrl.endsWith('/') ? instanceUrl : `${instanceUrl}/`; - url += `${domain}/${name}.rss`; +function setupCopyButtons() { + document.querySelectorAll('[data-copy-feed]').forEach((button) => { + button.addEventListener('click', async () => { + const feedLink = button.closest('[data-domain]')?.querySelector('[data-feed-url]'); + if (!feedLink?.href) return; - const queryParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => queryParams.append(key, value)); - const queryString = queryParams.toString(); - if (queryString) url += `?${queryString}`; - - link.href = url; + 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); + } }); - }; + }); } -// Main initialization function initializeFeedDirectory() { - const defaultInstanceUrl = atob('aHR0cHM6Ly8xLmgyci53b3JrZXJzLmRldi8='); - let instanceUrl = defaultInstanceUrl; + const defaultInstanceUrl = getDefaultInstanceUrl(); + let currentInstanceUrl = readInstanceUrlFromHash(defaultInstanceUrl); const searchInput = document.getElementById('search-input'); - const feedItems = document.querySelectorAll('[data-domain]'); + const feedItems = Array.from(document.querySelectorAll('[data-domain]')); const instanceInput = document.getElementById('instance-url-input'); + const updateFeedUrls = createUpdateFeedUrlsFunction(); + + updateInstanceSummary(currentInstanceUrl); - // Initialize instance URL field if (instanceInput) { - instanceInput.value = defaultInstanceUrl; - instanceUrl = defaultInstanceUrl; + instanceInput.value = currentInstanceUrl; } - // Create update function with current instance URL - const updateFeedUrls = createUpdateFeedUrlsFunction(); - - // Setup all functionality setupSearch(searchInput, feedItems); - setupInstanceUrlUpdates(instanceInput, defaultInstanceUrl, (newUrl) => { - instanceUrl = newUrl; - updateFeedUrls(instanceUrl); - }); - setupParameterForms(() => updateFeedUrls(instanceUrl)); + setupInstanceEditor( + defaultInstanceUrl, + () => currentInstanceUrl, + (nextUrl) => { + currentInstanceUrl = nextUrl; + }, + updateFeedUrls + ); + setupParameterForms(updateFeedUrls, () => currentInstanceUrl); setupCloseForms(); - setupParameterInputs(() => updateFeedUrls(instanceUrl)); + setupParameterInputs(updateFeedUrls, () => currentInstanceUrl); + setupCopyButtons(); - // Initialize feed URLs on page load - updateFeedUrls(instanceUrl); + updateFeedUrls(currentInstanceUrl); } -// Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeFeedDirectory); } else { diff --git a/src/content/docs/feed-directory/index.mdx b/src/content/docs/feed-directory/index.mdx index e0a3bf94..4b75c0c1 100644 --- a/src/content/docs/feed-directory/index.mdx +++ b/src/content/docs/feed-directory/index.mdx @@ -8,21 +8,15 @@ head: content: noindex --- -This directory contains a list of pre-built configurations to create RSS feeds for various websites. - -## Instance URL - -An Instance URL is the address of a running `html2rss-web` application. You can use a public instance, but we encourage you to host your own. - -[🚀 Host Your Own Instance (and share it!)](/web-application/how-to/deployment) +import FeedDirectory from "../../../components/FeedDirectory.astro"; -Find more public instances on the [community-run wiki](https://github.com/html2rss/html2rss-web/wiki/Instances). + --- -import FeedDirectory from "../../../components/FeedDirectory.astro"; +Need a different instance? You can use the built-in default, 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/how-to/deployment) --- @@ -30,4 +24,4 @@ import FeedDirectory from "../../../components/FeedDirectory.astro"; The feed configurations in this directory are community-driven. If you've created a new feed configuration, we encourage you to share it with the community. -[Contribute on GitHub](https://github.com/html2rss/html2rss-configs) +[Contribute on GitHub](https://github.com/html2rss/html2rss-configs/tree/master/lib/html2rss/configs) From 45105d0f25fb2feadfa4ada84c656b385240f670 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 21 Mar 2026 13:47:44 +0100 Subject: [PATCH 2/2] fix: harden feed directory instance handling --- src/components/FeedDirectory.astro | 8 ++- src/components/feed-directory.js | 83 +++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/components/FeedDirectory.astro b/src/components/FeedDirectory.astro index c4c2a272..25af5943 100644 --- a/src/components/FeedDirectory.astro +++ b/src/components/FeedDirectory.astro @@ -22,7 +22,7 @@ const staticFeedUrls = configs.map((config) => ({ })); --- -
+
@@ -62,7 +62,7 @@ const staticFeedUrls = configs.map((config) => ({
-