kagisearch/kite-public/main 1.7M tokens More Tools
```
├── .editorconfig (omitted)
├── .env (omitted)
├── .env.development (omitted)
├── .github/
   ├── ISSUE_TEMPLATE/
      ├── bug_report.md (200 tokens)
      ├── feature_request.md (100 tokens)
      ├── mobile_app.md (100 tokens)
   ├── workflows/
      ├── build-test.yml (1100 tokens)
      ├── ci.yml (500 tokens)
      ├── quality.yml (2.5k tokens)
      ├── sort-feeds.yml (3.1k tokens)
├── .gitignore (100 tokens)
├── .prettierrc
├── LICENSE (omitted)
├── README.md (1700 tokens)
├── biome.json (500 tokens)
├── bun.lock (omitted)
├── contentFilters.json (5.2k tokens)
├── core_feeds.py (11.4k tokens)
├── kite_feeds.json (134.3k tokens)
├── media_data.json (46.1k tokens)
├── package.json (700 tokens)
├── scripts/
   ├── console-utils.ts (700 tokens)
   ├── shared-runner.ts (600 tokens)
   ├── shared-types.ts (500 tokens)
   ├── sort-core-feeds.py (1000 tokens)
   ├── sort-feeds.ts (700 tokens)
   ├── sort-locales.ts (1100 tokens)
   ├── sort-media.ts (900 tokens)
   ├── translation-analyzer.ts (2.1k tokens)
   ├── validate-feeds.ts (2.6k tokens)
   ├── validate-media.ts (600 tokens)
├── src/
   ├── app.css (2.9k tokens)
   ├── app.d.ts (omitted)
   ├── app.html (500 tokens)
   ├── app.ts
   ├── lib/
      ├── assets/
         ├── lottie/
            ├── big-fire.json (102.1k tokens)
            ├── small-fire.json (10.6k tokens)
            ├── snow.json (35.3k tokens)
            ├── storm.json (35.1k tokens)
            ├── sunny-cloudy.json (12.5k tokens)
      ├── client/
         ├── localization.svelte.ts (200 tokens)
         ├── pcm-processor.ts (900 tokens)
         ├── rtl-detection.ts (100 tokens)
         ├── settings-backup.ts (700 tokens)
         ├── speech-utils.ts (4.3k tokens)
         ├── storyLocalization.svelte.ts (700 tokens)
         ├── sync-manager.ts (8.5k tokens)
         ├── sync-settings-watcher.svelte.ts (1000 tokens)
         ├── utils/
            ├── device-id.ts (200 tokens)
            ├── safe-storage.ts (200 tokens)
      ├── components/
         ├── AppNavigation.svelte (7.8k tokens)
         ├── BackToTop.svelte (800 tokens)
         ├── BaseModal.svelte (1300 tokens)
         ├── BetaLabel.svelte (100 tokens)
         ├── CategoryNavigation.svelte (3k tokens)
         ├── ChaosIndex.svelte (5.3k tokens)
         ├── ClusteringExplainerModal.svelte (2.5k tokens)
         ├── DataLoader.svelte (4.6k tokens)
         ├── Footer.svelte (800 tokens)
         ├── Header.svelte (5.1k tokens)
         ├── HistoryManager.svelte (1600 tokens)
         ├── IntroScreen.svelte (1100 tokens)
         ├── KeyboardNavigationHandler.svelte (1300 tokens)
         ├── KeyboardShortcutsHelp.svelte (700 tokens)
         ├── LottieAnimation.svelte (300 tokens)
         ├── MapView.svelte (100 tokens)
         ├── OnThisDay.svelte (400 tokens)
         ├── OnboardingModal.svelte (1100 tokens)
         ├── ReportButton.svelte (2.4k tokens)
         ├── Select.svelte (5.9k tokens)
         ├── Settings.svelte (3.7k tokens)
         ├── ShareButton.svelte (1500 tokens)
         ├── SourceOverlay.svelte (3.8k tokens)
         ├── SplashScreen.svelte (700 tokens)
         ├── StoryList.svelte (3.3k tokens)
         ├── TemporaryCategoryTooltip.svelte (600 tokens)
         ├── TimeTravel.svelte (4k tokens)
         ├── Toast.svelte (500 tokens)
         ├── Tooltip.svelte (1800 tokens)
         ├── ViewModeToggle.svelte (400 tokens)
         ├── WikipediaPopup.svelte (200 tokens)
         ├── WikipediaTooltip.svelte (2.8k tokens)
         ├── __tests__/
            ├── BaseModal.test.ts (800 tokens)
            ├── TestModalWithContent.svelte (100 tokens)
         ├── common/
            ├── FaviconImage.svelte (800 tokens)
            ├── GlobePreview.svelte (2.5k tokens)
         ├── contribute/
            ├── ContributeCategoryStep.svelte (2k tokens)
            ├── ContributeFeedStep.svelte (1500 tokens)
            ├── ContributeHistory.svelte (1700 tokens)
            ├── ContributeOnboarding.svelte (4.9k tokens)
            ├── ContributeSubmitStep.svelte (1400 tokens)
         ├── crypto/
            ├── CryptoGrid.svelte (1200 tokens)
            ├── CryptoPrice.svelte (1200 tokens)
         ├── f1/
            ├── F1Schedule.svelte (1500 tokens)
            ├── F1Standings.svelte (1600 tokens)
         ├── nfl/
            ├── NFLScores.svelte (3.3k tokens)
            ├── NFLStandings.svelte (2.2k tokens)
         ├── nhl/
            ├── NHLScores.svelte (3.3k tokens)
            ├── NHLStandings.svelte (2.1k tokens)
         ├── onboarding/
            ├── OnboardingStepAppearance.svelte (200 tokens)
            ├── OnboardingStepCategories.svelte (200 tokens)
            ├── OnboardingStepSections.svelte (100 tokens)
         ├── onthisday/
            ├── OnThisDayEventTimeline.svelte (300 tokens)
            ├── OnThisDayPeopleCarousel.svelte (1700 tokens)
            ├── OnThisDaySkeleton.svelte (600 tokens)
         ├── search/
            ├── SearchInput.svelte (1400 tokens)
            ├── SearchModal.svelte (3.3k tokens)
            ├── SearchResults.svelte (4.2k tokens)
            ├── index.ts
         ├── settings/
            ├── ContributeCategoryModal.svelte (1700 tokens)
            ├── SettingsAbout.svelte (1500 tokens)
            ├── SettingsAccount.svelte (4k tokens)
            ├── SettingsAppearance.svelte (1700 tokens)
            ├── SettingsCategories.svelte (5.7k tokens)
            ├── SettingsContentFilter.svelte (4.3k tokens)
            ├── SettingsExperimental.svelte (1500 tokens)
            ├── SettingsFilters.svelte (4.3k tokens)
            ├── SettingsGeneral.svelte (2.2k tokens)
            ├── SettingsImagePreloading.svelte (1100 tokens)
            ├── SettingsLanguage.svelte (2.5k tokens)
            ├── SettingsSections.svelte (1500 tokens)
            ├── SettingsStories.svelte (1700 tokens)
            ├── SettingsSyncPrivacy.svelte (3.4k tokens)
            ├── SyncSetupModal.svelte (1600 tokens)
            ├── snippets/
               ├── DataLanguageSelector.svelte (2.6k tokens)
               ├── LanguageSelector.svelte (800 tokens)
               ├── SectionsList.svelte (1600 tokens)
               ├── StoryCountSlider.svelte (200 tokens)
               ├── ThemeSelector.svelte (600 tokens)
         ├── story/
            ├── CitationItem.svelte (800 tokens)
            ├── CitationText.svelte (4.7k tokens)
            ├── CitationTooltip.svelte (3.3k tokens)
            ├── SectionWrapper.svelte (100 tokens)
            ├── SelectableText.svelte (1800 tokens)
            ├── SelectableText.test.ts (500 tokens)
            ├── SimplifyButton.svelte (600 tokens)
            ├── StoryActionItems.svelte (500 tokens)
            ├── StoryActions.svelte (600 tokens)
            ├── StoryCard.svelte (2.3k tokens)
            ├── StoryCardSkeleton.svelte (200 tokens)
            ├── StoryContentSkeleton.svelte (800 tokens)
            ├── StoryDidYouKnow.svelte (400 tokens)
            ├── StoryHeader.svelte (4.4k tokens)
            ├── StoryHighlights.svelte (1000 tokens)
            ├── StoryImage.svelte (1000 tokens)
            ├── StoryInternationalReactions.svelte (800 tokens)
            ├── StoryListSection.svelte (600 tokens)
            ├── StoryPerspectives.svelte (1900 tokens)
            ├── StoryQuote.svelte (900 tokens)
            ├── StorySectionManager.svelte (3.6k tokens)
            ├── StorySources.svelte (1700 tokens)
            ├── StorySuggestedQnA.svelte (700 tokens)
            ├── StorySummary.svelte (1400 tokens)
            ├── StoryTextSection.svelte (300 tokens)
            ├── StoryTimeline.svelte (1100 tokens)
         ├── weather/
            ├── Weather.svelte (900 tokens)
      ├── config.ts (200 tokens)
      ├── config/
         ├── features.ts (300 tokens)
      ├── constants/
         ├── languages.ts (300 tokens)
         ├── sections.ts (500 tokens)
      ├── data/
         ├── contentFilters.json (6.5k tokens)
         ├── countryCoordinates.ts (300 tokens)
         ├── migrations.ts (700 tokens)
         ├── migrations/
            ├── README.md (700 tokens)
            ├── index.ts (100 tokens)
            ├── types.ts (100 tokens)
            ├── v1_language_preferences.ts (500 tokens)
            ├── v2_remove_china_taiwan.ts (800 tokens)
         ├── model-costs.ts (1200 tokens)
         ├── regions.ts (600 tokens)
         ├── setting.svelte.ts (900 tokens)
         ├── settings.svelte.ts (6.1k tokens)
      ├── db/
         ├── dexie.ts (2.7k tokens)
      ├── hooks/
         ├── useCategoryManager.svelte.ts (700 tokens)
         ├── useDataHandlers.svelte.ts (1500 tokens)
         ├── useFaviconPrefetch.ts (100 tokens)
         ├── useImagePreloading.svelte.ts (400 tokens)
         ├── usePageDerived.svelte.ts (1200 tokens)
         ├── usePageEffects.svelte.ts (300 tokens)
         ├── usePageHelpers.svelte.ts (1500 tokens)
         ├── usePageSetup.svelte.ts (1000 tokens)
         ├── usePageState.svelte.ts (1600 tokens)
         ├── useSinglePageMode.svelte.ts (1500 tokens)
         ├── useStoryFlashcards.svelte.ts (1100 tokens)
         ├── useStorySimplification.svelte.ts (600 tokens)
         ├── useStoryTTS.svelte.ts (700 tokens)
         ├── useStoryToggle.svelte.ts (800 tokens)
      ├── locales/
         ├── ar.json (33.6k tokens)
         ├── de.json (35.8k tokens)
         ├── en.json (34k tokens)
         ├── es.json (35.6k tokens)
         ├── et.json (34.2k tokens)
         ├── fr.json (36.1k tokens)
         ├── he.json (33k tokens)
         ├── hi.json (35k tokens)
         ├── index.ts (100 tokens)
         ├── it.json (35.6k tokens)
         ├── ja.json (31.5k tokens)
         ├── nl.json (35.3k tokens)
         ├── pt.json (35.4k tokens)
         ├── ru.json (35k tokens)
         ├── uk.json (35.1k tokens)
         ├── zh-Hans.json (30.5k tokens)
         ├── zh-Hant.json (30.1k tokens)
      ├── server/
         ├── proxy.ts (500 tokens)
      ├── services/
         ├── __tests__/
            ├── api-check.test.ts (200 tokens)
            ├── batchService.test.ts (900 tokens)
            ├── chaosIndexService.test.ts (700 tokens)
            ├── imagePreloadingService.real.test.ts (700 tokens)
            ├── imagePreloadingService.test.ts (1500 tokens)
            ├── integration/
               ├── batchService.integration.test.ts (600 tokens)
               ├── chaosIndexService.integration.test.ts (500 tokens)
               ├── mediaService.integration.test.ts (800 tokens)
               ├── onThisDayService.integration.test.ts (600 tokens)
               ├── storiesService.integration.test.ts (700 tokens)
            ├── simple.test.ts (100 tokens)
            ├── wikipediaService.test.ts (800 tokens)
         ├── batchNotificationService.ts (900 tokens)
         ├── batchService.ts (1100 tokens)
         ├── categoryMetadataService.ts (600 tokens)
         ├── chaosIndexService.ts (300 tokens)
         ├── dataService.ts (800 tokens)
         ├── faviconService.ts (1400 tokens)
         ├── imagePreloadingService.ts (1600 tokens)
         ├── mediaService.ts (400 tokens)
         ├── navigationHandlerService.ts (1500 tokens)
         ├── onThisDayService.ts (400 tokens)
         ├── search/
            ├── ContentEditableChipService.ts (2.1k tokens)
            ├── SearchExecutorService.ts (1700 tokens)
            ├── SearchFilterService.ts (1300 tokens)
            ├── SearchService.ts (2000 tokens)
            ├── index.ts (100 tokens)
            ├── types.ts (200 tokens)
         ├── storiesService.ts (500 tokens)
         ├── timeTravelNavigationService.ts (700 tokens)
         ├── translateApi.ts (1100 tokens)
         ├── urlNavigationService.ts (1800 tokens)
         ├── wikipediaService.ts (900 tokens)
      ├── stores/
         ├── categories.svelte.ts (2k tokens)
         ├── categoryMetadata.svelte.ts (300 tokens)
         ├── contentFilter.svelte.ts (1900 tokens)
         ├── dataLanguage.svelte.ts (300 tokens)
         ├── experimental.svelte.ts (500 tokens)
         ├── fontSize.svelte.ts (800 tokens)
         ├── headerAnimation.svelte.ts (100 tokens)
         ├── keyboardNavigation.svelte.ts (400 tokens)
         ├── language.svelte.ts (700 tokens)
         ├── pageMetadata.svelte.ts
         ├── preferredSources.svelte.ts (500 tokens)
         ├── preloadingConfig.svelte.ts (900 tokens)
         ├── sections.svelte.ts (1000 tokens)
         ├── settings.svelte.ts (1000 tokens)
         ├── storyCount.svelte.ts (400 tokens)
         ├── theme.svelte.ts (500 tokens)
         ├── timeTravel.svelte.ts (200 tokens)
         ├── timeTravelBatch.svelte.ts (500 tokens)
         ├── toast.svelte.ts (400 tokens)
         ├── ttsManager.svelte.ts (200 tokens)
      ├── types.ts (1200 tokens)
      ├── types/
         ├── citation.ts (100 tokens)
         ├── components.ts (100 tokens)
         ├── sync.ts (300 tokens)
      ├── util.ts
      ├── utils/
         ├── api.ts (200 tokens)
         ├── apiUrl.ts (100 tokens)
         ├── category.ts (300 tokens)
         ├── categoryIdTransform.ts (700 tokens)
         ├── categoryServer.ts (700 tokens)
         ├── categorySwipeHandler.ts (1700 tokens)
         ├── citationAggregator.ts (1000 tokens)
         ├── citationContext.ts (1900 tokens)
         ├── citationProcessing.ts (200 tokens)
         ├── citationUtils.ts (800 tokens)
         ├── cn.ts (100 tokens)
         ├── colonSplitter.test.ts (500 tokens)
         ├── colonSplitter.ts (600 tokens)
         ├── contentFilter.ts (900 tokens)
         ├── csv.ts (100 tokens)
         ├── dateSlug.ts (300 tokens)
         ├── device.ts (200 tokens)
         ├── domainUtils.ts (300 tokens)
         ├── download.ts (100 tokens)
         ├── feedContribution.ts (2000 tokens)
         ├── formatTimeAgo.ts (300 tokens)
         ├── formatTimelineDate.ts (400 tokens)
         ├── getTimeAgo.ts (700 tokens)
         ├── imagePreloader.ts (2000 tokens)
         ├── languageDetection.ts (300 tokens)
         ├── languageMapping.ts (200 tokens)
         ├── languagePreference.ts (600 tokens)
         ├── logger.ts (400 tokens)
         ├── mapUtils.ts (600 tokens)
         ├── mapsProvider.ts (600 tokens)
         ├── modalBehavior.svelte.ts (400 tokens)
         ├── scrollLock.ts (200 tokens)
         ├── searchFilters.ts (900 tokens)
         ├── sentenceSplitter.test.ts (1500 tokens)
         ├── sentenceSplitter.ts (1500 tokens)
         ├── storyId.ts (200 tokens)
         ├── storyOrdering.ts (600 tokens)
         ├── storyTextExtractor.ts (1000 tokens)
         ├── string.ts (100 tokens)
         ├── textParsing.ts (400 tokens)
         ├── textUtils.test.ts (300 tokens)
         ├── textUtils.ts (100 tokens)
         ├── urlEncoder.test.ts (2.5k tokens)
         ├── urlEncoder.ts (2.1k tokens)
         ├── urlShortener.ts (200 tokens)
   ├── params/
      ├── batchId.ts (100 tokens)
      ├── categoryId.ts (200 tokens)
      ├── encodedId.ts (100 tokens)
   ├── routes/
      ├── +error.svelte (100 tokens)
      ├── +layout.server.ts
      ├── +layout.svelte (800 tokens)
      ├── +page.svelte (6.2k tokens)
      ├── [batchId=batchId]/
         ├── +page.server.ts (100 tokens)
         ├── +page.svelte (100 tokens)
         ├── [categoryId=categoryId]/
            ├── +page.server.ts (400 tokens)
            ├── +page.svelte
            ├── [storyIndex]/
               ├── +page.svelte
      ├── [batchId]/
         ├── +page.server.ts (100 tokens)
         ├── +page.svelte (100 tokens)
         ├── [categoryId]/
            ├── +page.svelte
            ├── [storyIndex]/
               ├── +page.svelte
      ├── [categoryId=categoryId]/
         ├── [encodedId=encodedId]/
            ├── +page.svelte
            ├── [slug]/
               ├── +page.svelte
         ├── latest/
            ├── +page.svelte
      ├── about/
         ├── +page.svelte
      ├── api/
         ├── auth/
            ├── +server.ts
         ├── batches/
            ├── +server.ts
            ├── [batchId]/
               ├── +server.ts
               ├── categories/
                  ├── +server.ts
                  ├── [categoryId]/
                     ├── stories/
                        ├── +server.ts
               ├── chaos/
                  ├── +server.ts
               ├── languages/
                  ├── +server.ts
               ├── onthisday/
                  ├── +server.ts
            ├── latest/
               ├── +server.ts
               ├── categories/
                  ├── +server.ts
                  ├── [categoryId]/
                     ├── stories/
                        ├── +server.ts
               ├── chaos/
                  ├── +server.ts
               ├── onthisday/
                  ├── +server.ts
         ├── categories/
            ├── metadata/
               ├── +server.ts
         ├── chaos/
            ├── history/
               ├── +server.ts
         ├── contribute/
            ├── +server.ts
            ├── pending-prs/
               ├── +server.ts
         ├── favicon-proxy/
            ├── +server.ts
         ├── feed-check/
            ├── +server.ts
         ├── geocode/
            ├── +server.ts
         ├── health/
            ├── +server.ts
         ├── image-proxy/
            ├── +server.ts
         ├── locale/
            ├── [lang]/
               ├── +server.ts
         ├── media/
            ├── +server.ts
            ├── [host]/
               ├── +server.ts
         ├── openapi/
            ├── +server.ts
         ├── reports/
            ├── +server.ts
         ├── search/
            ├── +server.ts
         ├── shorten/
            ├── +server.ts
         ├── simplify/
            ├── +server.ts
         ├── speech/
            ├── +server.ts
         ├── sync/
            ├── +server.ts
            ├── clear/
               ├── +server.ts
            ├── export/
               ├── +server.ts
            ├── read-history-bulk/
               ├── +server.ts
            ├── read-history-changes/
               ├── +server.ts (100 tokens)
            ├── read-history/
               ├── +server.ts (100 tokens)
         ├── vocabulary/
            ├── +server.ts
         ├── widgets/
            ├── crypto/
               ├── price/
                  ├── +server.ts
            ├── f1/
               ├── schedule/
                  ├── +server.ts
               ├── standings/
                  ├── +server.ts
            ├── nfl/
               ├── polymarket/
                  ├── +server.ts
               ├── scores/
                  ├── +server.ts
               ├── standings/
                  ├── +server.ts
            ├── nhl/
               ├── polymarket/
                  ├── +server.ts
               ├── scores/
                  ├── +server.ts
               ├── standings/
                  ├── +server.ts
            ├── weather/
               ├── +server.ts
      ├── contribute/
         ├── +page.svelte (2.6k tokens)
      ├── latest/
         ├── +page.svelte (100 tokens)
         ├── [categoryId]/
            ├── +page.svelte
            ├── [storyIndex]/
               ├── +page.svelte
      ├── s/
         ├── [code]/
            ├── +page.server.ts (200 tokens)
   ├── styles/
      ├── index.css (1700 tokens)
   ├── tests/
      ├── integration-setup.ts (200 tokens)
      ├── setup.integration.ts (100 tokens)
      ├── setup.ts (200 tokens)
   ├── vite-env.d.ts (omitted)
├── static/
   ├── 04.png
   ├── 15.png
   ├── 16.png
   ├── 17.png
   ├── 18.png
   ├── 20.png
   ├── 28.png
   ├── 33.png
   ├── apple-touch-icon-dark.png
   ├── apple-touch-icon.png
   ├── badges/
      ├── app-store/
         ├── AR/
            ├── black.svg (2k tokens)
            ├── white.svg (2000 tokens)
         ├── AZ/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2000 tokens)
         ├── BG/
            ├── black.svg (1800 tokens)
            ├── white.svg (1800 tokens)
         ├── CAES/
            ├── black.svg (2.4k tokens)
            ├── white.svg (2.4k tokens)
         ├── CN(SC)/
            ├── black.svg (1700 tokens)
            ├── white.svg (1700 tokens)
         ├── CZ/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
         ├── DE/
            ├── black.svg (1800 tokens)
            ├── white.svg (1800 tokens)
         ├── DK/
            ├── black.svg (1600 tokens)
            ├── white.svg (1600 tokens)
         ├── EE/
            ├── black.svg (2.2k tokens)
            ├── white.svg (2.1k tokens)
         ├── ES/
            ├── black.svg (2.3k tokens)
            ├── white.svg (2.2k tokens)
         ├── ESMX/
            ├── black.svg (2.4k tokens)
            ├── white.svg (2.3k tokens)
         ├── FI/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2000 tokens)
         ├── FR/
            ├── black.svg (2.5k tokens)
            ├── white.svg (2.4k tokens)
         ├── FRCA/
            ├── black.svg (2.5k tokens)
            ├── white.svg (2.4k tokens)
         ├── GR/
            ├── black.svg (1800 tokens)
            ├── white.svg (1800 tokens)
         ├── HB/
            ├── black.svg (1400 tokens)
            ├── white.svg (1400 tokens)
         ├── HKTW(TC)/
            ├── black.svg (1700 tokens)
            ├── white.svg (1600 tokens)
         ├── HR/
            ├── black.svg (1900 tokens)
            ├── white.svg (1900 tokens)
         ├── HU/
            ├── black.svg (2.3k tokens)
            ├── white.svg (2.2k tokens)
         ├── ID/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
         ├── IL/
            ├── black.svg (1800 tokens)
            ├── white.svg (1700 tokens)
         ├── IN/
            ├── black.svg (2.8k tokens)
            ├── white.svg (2.8k tokens)
         ├── IT/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2k tokens)
         ├── JP/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
         ├── KR/
            ├── black.svg (1900 tokens)
            ├── white.svg (1800 tokens)
         ├── LT/
            ├── black.svg (2.3k tokens)
            ├── white.svg (2.3k tokens)
         ├── LV/
            ├── black.svg (2.3k tokens)
            ├── white.svg (2.2k tokens)
         ├── MT/
            ├── black.svg (1800 tokens)
            ├── white.svg (1700 tokens)
         ├── MY/
            ├── black.svg (2.3k tokens)
            ├── white.svg (2.2k tokens)
         ├── NL/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2.2k tokens)
         ├── NO/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2k tokens)
         ├── PH/
            ├── black.svg (2.2k tokens)
            ├── white.svg (2.1k tokens)
         ├── PL/
            ├── black.svg (1800 tokens)
            ├── white.svg (1800 tokens)
         ├── PTBR/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
         ├── PTPT/
            ├── black.svg (2.4k tokens)
            ├── white.svg (2.3k tokens)
         ├── RO/
            ├── black.svg (2.5k tokens)
            ├── white.svg (2.4k tokens)
         ├── RU/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2k tokens)
         ├── SE/
            ├── black.svg (1800 tokens)
            ├── white.svg (1800 tokens)
         ├── SI/
            ├── black.svg (1900 tokens)
            ├── white.svg (1800 tokens)
         ├── SK/
            ├── black.svg (2.1k tokens)
            ├── white.svg (2000 tokens)
         ├── TH/
            ├── black.svg (2.8k tokens)
            ├── white.svg (2.7k tokens)
         ├── TR/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
         ├── UA/
            ├── black.svg (2.2k tokens)
            ├── white.svg (2.1k tokens)
         ├── US/
            ├── black.svg (2.2k tokens)
            ├── white.svg (2.1k tokens)
         ├── VN/
            ├── black.svg (2000 tokens)
            ├── white.svg (1900 tokens)
      ├── google-play/
         ├── de.svg (900 tokens)
         ├── en.svg (1100 tokens)
         ├── es.svg (900 tokens)
         ├── fr.svg (900 tokens)
         ├── he.svg (800 tokens)
         ├── hi.svg (400 tokens)
         ├── it.svg (1400 tokens)
         ├── ja.svg (2.5k tokens)
         ├── nl.svg (1300 tokens)
         ├── pt-BR.png
         ├── pt.png
         ├── ru.svg (1200 tokens)
         ├── uk.svg (900 tokens)
         ├── zh-Hans.png
         ├── zh-Hant.svg (1100 tokens)
   ├── doggo_default.svg (1900 tokens)
   ├── doggo_resting.svg (4.1k tokens)
   ├── favicon.ico
   ├── favicon.svg (600 tokens)
   ├── fonts/
      ├── lufga/
         ├── lufga-light-webfont.ttf
         ├── lufga-medium-webfont.ttf
         ├── lufga-medium-webfont.woff
         ├── lufga-medium-webfont.woff2
         ├── lufga-regular-webfont.svg (84.3k tokens)
         ├── lufga-regular-webfont.ttf
         ├── lufga-regular-webfont.woff
         ├── lufga-regular-webfont.woff2
         ├── lufga-semibold-webfont.svg (34.2k tokens)
         ├── lufga-semibold-webfont.ttf
         ├── lufga-semibold-webfont.woff
         ├── lufga-semibold-webfont.woff2
   ├── kagi-news-icon-192.png
   ├── kagi-news-icon-512.png
   ├── kagi-news-icon-dark-192.png
   ├── kagi-news-icon-dark-512.png
   ├── kite-banner.png
   ├── kite-icon.png
   ├── kite.css (1100 tokens)
   ├── manifest.json (200 tokens)
   ├── og-image.png
   ├── svg/
      ├── articles.svg (100 tokens)
      ├── back.svg (100 tokens)
      ├── brightness-dark.svg (100 tokens)
      ├── brightness.svg (100 tokens)
      ├── ear.svg (100 tokens)
      ├── eye-off.svg (100 tokens)
      ├── font-size.svg (200 tokens)
      ├── gear.svg (200 tokens)
      ├── kagi_news_compact.svg (1000 tokens)
      ├── kagi_news_compact_dark.svg (1400 tokens)
      ├── kagi_news_full.svg (1600 tokens)
      ├── kagi_news_full_dark.svg (1800 tokens)
      ├── kagi_news_icon.svg (800 tokens)
      ├── kagi_news_icon_dark.svg (900 tokens)
      ├── kite.svg (1900 tokens)
      ├── kite_dark.svg (1500 tokens)
      ├── map.svg (100 tokens)
      ├── placeholder.svg (100 tokens)
      ├── quote.svg (100 tokens)
      ├── rss.svg (100 tokens)
      ├── sources.svg (100 tokens)
      ├── spin_body.svg (3.4k tokens)
      ├── spin_circle_dark.svg (300 tokens)
      ├── spin_circle_light.svg (4.1k tokens)
      ├── translate.svg (300 tokens)
   ├── tune1.mp3
   ├── tune2.mp3
   ├── tune3.mp3
   ├── tune4.mp3
   ├── tune5.mp3
   ├── tune6.mp3
   ├── weather-icons/
      ├── barometer.svg (200 tokens)
      ├── celsius.svg (200 tokens)
      ├── clear-day.svg (200 tokens)
      ├── clear-night.svg (200 tokens)
      ├── cloudy.svg (100 tokens)
      ├── compass.svg (200 tokens)
      ├── dust-day.svg (500 tokens)
      ├── dust-night.svg (500 tokens)
      ├── dust-wind.svg (500 tokens)
      ├── dust.svg (500 tokens)
      ├── fahrenheit.svg (100 tokens)
      ├── falling-stars.svg (600 tokens)
      ├── fog-day.svg (300 tokens)
      ├── fog-night.svg (300 tokens)
      ├── fog.svg (300 tokens)
      ├── hail.svg (400 tokens)
      ├── haze-day.svg (500 tokens)
      ├── haze-night.svg (500 tokens)
      ├── haze.svg (400 tokens)
      ├── horizon.svg (200 tokens)
      ├── humidity.svg (300 tokens)
      ├── hurricane.svg (200 tokens)
      ├── lightning-bolt.svg (100 tokens)
      ├── mist.svg (200 tokens)
      ├── moon-first-quarter.svg (200 tokens)
      ├── moon-full.svg (100 tokens)
      ├── moon-last-quarter.svg (200 tokens)
      ├── moon-new.svg (100 tokens)
      ├── moon-waning-crescent.svg (100 tokens)
      ├── moon-waning-gibbous.svg (200 tokens)
      ├── moon-waxing-crescent.svg (100 tokens)
      ├── moon-waxing-gibbous.svg (100 tokens)
      ├── moonrise.svg (200 tokens)
      ├── moonset.svg (200 tokens)
      ├── not-available.svg (200 tokens)
      ├── overcast-day.svg (400 tokens)
      ├── overcast-night.svg (400 tokens)
      ├── overcast.svg (300 tokens)
      ├── partly-cloudy-day-drizzle.svg (500 tokens)
      ├── partly-cloudy-day-fog.svg (400 tokens)
      ├── partly-cloudy-day-hail.svg (500 tokens)
      ├── partly-cloudy-day-haze.svg (600 tokens)
      ├── partly-cloudy-day-rain.svg (600 tokens)
      ├── partly-cloudy-day-sleet.svg (900 tokens)
      ├── partly-cloudy-day-smoke.svg (600 tokens)
      ├── partly-cloudy-day-snow.svg (800 tokens)
      ├── partly-cloudy-day.svg (200 tokens)
      ├── partly-cloudy-night-drizzle.svg (600 tokens)
      ├── partly-cloudy-night-fog.svg (400 tokens)
      ├── partly-cloudy-night-hail.svg (500 tokens)
      ├── partly-cloudy-night-haze.svg (600 tokens)
      ├── partly-cloudy-night-rain.svg (600 tokens)
      ├── partly-cloudy-night-sleet.svg (900 tokens)
      ├── partly-cloudy-night-smoke.svg (600 tokens)
      ├── partly-cloudy-night-snow.svg (800 tokens)
      ├── partly-cloudy-night.svg (300 tokens)
      ├── pressure-high-alt.svg (100 tokens)
      ├── pressure-high.svg (100 tokens)
      ├── pressure-low-alt.svg (100 tokens)
      ├── pressure-low.svg (100 tokens)
      ├── rain.svg (400 tokens)
      ├── raindrop.svg (100 tokens)
      ├── raindrops.svg (200 tokens)
      ├── sleet.svg (800 tokens)
      ├── smoke-particles.svg (400 tokens)
      ├── smoke.svg (500 tokens)
      ├── snow.svg (600 tokens)
      ├── snowflake.svg (200 tokens)
      ├── solar-eclipse.svg (300 tokens)
      ├── star.svg (200 tokens)
      ├── starry-night.svg (500 tokens)
      ├── sunrise.svg (200 tokens)
      ├── sunset.svg (200 tokens)
      ├── thermometer-celsius.svg (300 tokens)
      ├── thermometer-colder.svg (300 tokens)
      ├── thermometer-fahrenheit.svg (300 tokens)
      ├── thermometer-glass-celsius.svg (300 tokens)
      ├── thermometer-glass-fahrenheit.svg (300 tokens)
      ├── thermometer-glass.svg (200 tokens)
      ├── thermometer-mercury-cold.svg
      ├── thermometer-mercury.svg
      ├── thermometer-warmer.svg (300 tokens)
      ├── thermometer.svg (200 tokens)
      ├── thunderstorms-day-rain.svg (600 tokens)
      ├── thunderstorms-day-snow.svg (900 tokens)
      ├── thunderstorms-day.svg (300 tokens)
      ├── thunderstorms-night-rain.svg (700 tokens)
      ├── thunderstorms-night-snow.svg (1100 tokens)
      ├── thunderstorms-night.svg (400 tokens)
      ├── thunderstorms-rain.svg (500 tokens)
      ├── thunderstorms-snow.svg (800 tokens)
      ├── thunderstorms.svg (200 tokens)
      ├── tornado.svg (400 tokens)
      ├── umbrella.svg (200 tokens)
      ├── uv-index-1.svg (200 tokens)
      ├── uv-index-10.svg (300 tokens)
      ├── uv-index-11.svg (300 tokens)
      ├── uv-index-2.svg (300 tokens)
      ├── uv-index-3.svg (300 tokens)
      ├── uv-index-4.svg (300 tokens)
      ├── uv-index-5.svg (300 tokens)
      ├── uv-index-6.svg (300 tokens)
      ├── uv-index-7.svg (200 tokens)
      ├── uv-index-8.svg (300 tokens)
      ├── uv-index-9.svg (300 tokens)
      ├── uv-index.svg (300 tokens)
      ├── wind-beaufort-0.svg (200 tokens)
      ├── wind-beaufort-1.svg (300 tokens)
      ├── wind-beaufort-10.svg (300 tokens)
      ├── wind-beaufort-11.svg (300 tokens)
      ├── wind-beaufort-12.svg (400 tokens)
      ├── wind-beaufort-2.svg (300 tokens)
      ├── wind-beaufort-3.svg (300 tokens)
      ├── wind-beaufort-4.svg (300 tokens)
      ├── wind-beaufort-5.svg (300 tokens)
      ├── wind-beaufort-6.svg (300 tokens)
      ├── wind-beaufort-7.svg (200 tokens)
      ├── wind-beaufort-8.svg (300 tokens)
      ├── wind-beaufort-9.svg (300 tokens)
      ├── wind.svg (200 tokens)
      ├── windsock.svg (400 tokens)
├── svelte.config.js (100 tokens)
├── tsconfig.json (100 tokens)
├── tsconfig.svelte-check.json (100 tokens)
├── vite.config.ts (100 tokens)
├── vitest.config.integration.ts (100 tokens)
├── vitest.config.ts (200 tokens)
├── vitest.config.unit.ts (200 tokens)
```


## /.github/ISSUE_TEMPLATE/bug_report.md

---
name: Bug Report
about: Report an issue with Kagi News web version or API
title: ''
labels: ''
assignees: ''
---

**Important:** This issue tracker is for the **WEB version** of Kagi News and issues related to the **API/data** it provides.

For issues with the **official mobile apps**, please use [Kagifeedback](https://kagifeedback.org) instead.

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Environment (please complete the following information):**
 - Browser: [e.g. Chrome, Safari, Orion, Firefox]

**Additional context**
Add any other context about the problem here.


## /.github/ISSUE_TEMPLATE/feature_request.md

---
name: Feature Request
about: Suggest a feature for Kagi News web version or API
title: ''
labels: ''
assignees: ''
---

**Important:** This issue tracker is for the **WEB version** of Kagi News and issues related to the **API/data** it provides.

For feature requests related to the **official mobile apps**, please use [Kagifeedback](https://kagifeedback.org) instead.

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Additional context**
Add any other context or screenshots about the feature request here.


## /.github/ISSUE_TEMPLATE/mobile_app.md

---
name: Mobile App Issue
about: Issues with the official Kagi News mobile apps
title: ''
labels: ''
assignees: ''
---

## ⚠️ Wrong Place!

This issue tracker is for the **WEB version** of Kagi News only.

For issues with the **official mobile apps** (iOS/Android), please use:

👉 **[Kagifeedback](https://kagifeedback.org)** 👈

Please close this issue and repost it there. Thank you!


## /.github/workflows/build-test.yml

```yml path="/.github/workflows/build-test.yml" 
name: Build & Test

on:
  push:
    branches: [ main ]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: build-test-${{ github.ref }}
  cancel-in-progress: true

jobs:
  changes:
    name: Determine relevant changes
    runs-on: ubuntu-latest
    permissions:
      pull-requests: read
    outputs:
      app: ${{ contains(fromJSON(steps.filter.outputs.changes), 'app') && 'true' || 'false' }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Filter paths
        id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            app:
              - 'src/**'
              - 'package.json'
              - 'bun.lock'
              - 'svelte.config.*'
              - 'vite.config.*'
              - 'tsconfig*.json'
              - 'vitest*.ts'
              - 'tailwind.config.*'
              - 'static/**'

  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs['app'] == 'true'
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: SvelteKit sync
        run: bunx --yes svelte-kit sync

      - name: Build application
        run: bun run build

      - name: Analyze bundle size
        uses: preactjs/compressed-size-action@v2
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          pattern: './build/**/*.{js,css,html}'
          exclude: '{**/*.map,**/node_modules/**}'
          strip-hash: '\\b\\w{8}\\b'
          minimum-change-threshold: 100


  test:
    name: Run Tests
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs['app'] == 'true'
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: SvelteKit sync
        run: bunx --yes svelte-kit sync

      - name: Run unit tests with coverage
        run: npx --yes vitest run --config vitest.config.unit.ts --coverage

      - name: Build for preview server
        run: bun run build

      - name: Start preview server
        env:
          NODE_ENV: production
        run: |
          nohup bun run preview -- --port 5173 --host 0.0.0.0 > preview.log 2>&1 & echo $! > preview.pid

      - name: Wait for health endpoint
        run: |
          for i in {1..60}; do
            if curl -sf http://127.0.0.1:5173/api/health > /dev/null; then
              echo "Server is up"; exit 0; fi; sleep 1; done; echo "Server did not start"; cat preview.log; exit 1

      - name: Smoke test key routes
        run: |
          set -e
          for path in "/" "/latest"; do
            echo "Fetching http://127.0.0.1:5173$path"
            status=$(curl -s -o /tmp/page.html -w "%{http_code}" "http://127.0.0.1:5173$path" || true)
            if [ "$status" -lt 200 ] || [ "$status" -ge 400 ]; then
              echo "Route $path returned status $status"
              echo "--- preview.log (tail) ---" && tail -n 200 preview.log || true
              exit 1
            fi
          done

      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v12
        with:
          urls: |
            http://127.0.0.1:5173?skip_intro=1
            http://127.0.0.1:5173/latest?skip_intro=1
          configPath: './.lighthouserc.json'
          uploadArtifacts: true
          temporaryPublicStorage: false
          artifactName: 'lighthouse-results'

      - name: Create Lighthouse index.html
        run: |
          set -e
          mkdir -p .lighthouseci
          cd .lighthouseci
          echo '<!doctype html><meta charset="utf-8"><title>Lighthouse Reports</title><h1>Lighthouse Reports</h1><ul>' > index.html
          for f in *.html; do
            if [ -f "$f" ]; then
              echo "<li><a href=\"$f\">$f</a></li>" >> index.html
            fi
          done
          echo '</ul>' >> index.html

      - name: Deploy Lighthouse reports to gh-pages (PRs)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
        uses: rossjrw/pr-preview-action@v1
        with:
          source-dir: ./.lighthouseci
          preview-branch: gh-pages
          umbrella-dir: lhci
          action: auto

      - name: Run tests
        run: bun run test:all

      - name: Stop preview server
        if: always()
        run: |
          if [ -f preview.pid ]; then kill $(cat preview.pid) || true; fi


```

## /.github/workflows/ci.yml

```yml path="/.github/workflows/ci.yml" 
name: Continuous Integration

on:
  push:
    branches: [ main ]
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  changes:
    name: Determine relevant changes
    runs-on: ubuntu-latest
    permissions:
      pull-requests: read
    outputs:
      app: ${{ contains(fromJSON(steps.filter.outputs.changes), 'app') && 'true' || 'false' }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Filter paths
        id: filter
        uses: dorny/paths-filter@v3
        with:
          filters: |
            app:
              - 'src/**'
              - 'package.json'
              - 'bun.lock'
              - 'svelte.config.*'
              - 'vite.config.*'
              - 'tsconfig*.json'
              - 'vitest*.ts'
              - 'tailwind.config.*'
              - 'static/**'

  setup:
    name: Install Dependencies
    runs-on: ubuntu-latest
    needs: changes
    if: needs.changes.outputs['app'] == 'true'
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Bun
        uses: oven-sh/setup-bun@v2

      - name: Cache Bun dependencies
        id: bun-cache
        uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}

      - name: Install dependencies (if cache miss)
        if: steps.bun-cache.outputs.cache-hit != 'true'
        run: bun install --frozen-lockfile

  type-check:
    name: Type & Svelte Check
    needs: [setup, changes]
    if: needs.changes.outputs['app'] == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Bun
        uses: oven-sh/setup-bun@v2

      - name: Restore Bun cache
        uses: actions/cache@v4
        with:
          path: ~/.bun/install/cache
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Run TypeScript & Svelte checks
        run: bunx --yes svelte-kit sync && bunx --yes svelte-check --tsconfig ./tsconfig.svelte-check.json

```

## /.github/workflows/quality.yml

```yml path="/.github/workflows/quality.yml" 
name: Code Quality Checks

on:
  push:
    branches: [ main ]
    paths:
      - '**/*.json'
      - 'src/**/*.{html,js,ts,svelte,css}'
  pull_request:
    paths:
      - '**/*.json'
      - 'src/**/*.{html,js,ts,svelte,css}'
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write
  checks: write

concurrency:
  group: quality-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for better diff detection

      - name: Setup Bun
        uses: oven-sh/setup-bun@v2

      - name: Cache Bun dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.bun/install/cache
            node_modules
          key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            ${{ runner.os }}-bun-

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Setup reviewdog
        uses: reviewdog/action-setup@v1
        with:
          reviewdog_version: latest

      # Get changed files for all subsequent steps
      - name: Get changed files
        id: changed-files
        shell: bash
        run: |
          set -e
          
          # Define file patterns to check
          globs=( '*.json' '*.ts' '*.tsx' '*.js' '*.jsx' '*.svelte' '*.css' '*.html' )
          
          # Get changed files based on event type
          if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
            # For PRs, compare against the base branch
            base_sha="${{ github.event.pull_request.base.sha }}"
            head_sha="${{ github.event.pull_request.head.sha }}"
            changed_files="$(git diff --name-only "$base_sha".."$head_sha" -- "${globs[@]}" 2>/dev/null || true)"
          else
            # For pushes, compare with previous commit or list all files if initial commit
            if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
              changed_files="$(git diff --name-only HEAD~1 HEAD -- "${globs[@]}" 2>/dev/null || true)"
            else
              changed_files="$(git ls-files "${globs[@]}")"
            fi
          fi
          
          # Filter to existing files and save to output
          existing_files=""
          for file in $changed_files; do
            if [ -f "$file" ]; then
              existing_files="$existing_files $file"
            fi
          done
          
          echo "files<<EOF" >> $GITHUB_OUTPUT
          echo "$existing_files" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
          
          # Also check for specific file types
          json_files=$(echo "$existing_files" | tr ' ' '\n' | grep '\.json{{contextString}}#39; || true)
          echo "json_files<<EOF" >> $GITHUB_OUTPUT
          echo "$json_files" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      ########################################################################
      # Prettier formatting check
      ########################################################################
      - name: Run Prettier (write mode)
        if: steps.changed-files.outputs.files != ''
        shell: bash
        run: |
          files="${{ steps.changed-files.outputs.files }}"
          if [ -n "$files" ]; then
            echo "$files" | xargs bunx prettier --write
          fi

      - name: Suggest Prettier fixes (same-repo PRs only)
        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changed-files.outputs.files != ''
        uses: reviewdog/action-suggester@v1
        with:
          tool_name: prettier
          level: info

      ########################################################################
      # JSON validation
      ########################################################################
      - name: Install jq for JSON validation
        if: steps.changed-files.outputs.json_files != ''
        run: sudo apt-get update && sudo apt-get install -y jq

      - name: Validate JSON files
        if: steps.changed-files.outputs.json_files != ''
        shell: bash
        run: |
          set +e
          echo "🔎 Validating JSON files..."
          
          json_files="${{ steps.changed-files.outputs.json_files }}"
          
          if [ -z "$json_files" ]; then
            echo "No JSON files to validate" > /tmp/json_diag.log
            exit 0
          fi

          diag=""
          for file in $json_files; do
            if [ -f "$file" ]; then
              if ! jq empty "$file" 2>/dev/null; then
                diag+="$file:1: Invalid JSON syntax\n"
              fi
            fi
          done
          echo -e "$diag" | tee /tmp/json_diag.log

      - name: Annotate JSON validation
        if: steps.changed-files.outputs.json_files != ''
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
            reporter="github-pr-review"
          else
            reporter="github-check"
          fi
          reviewdog -efm="%f:%l:%m" -name="json-validator" -reporter="$reporter" -fail-on-error=false < /tmp/json_diag.log 

      ########################################################################
      # Biome Lint (advisory)
      ########################################################################
      - name: Run Biome lint
        if: steps.changed-files.outputs.files != ''
        shell: bash
        run: |
          set +e

          files="${{ steps.changed-files.outputs.files }}"

          if [ -z "$files" ]; then
            echo "No files to lint with Biome."
            touch /tmp/biome.log
            exit 0
          fi

          echo "$files" | xargs bunx biome lint --formatter=compact > /tmp/biome.log 2>&1 || true

      - name: Annotate Biome results
        if: steps.changed-files.outputs.files != ''
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
            reporter="github-pr-review"
          else
            reporter="github-check"
          fi
          reviewdog -efm="%f:%l:%c %m" -name="biome-lint" -reporter="$reporter" -fail-on-error=false < /tmp/biome.log

      - name: Upload Biome log
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: biome-logs
          path: /tmp/biome.log

      ########################################################################
      # Data file validation (advisory)
      ########################################################################
      - name: Check for data file changes
        id: data-changes
        shell: bash
        run: |
          # Get all changed files from the previous step
          all_files="${{ steps.changed-files.outputs.files }}"
          
          # Check for specific data files
          changed_media=false
          changed_feeds=false
          changed_locales=false
          
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            base_sha="${{ github.event.pull_request.base.sha }}"
            head_sha="${{ github.event.pull_request.head.sha }}"
            diff_files=$(git diff --name-only "$base_sha".."$head_sha")
          else
            if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
              diff_files=$(git diff --name-only HEAD~1 HEAD)
            else
              diff_files=$(git ls-files)
            fi
          fi
          
          echo "$diff_files" | grep -q '^media_data\.json{{contextString}}#39; && changed_media=true || true
          echo "$diff_files" | grep -q '^kite_feeds\.json{{contextString}}#39; && changed_feeds=true || true
          echo "$diff_files" | grep -E -q '^src/lib/locales/.+\.json{{contextString}}#39; && changed_locales=true || true
          
          echo "media=$changed_media" >> $GITHUB_OUTPUT
          echo "feeds=$changed_feeds" >> $GITHUB_OUTPUT
          echo "locales=$changed_locales" >> $GITHUB_OUTPUT

      - name: Validate data files
        shell: bash
        run: |
          # Validate media data if changed
          if [ "${{ steps.data-changes.outputs.media }}" = "true" ]; then
            echo "Validating media_data.json..."
            bun scripts/validate-media.ts 2> /tmp/media_diag.log || true
          else
            echo "media_data.json unchanged; skipping validator" > /tmp/media_diag.log
          fi
          
          # Feeds validation handled in dedicated steps below
          
          # Validate locales if changed
          if [ "${{ steps.data-changes.outputs.locales }}" = "true" ]; then
            echo "Validating locale files..."
            bun scripts/sort-locales.ts --output-issues --output-file locales-issues.json || true
          fi

      - name: Generate feed issues (advisory)
        if: steps.data-changes.outputs.feeds == 'true'
        shell: bash
        run: |
          echo "Generating feed issues report..."
          bun scripts/sort-feeds.ts --output-issues --output-file feeds-issues.json || true

      - name: Run feed validator
        id: feeds-run-validator
        if: steps.data-changes.outputs.feeds == 'true'
        shell: bash
        run: |
          set -o pipefail
          exit_code=0
          bun scripts/validate-feeds.ts 2> /tmp/feeds_diag.log 1> /tmp/feeds_patch.diff || exit_code=$?
          echo "exit_code=$exit_code" >> $GITHUB_OUTPUT
          # Ensure log files exist even if empty
          touch /tmp/feeds_diag.log /tmp/feeds_patch.diff

      - name: Annotate media validation issues
        if: steps.data-changes.outputs.media == 'true'
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if [ -f /tmp/media_diag.log ]; then
            if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
              reporter="github-pr-review"
            else
              reporter="github-check"
            fi
            reviewdog -efm="%f:%l:%m" -name="media-validator" -reporter="$reporter" -fail-on-error=false < /tmp/media_diag.log
          fi

      - name: Annotate feed validation issues
        if: steps.data-changes.outputs.feeds == 'true'
        env:
          REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if [ -f /tmp/feeds_diag.log ]; then
            if [ "${{ github.event_name }}" = "pull_request" ] && [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
              reporter="github-pr-review"
            else
              reporter="github-check"
            fi
            reviewdog -efm="%f:%l:%m" -name="feed-validator" -reporter="$reporter" -fail-on-error=false < /tmp/feeds_diag.log
          fi

      - name: Upload feed validator artifacts
        if: steps.data-changes.outputs.feeds == 'true'
        uses: actions/upload-artifact@v4
        with:
          name: feed-validator-logs
          path: |
            /tmp/feeds_diag.log
            /tmp/feeds_patch.diff

      - name: Add feed diagnostics summary
        if: steps.data-changes.outputs.feeds == 'true'
        shell: bash
        run: |
          echo "## Feed validator diagnostics" >> "$GITHUB_STEP_SUMMARY"
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "Total diagnostics: $(wc -l < /tmp/feeds_diag.log)" >> "$GITHUB_STEP_SUMMARY"
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "First 10 issues:" >> "$GITHUB_STEP_SUMMARY"
          sed -n '1,10p' /tmp/feeds_diag.log >> "$GITHUB_STEP_SUMMARY"

      - name: Apply feed diff to working tree (same-repo PRs and push)
        if: steps.data-changes.outputs.feeds == 'true' && fromJSON(steps.feeds-run-validator.outputs.exit_code) != 0 && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
        run: |
          patch -p1 < /tmp/feeds_patch.diff

      - name: Suggest feed fixes via action-suggester (same-repo PRs only)
        if: steps.data-changes.outputs.feeds == 'true' && fromJSON(steps.feeds-run-validator.outputs.exit_code) != 0 && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
        uses: reviewdog/action-suggester@v1
        with:
          tool_name: feed-validator
          level: error
```

## /.github/workflows/sort-feeds.yml

```yml path="/.github/workflows/sort-feeds.yml" 
name: Auto-sort data files

on:
  push:
    paths:
      - 'kite_feeds.json'
      - 'core_feeds.py'
      - 'media_data.json'
      - 'src/lib/locales/*.json'
    branches:
      - main
      - 'feature/**'
  pull_request:
    paths:
      - 'kite_feeds.json'
      - 'core_feeds.py'
      - 'media_data.json'
      - 'src/lib/locales/*.json'
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write
  checks: write

concurrency:
  group: sort-feeds-${{ github.ref }}
  cancel-in-progress: true

jobs:
  sort-feeds:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      with:
        token: ${{ secrets.GITHUB_TOKEN }}
        fetch-depth: 0

    - name: Setup Bun
      uses: oven-sh/setup-bun@v2

    - name: Restore Bun cache
      uses: actions/cache@v4
      with:
        path: ~/.bun/install/cache
        key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
        restore-keys: |
          ${{ runner.os }}-bun-

    - name: Install jq for JSON processing
      run: sudo apt-get update && sudo apt-get install -y jq

    - name: Install dependencies
      run: bun install --frozen-lockfile

    - name: Detect changed targets
      id: detect
      shell: bash
      run: |
        set -e
        if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
          git fetch origin main
          CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
        else
          CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD || true)
        fi
        echo "Changed files:\n$CHANGED_FILES"

        FEEDS_CHANGED=false
        CORE_FEEDS_CHANGED=false
        MEDIA_CHANGED=false
        LOCALES_CHANGED=false
        echo "$CHANGED_FILES" | grep -q '^kite_feeds.json{{contextString}}#39; && FEEDS_CHANGED=true || true
        echo "$CHANGED_FILES" | grep -q '^core_feeds.py{{contextString}}#39; && CORE_FEEDS_CHANGED=true || true
        echo "$CHANGED_FILES" | grep -q '^media_data.json{{contextString}}#39; && MEDIA_CHANGED=true || true
        echo "$CHANGED_FILES" | grep -E -q '^src/lib/locales/.+\.json{{contextString}}#39; && LOCALES_CHANGED=true || true

        echo "feeds_changed=$FEEDS_CHANGED" >> $GITHUB_OUTPUT
        echo "core_feeds_changed=$CORE_FEEDS_CHANGED" >> $GITHUB_OUTPUT
        echo "media_changed=$MEDIA_CHANGED" >> $GITHUB_OUTPUT
        echo "locales_changed=$LOCALES_CHANGED" >> $GITHUB_OUTPUT

    - name: Sort feeds
      if: steps.detect.outputs.feeds_changed == 'true'
      run: |
        set -e
        echo "🔄 Sorting feeds..."
        bun scripts/sort-feeds.ts || { echo "❌ Failed to sort feeds"; exit 1; }
        echo "✅ Feeds sorted"

    - name: Sort core feeds
      if: steps.detect.outputs.core_feeds_changed == 'true'
      run: |
        set -e
        echo "🔄 Sorting core feeds..."
        python3 scripts/sort-core-feeds.py || { echo "❌ Failed to sort core feeds"; exit 1; }
        echo "✅ Core feeds sorted"

    - name: Sort media
      if: steps.detect.outputs.media_changed == 'true'
      run: |
        set -e
        echo "🔄 Sorting media..."
        bun scripts/sort-media.ts || { echo "❌ Failed to sort media"; exit 1; }
        echo "✅ Media sorted"

    - name: Sort locales
      if: steps.detect.outputs.locales_changed == 'true'
      run: |
        set -e
        echo "🔄 Sorting locales..."
        bun scripts/sort-locales.ts || { echo "❌ Failed to sort locales"; exit 1; }
        echo "✅ Locales sorted"

    - name: Generate issues for reviewdog
      if: github.event_name == 'pull_request'
      run: |
        ISSUE_FILES=()
        if [ "${{ steps.detect.outputs.feeds_changed }}" = "true" ]; then
          bun scripts/sort-feeds.ts --output-issues --output-file feeds-issues.json || true
          if jq -e . feeds-issues.json >/dev/null 2>&1; then ISSUE_FILES+=(feeds-issues.json); else echo "feeds-issues.json is not valid JSON; skipping"; fi
        fi
        if [ "${{ steps.detect.outputs.core_feeds_changed }}" = "true" ]; then
          python3 scripts/sort-core-feeds.py --output-issues --output-file core-feeds-issues.json || true
          if jq -e . core-feeds-issues.json >/dev/null 2>&1; then ISSUE_FILES+=(core-feeds-issues.json); else echo "core-feeds-issues.json is not valid JSON; skipping"; fi
        fi
        if [ "${{ steps.detect.outputs.media_changed }}" = "true" ]; then
          bun scripts/sort-media.ts --output-issues --output-file media-issues.json || true
          if jq -e . media-issues.json >/dev/null 2>&1; then ISSUE_FILES+=(media-issues.json); else echo "media-issues.json is not valid JSON; skipping"; fi
        fi
        if [ "${{ steps.detect.outputs.locales_changed }}" = "true" ]; then
          bun scripts/sort-locales.ts --output-issues --output-file locales-issues.json || true
          if jq -e . locales-issues.json >/dev/null 2>&1; then ISSUE_FILES+=(locales-issues.json); else echo "locales-issues.json is not valid JSON; skipping"; fi
        fi

        if [ ${#ISSUE_FILES[@]} -gt 0 ]; then
          jq -s 'map(.issues[]? // empty) | map(select(.severity == "warning" or .severity == "error"))' "${ISSUE_FILES[@]}" > all-issues.json || echo "[]" > all-issues.json
          echo "Generated $(jq length all-issues.json) issues for review"
        else
          echo "No relevant files changed; skipping issue generation"
        fi

    - name: Check for changes
      id: verify-changed-files
      run: |
        if [ -n "$(git status --porcelain)" ]; then
          echo "changed=true" >> $GITHUB_OUTPUT
        else
          echo "changed=false" >> $GITHUB_OUTPUT
        fi



    - name: Commit changes
      if: steps.verify-changed-files.outputs.changed == 'true' && github.event_name == 'push'
      run: |
        git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
        git config --local user.name "github-actions[bot]"
        git add kite_feeds.json core_feeds.py media_data.json src/lib/locales/*.json
        git commit -m "$(cat <<'EOF'
        chore: auto-sort and deduplicate feeds

        - Sort categories by type order (country → region → city → topic)
        - Sort feeds alphabetically within categories by URL
        - Remove duplicate feed URLs

        🤖 Generated with GitHub Actions
        EOF
        )"
        git push

    - name: Skip sorting for external PRs
      if: steps.verify-changed-files.outputs.changed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository
      run: |
        echo "🔄 External PR detected - skipping auto-sort"
        echo "📝 Feeds will be automatically sorted when merged to main"
        echo "✅ Check completed successfully"

    - name: Commit changes to same-repo PR
      if: steps.verify-changed-files.outputs.changed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      run: |
        git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
        git config --local user.name "github-actions[bot]"
        git add kite_feeds.json core_feeds.py media_data.json src/lib/locales/*.json
        git commit -m "$(cat <<'EOF'
        chore: auto-sort and deduplicate feeds

        - Sort categories by type order (country → region → city → topic)
        - Sort feeds alphabetically within categories by URL
        - Remove duplicate feed URLs

        🤖 Generated with GitHub Actions
        EOF
        )"
        git push origin HEAD:${{ github.event.pull_request.head.ref }}

    - name: Comment on same-repo PR about auto-sorting
      if: steps.verify-changed-files.outputs.changed == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      uses: actions/github-script@v7
      with:
        script: |
          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: '🤖 **Feeds automatically sorted**\n\nI detected that `kite_feeds.json` was modified but not properly sorted, so I automatically applied the correct sorting and pushed a new commit to this PR.\n\n**Changes made:**\n- ✅ Sort categories by type order (country → region → city → topic)\n- ✅ Sort feeds alphabetically within categories by URL\n- ✅ Remove duplicate feed URLs\n\nThe PR is now ready for review! 🚀'
          });

    - name: Setup reviewdog
      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      uses: reviewdog/action-setup@v1
      with:
        reviewdog_version: latest

    - name: Apply reviewdog for changed files
      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      env:
        REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        # Get list of changed files in this PR
        git fetch origin main
        CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
        echo "Changed files: $CHANGED_FILES"
        
        # Filter issues to only those affecting changed files and high-priority types
        if [ -f "all-issues.json" ]; then
          # Create reviewdog input for changed files only
          echo "$CHANGED_FILES" | while read -r file; do
            if [ -n "$file" ]; then
              # Filter issues for this specific file and high-priority types (missing translations, duplicates)
              jq --arg file "$file" '
                map(select(.file == $file and (.type == "missing" or .type == "duplicate"))) |
                map({
                  message: .message,
                  location: {
                    path: .file,
                    range: {
                      start: {line: .line}
                    }
                  },
                  severity: (if .severity == "error" then "ERROR" elif .severity == "warning" then "WARNING" else "INFO" end)
                })
              ' all-issues.json
            fi
          done | jq -s 'map(.[]?) | map(select(. != null))' > reviewdog-input.json
          
          # Apply reviewdog suggestions if we have any issues
          if [ "$(jq length reviewdog-input.json)" -gt 0 ]; then
            echo "Applying $(jq length reviewdog-input.json) reviewdog suggestions..."
            jq -c '.[]' reviewdog-input.json | reviewdog -f=rdjsonl -name="data-quality" -reporter=github-pr-review -filter-mode=nofilter
          else
            echo "No high-priority issues found in changed files"
          fi
        fi

    - name: Comment about comprehensive analysis
      if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
      uses: actions/github-script@v7
      with:
        script: |
          const fs = require('fs');
          
          // Read all analysis results
          let feedsData = {}, coreFeedsData = {}, mediaData = {}, localesData = {};
          let totalIssues = 0;

          try {
            if (fs.existsSync('feeds-issues.json')) {
              feedsData = JSON.parse(fs.readFileSync('feeds-issues.json', 'utf8'));
              totalIssues += feedsData.summary?.totalIssues || 0;
            }
            if (fs.existsSync('core-feeds-issues.json')) {
              coreFeedsData = JSON.parse(fs.readFileSync('core-feeds-issues.json', 'utf8'));
              totalIssues += coreFeedsData.summary?.totalIssues || 0;
            }
            if (fs.existsSync('media-issues.json')) {
              mediaData = JSON.parse(fs.readFileSync('media-issues.json', 'utf8'));
              totalIssues += mediaData.summary?.totalIssues || 0;
            }
            if (fs.existsSync('locales-issues.json')) {
              localesData = JSON.parse(fs.readFileSync('locales-issues.json', 'utf8'));
              totalIssues += localesData.summary?.totalIssues || 0;
            }
          } catch (e) {
            console.log('Error reading analysis files:', e.message);
            return;
          }
          
          if (totalIssues === 0) {
            console.log('No issues found, skipping comment');
            return;
          }
          
          let body = `🔍 **Data Quality Analysis Report**\n\nFound **${totalIssues} total issues** across data files:\n\n`;
          
          // Feeds/Translation analysis
          if (feedsData.summary?.totalIssues > 0) {
            body += `### 🔑 Translation Issues (${feedsData.summary.totalIssues})\n`;
            if (feedsData.summary.byType?.unused) body += `- 📝 ${feedsData.summary.byType.unused} unused keys\n`;
            if (feedsData.summary.byType?.missing) body += `- 🌍 ${feedsData.summary.byType.missing} missing translations\n`;
            if (feedsData.summary.byType?.orphaned) body += `- 👻 ${feedsData.summary.byType.orphaned} orphaned keys\n`;
            body += '\n';
          }
          
          // Media analysis
          if (mediaData.summary?.totalIssues > 0) {
            body += `### 📰 Media Issues (${mediaData.summary.totalIssues})\n`;
            if (mediaData.summary.byType?.unused) body += `- 🔗 ${mediaData.summary.byType.unused} unused domains\n`;
            if (mediaData.summary.byType?.duplicate) body += `- 🔄 ${mediaData.summary.byType.duplicate} duplicate organizations\n`;
            body += '\n';
          }
          
          // Locale analysis
          if (localesData.summary?.totalIssues > 0) {
            body += `### 🌐 Locale Issues (${localesData.summary.totalIssues})\n`;
            if (localesData.summary.byType?.missing) body += `- 🌍 ${localesData.summary.byType.missing} missing translations\n`;
            if (localesData.summary.byType?.orphaned) body += `- 👻 ${localesData.summary.byType.orphaned} orphaned keys\n`;
            if (localesData.summary.byType?.duplicate) body += `- 🔄 ${localesData.summary.byType.duplicate} duplicate keys\n`;
            body += '\n';
          }
          
          body += `### 🤖 Automated Actions\n`;
          body += `- ✅ **Reviewdog**: Applied suggestions for high-priority issues in changed files\n`;
          body += `- 📊 **Analysis**: Full report available in workflow logs\n`;
          body += `- 🔧 **Next Steps**: Review suggestions above and consider cleaning up unused keys\n\n`;
          body += `<details>\n<summary>💡 How to fix these issues</summary>\n\n`;
          body += `**For unused translation keys:**\n`;
          body += `- Remove keys that are no longer referenced in the codebase\n`;
          body += `- Check if keys are used in dynamic contexts (template strings, etc.)\n\n`;
          body += `**For missing translations:**\n`;
          body += `- Add missing keys to locale files\n`;
          body += `- Use the English text as a starting point for translation\n\n`;
          body += `**For orphaned keys:**\n`;
          body += `- Remove keys that don't exist in en.json (master locale)\n`;
          body += `- Or add them to en.json if they should exist\n\n`;
          body += `</details>`;
          
          github.rest.issues.createComment({
            issue_number: context.issue.number,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: body
          });


```

## /.gitignore

```gitignore path="/.gitignore" 
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
build
dist
dist-ssr
*.local
coverage

# Editor directories and files
.vscode/*
.history/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

.vite
.svelte-kit
__pycache__/

```

## /.prettierrc

```prettierrc path="/.prettierrc" 
{
  "plugins": [
    "prettier-plugin-svelte",
    "prettier-plugin-tailwindcss",
    "@trivago/prettier-plugin-sort-imports"
  ],
  "overrides": [
    {
      "files": "*.svelte",
      "options": {
        "parser": "svelte"
      }
    }
  ]
}

```

## /README.md

# Kagi News - News. Elevated.

[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/)
[![CC BY-NC 4.0](https://img.shields.io/badge/License-CC%20BY--NC%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc/4.0/)

This repository contains public files for [Kagi News](https://kite.kagi.com), news app for people who want a healthy news diet, developed by [Kagi](https://kagi.com).

Kagi News is designed for people who want to stay informed without getting overwhelmed. We provide daily updates of the most important news stories, from carefully curated sources and summarized by advanced language models to give you the essential information you need. We strive for diversity and transparency of resources and welcome your contributions to widen perspectives. This multi-source approach helps reveal the full picture beyond any single viewpoint.

Most of what powers Kagi News is open sourced and is found in this repository.

This includes

- Kagi News web app
- Community curated feeds
- Media information

## Core Principles

- Updated only once per day - no endless scrolling (12PM UTC)
- Facts and perspectives, opinion-free
- Zero tracking, zero ads
- Pure signal, no noise
- Quality over quantity
- Complete news diet in 5 minutes

You can read more about core principles behind Kagi News

- [Avoid News: Towards a Healthy News Diet](https://www.gwern.net/docs/culture/2010-dobelli.pdf) ([HN discussion](https://news.ycombinator.com/item?id=21430337))
- [News is bad for you](http://www.theguardian.com/media/2013/apr/12/news-is-bad-rolf-dobelli) ([HN discussion](https://news.ycombinator.com/item?id=6894244))
- [Stop reading news](https://fs.blog/2013/12/stop-reading-news/) ([HN discussion](https://news.ycombinator.com/item?id=19084099))

If you prefer to watch a short video, check this Ted talk: [Four reasons you should stop watching the news | Rolf Dobelli](https://www.youtube.com/watch?v=-miTTiaqFlI).

# Install & run Kagi News front-end

Kagi News front end is a statically served app and is fully open source.

Here is how to run it locally:

```bash
# Clone the repository
git clone https://github.com/kagisearch/kite-public.git
cd kite-public

# Make sure you have the Node.js LTS version installed.
node -v

# Install dependencies
npm install

# Run development server
npm run dev
```

Check out the Vite documentation on how a production build works: https://vite.dev/guide/static-deploy.html

Kagi News front-end uses Kagi News application data that can be found at [kite.kagi.com/kite.json](https://kite.kagi.com/kite.json) (explore other files from there). Note that kite.json and files referenced by it are licensed under [CC BY-NC license](https://creativecommons.org/licenses/by-nc/4.0/). This means that this data can be used free of charge (with attribution and for non-commercial use). If you would like to license this data for commercial use let us know through support@kagi.com.

Kagi News web app is just one example front-end that one can run on top of the Kagi News data. We encourage others to contribute improvements to the Kagi News frontend.

**We would also love to see what kind of custom front-ends you can create on top of Kagi News data!** Feel free to share them with us and others by editing this Readme file.

## Editing categories

To edit community curated categories, submit a pull request editing `kite_feeds.json`. If you do not know how to do that, you can [open an issue](https://github.com/kagisearch/kite-public/issues/new/choose) and share the feeds you want to add there.

This file contains RSS feeds for various categories.

To add a new category or modify existing ones, follow this structure:

```jsonc
{
  "Category Name": {
    "feeds": [
      "https://example.com/rss-feed-1",
      "https://example.com/rss-feed-2",
      // ...
    ],
  },
  "Another Category": {
    "feeds": [
      "https://another-example.com/rss-feed-1",
      "https://another-example.com/rss-feed-2",
      // ...
    ],
  },
}
```

### Adding a new category

1. Create a new object with the category name as the key
2. Add a "feeds" array containing the RSS feed URLs for that category
3. Ensure proper JSON formatting (commas between objects, no trailing comma)

All categories are created equal and will appear as top level categories in the Kagi News app.

Ideas for categories:

- Local news (city/state level)
- Regional news (country/region)
- Topical news (health, machine learning, aviation ...)

### Guidelines for adding RSS feeds

Kagi News does not scrape websites, but only publicly available RSS feeds.

When adding an RSS feed make sure to:

- Check that feed is working and has recent (daily) content.
- Choose sources that have high quality content. Do not use low quality/gossip/SEO content.
- **Feeds can be in any language.** Kagi News translates all content automatically, so a single category can (and should) contain feeds in multiple languages. Do not create separate categories for different languages (e.g. "Switzerland (DE)" and "Switzerland (FR)") — keep everything in one category. The `source_language` field in `kite_feeds.json` should be set to the language used by the majority of feeds; its exact value is not critical.

### Important

We require at least **25 feeds** for a category in order to surface it in Kagi News. This is to make sure we maintain high level of quality of events covered in the app. The more high quality feeds exist for a category, the better Kagi News coverage will be.

Kagi News does not scrape news websites, it only uses publicly available information in RSS feeds.

## Core Feeds

Kagi News's official core feeds are fully open source and available in `core_feeds.py`. These feeds cover essential news categories and form the foundation of Kagi News's coverage. While primarily maintained by the Kagi team for quality and consistency, they are open to community contributions - you can submit pull requests to suggest improvements or additions to these core feeds.

Core categories include:
- World News
- Business
- Technology
- Science
- Sports
- Culture
- Politics
- And many more specialized topics

The combination of these core feeds and community-curated feeds ensures comprehensive, diverse news coverage from multiple perspectives.

## Guidelines for editing Media information

Kagi News uses contents of `media_data.json` to show additional information about sources of information. Initial information has been sources from https://statemediamonitor.com/ and the classification methodology is explained here https://statemediamonitor.com/methodology/

Feel free to add additional information (by editing `media_data.json`) both for privately owned and state funded media organization. Add your sources of information in the pull request.

## Guidelines for editing Content Filters

Kagi News allows users to blur or hide articles on topics they prefer not to see. These personal content filters are defined in `src/lib/data/contentFilters.json` and are open source and community editable.

To edit content filters, submit a pull request editing `contentFilters.json`. Each filter follows this structure:

```json
{
  "id": "filter_id",
  "label": "Display Name",
  "keywords": {
    "default": ["keyword1", "keyword2"],
    "en": ["keyword1", "keyword2"],
    "pt": ["palavra1", "palavra2"],
    // ... other languages
  }
}
```

When adding or modifying filters:
- Use lowercase for all keywords
- Include translations for all supported languages
- Choose descriptive, clear labels for filter categories
- Test that keywords accurately match the intended topic

Feel free to add new filter categories or improve existing ones by adding relevant keywords in multiple languages.

## Custom front-ends

### Raycast
<a title="Install kagi-news Raycast Extension" href="https://www.raycast.com/mickaphd/kagi-news"><img src="https://www.raycast.com/mickaphd/kagi-news/install_button@2x.png?v=1.1" height="64" style="height: 64px;" alt=""></a>

### Pebble
<a title="Install Kagi News Pebble Application" href="https://apps.rebble.io/en_US/application/692b3f0549be450009b545ce"><img src="https://assets2.rebble.io/720x320/692d3f44d7dcba0009f1b174" height="100" alt=""></a>


## /biome.json

```json path="/biome.json" 
{
  "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true,
    "defaultBranch": "main"
  },
  "files": {
    "includes": [
      "src/**/*",
      "*.js",
      "*.ts",
      "*.json",
      "*.svelte",
      "*.html"
    ],
    "ignoreUnknown": true
  },
  "formatter": {
    "enabled": true,
    "formatWithErrors": false,
    "attributePosition": "auto",
    "indentStyle": "tab",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf",
    "useEditorconfig": false
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "style": {
        "noNonNullAssertion": "off",
        "useTemplate": "error"
      },
      "correctness": {
        "noUnusedVariables": "warn",
        "noUnusedImports": "warn"
      },
      "suspicious": {
        "noExplicitAny": "warn",
        "noArrayIndexKey": "off"
      },
      "complexity": {
        "noForEach": "off"
      }
    }
  },
  "javascript": {
    "formatter": {
      "arrowParentheses": "always",
      "bracketSameLine": false,
      "bracketSpacing": true,
      "jsxQuoteStyle": "double",
      "quoteProperties": "asNeeded",
      "quoteStyle": "single",
      "semicolons": "always",
      "trailingCommas": "all"
    }
  },
  "json": {
    "parser": {
      "allowComments": true,
      "allowTrailingCommas": false
    },
    "formatter": {
      "trailingCommas": "none"
    }
  },
  "overrides": [
    {
      "includes": [
        "src/lib/components/MapView.svelte"
      ],
      "linter": {
        "enabled": false
      },
      "formatter": {
        "enabled": false
      }
    },
    {
      "includes": [
        "**/*.svelte"
      ],
      "linter": {
        "rules": {
          "style": {
            "useConst": "off",
            "useImportType": "off",
            "noNonNullAssertion": "off"
          },
          "correctness": {
            "noUnusedVariables": "off",
            "noUnusedImports": "off"
          },
          "suspicious": {
            "noExplicitAny": "off"
          }
        }
      }
    },
    {
      "includes": [
        "**/*.config.js",
        "**/*.config.ts",
        "vite.config.*",
        "tailwind.config.*"
      ],
      "linter": {
        "rules": {
          "style": {
            "useConst": "off"
          },
          "suspicious": {
            "noExplicitAny": "off"
          }
        }
      }
    },
    {
      "includes": [
        "**/*.test.ts",
        "**/*.test.js",
        "**/*.spec.ts",
        "**/*.spec.js"
      ],
      "linter": {
        "rules": {
          "suspicious": {
            "noExplicitAny": "off"
          }
        }
      }
    }
  ]
}
```

## /contentFilters.json

```json path="/contentFilters.json" 
{
  "filters": [
    {
      "id": "politics",
      "label": "Politics",
      "keywords": {
        "default": ["trump", "biden", "election", "democrat", "republican", "congress", "senate", "parliament", "minister", "government", "politician"],
        "en": ["trump", "biden", "election", "democrat", "republican", "congress", "senate", "parliament", "minister", "government", "politician"],
        "pt": ["trump", "biden", "eleição", "democrata", "republicano", "congresso", "senado", "parlamento", "ministro", "governo", "político"],
        "it": ["trump", "biden", "elezione", "democratico", "repubblicano", "congresso", "senato", "parlamento", "ministro", "governo", "politico"],
        "fr": ["trump", "biden", "élection", "démocrate", "républicain", "congrès", "sénat", "parlement", "ministre", "gouvernement", "politicien"],
        "es": ["trump", "biden", "elección", "demócrata", "republicano", "congreso", "senado", "parlamento", "ministro", "gobierno", "político"],
        "de": ["trump", "biden", "wahl", "demokrat", "republikaner", "kongress", "senat", "parlament", "minister", "regierung", "politiker"],
        "nl": ["trump", "biden", "verkiezing", "democraat", "republikein", "congres", "senaat", "parlement", "minister", "regering", "politicus"],
        "zh-Hans": ["特朗普", "拜登", "选举", "民主党", "共和党", "国会", "参议院", "议会", "部长", "政府", "政治家"],
        "zh-Hant": ["特朗普", "拜登", "選舉", "民主黨", "共和黨", "國會", "參議院", "議會", "部長", "政府", "政治家"],
        "ja": ["トランプ", "バイデン", "選挙", "民主党", "共和党", "議会", "上院", "国会", "大臣", "政府", "政治家"],
        "hi": ["ट्रंप", "बाइडेन", "चुनाव", "डेमोक्रेट", "रिपब्लिकन", "कांग्रेस", "सीनेट", "संसद", "मंत्री", "सरकार", "राजनेता"],
        "ru": ["трамп", "байден", "выборы", "демократ", "республиканец", "конгресс", "сенат", "парламент", "министр", "правительство", "политик", "путин", "зеленский", "война", "кремль", "дума", "единая россия", "президент"],
        "uk": ["трамп", "байден", "вибори", "демократ", "республіканець", "конгрес", "сенат", "парламент", "міністр", "уряд", "політик"],
        "et": ["trump", "biden", "valimised", "demokraat", "vabariiklased", "kongress", "senat", "parlament", "minister", "valitsus", "poliitik"],
        "sv": ["trump", "biden", "val", "demokrat", "republikan", "kongress", "senat", "parlament", "minister", "regering", "politiker"]
      }
    },
    {
      "id": "conflicts",
      "label": "Conflicts",
      "keywords": {
        "default": ["ukraine", "russia", "israel", "gaza", "palestine", "war", "conflict", "missile", "attack", "military", "invasion", "bombing"],
        "en": ["ukraine", "russia", "israel", "gaza", "palestine", "war", "conflict", "missile", "attack", "military", "invasion", "bombing"],
        "pt": ["ucrânia", "rússia", "israel", "gaza", "palestina", "guerra", "conflito", "míssil", "ataque", "militar", "invasão", "bombardeio"],
        "it": ["ucraina", "russia", "israele", "gaza", "palestina", "guerra", "conflitto", "missile", "attacco", "militare", "invasione", "bombardamento"],
        "fr": ["ukraine", "russie", "israël", "gaza", "palestine", "guerre", "conflit", "missile", "attaque", "militaire", "invasion", "bombardement"],
        "es": ["ucrania", "rusia", "israel", "gaza", "palestina", "guerra", "conflicto", "misil", "ataque", "militar", "invasión", "bombardeo"],
        "de": ["ukraine", "russland", "israel", "gaza", "palästina", "krieg", "konflikt", "rakete", "angriff", "militär", "invasion", "bombardierung"],
        "nl": ["oekraïne", "rusland", "israël", "gaza", "palestina", "oorlog", "conflict", "raket", "aanval", "militair", "invasie", "bombardement"],
        "zh-Hans": ["乌克兰", "俄罗斯", "以色列", "加沙", "巴勒斯坦", "战争", "冲突", "导弹", "攻击", "军事", "入侵", "轰炸"],
        "zh-Hant": ["烏克蘭", "俄羅斯", "以色列", "加沙", "巴勒斯坦", "戰爭", "衝突", "導彈", "攻擊", "軍事", "入侵", "轟炸"],
        "ja": ["ウクライナ", "ロシア", "イスラエル", "ガザ", "パレスチナ", "戦争", "紛争", "ミサイル", "攻撃", "軍事", "侵攻", "爆撃"],
        "hi": ["यूक्रेन", "रूस", "इज़राइल", "गाजा", "फिलिस्तीन", "युद्ध", "संघर्ष", "मिसाइल", "हमला", "सैन्य", "आक्रमण", "बमबारी"],
        "ru": ["украина", "россия", "израиль", "газа", "палестина", "война", "конфликт", "ракета", "атака", "военный", "вторжение", "бомбардировка", "донбасс", "луганск", "донецк", "крым", "хамас", "цахал"],
        "uk": ["україна", "росія", "ізраїль", "газа", "палестина", "війна", "конфлікт", "ракета", "атака", "військовий", "вторгнення", "бомбардування"],
        "et": ["ukraina", "venemaa", "israel", "gaaza", "palestiina", "sõda", "konflikt", "rakett", "rünnak", "sõjavägi", "sissetung", "pommitamine"],
        "sv": ["ukraina", "ryssland", "israel", "gaza", "palestina", "krig", "konflikt", "misil", "attack", "militär", "invasion", "bombning"]
      }
    },
    {
      "id": "health",
      "label": "Health Crises",
      "keywords": {
        "default": ["covid", "coronavirus", "pandemic", "vaccine", "virus", "outbreak", "epidemic", "quarantine", "lockdown"],
        "en": ["covid", "coronavirus", "pandemic", "vaccine", "virus", "outbreak", "epidemic", "quarantine", "lockdown"],
        "pt": ["covid", "coronavírus", "pandemia", "vacina", "vírus", "surto", "epidemia", "quarentena", "confinamento"],
        "it": ["covid", "coronavirus", "pandemia", "vaccino", "virus", "focolaio", "epidemia", "quarantena", "lockdown"],
        "fr": ["covid", "coronavirus", "pandémie", "vaccin", "virus", "épidémie", "épidémie", "quarantaine", "confinement"],
        "es": ["covid", "coronavirus", "pandemia", "vacuna", "virus", "brote", "epidemia", "cuarentena", "confinamiento"],
        "de": ["covid", "coronavirus", "pandemie", "impfstoff", "virus", "ausbruch", "epidemie", "quarantäne", "lockdown"],
        "nl": ["covid", "coronavirus", "pandemie", "vaccin", "virus", "uitbraak", "epidemie", "quarantaine", "lockdown"],
        "zh-Hans": ["新冠", "冠状病毒", "大流行", "疫苗", "病毒", "暴发", "流行病", "隔离", "封锁"],
        "zh-Hant": ["新冠", "冠狀病毒", "大流行", "疫苗", "病毒", "暴發", "流行病", "隔離", "封鎖"],
        "ja": ["コロナ", "コロナウイルス", "パンデミック", "ワクチン", "ウイルス", "発生", "流行", "隔離", "ロックダウン"],
        "hi": ["कोविड", "कोरोनावायरस", "महामारी", "टीका", "वायरस", "प्रकोप", "महामारी", "संगरोध", "लॉकडाउन"],
        "ru": ["ковид", "коронавирус", "пандемия", "вакцина", "вирус", "вспышка", "эпидемия", "карантин", "локдаун", "спутник", "роспотребнадзор"],
        "uk": ["ковід", "коронавірус", "пандемія", "вакцина", "вірус", "спалах", "епідемія", "карантин", "локдаун"],
        "et": ["covid", "koroonaviirus", "pandeemia", "vaktsiin", "viirus", "puhang", "epideemia", "karanteen", "lukustamine"],
        "sv": ["covid", "coronavirus", "pandemi", "vaccin", "virus", "utbrott", "epidemi", "karantän", "lockdown"]
      }
    },
    {
      "id": "violence",
      "label": "Violence",
      "keywords": {
        "default": ["shooting", "killed", "murder", "suicide", "dead", "death toll", "massacre", "stabbing", "assault"],
        "en": ["shooting", "killed", "murder", "suicide", "dead", "death toll", "massacre", "stabbing", "assault"],
        "pt": ["tiroteio", "morto", "assassinato", "suicídio", "morto", "número de mortos", "massacre", "esfaqueamento", "agressão"],
        "it": ["sparatoria", "ucciso", "omicidio", "suicidio", "morto", "bilancio morti", "massacro", "accoltellamento", "aggressione"],
        "fr": ["fusillade", "tué", "meurtre", "suicide", "mort", "bilan mortel", "massacre", "agression", "agression"],
        "es": ["tiroteo", "muerto", "asesinato", "suicidio", "muerto", "número de muertos", "masacre", "apuñalamiento", "asalto"],
        "de": ["schießerei", "getötet", "mord", "selbstmord", "tot", "todesopfer", "massaker", "messerstecherei", "angriff"],
        "nl": ["schietpartij", "gedood", "moord", "zelfmoord", "dood", "dodental", "bloedbad", "steekpartij", "aanval"],
        "zh-Hans": ["枪击", "杀害", "谋杀", "自杀", "死亡", "死亡人数", "屠杀", "刺伤", "袭击"],
        "zh-Hant": ["槍擊", "殺害", "謀殺", "自殺", "死亡", "死亡人數", "屠殺", "刺傷", "襲擊"],
        "ja": ["銃撃", "殺害", "殺人", "自殺", "死亡", "死者数", "虐殺", "刺殺", "暴行"],
        "hi": ["गोलीबारी", "मारा गया", "हत्या", "आत्महत्या", "मृत", "मृत्यु संख्या", "नरसंहार", "छुरा घोंपना", "हमला"],
        "ru": ["стрельба", "убит", "убийство", "самоубийство", "мертвый", "число погибших", "резня", "ножевое ранение", "нападение", "теракт", "взрыв"],
        "uk": ["стрілянина", "вбитий", "вбивство", "самогубство", "мертвий", "кількість загиблих", "різанина", "ножове поранення", "напад"],
        "et": ["laskmine", "tapetud", "mõrv", "enesehukk", "surnud", "surmajuhtumite arv", "massimõrv", "nuga", "rünnak"],
        "sv": ["skjutning", "dödad", "mord", "självmord", "död", "dödstal", "massaker", "knivhuggning", "överfall"]
      }
    },
    {
      "id": "tabloid",
      "label": "Tabloid Sources",
      "keywords": {
        "default": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "en": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "pt": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "it": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "fr": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "es": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "de": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com", "bild.de"],
        "nl": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "zh-Hans": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "zh-Hant": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "ja": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "hi": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "ru": ["starhit.ru", "woman.ru", "teleprogramma.pro", "7days.ru", "cosmo.ru"],
        "uk": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "et": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"],
        "sv": ["dailymail.co.uk", "thesun.co.uk", "mirror.co.uk", "tmz.com", "pagesix.com", "radaronline.com"]
      }
    },
    {
      "id": "crypto",
      "label": "Cryptocurrency",
      "keywords": {
        "default": ["bitcoin", "ethereum", "crypto", "cryptocurrency", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "en": ["bitcoin", "ethereum", "crypto", "cryptocurrency", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "pt": ["bitcoin", "ethereum", "cripto", "criptomoeda", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "it": ["bitcoin", "ethereum", "cripto", "criptovaluta", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "fr": ["bitcoin", "ethereum", "crypto", "cryptomonnaie", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "es": ["bitcoin", "ethereum", "cripto", "criptomoneda", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "de": ["bitcoin", "ethereum", "krypto", "kryptowährung", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "nl": ["bitcoin", "ethereum", "crypto", "cryptocurrency", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "zh-Hans": ["比特币", "以太坊", "加密", "加密货币", "区块链", "nft", "defi", "web3", "狗狗币", "币安"],
        "zh-Hant": ["比特幣", "以太坊", "加密", "加密貨幣", "區塊鏈", "nft", "defi", "web3", "狗狗幣", "幣安"],
        "ja": ["ビットコイン", "イーサリアム", "仮想通貨", "暗号通貨", "ブロックチェーン", "nft", "defi", "web3", "ドージコイン", "バイナンス"],
        "hi": ["बिटकॉइन", "इथीरियम", "क्रिप्टो", "क्रिप्टोकरेंसी", "ब्लॉकचेन", "एनएफटी", "डीफाई", "वेब3", "डॉगकॉइन", "बिनेंस"],
        "ru": ["биткоин", "эфириум", "крипто", "криптовалюта", "блокчейн", "nft", "defi", "web3", "догкоин", "binance", "майнинг", "цифровая валюта"],
        "uk": ["біткоїн", "ефіріум", "крипто", "криптовалюта", "блокчейн", "nft", "defi", "web3", "догкоїн", "binance"],
        "et": ["bitcoin", "ethereum", "krüpto", "krüptovaluuta", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"],
        "sv": ["bitcoin", "ethereum", "krypto", "kryptovaluta", "blockchain", "nft", "defi", "web3", "dogecoin", "binance"]
      }
    },
    {
      "id": "celebrities",
      "label": "Celebrity News",
      "keywords": {
        "default": ["kardashian", "celebrity", "royal family", "hollywood", "gossip", "dating", "breakup", "scandal"],
        "en": ["kardashian", "celebrity", "royal family", "hollywood", "gossip", "dating", "breakup", "scandal"],
        "pt": ["kardashian", "celebridade", "família real", "hollywood", "fofoca", "namoro", "término", "escândalo"],
        "it": ["kardashian", "celebrità", "famiglia reale", "hollywood", "gossip", "incontri", "rottura", "scandalo"],
        "fr": ["kardashian", "célébrité", "famille royale", "hollywood", "potins", "rencontres", "rupture", "scandale"],
        "es": ["kardashian", "celebridad", "familia real", "hollywood", "chismes", "citas", "ruptura", "escándalo"],
        "de": ["kardashian", "prominente", "königsfamilie", "hollywood", "klatsch", "dating", "trennung", "skandal"],
        "nl": ["kardashian", "beroemdheid", "koninklijke familie", "hollywood", "roddels", "daten", "breuk", "schandaal"],
        "zh-Hans": ["卡戴珊", "名人", "皇室", "好莱坞", "八卦", "约会", "分手", "丑闻"],
        "zh-Hant": ["卡戴珊", "名人", "皇室", "好萊塢", "八卦", "約會", "分手", "醜聞"],
        "ja": ["カーダシアン", "セレブ", "王室", "ハリウッド", "ゴシップ", "デート", "破局", "スキャンダル"],
        "hi": ["कार्दशियन", "सेलिब्रिटी", "शाही परिवार", "हॉलीवुड", "गॉसिप", "डेटिंग", "ब्रेकअप", "स्कैंडल"],
        "ru": ["шаман", "би-2", "пугачева", "галкин", "киркоров", "собчак", "бузова", "дом-2", "малахов", "шоу-бизнес", "сплетни", "скандал"],
        "uk": ["кардашян", "знаменитість", "королівська родина", "голлівуд", "плітки", "побачення", "розрив", "скандал"],
        "et": ["kardashian", "kuulsus", "kuninglik perekond", "hollywood", "põnevik", "kohting", "lahkuminek", "skandaal"],
        "sv": ["kardashian", "kändis", "kungafamilj", "hollywood", "skvaller", "dejting", "uppbrott", "skandal"]
      }
    },
    {
      "id": "sports",
      "label": "Sports",
      "keywords": {
        "default": ["football", "soccer", "basketball", "baseball", "tennis", "golf", "olympics", "world cup", "championship", "league"],
        "en": ["football", "soccer", "basketball", "baseball", "tennis", "golf", "olympics", "world cup", "championship", "league"],
        "pt": ["futebol", "basquete", "beisebol", "tênis", "golfe", "olimpíadas", "copa do mundo", "campeonato", "liga"],
        "it": ["calcio", "basket", "baseball", "tennis", "golf", "olimpiadi", "coppa del mondo", "campionato", "lega"],
        "fr": ["football", "basketball", "baseball", "tennis", "golf", "jeux olympiques", "coupe du monde", "championnat", "ligue"],
        "es": ["fútbol", "baloncesto", "béisbol", "tenis", "golf", "olimpiadas", "copa mundial", "campeonato", "liga"],
        "de": ["fußball", "basketball", "baseball", "tennis", "golf", "olympia", "weltmeisterschaft", "meisterschaft", "liga"],
        "nl": ["voetbal", "basketbal", "honkbal", "tennis", "golf", "olympische spelen", "wereldbeker", "kampioenschap", "competitie"],
        "zh-Hans": ["足球", "篮球", "棒球", "网球", "高尔夫", "奥运会", "世界杯", "锦标赛", "联赛"],
        "zh-Hant": ["足球", "籃球", "棒球", "網球", "高爾夫", "奧運會", "世界盃", "錦標賽", "聯賽"],
        "ja": ["サッカー", "バスケットボール", "野球", "テニス", "ゴルフ", "オリンピック", "ワールドカップ", "選手権", "リーグ"],
        "hi": ["फुटबॉल", "बास्केटबॉल", "बेसबॉल", "टेनिस", "गोल्फ", "ओलंपिक", "विश्व कप", "चैंपियनशिप", "लीग"],
        "ru": ["футбол", "баскетбол", "бейсбол", "теннис", "гольф", "олимпиада", "чемпионат мира", "чемпионат", "лига", "хоккей", "цска", "спартак", "зенит", "рпл"],
        "uk": ["футбол", "баскетбол", "бейсбол", "теніс", "гольф", "олімпіада", "чемпіонат світу", "чемпіонат", "ліга"],
        "et": ["jalgpall", "korvpall", "pesapall", "tennis", "golf", "olümpiamängud", "maailmakarikas", "meistrivõistlused", "liiga"],
        "sv": ["fotboll", "basket", "baseball", "tennis", "golf", "olympiska spelen", "världsmästerskap", "mästerskap", "liga"]
      }
    },
    {
      "id": "ai",
      "label": "AI & Tech Hype",
      "keywords": {
        "default": ["chatgpt", "openai", "artificial intelligence", "ai breakthrough", "machine learning", "neural network", "deepfake", "generative ai", "ai safety", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "large language model"],
        "en": ["chatgpt", "openai", "artificial intelligence", "ai breakthrough", "machine learning", "neural network", "deepfake", "generative ai", "ai safety", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "large language model"],
        "pt": ["chatgpt", "openai", "inteligência artificial", "avanço em ia", "aprendizado de máquina", "rede neural", "deepfake", "ia generativa", "segurança de ia", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "modelo de linguagem grande"],
        "it": ["chatgpt", "openai", "intelligenza artificiale", "svolta nell'ia", "apprendimento automatico", "rete neurale", "deepfake", "ia generativa", "sicurezza ia", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "modello linguistico di grandi dimensioni"],
        "fr": ["chatgpt", "openai", "intelligence artificielle", "percée en ia", "apprentissage automatique", "réseau de neurones", "deepfake", "ia générative", "sécurité de l'ia", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "grand modèle de langage"],
        "es": ["chatgpt", "openai", "inteligencia artificial", "avance en ia", "aprendizaje automático", "red neuronal", "deepfake", "ia generativa", "seguridad de ia", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "modelo de lenguaje grande"],
        "de": ["chatgpt", "openai", "künstliche intelligenz", "ki-durchbruch", "maschinelles lernen", "neuronales netzwerk", "deepfake", "generative ki", "ki-sicherheit", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "großes sprachmodell"],
        "nl": ["chatgpt", "openai", "kunstmatige intelligentie", "ai-doorbraak", "machine learning", "neuraal netwerk", "deepfake", "generatieve ai", "ai-veiligheid", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "groot taalmodel"],
        "zh-Hans": ["chatgpt", "openai", "人工智能", "ai突破", "机器学习", "神经网络", "深度伪造", "生成式ai", "ai安全", "通用人工智能", "大语言模型", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "大型语言模型"],
        "zh-Hant": ["chatgpt", "openai", "人工智慧", "AI 突破", "機器學習", "神經網路", "深度偽造", "生成式 AI", "AI 安全", "通用人工智慧", "LLM", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "大型語言模型"],
        "ja": ["chatgpt", "openai", "人工知能", "aiブレークスルー", "機械学習", "ニューラルネットワーク", "ディープフェイク", "生成ai", "ai安全性", "汎用人工知能", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "大規模言語モデル"],
        "hi": ["चैटजीपीटी", "ओपनएआई", "कृत्रिम बुद्धिमत्ता", "एआई सफलता", "मशीन लर्निंग", "न्यूरल नेटवर्क", "डीपफेक", "जेनरेटिव एआई", "एआई सुरक्षा", "एजीआई", "एलएलएम", "एन्थ्रोपिक", "क्लॉड", "जेमिनी", "जीपीटी-4", "जीपीटी-5", "बड़ा भाषा मॉडल"],
        "ru": ["chatgpt", "openai", "искусственный интеллект", "прорыв в ии", "машинное обучение", "нейронная сеть", "дипфейк", "генеративный ии", "безопасность ии", "оии", "большая языковая модель", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "yagpt", "gigachat"],
        "uk": ["chatgpt", "openai", "штучний інтелект", "прорив у ші", "машинне навчання", "нейронна мережа", "дипфейк", "генеративний ші", "безпека ші", "загальний штучний інтелект", "велика мовна модель", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "велика мовна модель"],
        "et": ["chatgpt", "openai", "tehisintellekt", "ai läbimurre", "masinõpe", "neuraalvõrk", "deepfake", "generatiivne ai", "ai turvalisus", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "suur keelemudel"],
        "sv": ["chatgpt", "openai", "artificiell intelligens", "ai-genombrott", "maskininlärning", "neuronätverk", "deepfake", "generativ ai", "ai-säkerhet", "agi", "llm", "anthropic", "claude", "gemini", "gpt-4", "gpt-5", "stor språkmodell"]
      }
    },
    {
      "id": "climate",
      "label": "Climate Anxiety",
      "keywords": {
        "default": ["climate change", "global warming", "carbon emissions", "greenhouse gas", "climate crisis", "extreme weather", "sea level rise", "extinction", "climate emergency"],
        "en": ["climate change", "global warming", "carbon emissions", "greenhouse gas", "climate crisis", "extreme weather", "sea level rise", "extinction", "climate emergency"],
        "pt": ["mudança climática", "aquecimento global", "emissões de carbono", "gases de efeito estufa", "crise climática", "clima extremo", "aumento do nível do mar", "extinção", "emergência climática"],
        "it": ["cambiamento climatico", "riscaldamento globale", "emissioni di carbonio", "gas serra", "crisi climatica", "meteo estremo", "innalzamento del mare", "estinzione", "emergenza climatica"],
        "fr": ["changement climatique", "réchauffement climatique", "émissions de carbone", "gaz à effet de serre", "crise climatique", "météo extrême", "montée des eaux", "extinction", "urgence climatique"],
        "es": ["cambio climático", "calentamiento global", "emisiones de carbono", "gases de efecto invernadero", "crisis climática", "clima extremo", "aumento del nivel del mar", "extinción", "emergencia climática"],
        "de": ["klimawandel", "globale erwärmung", "kohlenstoffemissionen", "treibhausgas", "klimakrise", "extremwetter", "meeresspiegel", "aussterben", "klimanotstand"],
        "nl": ["klimaatverandering", "opwarming van de aarde", "koolstofemissies", "broeikasgas", "klimaatcrisis", "extreem weer", "zeespiegelstijging", "uitsterven", "klimaatnoodtoestand"],
        "zh-Hans": ["气候变化", "全球变暖", "碳排放", "温室气体", "气候危机", "极端天气", "海平面上升", "灭绝", "气候紧急状态"],
        "zh-Hant": ["氣候變化", "全球暖化", "碳排放", "溫室氣體", "氣候危機", "極端天氣", "海平面上升", "滅絕", "氣候緊急狀態"],
        "ja": ["気候変動", "地球温暖化", "炭素排出", "温室効果ガス", "気候危機", "極端な天候", "海面上昇", "絶滅", "気候非常事態"],
        "hi": ["जलवायु परिवर्तन", "ग्लोबल वार्मिंग", "कार्बन उत्सर्जन", "ग्रीनहाउस गैस", "जलवायु संकट", "चरम मौसम", "समुद्र स्तर में वृद्धि", "विलुप्ति", "जलवायु आपातकाल"],
        "ru": ["изменение климата", "глобальное потепление", "выбросы углерода", "парниковые газы", "климатический кризис", "экстремальная погода", "повышение уровня моря", "вымирание", "климатическая чрезвычайная ситуация"],
        "uk": ["зміна клімату", "глобальне потепління", "викиди вуглецю", "парникові гази", "кліматична криза", "екстремальна погода", "підвищення рівня моря", "вимирання", "кліматична надзвичайна ситуація"],
        "et": ["kliimamuutus", "globaalne soojenemine", "süsinikuheitmed", "kasvuhoonegaas", "kliimakriis", "äärmuslik ilm", "merepinna tõus", "väljasuremine", "kliimahädaolukord"],
        "sv": ["klimatförändring", "global uppvärmning", "koldioxidutsläpp", "växthusgas", "klimatkris", "extremt väder", "havsnivåhöjning", "utrotning", "klimatnödläge"]
      }
    },
    {
      "id": "sexual-misconduct",
      "label": "Sexual Misconduct",
      "keywords": {
        "default": ["sexual assault", "sexual harassment", "rape", "sexual abuse", "metoo", "groping", "molestation", "revenge porn", "pedophile"],
        "en": ["sexual assault", "sexual harassment", "rape", "sexual abuse", "metoo", "groping", "molestation", "revenge porn", "pedophile"],
        "pt": ["agressão sexual", "assédio sexual", "estupro", "abuso sexual", "metoo", "aperto/encoxação", "molestação", "pornografia de vingança", "pedófilo"],
        "it": ["violenza sessuale", "molestie sessuali", "stupro", "abuso sessuale", "metoo", "palpeggiamento", "molestia", "revenge porn", "pedofilo"],
        "fr": ["agression sexuelle", "harcèlement sexuel", "viol", "abus sexuel", "metoo", "attouchements", "molestation", "porno vengeance", "pédophile"],
        "es": ["agresión sexual", "acoso sexual", "violación", "abuso sexual", "metoo", "manoseo", "abusos", "porno de venganza", "pedófilo"],
        "de": ["sexueller Übergriff", "sexuelle Belästigung", "Vergewaltigung", "sexueller Missbrauch", "metoo", "Grapschen", "Belästigung", "Racheporno", "Pädophiler"],
        "nl": ["verkrachting", "seksuele intimidatie", "verkrachting", "seksueel misbruik", "metoo", "aanranding", "seksueel geweld", "wraakporno", "pedofiel"],
        "zh-Hans": ["性侵犯", "性骚扰", "强奸", "性虐待", "我也是", "猥亵", "骚扰", "复仇色情", "恋童癖者"],
        "zh-Hant": ["性侵犯", "性騷擾", "強姦", "性虐待", "metoo", "猥褻", "性騷擾", "復仇式色情", "戀童癖"],
        "ja": ["性的暴行", "セクハラ", "レイプ", "性的虐待", "metoo", "痴漢", "わいせつ行為", "リベンジポルノ", "小児性愛者"],
        "hi": ["यौन उत्पीड़न", "यौन उत्पीड़न", "बलात्कार", "यौन शोषण", "मीटू", "छेड़छाड़", "बाल यौन शोषण", "रिवेंज पोर्न", "बाल यौन शोषण"],
        "ru": ["сексуальное насилие", "сексуальное домогательство", "изнасилование", "сексуальное надругательство", "metoo", "лапать", "растление", "порноместь", "педофил"],
        "uk": ["сексуальное насилие", "сексуальное домогательство", "изнасилование", "сексуальное надругательство", "metoo", "лапать", "растление", "порноместь", "педофил"],
        "et": ["seksuaalne rünnak", "seksuaalne ahistamine", "vägistamine", "seksuaalne väärkohtlemine", "metoo", "käperdamine", "molestatsioon", "kättemaksuporno", "pedofiil"],
        "sv": ["sexuella övergrepp", "sexuella trakasserier", "våldtäkt", "sexuella övergrepp", "metoo", "ofredande", "molestering", "hämndporr", "pedofil"]
      }
    }
  ]
}

```

## /core_feeds.py

```py path="/core_feeds.py" 
feeds = {
    "AI": [
        "http://bair.berkeley.edu/blog/feed.xml",
        "http://blog.evjang.com/feeds/posts/default?alt=rss",
        "http://distill.pub/rss.xml",
        "http://magenta.tensorflow.org/feed.xml",
        "http://news.mit.edu/rss/topic/artificial-intelligence2",
        "http://newsletter.ruder.io/?format=rss",
        "https://aidisruptor.ai/feed",
        "https://aiweekly.co/issues.rss",
        "https://bensbites.com/blog/rss.xml",
        "https://blog.ml.cmu.edu/feed/",
        "https://evjang.com/feed",
        "https://garymarcus.substack.com/feed",
        "https://jalammar.github.io/feed.xml",
        "https://lastweekin.ai/feed",
        "https://lilianweng.github.io/index.xml",
        "https://lilianweng.github.io/lil-log/feed.xml",
        "https://machinelearning.apple.com/rss.xml",
        "https://news.google.com/rss/topics/CAAqIAgKIhpDQkFTRFFvSEwyMHZNRzFyZWhJQ1pXNG9BQVAB?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNREZvZVdoZkVnSmxiaWdBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqJAgKIh5DQkFTRUFvSEwyMHZNRzFyZWhJRlpXNHRSMElvQUFQAQ?hl=en-CA&gl=CA&ceid=CA%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqJAgKIh5DQkFTRUFvSEwyMHZNRzFyZWhJRlpXNHRSMElvQUFQAQ?hl=en-US&ceid=CA%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqLAgKIiZDQkFTRmdvTkwyY3ZNVEZpZUdNMk5UWjJOaElGWlc0dFIwSW9BQVAB?hl=en-CA&gl=CA&ceid=CA%3Aen&oc=11",
        "https://news.mit.edu/topic/mitartificial-intelligence2-rss.xml",
        "https://news.mit.edu/topic/mitmachine-learning-rss.xml",
        "https://rss.beehiiv.com/feeds/2R3C6Bt5wj.xml",
        "https://rss.beehiiv.com/feeds/4EFsI5UmM7.xml",
        "https://rss.beehiiv.com/feeds/GcFiF2T4I5.xml",
        "https://rss.beehiiv.com/feeds/gtwKcGOtrm.xml",
        "https://rss.beehiiv.com/feeds/lvG7CGESX1.xml",
        "https://rss.beehiiv.com/feeds/vGKavlVUFO.xml",
        "https://simonwillison.net/atom/everything/",
        "https://syncedreview.com/feed/",
        "https://techcrunch.com/category/artificial-intelligence/feed/",
        "https://techxplore.com/rss-feed/breaking/machine-learning-ai-news/",
        "https://techxplore.com/rss-feed/machine-learning-ai-news/",
        "https://the-decoder.com/feed/",
        "https://theconversation.com/us/topics/machine-learning-8332/articles.atom",
        "https://thegradient.pub/rss/",
        "https://towardsdatascience.com/feed",
        "https://venturebeat.com/category/ai/feed/",
        "https://www.ai-supremacy.com/feed",
        "https://www.amazon.science/index.rss",
        "https://www.exponentialview.co/feed",
        "https://www.interconnects.ai/feed",
        "https://www.latent.space/feed",
        "https://www.louisbouchard.ai/rss/",
        "https://www.nextomoro.com/rss/",
        "https://www.reddit.com/r/Anthropic/.rss",
        "https://www.reddit.com/r/ChatGPT/.rss",
        "https://www.reddit.com/r/ChatGPTCoding/.rss",
        "https://www.reddit.com/r/DeepSeek/.rss",
        "https://www.reddit.com/r/LocalLLaMA/.rss",
        "https://www.reddit.com/r/MachineLearning/.rss",
        "https://www.reddit.com/r/MachineLearning/top/.rss",
        "https://www.reddit.com/r/OpenAI/.rss",
        "https://www.reddit.com/r/agi/.rss",
        "https://www.reddit.com/r/artificial/.rss",
        "https://www.sciencedaily.com/rss/computers_math/artificial_intelligence.xml",
        "https://www.swyx.io/rss.xml",
        "https://www.theverge.com/rss/ai-artificial-intelligence/index.xml",
        "https://www.tostring.ai/feed",
        "https://www.unite.ai/feed/",
        "https://www.wired.com/feed/rss",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UC5Wz4fFacYuON6IKbhSa7Zw",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCCKx8mAHiFus-XYQLy_WnaA",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCDZfn935vwaahDabsfylfIQ",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCDq7SjbgRKty5TgGafW8Clg",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCEAbqW0HFB_UxZoUDO0kJBw",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCPocEocBKqXv5rzQYWugHMQ",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCZHmQk67mSJgfCCTn7xBfew",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCbfYPyITQ-7l4upoX8nvctg",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCcIXc5mJsHVYTZR1maL5l9w",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UChpleBmo18P08aKCIgti38g",
    ],
    "Asia": [
        "http://feeds.bbci.co.uk/news/world/asia/rss.xml",
        "https://feeds.npr.org/1125/rss.xml",
        "https://rss.dw.com/rdf/rss-en-asia",
        "https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml",
        "https://www.economist.com/asia/rss.xml",
        "https://www.euronews.com/rss?level=program&name=asia-news",
        "https://www.ft.com/world/asia-pacific?format=rss",
        "https://www.scmp.com/rss/3/feed",
        "https://www.theguardian.com/world/asia/rss",
    ],
    "Autos": [
        "https://www.sfgate.com/rss/feed/car-news-rss-feed-441.php",
    ],
    "Bay": [
        "http://www.smdailyjournal.com/search/?f=rss&t=article&c=news/local&l=50&s=start_time&sd=desc",
        "https://48hills.org/feed/",
        "https://abc7news.com/feed/",
        "https://calmatters.org/feed/",
        "https://hoodline.com/news/bay-area/rss/",
        "https://localnewsmatters.org/feed/",
        "https://lookout.co/feed/",
        "https://midpenpost.org/feed/",
        "https://missionlocal.org/feed/",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNRFp3ZG5JU0FtVnVLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://oaklandside.org/feed/",
        "https://pacificsun.com/feed/",
        "https://padailypost.com/feed/",
        "https://sanjosespotlight.com/feed/",
        "https://sf.eater.com/rss/index.xml",
        "https://sfbayca.com/feed/",
        "https://sfbaytimes.com/feed/",
        "https://sfbayview.com/feed/",
        "https://sfist.com/rss/",
        "https://sfnews.us/rss/",
        "https://sfstandard.com/feed/",
        "https://sftimes.com/feed/",
        "https://whatnow.com/san-francisco/feed/",
        "https://www.almanacnews.com/feed/",
        "https://www.cbsnews.com/sanfrancisco/latest/rss/local-news",
        "https://www.eastbaytimes.com/location/california/bay-area/feed/",
        "https://www.kron4.com/feed/",
        "https://www.kron4.com/news/app-feed",
        "https://www.ktvu.com/rss/category/local-news",
        "https://www.marinij.com/local-news/feed/",
        "https://www.mercurynews.com/location/california/bay-area/feed/",
        "https://www.mercurynews.com/location/california/bay-area/peninsula/feed/",
        "https://www.nbcbayarea.com/news/local/feed/",
        "https://www.paloaltoonline.com/feed/",
        "https://www.reddit.com/r/bayarea/.rss",
        "https://www.reddit.com/r/sanfrancisco/.rss",
        "https://www.sfgate.com/bayarea/feed/bay-area-news-429.php",
        "https://www.sfpublicpress.org/category/news/feed/",
        "https://www.siliconvalley.com/feed/",
        "https://www.thesfnews.com/feed",
    ],
    "Books": [
        "https://feeds.feedburner.com/nybooks",
        "https://www.goodreads.com/blog/list_rss",
        "https://www.nytimes.com/section/books/review/rss.xml",
        "https://www.nytimes.com/svc/collections/v1/publish/",
        "https://www.theguardian.com/books/booksblog/rss",
    ],
    "Business": [
        "http://feeds.benzinga.com/benzinga",
        "http://feeds.feedburner.com/AtlanticBusinessChannel",
        "http://feeds.feedburner.com/CalculatedRisk",
        "http://feeds.feedburner.com/TheBigPicture",
        "http://feeds2.feedburner.com/businessinsider",
        "http://fortune.com/feed/",
        "http://freakonomics.com//feed/",
        "http://www.economist.com/rss/the_world_this_week_rss.xml",
        "http://www.marketwatch.com/rss/topstories/",
        "http://www.metafilter.com/tags/business/rss",
        "http://www.metafilter.com/tags/economics/rss",
        "http://www.sbnonline.com/feed/",
        "http://www.thenonprofittimes.com/feed/",
        "https://abcnews.go.com/abcnews/moneyheadlines",
        "https://api.axios.com/feed/business",
        "https://economictimes.indiatimes.com/rssfeedsdefault.cms",
        "https://feeds.a.dj.com/rss/RSSMarketsMain.xml",
        "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml",
        "https://feeds.bbci.co.uk/news/business/rss.xml",
        "https://feeds.bloomberg.com/business/news.rss",
        "https://feeds.bloomberg.com/economics/news.rss",
        "https://feeds.bloomberg.com/markets/news.rss",
        "https://feeds.bloomberg.com/wealth/news.rss",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/economy-and-business",
        "https://feeds.feedburner.com/Mixergy-main-podcast",
        "https://feeds.npr.org/1006/rss.xml",
        "https://feeds.npr.org/1017/rss.xml",
        "https://feeds.washingtonpost.com/rss/business",
        "https://finance.yahoo.com/news/rssindex",
        "https://fortune.com/feed",
        "https://homebusinessmag.com/feed/",
        "https://indianexpress.com/section/business/feed/",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNR1J5Y1hBU0FtVnVLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNREpmTjNRU0FtVnVLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGx6TVdZU0FtVnVHZ0pWVXlnQVAB?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqKAgKIiJDQkFTRXdvSkwyMHZNR2RtY0hNekVnWmxjeTAwTVRrb0FBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://openrss.org/apnews.com/business",
        "https://openrss.org/www.reuters.com/business/",
        "https://qz.com/rss",
        "https://rss.dw.com/rdf/rss-en-bus",
        "https://rss.nytimes.com/services/xml/rss/nyt/Business.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/Economy.xml",
        "https://seekingalpha.com/feed.xml",
        "https://seekingalpha.com/listing/most-popular-articles.xml",
        "https://seekingalpha.com/market_currents.xml",
        "https://seekingalpha.com/tag/editors-picks.xml",
        "https://soylentnews.org/index.rss",
        "https://theconversation.com/us/business/articles.atom",
        "https://tim.blog/feed/",
        "https://time.com/business/feed",
        "https://www.cbsnews.com/latest/rss/moneywatch",
        "https://www.chicagotribune.com/business/feed/",
        "https://www.cnbc.com/id/100003114/device/rss/rss.html",
        "https://www.economist.com/finance-and-economics/rss.xml",
        "https://www.euronews.com/rss?level=vertical&name=business",
        "https://www.firstpost.com/commonfeeds/v1/mfp/rss/business.xml",
        "https://www.ft.com/companies?format=rss",
        "https://www.ft.com/markets?format=rss",
        "https://www.ft.com/rss/home",
        "https://www.ft.com/rss/home/international",
        "https://www.ft.com/technology?format=rss",
        "https://www.gobankingrates.com/feed/",
        "https://www.investing.com/rss/news.rss",
        "https://www.latimes.com/business/rss2.0.xml",
        "https://www.mckinsey.com/insights/rss.aspx",
        "https://www.pbs.org/newshour/feeds/rss/economy",
        "https://www.reddit.com/r/Economics/.rss",
        "https://www.reddit.com/r/GlobalMarkets/.rss",
        "https://www.reddit.com/r/business/.rss",
        "https://www.reddit.com/r/economy/.rss",
        "https://www.reddit.com/r/finance/.rss",
        "https://www.reddit.com/r/investing/.rss",
        "https://www.rt.com/rss/business/",
        "https://www.scmp.com/rss/92/feed",
        "https://www.seattletimes.com/business/feed/",
        "https://www.sfgate.com/rss/feed/business-and-technology-news-448.php",
        "https://www.spiegel.de/international/business/index.rss",
        "https://www.theguardian.com/uk/business/rss",
        "https://www.theguardian.com/uk/money/rss",
        "https://www.wired.com/feed/category/business/latest/rss",
        "https://www.youtube.com/feeds/videos.xml?user=Bloomberg",
        "https://www.youtube.com/feeds/videos.xml?user=businessinsider",
    ],
    "Celebs and Movies": [
        "http://entertainmentweekly.tumblr.com/rss",
        "http://www.tmz.com/rss.xml",
        "https://feeds2.feedburner.com/slashfilm",
        "https://indianexpress.com/section/entertainment/feed/",
        "https://www.avclub.com/rss",
    ],
    "Culture": [
        "http://feeds.feedburner.com/99pi",
        "http://feeds.kottke.org/main",
        "https://aeon.co/feed.rss",
        "https://feeds.feedburner.com/damninteresting/all",
        "https://feeds.npr.org/1008/rss.xml",
        "https://feeds.npr.org/1045/rss.xml",
        "https://feeds.npr.org/1048/rss.xml",
        "https://quillette.com/tag/art-and-culture/rss/",
        "https://rss.csmonitor.com/feeds/theculture",
        "https://rss.dw.com/rdf/rss-en-cul",
        "https://theconversation.com/us/arts/articles.atom",
        "https://www.atlasobscura.com/feeds/latest",
        "https://www.boston.com/category/culture/feed/",
        "https://www.euronews.com/rss?level=vertical&name=culture",
        "https://www.firstpost.com/commonfeeds/v1/mfp/rss/art-and-culture.xml",
        "https://www.latimes.com/entertainment-arts/rss2.0.xml",
        "https://www.openculture.com/feed",
        "https://www.pbs.org/newshour/feeds/rss/arts",
        "https://www.pbs.org/newshour/feeds/rss/education",
        "https://www.scmp.com/rss/318202/feed",
        "https://www.scmp.com/rss/322296/feed",
        "https://www.sfgate.com/rss/feed/culture-530.php",
        "https://www.wired.com/feed/category/culture/latest/rss",
    ],
    "Design": [
        "http://airbnb.design/feed/",
        "http://boxesandarrows.com/rss/",
        "http://feeds.feedburner.com/uxmovement",
        "https://feeds.feedburner.com/CssTricks",
        "https://feeds.feedburner.com/JustCreativeDesignBlog",
        "https://feeds.npr.org/1047/rss.xml",
        "https://interaction-design.org/rss/site_news.xml",
        "https://slack.design/feed/",
        "https://usabilitygeek.com/feed/",
        "https://uxdesign.cc/feed",
        "https://uxmatters.com/index.xml",
        "https://uxmovement.com/feed/",
        "https://uxplanet.org/feed",
        "https://uxstudioteam.com/ux-blog/feed/",
        "https://web.dev/feed.xml",
        "https://www.invisionapp.com/inside-design/feed",
        "https://www.nngroup.com/feed/rss/",
        "https://www.smashingmagazine.com/feed",
        "https://www.smashingmagazine.com/feed/",
    ],
    "Food": [
        "http://feeds.feedburner.com/seriouseatsfeaturesvideos",
        "http://feeds.feedburner.com/smittenkitchen",
        "https://www.101cookbooks.com/feed",
        "https://www.sfgate.com/rss/feed/food-dining-550.php",
        "https://www.skinnytaste.com/feed/",
    ],
    "Freedom From Accounts": [
        "http://beeple.tumblr.com/rss",
        "https://vimeo.com/user36872580/videos/rss",
    ],
    "Gaming": [
        "http://feeds.arstechnica.com/arstechnica/gaming/",
        "http://feeds.feedburner.com/RockPaperShotgun",
        "http://feeds.feedburner.com/TheAncientGamingNoob",
        "http://feeds.feedburner.com/psblog",
        "http://feeds.ign.com/ign/all",
        "http://feeds.ign.com/ign/games-all",
        "http://indiegamesplus.com/feed",
        "http://itrunsdoom.tumblr.com/rss",
        "http://news.xbox.com/feed",
        "http://rss.slashdot.org/Slashdot/slashdotGames",
        "http://tinycartridge.com/rss",
        "http://toucharcade.com/feed/",
        "http://www.metafilter.com/tags/games/rss",
        "http://www.metafilter.com/tags/gaming/rss",
        "http://www.newgamernation.com/feed/",
        "http://www.nintendolife.com/feeds/latest",
        "http://www.pushsquare.com/feeds/latest",
        "http://www.vg247.com/feed/",
        "https://80.lv/feed",
        "https://arstechnica.com/gaming/feed/",
        "https://dotesports.com/feed",
        "https://esports.gg/api/feed/",
        "https://esportsinsider.com/feed",
        "https://feeds.feedburner.com/Co-optimus",
        "https://feeds.feedburner.com/GeekNative",
        "https://feeds.npr.org/820266919/rss.xml",
        "https://galyonk.in/feed",
        "https://gameinformer.com/news.xml",
        "https://gameinformer.com/reviews.xml",
        "https://gamerant.com/feed/",
        "https://gamerant.com/feed/gaming/",
        "https://gameranx.com/feed/",
        "https://games.mxdwn.com/feed/",
        "https://gaming-age.com/category/news/feed/",
        "https://gaming-age.com/category/reviews/feed/",
        "https://gamingbolt.com/category/news/feed",
        "https://gamingbolt.com/video-game-reviews/feed",
        "https://gematsu.com/feed",
        "https://indiegamecloud.com/feed/",
        "https://insider-gaming.com/feed/",
        "https://kotaku.com/rss",
        "https://majornelson.com/feed/",
        "https://mynintendonews.com/feed",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNREZ0ZHpFU0FtVnVLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNREp4YURjNUVnSnlkU2dBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNRFkxYTNKNkVnSmxiaWdBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.xbox.com/en-us/feed/",
        "https://nichegamer.com/feed/",
        "https://nowrongwaytoplay.tumblr.com/rss",
        "https://retrododo.com/feed/",
        "https://rpgamer.com/category/news/feed/",
        "https://rpgamer.com/category/reviews/feed/",
        "https://store.steampowered.com/feeds/news.xml",
        "https://tagn.wordpress.com/feed/",
        "https://techaeris.com/feed/",
        "https://techcrunch.com/category/gaming/feed/",
        "https://techiemag.co.uk/category/gaming/feed/",
        "https://thegamepost.com/news/feed/",
        "https://thegamepost.com/reviews/feed/",
        "https://thisweekinvideogames.com/reviews/feed/",
        "https://toucharcade.com/feed/",
        "https://wccftech.com/feed/",
        "https://www.cgchannel.com/feed/",
        "https://www.comingsoon.net/games/feed",
        "https://www.destructoid.com/feed/",
        "https://www.esports.net/feed/",
        "https://www.eurogamer.net/?format=rss",
        "https://www.eurogamer.net/feed",
        "https://www.eurogamer.net/feed/news",
        "https://www.eurogamer.net/feed/reviews",
        "https://www.eventhubs.com/news/feed/frontpage/",
        "https://www.gamedeveloper.com/rss.xml",
        "https://www.gamepressure.com/rss/news.xml",
        "https://www.gamer.org/category/news/feed/",
        "https://www.gamer.org/category/reviews/feed/",
        "https://www.gamersheroes.com/gaming-news/feed/",
        "https://www.gamersheroes.com/honest-game-reviews/feed/",
        "https://www.gamesindustry.biz/feed",
        "https://www.gameskinny.com/news/feed/",
        "https://www.gameskinny.com/reviews/feed/",
        "https://www.gamespot.com/feeds/game-news",
        "https://www.gamespot.com/feeds/mashup/",
        "https://www.gamespot.com/feeds/new-games",
        "https://www.gamespot.com/feeds/reviews",
        "https://www.gamesradar.com/feeds.xml",
        "https://www.gamesradar.com/feeds/articletype/news/",
        "https://www.gamesradar.com/feeds/articletype/review/",
        "https://www.gamewatcher.com/feeds/rss",
        "https://www.gematsu.com/feed",
        "https://www.giantbomb.com/feeds/mashup",
        "https://www.ign.com/rss/v2/articles/feed?categories=news",
        "https://www.ign.com/rss/v2/articles/feed?categories=review",
        "https://www.independent.co.uk/games/rss",
        "https://www.indieretronews.com/feeds/posts/default",
        "https://www.inoreader.com/stream/user/1004847564/tag/Gaming%20Company%20Feeds",
        "https://www.makeupandbeautyblog.com/feed/",
        "https://www.mmorpg.com/rss",
        "https://www.neowin.net/news/rss/gaming/",
        "https://www.only-gaming.com/feed/",
        "https://www.operationsports.com/feed/",
        "https://www.pcgamer.com/feeds.xml",
        "https://www.pcgamer.com/uk/feeds/articletype/news/",
        "https://www.pcgamer.com/uk/feeds/articletype/review/",
        "https://www.pcgamesn.com/mainrss.xml",
        "https://www.pcinvasion.com/feed/",
        "https://www.pcworld.com/gaming/news/feed",
        "https://www.penny-arcade.com/feed",
        "https://www.polygon.com/feed/news/",
        "https://www.polygon.com/rss/index.xml",
        "https://www.pushsquare.com/feeds/news",
        "https://www.pushsquare.com/feeds/reviews",
        "https://www.reddit.com/r/Games/.rss",
        "https://www.reddit.com/r/NintendoSwitch/.rss",
        "https://www.reddit.com/r/PS5/.rss",
        "https://www.reddit.com/r/XboxSeriesX/.rss",
        "https://www.reddit.com/r/consoles/.rss",
        "https://www.reddit.com/r/esports/.rss",
        "https://www.reddit.com/r/gamers/.rss",
        "https://www.reddit.com/r/gaming.rss",
        "https://www.reddit.com/r/indiegames/.rss",
        "https://www.reddit.com/r/pcgaming/.rss",
        "https://www.reddit.com/r/playstation/.rss",
        "https://www.reddit.com/r/retrogaming/.rss",
        "https://www.retrogarden.co.uk/feed/",
        "https://www.retronews.com/feed/",
        "https://www.rockpapershotgun.com/feed/",
        "https://www.siliconera.com/feed/",
        "https://www.techradar.com/feeds/tag/gaming",
        "https://www.thegamer.com/feed/",
        "https://www.thegamer.com/feed/category/game-news/",
        "https://www.theguardian.com/games/rss",
        "https://www.theverge.com/rss/games/index.xml",
        "https://www.timeextension.com/feeds/latest",
        "https://www.tomshardware.com/feeds/tag/pc-gaming",
        "https://www.vg247.com/feed/",
        "https://www.vg247.com/feed/news",
        "https://www.vg247.com/feed/reviews",
        "https://www.videogameschronicle.com/category/news/feed/",
        "https://www.videogameschronicle.com/category/review/feed/",
    ],
    "Guns": [
        "https://homemadeguns.wordpress.com/feed/",
        "https://www.forgottenweapons.com/feed/",
        "https://www.nrablog.com/rss",
        "https://www.thefirearmblog.com/blog/feed/",
    ],
    "Linux": [
        "http://feeds.feedburner.com/UbuntuhandbookNewsTutorialsHowtosForUbuntuLinux",
        "https://linuxconfig.org/feed",
        "https://linuxhandbook.com/rss/",
        "https://linuxhint.com/feed/",
        "https://tecadmin.net/feed/",
        "https://www.cyberciti.com/feed/",
        "https://www.omgubuntu.co.uk/feed",
        "https://www.rosehosting.com/blog/feed/",
        "https://www.tecmint.com/feed/",
    ],
    "Middle East": [
        "https://feeds.npr.org/1009/rss.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/MiddleEast.xml",
        "https://www.lemonde.fr/middle-east/rss_full.xml",
        "https://www.scmp.com/rss/322264/feed",
    ],
    "Music": [
        "https://3hive.com/feed/",
        "https://daily.bandcamp.com/feed/",
        "https://pitchfork.com/rss/reviews/best/albums/",
        "https://www.nme.com/feed",
        "https://www.stereogum.com/feed/",
    ],
    "Podcasts": [
        "http://rss.acast.com/themagnusarchives",
        "http://www.uncannyjapan.com/feed/podcast/",
        "https://darknetdiaries.com/podcast.xml",
        "https://rss.acast.com/plumbingthedeathstar",
        "https://www.lorepodcast.com/episodes?format=RSS",
    ],
    "Science": [
        "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
        "http://feeds.feedburner.com/AllDiscovermagazinecomContent",
        "http://feeds.feedburner.com/BodyHorrors",
        "http://rss.cnn.com/rss/edition_space.rss",
        "http://rss.sciam.com/ScientificAmerican-Global",
        "http://rss.sciam.com/ScientificAmerican-Global?format=xml",
        "http://www.futurity.org/feed/",
        "http://www.metafilter.com/tags/science/rss",
        "http://www.nature.com/nature/current_issue/rss",
        "http://www.quantamagazine.org/feed/",
        "http://www.twis.org/feed/",
        "https://api.axios.com/feed/science",
        "https://charmingscience.com/feed/",
        "https://cosmosmagazine.com/feed/",
        "https://eos.org/feed",
        "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/climate",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/health",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/science-tech",
        "https://feeds.feedburner.com/AllDiscovermagazinecomContent",
        "https://feeds.newscientist.com/home",
        "https://feeds.npr.org/1007/rss.xml",
        "https://feeds.npr.org/1025/rss.xml",
        "https://feeds.npr.org/1026/rss.xml",
        "https://feeds.npr.org/1028/rss.xml",
        "https://feeds.science.org/rss/science.xml",
        "https://feeds.simplecast.com/y1LF_sn2",
        "https://flowingdata.com/feed",
        "https://futurism.com/feed",
        "https://gizmodo.com/tag/science/rss",
        "https://grist.org/feed/",
        "https://nautil.us/feed/",
        "https://news.climate.columbia.edu/feed/",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNRE0yWHpJU0FtbDBLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIQgKIhtDQkFTRGdvSUwyMHZNRFZ4YW5RU0FtVnVLQUFQAQ?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNR1F3TmpOMkVnSmxiaWdBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNREU0TXpOM0VnSmxiaWdBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqIggKIhxDQkFTRHdvSkwyMHZNREp3ZVRBNUVnSmxiaWdBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqJQgKIh9DQkFTRVFvSUwyMHZNREUxTkRBU0JXVnVMVWRDS0FBUAE?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp0Y1RjU0FtVnVHZ0pWVXlnQVAB?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news.mit.edu/rss/research",
        "https://news.mit.edu/rss/topic/science-technology-and-society",
        "https://openrss.org/apnews.com/science",
        "https://pa.tedcdn.com/feeds/talks.rss",
        "https://phys.org/rss-feed/",
        "https://phys.org/rss-feed/breaking/",
        "https://podcasts.files.bbci.co.uk/b00snr0w.rss",
        "https://podcasts.files.bbci.co.uk/p002w557.rss",
        "https://quillette.com/tag/science-tech/rss",
        "https://reddit.com/r/science/.rss",
        "https://rss.dw.com/xml/rss_en_environment",
        "https://rss.dw.com/xml/rss_en_science",
        "https://rss.nytimes.com/services/xml/rss/nyt/Climate.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/Science.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/Space.xml",
        "https://sciencealert.com/feed/gn/",
        "https://sciencebasedmedicine.org/feed/",
        "https://scienceblog.com/feed/",
        "https://sciencefeatured.com/feed/",
        "https://scitechdaily.com/feed/",
        "https://sciworthy.com/feed/",
        "https://singularityhub.com/feed/",
        "https://soylentnews.org/index.rss",
        "https://theconversation.com/us/technology/articles.atom",
        "https://thelumberjack.org/category/science/feed/",
        "https://time.com/science/feed/",
        "https://undark.org/feed/",
        "https://what-if.xkcd.com/feed.atom",
        "https://www.advancedsciencenews.com/feed/",
        "https://www.aljazeera.com/xml/rss/all.xml",
        "https://www.anthropocenemagazine.org/feed/",
        "https://www.astro.gla.ac.uk/users/eduard/cesra/?feed=rss2",
        "https://www.economist.com/science-and-technology/rss.xml",
        "https://www.kolabtree.com/blog/feed/",
        "https://www.latimes.com/science/rss2.0.xml",
        "https://www.livescience.com/feeds.xml",
        "https://www.nasa.gov/feed/",
        "https://www.nasa.gov/rss/dyn/lg_image_of_the_day.rss",
        "https://www.nature.com/nature.rss",
        "https://www.nsf.gov/rss/rss_www_news.xml",
        "https://www.pbs.org/newshour/feeds/rss/science",
        "https://www.popsci.com/rss.xml",
        "https://www.psypost.org/feed/",
        "https://www.reddit.com/r/Anthropology/.rss",
        "https://www.reddit.com/r/EarthScience/.rss",
        "https://www.reddit.com/r/Futurism/.rss",
        "https://www.reddit.com/r/Futurology/.rss",
        "https://www.reddit.com/r/Physics/.rss",
        "https://www.reddit.com/r/biology/.rss",
        "https://www.reddit.com/r/chemistry/.rss",
        "https://www.reddit.com/r/climate/.rss",
        "https://www.reddit.com/r/environment/.rss",
        "https://www.reddit.com/r/environmental_science/.rss",
        "https://www.reddit.com/r/hardscience/.rss",
        "https://www.reddit.com/r/science/.rss",
        "https://www.reddit.com/r/space/.rss",
        "https://www.sci.news/feed",
        "https://www.scidev.net/global/rss.xml/?type=header",
        "https://www.science.org/rss/news_current.xml",
        "https://www.sciencealert.com/rss",
        "https://www.sciencedaily.com/rss/all.xml",
        "https://www.sciencenews.org/feed",
        "https://www.scientificamerican.com/platform/syndication/rss/",
        "https://www.scimex.org/rss",
        "https://www.smithsonianmag.com/rss/science-nature/",
        "https://www.snexplores.org/feed",
        "https://www.space.com/feeds/all",
        "https://www.technologynetworks.com/applied-sciences/news/rss",
        "https://www.the-scientist.com/rss/feed-the-scientist.xml",
        "https://www.theguardian.com/environment/climate-crisis/rss",
        "https://www.theguardian.com/science/rss",
        "https://www.thehindu.com/sci-tech/science/feeder/default.rss",
        "https://www.wired.com/feed/category/science/latest/rss",
        "https://www.yahoo.com/news/rss/science",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCqZQJ4600a9wIfMPbYc60OQ",
        "https://www.zmescience.com/feed/",
    ],
    "Sports": [
        "http://feeds.bbci.co.uk/sport/rss.xml",
        "http://feeds.bbci.co.uk/sport/rss.xml?edition=int#",
        "http://feeds.skynews.com/feeds/rss/sports.xml",
        "http://newsrss.bbc.co.uk/rss/sportonline_world_edition/front_page/rss.xml",
        "http://rss.cnn.com/rss/edition_sport.rss",
        "http://www.espn.com/espn/rss/news",
        "https://api.foxsports.com/v1/rss?partnerKey=zBaFxRyGKCfxBagJG9b8pqLyndmvo7UU",
        "https://e00-marca.uecdn.es/rss/en/index.xml",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/sports",
        "https://feeds.washingtonpost.com/rss/sports",
        "https://golfnews.co.uk/feed/",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiQkNCQVNLd29JTDIwdk1EWnVkR29TQW1WdUdnSlZVeUlPQ0FRYUNnb0lMMjB2TURVNWVXb3FCd29GRWdOT1Jrd29BQSoqCAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVABUAE?hl=en-US&gl=US&ceid=US%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiR0NCQVNMd29JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1ETTNhSG9xQ0FvR0VnUkhiMnhtS0FBKi4IACoqCAoiJENCQVNGUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlnQVABUAE?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiRENCQVNMUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1ESjRlaklxQmdvRUVnSkdNU2dBKi4IACoqCAoiJENCQVNGUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlnQVABUAE?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiS0NCQVNNZ29JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1ERnpaMndxQ3dvSkVnZERlV05zYVc1bktBQSouCAAqKggKIiRDQkFTRlFvSUwyMHZNRFp1ZEdvU0JXVnVMVWRDR2dKSFFpZ0FQAVAB?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiS0NCQVNNZ29JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1EbDRjRjhxQ3dvSkVnZERjbWxqYTJWMEtBQSouCAAqKggKIiRDQkFTRlFvSUwyMHZNRFp1ZEdvU0JXVnVMVWRDR2dKSFFpZ0FQAVAB?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiSENCQVNNQW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1EWmljV1FxQ1FvSEVnVlNkV2RpZVNnQSouCAAqKggKIiRDQkFTRlFvSUwyMHZNRFp1ZEdvU0JXVnVMVWRDR2dKSFFpZ0FQAVAB?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiSkNCQVNNUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1EZGljekFxQ2dvSUVnWlVaVzV1YVhNb0FBKi4IACoqCAoiJENCQVNGUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlnQVABUAE?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiSkNCQVNNUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1HcDRlSFFxQ2dvSUVnWlNZV05wYm1jb0FBKi4IACoqCAoiJENCQVNGUW9JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlnQVABUAE?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB/sections/CAQiTENCQVNNd29JTDIwdk1EWnVkR29TQldWdUxVZENHZ0pIUWlJT0NBUWFDZ29JTDIwdk1ESjJlRFFxREFvS0VnaEdiMjkwWW1Gc2JDZ0EqLggAKioICiIkQ0JBU0ZRb0lMMjB2TURadWRHb1NCV1Z1TFVkQ0dnSkhRaWdBUAFQAQ?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB?hl=en-GB&gl=GB&ceid=GB%3Aen",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRFp1ZEdvU0FtVnVHZ0pWVXlnQVAB?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://openrss.org/www.inquirer.com/sports/",
        "https://rss.dw.com/rdf/rss-en-sports",
        "https://rss.nytimes.com/services/xml/rss/nyt/Sports.xml",
        "https://sports.yahoo.com/rss/",
        "https://time.com/sports/feed/",
        "https://www.cbssports.com/rss/headlines/",
        "https://www.chicagotribune.com/sports/feed/",
        "https://www.cyclingnews.com/feeds.xml",
        "https://www.dawn.com/feeds/sport/",
        "https://www.espn.com/espn/rss/news",
        "https://www.football365.com/rss",
        "https://www.foxsports.com/feedout/syndicatedContent?categoryId=117",
        "https://www.independent.co.uk/sport/rss",
        "https://www.latimes.com/sports/rss2.0.xml",
        "https://www.reddit.com/r/CFB.rss",
        "https://www.reddit.com/r/formula1.rss",
        "https://www.reddit.com/r/golf.rss",
        "https://www.reddit.com/r/indepthsports.rss",
        "https://www.reddit.com/r/mlb.rss",
        "https://www.reddit.com/r/nba.rss",
        "https://www.reddit.com/r/nfl.rss",
        "https://www.reddit.com/r/soccer.rss",
        "https://www.reddit.com/r/sports.rss",
        "https://www.scmp.com/rss/95/feed",
        "https://www.seattletimes.com/sports/feed/",
        "https://www.sfgate.com/rss/feed/top-sports-stories-rss-feed-487.php",
        "https://www.skysports.com/rss/12040",
        "https://www.sportskeeda.com/feed",
        "https://www.teamtalk.com/rss",
        "https://www.theguardian.com/sport/rss",
        "https://www.theguardian.com/uk/sport/rss",
        "https://www.thehindu.com/sport/feeder/default.rss",
        "https://www.triathlonmag.com.au/rss",
        "https://www.westhesportsguy.com/feeds/posts/default",
    ],
    "Technology": [
        "http://anandtech.com/rss/",
        "http://atomicsupermen.wordpress.com/feed/",
        "http://bloggingthemonkey.blogspot.com/feeds/posts/default",
        "http://boingboing.net/feed",
        "http://feeds.arstechnica.com/arstechnica/index",
        "http://feeds.feedburner.com/AllDiscovermagazinecomContent?format=xml",
        "http://feeds.feedburner.com/IeeeSpectrumFullText",
        "http://feeds.feedburner.com/IntoMobile?format=xml",
        "http://feeds.feedburner.com/KhronosorgNews",
        "http://feeds.feedburner.com/Liliputing?format=xml",
        "http://feeds.feedburner.com/Medgadget",
        "http://feeds.feedburner.com/NanotechwebTechUpdate",
        "http://feeds.feedburner.com/RedmondPie",
        "http://feeds.feedburner.com/Techcrunch",
        "http://feeds.feedburner.com/armdevices?format=xml",
        "http://feeds.feedburner.com/cnx-software/blog?format=xml",
        "http://feeds.feedburner.com/igyaan",
        "http://feeds.feedburner.com/oled-info",
        "http://feeds.feedburner.com/reason/AllArticles",
        "http://feeds.feedburner.com/ubergizmo",
        "http://feeds.feedburner.com/venturebeat/SZYF",
        "http://feeds.mashable.com/Mashable",
        "http://freegamer.blogspot.com/feeds/posts/default",
        "http://gliden64.blogspot.com/feeds/posts/default",
        "http://gpuopen.com/feed/",
        "http://lwn.net/headlines/newrss",
        "http://makezine.com/feed/",
        "http://mobilesemi.blogspot.com/feeds/posts/default",
        "http://rss.cnn.com/rss/cnn_tech.rss",
        "http://rss.nytimes.com/services/xml/rss/nyt/Technology.xml",
        "http://rss.slashdot.org/Slashdot/slashdot",
        "http://rss.slashdot.org/Slashdot/slashdotMain",
        "http://semiaccurate.com/feed/",
        "http://semiengineering.com/feed/",
        "http://sreweekly.com/feed/",
        "http://stratechery.com/feed/",
        "http://thehackernews.com/feeds/posts/default",
        "http://www.androidauthority.com/feed",
        "http://www.androidpolice.com/feed",
        "http://www.cancelledscifi.com/feed/",
        "http://www.computerweekly.com/rss/All-Computer-Weekly-content.xml",
        "http://www.cringely.com/feed/",
        "http://www.digitimes.com/rss/daily.xml",
        "http://www.engadget.com/rss.xml",
        "http://www.extremetech.com/feed",
        "http://www.fark.com/fark.rss",
        "http://www.fudzilla.com/?format=feed",
        "http://www.jeffq.com/blog/feed/",
        "http://www.lakka.tv/articles/feed.xml",
        "http://www.libretro.com/index.php/feed/",
        "http://www.macrumors.com/macrumors.xml",
        "http://www.metafilter.com/tags/apple/rss",
        "http://www.metafilter.com/tags/computers/rss",
        "http://www.metafilter.com/tags/google/rss",
        "http://www.metafilter.com/tags/internet/rss",
        "http://www.metafilter.com/tags/technology/rss",
        "http://www.metafilter.com/tags/web/rss",
        "http://www.mondo2000.com/feed/",
        "http://www.phdcomics.com/gradfeed.php",
        "http://www.phoronix.com/phoronix-rss.php",
        "http://www.pocketables.com/feed",
        "http://www.sammobile.com/feed/",
        "http://www.smbc-comics.com/rss.php",
        "http://www.techcentral.ie/feed/",
        "http://www.techradar.com/rss",
        "http://www.theregister.co.uk/headlines.atom",
        "http://www.theverge.com/rss/full.xml",
        "http://www.tomshardware.com/feeds/rss2/all.xml",
        "http://www.umpcportal.com/feed/",
        "http://www.wired.com/feed/",
        "http://www.xda-developers.com/feed/",
        "http://www.zdnet.com/news/rss.xml",
        "http://xenia.jp/feed.xml",
        "https://512pixels.net/feed/",
        "https://9to5google.com/feed",
        "https://9to5mac.com/feed/",
        "https://abcnews.go.com/abcnews/technologyheadlines",
        "https://androidpctv.com/feed/",
        "https://api.axios.com/feed/technology",
        "https://appleinsider.com/rss/news/",
        "https://atp.fm/rss",
        "https://betanews.com/feed/",
        "https://cloudseclist.com/feed.xml",
        "https://coywolf.com/news/feed/",
        "https://digitechbytes.com/feed/",
        "https://electrek.co/feed/",
        "https://feeds.bbci.co.uk/news/technology/rss.xml",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/science-tech",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/technology",
        "https://feeds.feedblitz.com/newatlas",
        "https://feeds.feedburner.com/ausdroid/feed",
        "https://feeds.feedburner.com/codinghorror",
        "https://feeds.feedburner.com/ndtvnews-top-stories",
        "https://feeds.feedburner.com/thenextweb",
        "https://feeds.macrumors.com/MacRumors-All",
        "https://feeds.npr.org/1019/rss.xml",
        "https://feeds.twit.tv/twit.xml",
        "https://feeds.washingtonpost.com/rss/business/technology",
        "https://flipshope.com/blog/feed",
        "https://futurefive.co.nz/feed",
        "https://futurism.com/feed",
        "https://gigaom.com/feed/?x=1",
        "https://gizmodo.com/feed",
        "https://gizmodo.com/rss",
        "https://hackaday.com/feed/",
        "https://indianexpress.com/section/technology/feed/",
        "https://insiderpaper.com/feed/",
        "https://knowtechie.com/feed/",
        "https://krebsonsecurity.com/feed/",
        "https://kubernetes.io/feed.xml",
        "https://latesttechno.in/feed/",
        "https://lobste.rs/rss",
        "https://mobilesyrup.com/feed",
        "https://moviecode.tumblr.com/rss",
        "https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml",
        "https://news.google.com/rss/topics/CAAqJggKIiBDQkFTRWdvSUwyMHZNRGRqTVhZU0FtVnVHZ0pWVXlnQVAB?hl=en-US&gl=US&ceid=US%3Aen&oc=11",
        "https://news969.com/feed/",
        "https://nocamels.com/feed/",
        "https://openrss.org/www.reuters.com/technology/",
        "https://osmc.tv/rss",
        "https://randomascii.wordpress.com/feed/",
        "https://readwrite.com/feed/",
        "https://restic.net/blog/index.xml",
        "https://retropie.org.uk/feed/",
        "https://rss.nytimes.com/services/xml/rss/nyt/PersonalTech.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/Technology.xml",
        "https://rss.politico.com/technology.xml",
        "https://rthackersnews.blogspot.com/feeds/posts/default?alt=rss",
        "https://soylentnews.org/index.rss",
        "https://spectrum.ieee.org/feeds/feed.rss",
        "https://spider760.blogspot.com/feeds/posts/default?alt=rss",
        "https://stallman.org/rss/rss.xml",
        "https://techcavit.com/feed/",
        "https://techcrunch.com/feed/",
        "https://technode.com/feed/",
        "https://techviral.net/feed/",
        "https://techxplore.com/rss-feed/",
        "https://themarkup.org/feeds/rss.xml",
        "https://thenextweb.com/feed/",
        "https://tildes.net/topics.rss",
        "https://time.com/tech/feed/",
        "https://www.404media.co/rss/",
        "https://www.aclu.org/taxonomy/channel-term/1/feed",
        "https://www.androidauthority.com/feed/",
        "https://www.androidheadlines.com/feed",
        "https://www.antifraudnews.com/feed/",
        "https://www.betechwise.com/feed/",
        "https://www.bleepingcomputer.com/feed/",
        "https://www.blogger.com/feeds/4457929370096894305/posts/default",
        "https://www.chinatechnews.com/feed",
        "https://www.cnet.com/rss/news/",
        "https://www.codeofhonor.com/blog/feed",
        "https://www.cultofmac.com/feed/",
        "https://www.dawn.com/feeds/tech/",
        "https://www.developer-tech.com/feed",
        "https://www.digitaltrends.com/feed/",
        "https://www.droid-life.com/feed",
        "https://www.eff.org/rss/pressrelease",
        "https://www.engadget.com/rss.xml",
        "https://www.eweek.com/feed/",
        "https://www.extremetech.com/feed",
        "https://www.fastcompany.com/latest/rss?truncated=true",
        "https://www.firstpost.com/commonfeeds/v1/mfp/rss/tech.xml",
        "https://www.gadgetsinnepal.com.np/feed/",
        "https://www.geekwire.com/feed",
        "https://www.ghacks.net/feed/",
        "https://www.lastweekinaws.com/feed",
        "https://www.latimes.com/business/technology/rss2.0.xml",
        "https://www.lighthome.in/feed/",
        "https://www.macobserver.com/feed/",
        "https://www.macsparky.com/feed/",
        "https://www.macworld.com/en-us/feed",
        "https://www.macworld.com/index.rss",
        "https://www.mercurynews.com/tag/siliconbeat/feed/",
        "https://www.microled-info.com/rss.xml",
        "https://www.nature.com/nature.rss",
        "https://www.naval-technology.com/feed/",
        "https://www.newskart.com/feed",
        "https://www.nextgov.com/rss/all/",
        "https://www.pcmag.com/feeds/rss/latest",
        "https://www.pcworld.com/index.rss",
        "https://www.platformexecutive.com/feed",
        "https://www.popularmechanics.com/rss/default.xml",
        "https://www.quantamagazine.org/feed",
        "https://www.raspberrypi.org/feed/",
        "https://www.rayarena.com/feed",
        "https://www.reddit.com/r/Apple/.rss",
        "https://www.reddit.com/r/Google/.rss",
        "https://www.reddit.com/r/gadgets/.rss",
        "https://www.reddit.com/r/hardware/.rss",
        "https://www.reddit.com/r/iphone/.rss",
        "https://www.reddit.com/r/technology/.rss",
        "https://www.relay.fm/analogue/feed",
        "https://www.relay.fm/clockwise/feed",
        "https://www.relay.fm/rocket/feed",
        "https://www.researchsnipers.com/feed/",
        "https://www.schneier.com/feed/atom",
        "https://www.scmp.com/rss/36/feed",
        "https://www.securityweek.com/feed/",
        "https://www.servethehome.com/feed/",
        "https://www.siliconrepublic.com/feed",
        "https://www.slashgear.com/feed/",
        "https://www.techdirt.com/techdirt_rss.xml",
        "https://www.techfeeddata.com/feed",
        "https://www.techinasia.com/feed",
        "https://www.techjuice.pk/feed/",
        "https://www.techmeme.com/feed.xml",
        "https://www.technews.city/feeds/posts/default?alt=rss",
        "https://www.technewsworld.com/perl/syndication/rssfull.pl",
        "https://www.technologydrift.com/feed/",
        "https://www.technologyreview.com/feed",
        "https://www.techpout.com/feed/",
        "https://www.techrepublic.com/rssfeeds/articles/?feedType=rssfeeds&sort=latest",
        "https://www.techspot.com/backend.xml",
        "https://www.theblackweb.in/feed/",
        "https://www.theguardian.com/uk/technology/rss",
        "https://www.theverge.com/rss/frontpage",
        "https://www.theverge.com/rss/index.xml",
        "https://www.vox.com/rss/technology/index.xml",
        "https://www.wired.com/feed/rss",
        "https://www.wired.com/feed/tag/ai/latest/rss",
        "https://www.youtube.com/feeds/videos.xml?user=CNETTV",
        "https://www.youtube.com/feeds/videos.xml?user=LinusTechTips",
        "https://www.youtube.com/feeds/videos.xml?user=TheVerge",
        "https://www.youtube.com/feeds/videos.xml?user=marquesbrownlee",
        "https://www.youtube.com/feeds/videos.xml?user=unboxtherapy",
        "https://xkcd.com/rss.xml",
    ],
    "Travel": [
        "http://rss.cnn.com/rss/cnn_travel.rss",
        "https://rss.nytimes.com/services/xml/rss/nyt/Travel.xml",
        "https://thepointsguy.com/feed/",
        "https://www.adventure-journal.com/feed/",
        "https://www.cntraveler.com/feed/rss",
        "https://www.euronews.com/rss?level=vertical&name=travel",
        "https://www.latimes.com/travel/rss2.0.xml",
        "https://www.lonelyplanet.com/blog/feed/atom/",
        "https://www.sfgate.com/rss/feed/travel-news-and-features-520.php",
        "https://www.theguardian.com/travel/rss",
        "https://www.thehindu.com/life-and-style/travel/feeder/default.rss",
    ],
    "USA": [
        "http://dailysignal.com//feed/",
        "http://feeds.feedburner.com/Davidthompson",
        "http://feeds.foxnews.com/foxnews/national",
        "http://rss.cnn.com/rss/cnn_allpolitics.rss",
        "http://rss.cnn.com/rss/edition_us.rss",
        "http://rss.slashdot.org/Slashdot/slashdotPolitics",
        "http://www.marketwatch.com/rss/topstories/",
        "http://www.metafilter.com/tags/us/rss",
        "http://www.metafilter.com/tags/uspolitics/rss",
        "http://www.newyorker.com/services/rss/feeds/everything.xml",
        "http://www.slate.com/articles/news_and_politics.fulltext.all.10.rss",
        "http://www.slate.com/articles/news_and_politics/politics.teaser.all.10.rss/",
        "https://abcnews.go.com/abcnews/politicsheadlines",
        "https://abcnews.go.com/abcnews/usheadlines",
        "https://api.axios.com/feed/politics",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/usa",
        "https://feeds.feedburner.com/Popehat",
        "https://feeds.nbcnews.com/msnbc/public/news",
        "https://feeds.npr.org/1014/rss.xml",
        "https://feeds.washingtonpost.com/rss/national",
        "https://feeds.washingtonpost.com/rss/rss_fact-checker",
        "https://feeds.washingtonpost.com/rss/rss_the-fix",
        "https://fivethirtyeight.com/features/feed/",
        "https://justthenews.com/rss.xml",
        "https://nypost.com/politics/feed/",
        "https://nypost.com/us-news/feed/",
        "https://openrss.org/apnews.com/politics",
        "https://openrss.org/apnews.com/us-news",
        "https://openrss.org/www.axios.com",
        "https://openrss.org/www.inquirer.com/politics/",
        "https://pjmedia.com/news-and-politics/feed",
        "https://politicalwire.com/feed/",
        "https://prospect.org/api/rss/content.rss",
        "https://quillette.com/tag/politics/rss",
        "https://reason.com/feed/",
        "https://reason.com/volokh/atom.xml",
        "https://rss.csmonitor.com/feeds/usa",
        "https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml",
        "https://rss.nytimes.com/services/xml/rss/nyt/US.xml",
        "https://status451.com/feed/",
        "https://theconversation.com/us/articles.atom",
        "https://theconversation.com/us/politics/articles.atom",
        "https://thehill.com/homenews/campaign/feed/",
        "https://thehill.com/homenews/feed/",
        "https://thehill.com/homenews/media/feed/",
        "https://thehill.com/lobbying/feed/",
        "https://thehill.com/regulation/feed/",
        "https://theintercept.com/feed/",
        "https://time.com/politics/feed",
        "https://time.com/us/feed",
        "https://www.allsides.com/rss/news",
        "https://www.alternet.org/feeds/feed.rss",
        "https://www.astralcodexten.com/feed",
        "https://www.boston.com/tag/politics/feed/",
        "https://www.cbsnews.com/latest/rss/politics",
        "https://www.chicagotribune.com/news/politics/feed/",
        "https://www.compactmag.com/rss/",
        "https://www.latimes.com/politics/rss2.0.xml",
        "https://www.lemonde.fr/united-states/rss_full.xml",
        "https://www.nationalreview.com/corner/feed/",
        "https://www.nationalreview.com/feed/",
        "https://www.pbs.org/newshour/feeds/rss/politics",
        "https://www.pewresearch.org/feed/",
        "https://www.propublica.org/feeds/propublica/main",
        "https://www.reddit.com/r/AmericanPolitics/.rss",
        "https://www.reddit.com/r/NeutralPolitics/.rss",
        "https://www.reddit.com/r/politics/.rss",
        "https://www.riskhedge.com/rss",
        "https://www.rollingstone.com/politics/feed/",
        "https://www.salon.com/feed/",
        "https://www.scmp.com/rss/322262/feed",
        "https://www.seattletimes.com/nation/feed/",
        "https://www.spiked-online.com/topic/usa/feed/",
        "https://www.theatlantic.com/feed/best-of/",
        "https://www.themarshallproject.org/rss/recent",
        "https://www.thenation.com/feed/?post_type=article",
        "https://www.vox.com/rss/politics/index.xml",
        "https://www.washingtonexaminer.com/section/politics/feed/",
        "https://www.yahoo.com/news/rss/politics",
        "https://www.yahoo.com/news/rss/us",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCDRIjKy6eZOvKtOELtTdeUA",
    ],
    "Web Comics": [
        "http://nedroid.com/feed/atom/",
        "http://www.harkavagrant.com/rssfeed.php",
        "http://www.marycagle.com/rss.php",
        "http://www.threepanelsoul.com/rss.php",
        "https://killsixbilliondemons.com/feed/",
        "https://www.foxtrot.com/feed/",
        "https://xkcd.com/atom.xml",
    ],
    "World": [
        "http://tass.com/rss/v2.xml",
        "https://abcnews.go.com/abcnews/internationalheadlines",
        "https://acleddata.com/feed/",
        "https://aljazeera.com/xml/rss/all.xml",
        "https://allafrica.com/tools/headlines/rdf/latest/headlines.rdf",
        "https://en.mercopress.com/rss/",
        "https://feeds.a.dj.com/rss/RSSOpinion.xml",
        "https://feeds.a.dj.com/rss/RSSWorldNews.xml",
        "https://feeds.bbci.co.uk/news/world/rss.xml",
        "https://feeds.elpais.com/mrss-s/list/ep/site/english.elpais.com/section/international",
        "https://feeds.elpais.com/mrss-s/pages/ep/site/english.elpais.com/portada",
        "https://feeds.feedburner.com/ndtvnews-world-news",
        "https://feeds.npr.org/1004/rss.xml",
        "https://feeds.washingtonpost.com/rss/world",
        "https://foreignpolicy.com/category/world-brief/feed/",
        "https://foreignpolicy.com/feed/",
        "https://jacobin.com/feed/",
        "https://japantoday.com/category/world/feed",
        "https://meduza.io/rss/en/all",
        "https://michaelwest.com.au/feed/",
        "https://novaramedia.com/category/articles/feed/",
        "https://novaramedia.com/feed/",
        "https://openrss.org/apnews.com/world-news",
        "https://openrss.org/www.reuters.com/world/",
        "https://restofworld.org/feed/latest",
        "https://rss.csmonitor.com/feeds/all",
        "https://rss.dw.com/rdf/rss-en-all",
        "https://rss.dw.com/rdf/rss-en-world",
        "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
        "https://thespectator.com/feed/",
        "https://theweek.com/feeds.xml",
        "https://time.com/world/feed",
        "https://www.abc.net.au/news/feed/45910/rss.xml",
        "https://www.al-monitor.com/rss",
        "https://www.allsides.com/blog",
        "https://www.allsides.com/rss/news",
        "https://www.arabnews.com/taxonomy/term/3/feed",
        "https://www.boston.com/tag/world-news/feed/",
        "https://www.cbsnews.com/latest/rss/world",
        "https://www.compactmag.com/rss/",
        "https://www.dawn.com/feeds/world/",
        "https://www.economist.com/asia/rss.xml",
        "https://www.economist.com/britain/rss.xml",
        "https://www.economist.com/china/rss.xml",
        "https://www.economist.com/europe/rss.xml",
        "https://www.economist.com/international/rss.xml",
        "https://www.economist.com/middle-east-and-africa/rss.xml",
        "https://www.economist.com/the-americas/rss.xml",
        "https://www.euronews.com/rss?level=vertical&name=news",
        "https://www.firstpost.com/commonfeeds/v1/mfp/rss/world.xml",
        "https://www.foreignaffairs.com/rss.xml",
        "https://www.france24.com/en/rss",
        "https://www.ft.com/rss/home/international",
        "https://www.ft.com/world?format=rss",
        "https://www.globalissues.org/news/feed",
        "https://www.lemonde.fr/en/rss/une.xml",
        "https://www.lemonde.fr/opinion/rss_full.xml",
        "https://www.mintpressnews.com/feed/",
        "https://www.newarab.com/rss",
        "https://www.pbs.org/newshour/feeds/rss/world",
        "https://www.pewresearch.org/feed/",
        "https://www.politico.eu/feed/",
        "https://www.reddit.com/r/UpliftingNews/.rss",
        "https://www.reddit.com/r/inthenews/.rss",
        "https://www.reddit.com/r/truenews/.rss",
        "https://www.reddit.com/r/worldnews/.rss",
        "https://www.rferl.org/api/zbqiml-vomx-tpeqkmy",
        "https://www.rfi.fr/en/international/rss",
        "https://www.rt.com/rss/",
        "https://www.rt.com/rss/news/",
        "https://www.scmp.com/rss/5/feed",
        "https://www.scmp.com/rss/91/feed",
        "https://www.semafor.com/rss.xml",
        "https://www.spiegel.de/international/index.rss",
        "https://www.spiegel.de/international/world/index.rss",
        "https://www.spiked-online.com/topic/world/feed/",
        "https://www.straitstimes.com/news/world/rss.xml",
        "https://www.theglobeandmail.com/arc/outboundfeeds/rss/category/world/",
        "https://www.theguardian.com/world/rss",
        "https://www.thehindu.com/news/international/feeder/default.rss",
        "https://www.timesofisrael.com/feed/",
        "https://www.voanews.com/api/",
        "https://www.wsws.org/en/rss.xml",
        "https://www.youtube.com/feeds/videos.xml?channel_id=UCQfwfsi5VrQ8yKZ-UWmAEFg",
    ]
}

```

## /package.json

```json path="/package.json" 
{
	"name": "kite-public",
	"version": "0.1.0",
	"private": true,
	"description": "Kite - News Elevated",
	"homepage": "https://github.com/kagisearch/kite-public#readme",
	"bugs": {
		"url": "https://github.com/kagisearch/kite-public/issues"
	},
	"repository": {
		"type": "git",
		"url": "git+https://github.com/kagisearch/kite-public.git"
	},
	"license": "MIT",
	"author": "Kagi Search",
	"type": "module",
	"scripts": {
		"postinstall": "echo '{\"version\":3,\"sources\":[],\"mappings\":\"\"}' > node_modules/@adobe/css-tools/dist/esm/adobe-css-tools.mjs.map",
		"dev": "vite dev",
		"build": "vite build",
		"preview": "vite preview",
		"start": "node build",
		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
		"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
		"format": "biome format --write .",
		"format:check": "biome format .",
		"lint": "biome lint .",
		"lint:fix": "biome lint --write .",
		"biome:check": "biome check .",
		"biome:fix": "biome check --write .",
		"test": "vitest",
		"test:ui": "vitest --ui",
		"test:coverage": "vitest run --coverage",
		"test:unit": "vitest run --config vitest.config.unit.ts",
		"test:integration": "vitest run --config vitest.config.integration.ts",
		"test:all": "vitest run"
	},
	"dependencies": {
		"@anthropic-ai/sdk": "^0.71.2",
		"@google-cloud/vertexai": "^1.10.0",
		"@google/genai": "^1.37.0",
		"@skeletonlabs/floating-ui-svelte": "^0.3.9",
		"@tabler/icons-svelte": "^3.36.1",
		"@tailwindcss/vite": "^4.1.18",
		"@types/accept-language-parser": "^1.5.8",
		"@types/node": "^25.0.9",
		"accept-language-parser": "^1.5.0",
		"chartjs-adapter-date-fns": "^3.0.0",
		"cheerio": "^1.1.2",
		"clsx": "^2.1.1",
		"date-fns": "^4.1.0",
		"dexie": "^4.2.1",
		"dotenv": "^17.2.3",
		"drizzle-orm": "^0.45.1",
		"jose": "^6.1.3",
		"lottie-web": "^5.13.0",
		"mustache": "^4.2.0",
		"openai": "^6.16.0",
		"overlayscrollbars": "^2.13.0",
		"overlayscrollbars-svelte": "^0.5.5",
		"patchright": "^1.57.0",
		"pino": "^10.2.0",
		"pino-pretty": "^13.1.3",
		"postgres": "^3.4.8",
		"sortablejs": "^1.15.6",
		"svelte-dnd-action": "^0.9.69",
		"svelte-meta-tags": "^4.5.0",
		"svelte-portal": "^2.2.1",
		"swagger-jsdoc": "^6.2.8",
		"tailwind-merge": "^3.4.0",
		"tailwindcss": "^4.1.18",
		"three": "^0.182.0",
		"topojson-client": "^3.1.0",
		"uuid": "^13.0.0",
		"world-atlas": "^2.0.2",
		"ws": "^8.19.0"
	},
	"overrides": {
		"esbuild": "^0.25.0",
		"cookie": "^0.7.0"
	},
	"devDependencies": {
		"@biomejs/biome": "2.3.11",
		"@sveltejs/adapter-node": "^5.5.1",
		"@sveltejs/kit": "^2.49.5",
		"@sveltejs/vite-plugin-svelte": "^6.2.4",
		"@testing-library/jest-dom": "^6.9.1",
		"@testing-library/svelte": "^5.3.1",
		"@testing-library/user-event": "^14.6.1",
		"@trivago/prettier-plugin-sort-imports": "^6.0.2",
		"@types/mustache": "^4.2.6",
		"@types/pg": "^8.16.0",
		"@types/swagger-jsdoc": "^6.0.4",
		"@types/three": "^0.182.0",
		"@types/topojson-client": "^3.1.5",
		"@types/ws": "^8.18.1",
		"@vitest/coverage-v8": "^4.0.17",
		"@vitest/ui": "^4.0.17",
		"better-npm-audit": "^3.11.0",
		"drizzle-kit": "^0.31.8",
		"jsdom": "^27.4.0",
		"pg": "^8.17.1",
		"prettier": "^3.8.0",
		"prettier-plugin-svelte": "^3.4.1",
		"prettier-plugin-tailwindcss": "^0.7.2",
		"svelte": "^5.46.4",
		"svelte-check": "^4.3.5",
		"typescript": "^5.9.3",
		"vite": "^7.3.1",
		"vitest": "^4.0.17"
	}
}

```

## /scripts/console-utils.ts

```ts path="/scripts/console-utils.ts" 
/**
 * Shared console utilities for consistent formatting across scripts
 */

let routeInfoLogsToStderr = false;

export function setInfoLogsToStderr(enable: boolean): void {
  routeInfoLogsToStderr = enable;
}

/**
 * Log a success message with checkmark
 */
export function logSuccess(message: string): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`✅ ${message}`);
}

/**
 * Log a warning message with warning icon
 */
export function logWarning(message: string): void {
  console.warn(`⚠️  ${message}`);
}

/**
 * Log an error message with X icon
 */
export function logError(message: string): void {
  console.error(`❌ ${message}`);
}

/**
 * Log an info message with info icon
 */
export function logInfo(message: string): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`ℹ️  ${message}`);
}

/**
 * Log a processing message with appropriate icon
 */
export function logProcessing(message: string): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`📄 ${message}`);
}

/**
 * Log a search/analysis message
 */
export function logAnalysis(message: string): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`🔍 ${message}`);
}

/**
 * Log a balance/no-change message
 */
export function logNoChange(message: string): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`⚖️  ${message}`);
}

/**
 * Log a bullet point with custom icon
 */
export function logBullet(message: string, icon = '🔹'): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`  ${icon} ${message}`);
}

/**
 * Format a count with appropriate pluralization
 */
export function formatCount(count: number, singular: string, plural?: string): string {
  const word = count === 1 ? singular : (plural || `${singular}s`);
  return `${count} ${word}`;
}

/**
 * Format a list of items with truncation
 */
export function formatList(items: string[], maxItems = 5): string {
  if (items.length <= maxItems) {
    return items.join(', ');
  }

  const shown = items.slice(0, maxItems);
  const remaining = items.length - maxItems;
  return `${shown.join(', ')}${remaining > 0 ? `… (+${remaining} more)` : ''}`;
}

/**
 * Create a section header with emoji
 */
export function logSectionHeader(title: string, emoji = '📊'): void {
  const writer = routeInfoLogsToStderr ? console.error : console.log;
  writer(`\n${emoji} ${title}:`);
}

/**
 * Log a summary line with count and description
 */
export function logSummaryLine(count: number, description: string, emoji: string): void {
  if (count > 0) {
    const writer = routeInfoLogsToStderr ? console.error : console.log;
    writer(`  ${emoji} ${formatCount(count, description)}`);
  }
}

/**
 * Log detailed items with truncation
 */
export function logDetailedItems(
  title: string,
  items: string[],
  emoji = '📝',
  maxItems = 10
): void {
  if (items.length === 0) return;

  console.log(`\n${emoji} ${title}:`);
  for (const item of items.slice(0, maxItems)) {
    const writer = routeInfoLogsToStderr ? console.error : console.log;
    writer(`  - ${item}`);
  }

  if (items.length > maxItems) {
    const writer = routeInfoLogsToStderr ? console.error : console.log;
    writer(`  ... and ${items.length - maxItems} more`);
  }
}
```

## /scripts/shared-runner.ts

```ts path="/scripts/shared-runner.ts" 
import type { Issue, ScriptOutput } from './shared-types.js';
import { outputIssues } from './shared-types.js';
import { setInfoLogsToStderr } from './console-utils.js';

export interface ScriptConfig {
  type: 'feeds' | 'media' | 'locales';
  name: string;
}

export interface ScriptResult {
  issues: Issue[];
  summary: {
    totalIssues: number;
    byType: Record<string, number>;
  };
}

/**
 * Common script execution wrapper that handles:
 * - --output-issues flag detection
 * - ScriptOutput creation and outputIssues() calling
 * - Error handling
 */
export async function runScript(
  config: ScriptConfig,
  mainFunction: () => Promise<void> | void,
  analysisFunction: () => Promise<ScriptResult> | ScriptResult
): Promise<void> {
  try {
    // Always run the main function first (sorting/processing)
    await mainFunction();
    
    // If --output-issues flag is present, generate issues for reviewdog
    if (process.argv.includes('--output-issues')) {
      // Route non-JSON logs to stderr to keep stdout clean when redirecting
      setInfoLogsToStderr(true);
      const result = await analysisFunction();
      const output: ScriptOutput = {
        type: config.type,
        issues: result.issues,
        summary: result.summary,
        timestamp: new Date().toISOString()
      };
      outputIssues(output);
      return;
    }
    
    // Otherwise, run analysis and show console output
    const result = await analysisFunction();
    showConsoleSummary(config.name, result);
    
  } catch (e: unknown) {
    const message = e instanceof Error ? e.message : String(e);
    console.error('❌ Error:', message);
    process.exit(1);
  }
}

/**
 * Display a formatted console summary of analysis results
 */
function showConsoleSummary(scriptName: string, result: ScriptResult): void {
  if (result.summary.totalIssues > 0) {
    console.log(`\n🔍 ${scriptName} Analysis Results:`);
    
    // Display counts by type
    for (const [type, count] of Object.entries(result.summary.byType)) {
      if (count > 0) {
        const emoji = getEmojiForType(type);
        console.log(`  ${emoji} ${count} ${type} ${count === 1 ? 'issue' : 'issues'}`);
      }
    }
    
    console.log(`  📊 ${result.summary.totalIssues} total issues`);
  } else {
    console.log(`\n✅ No ${scriptName.toLowerCase()} issues found`);
  }
}

/**
 * Get appropriate emoji for issue type
 */
function getEmojiForType(type: string): string {
  const emojiMap: Record<string, string> = {
    unused: '🔑',
    missing: '🌍',
    orphaned: '👻',
    duplicate: '🔄',
    invalid: '❌',
    unreachable: '🔗'
  };
  return emojiMap[type] || '📝';
}

/**
 * Wrapper for script entry point
 */
// Note: createScriptEntry cannot work because import.meta.url refers to this file, not the caller
// Each script needs to handle its own entry point detection
```

## /scripts/shared-types.ts

```ts path="/scripts/shared-types.ts" 
// Shared types for all sorting scripts
export interface Issue {
  type: 'unused' | 'missing' | 'orphaned' | 'duplicate' | 'invalid';
  file: string;
  line?: number;
  column?: number;
  endLine?: number;
  endColumn?: number;
  message: string;
  suggestion?: string; // For reviewdog suggested edits
  severity: 'error' | 'warning' | 'info';
}

export interface ScriptOutput {
  type: 'feeds' | 'media' | 'locales';
  issues: Issue[];
  summary: {
    totalIssues: number;
    byType: Record<string, number>;
  };
  timestamp: string;
}

export function outputIssues(output: ScriptOutput) {
  if (!process.argv.includes('--output-issues')) return;

  const outFlagIndex = process.argv.indexOf('--output-file');
  const outPath = outFlagIndex !== -1 ? process.argv[outFlagIndex + 1] : undefined;

  const json = JSON.stringify(output, null, 2);

  if (!outPath) {
    // Fallback to stdout JSON
    console.log(json);
    return;
  }

  // Atomic write: write to temp then rename
  const path = require('node:path');
  const fs = require('node:fs');
  const dir = path.dirname(outPath);
  const base = path.basename(outPath);
  const temp = path.join(dir, `.${base}.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  try {
    fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(temp, json, 'utf-8');
    fs.renameSync(temp, outPath);
  } catch (e) {
    try { fs.unlinkSync(temp); } catch {}
    // If atomic write fails, fallback to stdout to not break pipeline
    console.log(json);
  }
}

export function findLineNumber(content: string, searchKey: string): number {
  const lines = content.split('\n');
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].includes(`"${searchKey}"`)) {
      return i + 1; // 1-based line numbers
    }
  }
  return 1;
}

export function findKeyRange(content: string, searchKey: string): { start: number; end: number } {
  const lines = content.split('\n');
  let startLine = -1;
  let endLine = -1;
  let braceCount = 0;
  let inTargetKey = false;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    
    if (line.includes(`"${searchKey}"`)) {
      startLine = i + 1;
      inTargetKey = true;
      braceCount = 0;
    }
    
    if (inTargetKey) {
      // Count braces to find the end of this key's object
      const openBraces = (line.match(/{/g) || []).length;
      const closeBraces = (line.match(/}/g) || []).length;
      braceCount += openBraces - closeBraces;
      
      // If we're back to 0 braces and we've seen the key, this is the end
      if (braceCount <= 0 && line.includes('}')) {
        endLine = i + 1;
        break;
      }
    }
  }
  
  return { start: startLine, end: endLine > 0 ? endLine : startLine };
}
```

## /scripts/sort-core-feeds.py

```py path="/scripts/sort-core-feeds.py" 
#!/usr/bin/env python3
"""Sort and deduplicate core feeds."""

import json
import sys
from pathlib import Path
import importlib.util
import glob

CORE_FEEDS_FILE = 'core_feeds.py'
LOCALES_DIR = 'src/lib/locales'

def analyze_translations():
    """Analyze translation issues for core feeds categories."""
    issues = []

    # Load core feeds categories
    if not Path(CORE_FEEDS_FILE).exists():
        return issues

    spec = importlib.util.spec_from_file_location("core_feeds", CORE_FEEDS_FILE)
    core_feeds_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(core_feeds_module)

    if not hasattr(core_feeds_module, 'feeds'):
        return issues

    core_categories = set(core_feeds_module.feeds.keys())

    # Check each locale file for missing translations
    locale_files = glob.glob(f"{LOCALES_DIR}/*.json")

    for locale_file in locale_files:
        locale_name = Path(locale_file).stem

        with open(locale_file, 'r') as f:
            locale_data = json.load(f)

        # Check for missing translations for core categories
        for category in core_categories:
            if category not in locale_data.get('categories', {}):
                issues.append({
                    "type": "missing",
                    "file": f"src/lib/locales/{locale_name}.json",
                    "message": f"Missing translation for core category '{category}'",
                    "severity": "warning"
                })

    return issues

def main():
    # Check if file exists
    if not Path(CORE_FEEDS_FILE).exists():
        print(f"ℹ️  {CORE_FEEDS_FILE} does not exist, skipping")
        return 0

    print(f"📄 Reading {CORE_FEEDS_FILE}")

    # Import the module directly
    spec = importlib.util.spec_from_file_location("core_feeds", CORE_FEEDS_FILE)
    core_feeds_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(core_feeds_module)

    if not hasattr(core_feeds_module, 'feeds'):
        print("❌ Could not find 'feeds' dictionary in core_feeds.py")
        return 1

    feeds_dict = core_feeds_module.feeds

    # Sort categories and deduplicate feeds
    sorted_feeds = {}
    total_dupes = 0

    for category in sorted(feeds_dict.keys()):
        original_feeds = feeds_dict[category]
        unique_feeds = sorted(list(set(original_feeds)))

        # Find the actual duplicates
        seen = set()
        duplicates = []
        for feed in original_feeds:
            if feed in seen:
                duplicates.append(feed)
            else:
                seen.add(feed)

        dupes = len(duplicates)

        if dupes > 0:
            print(f"  🔹 {category}: removed {dupes} duplicate(s)")
            for dup in duplicates:
                print(f"      - {dup}")
            total_dupes += dupes

        sorted_feeds[category] = unique_feeds

    # Generate the Python file content
    output_lines = ['feeds = {']
    categories = sorted(sorted_feeds.keys())

    for i, category in enumerate(categories):
        output_lines.append(f'    "{category}": [')
        for feed in sorted_feeds[category]:
            output_lines.append(f'        "{feed}",')
        output_lines.append('    ]' + (',' if i < len(categories) - 1 else ''))

    output_lines.append('}')
    new_content = '\n'.join(output_lines) + '\n'

    # Read original content for comparison
    with open(CORE_FEEDS_FILE, 'r') as f:
        original_content = f.read()

    # Check if content changed
    if new_content != original_content:
        with open(CORE_FEEDS_FILE, 'w') as f:
            f.write(new_content)
        print(f"✅ {CORE_FEEDS_FILE} sorted. Categories: {len(sorted_feeds)}, Duplicates removed: {total_dupes}")
    else:
        print("ℹ️  No changes – core feeds already sorted")

    # If --output-issues flag is present, generate issues file
    if '--output-issues' in sys.argv:
        output_file = None
        if '--output-file' in sys.argv:
            idx = sys.argv.index('--output-file')
            if idx + 1 < len(sys.argv):
                output_file = sys.argv[idx + 1]

        # Analyze for translation issues
        issues = analyze_translations()

        # Count issues by type
        by_type = {}
        for issue in issues:
            issue_type = issue['type']
            by_type[issue_type] = by_type.get(issue_type, 0) + 1

        from datetime import datetime, timezone
        issues_output = {
            "type": "feeds",
            "issues": issues,
            "summary": {
                "totalIssues": len(issues),
                "byType": by_type
            },
            "timestamp": datetime.now(timezone.utc).isoformat()
        }

        if output_file:
            with open(output_file, 'w') as f:
                json.dump(issues_output, f, indent=2)
        else:
            print(json.dumps(issues_output, indent=2))

    return 0

if __name__ == '__main__':
    sys.exit(main())
```

## /scripts/sort-feeds.ts

```ts path="/scripts/sort-feeds.ts" 
import fs from 'node:fs';
import { runScript } from './shared-runner.js';
import type { ScriptResult } from './shared-runner.js';
import { analyzeTranslations, generateTranslationIssues } from './translation-analyzer.js';
import { logProcessing, logSuccess, logNoChange, logBullet } from './console-utils.js';

const FEEDS_FILE = 'kite_feeds.json';

type CategoryType = 'country' | 'region' | 'city' | 'topic' | string;

interface CategoryData {
  category_type?: CategoryType;
  source_language?: string;
  display_names?: Record<string, string>;
  feeds: string[];
  [k: string]: unknown;
}

interface FeedsJson {
  [categoryName: string]: CategoryData;
}

// Desired order of types
const CATEGORY_TYPE_ORDER: CategoryType[] = ['country', 'region', 'city', 'topic'];

function getCategoryType(cat: CategoryData): CategoryType {
  return cat.category_type ?? 'topic';
}

function typeRank(type: CategoryType): number {
  const idx = CATEGORY_TYPE_ORDER.indexOf(type);
  return idx === -1 ? CATEGORY_TYPE_ORDER.length : idx;
}

function sortCategories(data: FeedsJson): [string, CategoryData][] {
  return Object.entries(data).sort(([aName, aData], [bName, bData]) => {
    const rankDiff = typeRank(getCategoryType(aData)) - typeRank(getCategoryType(bData));
    if (rankDiff !== 0) return rankDiff;
    return aName.localeCompare(bName);
  });
}

function dedupeAndSort(urls: string[]): string[] {
  return [...new Set(urls)].sort((a, b) => a.localeCompare(b));
}



function main() {
  logProcessing(`Reading ${FEEDS_FILE}`);
  const raw = fs.readFileSync(FEEDS_FILE, 'utf-8');
  const json: FeedsJson = JSON.parse(raw);

  const out: FeedsJson = {};
  let totalDupes = 0;

  const sortedCats = sortCategories(json);
  for (const [name, cat] of sortedCats) {
    const original = cat.feeds.length;
    const feeds = dedupeAndSort(cat.feeds);
    const dupes = original - feeds.length;
    totalDupes += dupes;
    if (dupes) logBullet(`${name}: removed ${dupes} duplicate feed(s)`);
    out[name] = { ...cat, feeds };
  }

  const newRaw = `${JSON.stringify(out, null, 2)}\n`;
  if (newRaw !== raw) {
    fs.writeFileSync(FEEDS_FILE, newRaw, 'utf-8');
    logSuccess(`${FEEDS_FILE} sorted. Duplicates removed: ${totalDupes}`);
  } else {
    logNoChange('No changes – already sorted');
  }
}

// This function is now handled by generateTranslationIssues in translation-analyzer.ts

async function analyzeFeeds(): Promise<ScriptResult> {
  const translationIssues = await analyzeTranslations();
  const issues = generateTranslationIssues(translationIssues);
  
  const totalMissing = Object.values(translationIssues.missingTranslations).flat().length;
  const totalOrphaned = Object.values(translationIssues.orphanedTranslations).flat().length;
  const totalDuplicates = Object.values(translationIssues.duplicateKeys).flat().length;
  
  return {
    issues,
    summary: {
      totalIssues: issues.length,
      byType: {
        unused: translationIssues.unusedKeys.length,
        missing: totalMissing,
        orphaned: totalOrphaned,
        duplicate: totalDuplicates
      }
    }
  };
}

// Script entry point
if (import.meta.url === `file://${process.argv[1]}`) {
  await runScript(
    { type: 'feeds', name: 'Translation' },
    main,
    analyzeFeeds
  );
} 
```

## /scripts/sort-locales.ts

```ts path="/scripts/sort-locales.ts" 
import fs from 'node:fs';
import path from 'node:path';
import { runScript } from './shared-runner.js';
import type { ScriptResult } from './shared-runner.js';
import { 
  detectDuplicateKeys, 
  removeDuplicateKeys,
  getLocaleAnalyses,
} from './translation-analyzer.js';
import type { LocaleAnalysis } from './translation-analyzer.js';
import type { Issue } from './shared-types.js';
import { findKeyRange, findLineNumber } from './shared-types.js';
import { logSuccess, logWarning } from './console-utils.js';

const LOCALE_DIR = 'src/lib/locales';
const BASE = 'en.json';

// Desired order of key categories (prefixes)
const KEY_CATEGORY_ORDER = [
  'auth',
  'user', 
  'profile',
  'settings',
  'nav',
  'menu',
  'button',
  'form',
  'error',
  'success',
  'warning',
  'info',
  'common',
  'misc'
];

function getKeyCategory(key: string): string {
  const parts = key.split('.');
  return parts[0] || 'misc';
}

function getCategoryRank(category: string): number {
  const idx = KEY_CATEGORY_ORDER.indexOf(category);
  return idx === -1 ? KEY_CATEGORY_ORDER.length : idx;
}

function sortObject<T extends Record<string, unknown>>(obj: T): T {
  return Object.keys(obj)
    .sort((a, b) => {
      const categoryA = getKeyCategory(a);
      const categoryB = getKeyCategory(b);
      
      // First sort by category rank
      const rankDiff = getCategoryRank(categoryA) - getCategoryRank(categoryB);
      if (rankDiff !== 0) return rankDiff;
      
      // Then sort alphabetically within category
      return a.localeCompare(b);
    })
    .reduce((acc, key) => {
      // @ts-expect-error index
      acc[key] = obj[key];
      return acc;
    }, {} as T);
}

// These functions are now in translation-analyzer.ts

function generateLocaleIssues(analyses: LocaleAnalysis[]): Issue[] {
  const issues: Issue[] = [];
  
  for (const analysis of analyses) {
    const filePath = path.join(LOCALE_DIR, analysis.file);
    const content = fs.readFileSync(filePath, 'utf-8');
    
    // Issues for missing keys
    for (const missingKey of analysis.missing) {
      issues.push({
        type: 'missing',
        file: `src/lib/locales/${analysis.file}`,
        line: 1, // Add at beginning
        message: `Missing translation key '${missingKey}' (exists in en.json)`,
        severity: 'warning'
      });
    }
    
    // Issues for extra/orphaned keys
    for (const extraKey of analysis.extra) {
      const range = findKeyRange(content, extraKey);
      issues.push({
        type: 'orphaned',
        file: `src/lib/locales/${analysis.file}`,
        line: range.start,
        endLine: range.end,
        message: `Orphaned translation key '${extraKey}' (not in en.json)`,
        suggestion: '', // Remove the key
        severity: 'warning'
      });
    }
    
    // Issues for duplicate keys
    for (const duplicateKey of analysis.duplicates) {
      const lineNumber = findLineNumber(content, duplicateKey);
      issues.push({
        type: 'duplicate',
        file: `src/lib/locales/${analysis.file}`,
        line: lineNumber,
        message: `Duplicate translation key '${duplicateKey}'`,
        severity: 'error'
      });
    }
  }
  
  return issues;
}

function main(): LocaleAnalysis[] {
  const basePath = path.join(LOCALE_DIR, BASE);
  if (!fs.existsSync(basePath)) {
    console.error('Base locale en.json not found');
    return [];
  }
  const baseKeys = Object.keys(JSON.parse(fs.readFileSync(basePath, 'utf-8')));
  const analyses: LocaleAnalysis[] = [];

  const files = fs.readdirSync(LOCALE_DIR).filter((f) => f.endsWith('.json'));
  for (const file of files) {
    const full = path.join(LOCALE_DIR, file);
    let raw = fs.readFileSync(full, 'utf-8');
    
    // Check for and remove duplicate keys
    const duplicates = detectDuplicateKeys(raw);
    if (duplicates.length > 0) {
      logWarning(`${file}: found duplicate keys -> ${duplicates.join(', ')}`);
      raw = removeDuplicateKeys(raw);
      console.log(`${file}: removed duplicate keys`);
    }
    
    const data = JSON.parse(raw);

    // collect diagnostics
    const keys = Object.keys(data);
    const missing = baseKeys.filter((k) => !keys.includes(k));
    const extra = keys.filter((k) => !baseKeys.includes(k));
    
    analyses.push({ file, missing, extra, duplicates });
    
    if (missing.length) {
      logWarning(`${file}: missing keys -> ${missing.slice(0,5).join(', ')}${missing.length>5?'…':''}`);
    }
    if (extra.length) {
      logWarning(`${file}: extra keys -> ${extra.slice(0,5).join(', ')}${extra.length>5?'…':''}`);
    }

    const sorted = sortObject(data);
    const newRaw = `${JSON.stringify(sorted, null, 2)}\n`;
    if (newRaw !== raw) {
      fs.writeFileSync(full, newRaw, 'utf-8');
      logSuccess(`${file} sorted`);
    }
  }
  
  return analyses;
}

function analyzeLocales(): ScriptResult {
  // Get analyses without running main() again (it was already run)
  const analyses = getLocaleAnalyses();
  const issues = generateLocaleIssues(analyses);
  
  const totalMissing = analyses.reduce((sum, a) => sum + a.missing.length, 0);
  const totalExtra = analyses.reduce((sum, a) => sum + a.extra.length, 0);
  const totalDuplicates = analyses.reduce((sum, a) => sum + a.duplicates.length, 0);
  
  return {
    issues,
    summary: {
      totalIssues: issues.length,
      byType: {
        missing: totalMissing,
        orphaned: totalExtra,
        duplicate: totalDuplicates
      }
    }
  };
}

// Script entry point
if (import.meta.url === `file://${process.argv[1]}`) {
  await runScript(
    { type: 'locales', name: 'Locale' },
    () => { main(); }, // Wrap main to match signature
    analyzeLocales
  );
} 
```

## /scripts/sort-media.ts

```ts path="/scripts/sort-media.ts" 
import fs from 'node:fs';
import { runScript } from './shared-runner.js';
import type { ScriptResult } from './shared-runner.js';
import type { Issue } from './shared-types.js';
import { findLineNumber } from './shared-types.js';
import { logProcessing, logSuccess, logNoChange, logAnalysis } from './console-utils.js';

const FILE = 'media_data.json';

interface Entry {
  country: string;
  organization: string;
  domains: string[];
  description: string;
  owner: string;
  typology: string;
  [k: string]: unknown;
}

function sortAndUnique(arr: string[]) {
  return [...new Set(arr.map((d) => d.toLowerCase()))].sort();
}

async function findUnusedMediaDomains(data: Entry[]): Promise<string[]> {
  logAnalysis('Checking for unused media domains...');
  
  // Get all domains from media data
  const allDomains = new Set<string>();
  for (const entry of data) {
    for (const domain of entry.domains) {
      allDomains.add(domain.toLowerCase());
    }
  }
  
  // Read kite_feeds.json to check which domains are actually used in feeds
  const FEEDS_FILE = 'kite_feeds.json';
  if (!fs.existsSync(FEEDS_FILE)) {
    console.log('⚠️  kite_feeds.json not found, skipping unused domain check');
    return [];
  }
  
  const feedsContent = fs.readFileSync(FEEDS_FILE, 'utf-8');
  
  // Check which domains are not referenced in feeds
  const unusedDomains: string[] = [];
  for (const domain of allDomains) {
    // Look for domain references (with or without protocol) in feeds
    const domainRegex = new RegExp(`(https?://)?${domain.replace(/[.*+?^${}()|[\]\\]/g, '\\{{contextString}}amp;')}`, 'gi');
    if (!domainRegex.test(feedsContent)) {
      unusedDomains.push(domain);
    }
  }
  
  return unusedDomains;
}

function findDuplicateOrganizations(data: Entry[]): Entry[] {
  const seen = new Map<string, Entry>();
  const duplicates: Entry[] = [];
  
  for (const entry of data) {
    const key = `${entry.country}-${entry.organization}`;
    if (seen.has(key)) {
      duplicates.push(entry);
    } else {
      seen.set(key, entry);
    }
  }
  
  return duplicates;
}

async function generateMediaIssues(data: Entry[]): Promise<Issue[]> {
  const issues: Issue[] = [];
  const rawContent = fs.readFileSync(FILE, 'utf-8');
  
  // Find unused domains
  const unusedDomains = await findUnusedMediaDomains(data);
  for (const domain of unusedDomains) {
    const lineNumber = findLineNumber(rawContent, domain);
    issues.push({
      type: 'unused',
      file: FILE,
      line: lineNumber,
      message: `Media domain '${domain}' is not referenced in any feeds (kite_feeds.json)`,
      severity: 'info'
    });
  }
  
  // Find duplicate organizations
  const duplicates = findDuplicateOrganizations(data);
  for (const duplicate of duplicates) {
    const lineNumber = findLineNumber(rawContent, duplicate.organization);
    issues.push({
      type: 'duplicate',
      file: FILE,
      line: lineNumber,
      message: `Duplicate organization '${duplicate.organization}' in country '${duplicate.country}'`,
      severity: 'warning'
    });
  }
  
  return issues;
}

function main(): Entry[] {
  const raw = fs.readFileSync(FILE, 'utf8');
  const data: Entry[] = JSON.parse(raw);

  // process entries
  const processed = data.map((e) => ({
    ...e,
    domains: sortAndUnique(e.domains || []),
  }));

  processed.sort((a, b) => {
    // First sort by typology (category)
    const t = a.typology.localeCompare(b.typology);
    if (t !== 0) return t;
    
    // Then by country
    const c = a.country.localeCompare(b.country);
    if (c !== 0) return c;
    
    // Finally by organization
    return a.organization.localeCompare(b.organization);
  });

  const newRaw = `${JSON.stringify(processed, null, 2)}\n`;
  if (newRaw !== raw) {
    fs.writeFileSync(FILE, newRaw, 'utf8');
    logSuccess('media_data.json sorted and deduplicated');
  } else {
    logNoChange('media_data.json already sorted');
  }
  
  return processed;
}

async function analyzeMedia(): Promise<ScriptResult> {
  const processedData = main();
  const issues = await generateMediaIssues(processedData);
  
  return {
    issues,
    summary: {
      totalIssues: issues.length,
      byType: {
        unused: issues.filter(i => i.type === 'unused').length,
        duplicate: issues.filter(i => i.type === 'duplicate').length
      }
    }
  };
}

// Script entry point
if (import.meta.url === `file://${process.argv[1]}`) {
  await runScript(
    { type: 'media', name: 'Media' },
    () => { main(); }, // Wrap main to match signature
    analyzeMedia
  );
} 
```

## /scripts/translation-analyzer.ts

```ts path="/scripts/translation-analyzer.ts" 
import fs from 'node:fs';
import path from 'node:path';
import type { Issue } from './shared-types.js';
import { findLineNumber, findKeyRange } from './shared-types.js';

const LOCALES_DIR = 'src/lib/locales';
const BASE_LOCALE = 'en.json';

export interface LocaleEntry {
  text: string;
  translationContext?: string;
}

export interface LocaleData {
  [key: string]: LocaleEntry | string;
}

export interface TranslationIssues {
  unusedKeys: string[];
  missingTranslations: { [language: string]: string[] };
  orphanedTranslations: { [language: string]: string[] };
  duplicateKeys: { [language: string]: string[] };
}

export interface LocaleAnalysis {
  file: string;
  missing: string[];
  extra: string[];
  duplicates: string[];
}

/**
 * Comprehensive translation analysis that finds all types of translation issues
 */
export async function analyzeTranslations(): Promise<TranslationIssues> {
  console.log('🔍 Analyzing translation files...');
  
  const enLocalePath = path.join(LOCALES_DIR, BASE_LOCALE);
  if (!fs.existsSync(enLocalePath)) {
    console.log('⚠️  English locale file not found, skipping translation analysis');
    return { 
      unusedKeys: [], 
      missingTranslations: {}, 
      orphanedTranslations: {},
      duplicateKeys: {}
    };
  }
  
  const enLocale: LocaleData = JSON.parse(fs.readFileSync(enLocalePath, 'utf-8'));
  const enKeys = new Set(Object.keys(enLocale));
  
  // Find unused keys by checking source code
  const unusedKeys = await findUnusedKeys(enKeys);
  
  // Analyze all locale files
  const localeFiles = fs.readdirSync(LOCALES_DIR)
    .filter(file => file.endsWith('.json') && file !== 'index.ts');
  
  const missingTranslations: { [language: string]: string[] } = {};
  const orphanedTranslations: { [language: string]: string[] } = {};
  const duplicateKeys: { [language: string]: string[] } = {};
  
  for (const localeFile of localeFiles) {
    const language = localeFile.replace('.json', '');
    const localePath = path.join(LOCALES_DIR, localeFile);
    const rawContent = fs.readFileSync(localePath, 'utf-8');
    
    // Check for duplicate keys
    const duplicates = detectDuplicateKeys(rawContent);
    if (duplicates.length > 0) {
      duplicateKeys[language] = duplicates;
    }
    
    // Skip further analysis for files with duplicates (they can't be parsed reliably)
    if (duplicates.length > 0) continue;
    
    const localeData: LocaleData = JSON.parse(rawContent);
    const localeKeys = new Set(Object.keys(localeData));
    
    // Find missing translations (in English but not in this language)
    if (language !== 'en') {
      const missing = Array.from(enKeys).filter(key => !localeKeys.has(key));
      if (missing.length > 0) {
        missingTranslations[language] = missing;
      }
      
      // Find orphaned translations (in this language but not in English)
      const orphaned = Array.from(localeKeys).filter(key => !enKeys.has(key));
      if (orphaned.length > 0) {
        orphanedTranslations[language] = orphaned;
      }
    }
  }
  
  return { unusedKeys, missingTranslations, orphanedTranslations, duplicateKeys };
}

/**
 * Find translation keys that are not used in the source code
 */
async function findUnusedKeys(enKeys: Set<string>): Promise<string[]> {
  function collectSourceFiles(rootDir: string): string[] {
    const results: string[] = [];
    const stack: string[] = [rootDir];
    while (stack.length > 0) {
      const dir = stack.pop() as string;
      const entries = fs.readdirSync(dir, { withFileTypes: true });
      for (const entry of entries) {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory()) {
          if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
          stack.push(full);
        } else if (entry.isFile()) {
          if (/\.(svelte|ts|js)$/.test(entry.name)) results.push(full);
        }
      }
    }
    return results;
  }

  const sourceFiles = collectSourceFiles(path.join('src'));
  const sourceContents = sourceFiles.map((file) => fs.readFileSync(file, 'utf-8')).join('\n');
  
  const unusedKeys: string[] = [];
  for (const key of enKeys) {
    // Look for translation function calls: s('key'), s("key"), s(`key`)
    const keyRegex = new RegExp(`s\\s*\\(\\s*['"\`]${key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\{{contextString}}amp;')}['"\`]\\s*[,)]`, 'g');
    if (!keyRegex.test(sourceContents)) {
      unusedKeys.push(key);
    }
  }
  
  return unusedKeys;
}

/**
 * Detect duplicate keys in a JSON file by parsing the raw content
 */
export function detectDuplicateKeys(raw: string): string[] {
  const duplicates: string[] = [];
  const keyPattern = /"([^"]+)":\s*\{/g;
  const keys: string[] = [];
  let match: RegExpExecArray | null = keyPattern.exec(raw);
  
  while (match !== null) {
    const key = match[1];
    if (keys.includes(key)) {
      if (!duplicates.includes(key)) {
        duplicates.push(key);
      }
    } else {
      keys.push(key);
    }
    match = keyPattern.exec(raw);
  }
  
  return duplicates;
}

/**
 * Remove duplicate keys from JSON content, keeping only the last occurrence
 */
export function removeDuplicateKeys(raw: string): string {
  const duplicates = detectDuplicateKeys(raw);
  if (duplicates.length === 0) {
    return raw;
  }
  
  let result = raw;
  
  for (const duplicateKey of duplicates) {
    const escapedKey = duplicateKey.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\{{contextString}}amp;');
    const keyPattern = new RegExp(
      `"${escapedKey}":\\s*{(?:[^{}]*(?:{[^{}]*}[^{}]*)*)*}(?=\\s*[,}])`,
      'g'
    );
    
    const matches = [...result.matchAll(keyPattern)];
    
    if (matches.length > 1) {
      // Sort matches by position (reverse order to avoid index shifting)
      matches.sort((a, b) => ( (b.index ?? 0) - (a.index ?? 0) ));
      
      // Remove all but the last occurrence
      for (let i = 1; i < matches.length; i++) {
        const m = matches[i];
        if (m.index !== undefined) {
          let startIndex = m.index;
          let endIndex = m.index + m[0].length;
          
          // Handle comma removal
          const beforeComma = result.slice(Math.max(0, startIndex - 10), startIndex).match(/,\s*$/);
          const afterComma = result.slice(endIndex, endIndex + 10).match(/^\s*,/);
          
          if (beforeComma && !afterComma) {
            startIndex -= beforeComma[0].length;
          } else if (afterComma) {
            endIndex += afterComma[0].length;
          }
          
          result = result.slice(0, startIndex) + result.slice(endIndex);
        }
      }
    }
  }
  
  return result;
}

/**
 * Generate Issue objects for translation problems
 */
export function generateTranslationIssues(issues: TranslationIssues): Issue[] {
  const result: Issue[] = [];
  const enLocalePath = path.join(LOCALES_DIR, BASE_LOCALE);
  
  // Issues for unused keys
  if (fs.existsSync(enLocalePath)) {
    const enContent = fs.readFileSync(enLocalePath, 'utf-8');
    for (const key of issues.unusedKeys) {
      const range = findKeyRange(enContent, key);
      result.push({
        type: 'unused',
        file: 'src/lib/locales/en.json',
        line: range.start,
        endLine: range.end,
        message: `Translation key '${key}' appears to be unused in the codebase`,
        suggestion: '', // Remove the entire key block
        severity: 'info'
      });
    }
  }
  
  // Issues for missing translations
  for (const [language, missingKeys] of Object.entries(issues.missingTranslations)) {
    const localeFile = `src/lib/locales/${language}.json`;
    for (const key of missingKeys) {
      result.push({
        type: 'missing',
        file: localeFile,
        line: 1, // Add at the beginning
        message: `Missing translation for key '${key}'`,
        severity: 'warning'
      });
    }
  }
  
  // Issues for orphaned translations
  for (const [language, orphanedKeys] of Object.entries(issues.orphanedTranslations)) {
    const localeFile = `src/lib/locales/${language}.json`;
    const localeFilePath = path.join(LOCALES_DIR, `${language}.json`);
    
    if (fs.existsSync(localeFilePath)) {
      const localeContent = fs.readFileSync(localeFilePath, 'utf-8');
      for (const key of orphanedKeys) {
        const range = findKeyRange(localeContent, key);
        result.push({
          type: 'orphaned',
          file: localeFile,
          line: range.start,
          endLine: range.end,
          message: `Translation key '${key}' exists in ${language}.json but not in en.json (master)`,
          suggestion: '', // Remove the orphaned key
          severity: 'warning'
        });
      }
    }
  }
  
  // Issues for duplicate keys
  for (const [language, duplicateKeys] of Object.entries(issues.duplicateKeys)) {
    const localeFile = `src/lib/locales/${language}.json`;
    const localeFilePath = path.join(LOCALES_DIR, `${language}.json`);
    
    if (fs.existsSync(localeFilePath)) {
      const localeContent = fs.readFileSync(localeFilePath, 'utf-8');
      for (const key of duplicateKeys) {
        const lineNumber = findLineNumber(localeContent, key);
        result.push({
          type: 'duplicate',
          file: localeFile,
          line: lineNumber,
          message: `Duplicate translation key '${key}'`,
          severity: 'error'
        });
      }
    }
  }
  
  return result;
}

/**
 * Get locale analysis for individual files (used by sort-locales.ts)
 */
export function getLocaleAnalyses(): LocaleAnalysis[] {
  const basePath = path.join(LOCALES_DIR, BASE_LOCALE);
  if (!fs.existsSync(basePath)) {
    console.error('Base locale en.json not found');
    return [];
  }
  
  const baseKeys = Object.keys(JSON.parse(fs.readFileSync(basePath, 'utf-8')));
  const analyses: LocaleAnalysis[] = [];

  const files = fs.readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.json'));
  for (const file of files) {
    const full = path.join(LOCALES_DIR, file);
    const raw = fs.readFileSync(full, 'utf-8');
    
    // Check for duplicate keys
    const duplicates = detectDuplicateKeys(raw);
    
    // Skip parsing if there are duplicates
    if (duplicates.length > 0) {
      analyses.push({ file, missing: [], extra: [], duplicates });
      continue;
    }
    
    const data = JSON.parse(raw);
    const keys = Object.keys(data);
    const missing = baseKeys.filter((k) => !keys.includes(k));
    const extra = keys.filter((k) => !baseKeys.includes(k));
    
    analyses.push({ file, missing, extra, duplicates });
  }
  
  return analyses;
}
```

## /scripts/validate-feeds.ts

```ts path="/scripts/validate-feeds.ts" 
import { readFile } from 'node:fs/promises';

// Simple concurrency limiter
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
	const ret: R[] = [];
	const executing: Promise<void>[] = [];
	for (const item of items) {
		const p = fn(item).then((v) => {
			ret.push(v);
		});
		executing.push(p);
		if (executing.length >= limit) {
			await Promise.race(executing);
			executing.splice(executing.findIndex((e) => e === p), 1);
		}
	}
	await Promise.all(executing);
	return ret;
}

type IssueType =
	| 'duplicate'
	| 'network'
	| 'invalid-content-type'
	| 'not-rss'
	| 'banned-host'
	| 'thin-content'
	| 'no-items'
	| 'stale';

interface ValidationIssue {
	url: string;
	message: string;
	severity: 'error' | 'warning' | 'info';
	suggestRemoval?: boolean;
	type: IssueType;
}

interface DuplicateIssue extends ValidationIssue {
	category: string;
}

function stripHtml(html: string): string {
	return html
		.replace(/<script[\s\S]*?<\/script>/gi, '')
		.replace(/<style[\s\S]*?<\/style>/gi, '')
		.replace(/<[^>]+>/g, ' ')
		.replace(/&nbsp;/g, ' ')
		.replace(/&amp;/g, '&')
		.replace(/&lt;/g, '<')
		.replace(/&gt;/g, '>')
		.replace(/\s+/g, ' ')
		.trim();
}

function parseRssLikeItems(xml: string): { title: string; content: string; pubDate?: Date }[] {
	const items: { title: string; content: string; pubDate?: Date }[] = [];

	// RSS 2.0 items
	const rssItemRegex = /<item[\s\S]*?<\/item>/gi;
	const rssItems = xml.match(rssItemRegex) || [];
	for (const block of rssItems) {
		const title = (block.match(/<title[\s\S]*?>([\s\S]*?)<\/title>/i)?.[1] || '').trim();
		const contentEncoded = block.match(/<content:encoded[\s\S]*?>([\s\S]*?)<\/content:encoded>/i)?.[1] || '';
		const description = block.match(/<description[\s\S]*?>([\s\S]*?)<\/description>/i)?.[1] || '';
		const text = block.match(/<text[\s\S]*?>([\s\S]*?)<\/text>/i)?.[1] || '';
		const raw = contentEncoded || description || text;
		const content = stripHtml(raw);
		const pubStr = block.match(/<pubDate[\s\S]*?>([\s\S]*?)<\/pubDate>/i)?.[1]?.trim() ?? '';
		const pubDate = pubStr ? new Date(pubStr as string) : undefined;
		items.push({ title: stripHtml(title), content, pubDate });
	}

	// Atom entries
	if (items.length === 0) {
		const atomEntryRegex = /<entry[\s\S]*?<\/entry>/gi;
		const atomEntries = xml.match(atomEntryRegex) || [];
		for (const block of atomEntries) {
			const title = (block.match(/<title[\s\S]*?>([\s\S]*?)<\/title>/i)?.[1] || '').trim();
			const contentTag = block.match(/<content[\s\S]*?>([\s\S]*?)<\/content>/i)?.[1] || '';
			const summaryTag = block.match(/<summary[\s\S]*?>([\s\S]*?)<\/summary>/i)?.[1] || '';
			const raw = contentTag || summaryTag;
			const content = stripHtml(raw);
			const updated = block.match(/<updated[\s\S]*?>([\s\S]*?)<\/updated>/i)?.[1]?.trim() ?? '';
			const published = block.match(/<published[\s\S]*?>([\s\S]*?)<\/published>/i)?.[1]?.trim() ?? '';
			const dateStr = updated || published;
			const pubDate = dateStr ? new Date(dateStr as string) : undefined;
			items.push({ title: stripHtml(title), content, pubDate });
		}
	}

	return items;
}

function isThinContent(items: { title: string; content: string }[], sampleSize = 5): boolean {
	const sample = items.slice(0, sampleSize);
	if (sample.length === 0) return true;
	let thinCount = 0;
	for (const it of sample) {
		const content = (it.content || '').trim();
		const title = (it.title || '').trim();
		const contentNoUrl = content.replace(/https?:\/\/\S+/g, '').trim();
		const onlyUrl = content && !contentNoUrl;
		const veryShort = contentNoUrl.length < 40;
		const similarToTitle = contentNoUrl.length > 0 && title.length > 0 && Math.abs(contentNoUrl.length - title.length) < 10;
		if (onlyUrl || veryShort || similarToTitle) thinCount++;
	}
	return thinCount >= Math.ceil(sample.length * 0.8);
}

function maxItemDate(items: { pubDate?: Date }[]): Date | undefined {
	let max: Date | undefined;
	for (const it of items) {
		if (it.pubDate && !Number.isNaN(it.pubDate.getTime())) {
			if (!max || it.pubDate > max) max = it.pubDate;
		}
	}
	return max;
}

function isBannedHost(hostname: string): boolean {
	const h = hostname.toLowerCase();
	if (h === 'rsshub.app') return true;
	if (h === 'nitter.net') return true;
	return false;
}

async function checkUrl(url: string, timeoutMs = 15000): Promise<ValidationIssue[]> {
	const issues: ValidationIssue[] = [];
	try {
		const host = new URL(url).hostname;
		if (isBannedHost(host)) {
			issues.push({ url, message: `Banned/unsupported host: ${host}`, severity: 'warning', suggestRemoval: false, type: 'banned-host' });
		}
	} catch {
		// ignore URL parse errors here
	}

	try {
		const ctrl = new AbortController();
		const id = setTimeout(() => ctrl.abort(), timeoutMs);
		const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: ctrl.signal, headers: { 'User-Agent': 'kite-feed-validator/1.0 (+https://kagi.com)' } });
		clearTimeout(id);
		if (!res.ok) {
			issues.push({ url, message: `HTTP ${res.status}`, severity: 'error', suggestRemoval: true, type: 'network' });
			return issues;
		}
		const ct = res.headers.get('content-type') || '';
		if (!ct.includes('xml') && !ct.includes('rss') && !ct.includes('atom') && !ct.includes('+xml')) {
			issues.push({ url, message: `Unexpected content-type: ${ct}`, severity: 'error', suggestRemoval: true, type: 'invalid-content-type' });
			return issues;
		}
		const text = await res.text();
		if (!text.includes('<rss') && !text.includes('<feed') && !text.includes('<entry') && !text.includes('<item')) {
			issues.push({ url, message: 'Response does not appear to be RSS/Atom XML', severity: 'error', suggestRemoval: true, type: 'not-rss' });
			return issues;
		}

		const items = parseRssLikeItems(text);
		if (items.length === 0) {
			issues.push({ url, message: 'Feed has no items', severity: 'warning', suggestRemoval: false, type: 'no-items' });
			return issues;
		}

		if (isThinContent(items)) {
			issues.push({ url, message: 'Feed items appear to be headline/link-only (very thin content)', severity: 'warning', suggestRemoval: false, type: 'thin-content' });
		}

		const newest = maxItemDate(items);
		if (!newest) {
			issues.push({ url, message: 'Unable to determine item dates (may be stale)', severity: 'info', suggestRemoval: false, type: 'stale' });
		} else {
			const days = (Date.now() - newest.getTime()) / (1000 * 60 * 60 * 24);
			if (days > 90) {
				issues.push({ url, message: `Latest item is ${Math.floor(days)} days old (stale feed)`, severity: 'warning', suggestRemoval: false, type: 'stale' });
			}
		}

		return issues;
	} catch (err: unknown) {
		if (err && typeof err === 'object' && 'name' in err && (err as { name?: string }).name === 'AbortError') {
			return [{ url, message: 'Request timed out', severity: 'error', suggestRemoval: true, type: 'network' }];
		}
		const message = err instanceof Error ? err.message : String(err);
		return [{ url, message: `Fetch error: ${message}`, severity: 'error', suggestRemoval: true, type: 'network' }];
	}
}

async function main() {
	const dataRaw = await readFile('kite_feeds.json', 'utf-8');
	const data = JSON.parse(dataRaw) as Record<string, { feeds?: string[] }>;

	let duplicatesFound = false;

	// Gather feeds and check duplicates per category
	const allFeeds: string[] = [];
	const duplicateDiagnostics: DuplicateIssue[] = [];

	for (const [categoryName, category] of Object.entries(data)) {
		if (!Array.isArray(category?.feeds)) continue;
		const seen = new Set<string>();
		for (const feed of category.feeds) {
			if (seen.has(feed)) {
				duplicatesFound = true;
				duplicateDiagnostics.push({ url: feed, message: `Duplicate feed within category ${categoryName}`, category: categoryName, severity: 'warning', suggestRemoval: false, type: 'duplicate' });
			} else {
				seen.add(feed);
			}
			allFeeds.push(feed);
		}
	}

	// Determine feeds added in this PR (or last commit on push) and focus validation on them
	let addedUrls: string[] = [];
	try {
		let diffCmd = 'git diff -U0 --no-color ';
		if (process.env.GITHUB_EVENT_NAME === 'pull_request' && process.env.GITHUB_BASE_REF) {
			diffCmd += `${process.env.GITHUB_BASE_REF}...HEAD`;
		} else {
			diffCmd += 'HEAD~1';
		}
		diffCmd += ' -- kite_feeds.json';

		const { execSync } = await import('node:child_process');
		const out = execSync(diffCmd, { encoding: 'utf8' });
		addedUrls = out
			.split('\n')
			.filter((l) => l.startsWith('+') && l.includes('http'))
			.map((l) => l.replace(/^\+\s*"?,?/, '').replace(/",?$/, '').trim())
			.filter(Boolean);
	} catch {}

	const feedsToCheck = addedUrls.length > 0 ? addedUrls : allFeeds;
	const issues = await mapLimit(feedsToCheck, 10, async (url) => (await checkUrl(url)));

	const flattened = issues.flat();

	const filteredDuplicates = addedUrls.length > 0 ? duplicateDiagnostics.filter((d) => addedUrls.includes(d.url)) : duplicateDiagnostics;
	const diagnostics: ValidationIssue[] = [
		...filteredDuplicates,
		...flattened,
	];
	// Build ordered unified diff with grouped deletions and context to avoid misordered hunks
	const fileLines = dataRaw.split('\n');

	function lowerBound(arr: number[], target: number): number {
		let lo = 0;
		let hi = arr.length;
		while (lo < hi) {
			const mid = (lo + hi) >> 1;
			if (arr[mid] < target) lo = mid + 1; else hi = mid;
		}
		return lo;
	}

	function buildUnifiedDeleteDiff(filePath: string, lines: string[], removalLineNumbers: number[], contextLines = 2): string {
		const uniqueSorted = Array.from(new Set(removalLineNumbers)).sort((a, b) => a - b);
		if (uniqueSorted.length === 0) return '';

		const removeSet = new Set(uniqueSorted);
		const groups: Array<{ start: number; end: number }> = [];
		let gStart = uniqueSorted[0];
		let gPrev = gStart;
		for (let i = 1; i < uniqueSorted.length; i++) {
			const cur = uniqueSorted[i];
			if (cur === gPrev + 1) {
				gPrev = cur;
			} else {
				groups.push({ start: gStart, end: gPrev });
				gStart = cur;
				gPrev = cur;
			}
		}
		groups.push({ start: gStart, end: gPrev });

		let diffOut = '';
		let prevOldContextEnd = 0;

		for (const grp of groups) {
			const pre: number[] = [];
			for (let i = grp.start - 1; i >= 1 && pre.length < contextLines && i > prevOldContextEnd; i--) {
				if (!removeSet.has(i)) pre.push(i);
			}
			pre.reverse();

			const post: number[] = [];
			for (let i = grp.end + 1; i <= lines.length && post.length < contextLines; i++) {
				if (!removeSet.has(i)) post.push(i);
			}

			const oldStart = pre.length > 0 ? pre[0] : grp.start;
			const oldCount = pre.length + (grp.end - grp.start + 1) + post.length;
			const removedBeforeOldStart = lowerBound(uniqueSorted, oldStart);
			const newStart = oldStart - removedBeforeOldStart;
			const newCount = oldCount - (grp.end - grp.start + 1);

			diffOut += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n`;
			for (const li of pre) diffOut += ` ${lines[li - 1]}\n`;
			for (let li = grp.start; li <= grp.end; li++) diffOut += `-${lines[li - 1]}\n`;
			for (const li of post) diffOut += ` ${lines[li - 1]}\n`;

			prevOldContextEnd = post.length > 0 ? post[post.length - 1] : grp.end;
		}

		return `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n${diffOut}`;
	}

	// Emit diagnostics (focus on added feeds when present) and collect line numbers to remove
	const diagnosticsToReport = addedUrls.length > 0 ? diagnostics.filter((d) => addedUrls.includes(d.url)) : diagnostics;
	const removalLineNumbers: number[] = [];
	for (const issue of diagnosticsToReport) {
		console.error(`kite_feeds.json:1:${issue.url} -> ${issue.message}`);
		if (issue.suggestRemoval) {
			const idx = fileLines.findIndex((l) => l.includes(issue.url));
			if (idx !== -1) removalLineNumbers.push(idx + 1);
		}
	}

	const diff = buildUnifiedDeleteDiff('kite_feeds.json', fileLines, removalLineNumbers, 2);
	if (diff) process.stdout.write(diff);

	// Inform if all newly added feeds look problematic
	try {
		if (addedUrls.length > 0) {
			const problematic = new Set(
				diagnostics
					.filter((d) => addedUrls.includes(d.url) && (d.severity === 'error' || d.type === 'thin-content' || d.type === 'stale' || d.type === 'no-items'))
					.map((d) => d.url)
			);
			if (problematic.size === addedUrls.length) {
				console.error('kite_feeds.json:1:All newly added feeds appear broken, thin (headline/link-only), or stale. Prefer feeds with full content and recent posts; avoid scraping services.');
			}
		}
	} catch {}

	// Only fail the run when 404 HTTP errors are present among reported diagnostics
	if (diagnosticsToReport.some((d) => d.type === 'network' && /HTTP\s+404\b/.test(d.message))) {
		process.exitCode = 1;
	}
}

main().catch((e) => {
	console.error('Unexpected error', e);
}); 
```

## /scripts/validate-media.ts

```ts path="/scripts/validate-media.ts" 
import { readFile } from 'node:fs/promises';
import { readFile as read } from 'node:fs/promises';
import { URL } from 'node:url';

interface MediaEntry {
  country?: string;
  organization?: string;
  domains?: string[];
  description?: string;
  owner?: string;
  typology?: string;
  [k: string]: unknown;
}

interface Issue {
  message: string;
  line: number;
}

async function main() {
  const raw = await readFile('media_data.json', 'utf-8');
  const entries: MediaEntry[] = JSON.parse(raw);

  const issues: Issue[] = [];
  const domainSet = new Map<string, number>();

  const currentLine = 1;
  const lineMap: number[] = [];
  for (const [idx] of raw.split(/\n/).entries()) {
    lineMap.push(idx + 1);
  }

  for (const [idx, entry] of entries.entries()) {
    const baseLine = lineMap[idx];
    const required: (keyof MediaEntry)[] = [
      'country',
      'organization',
      'domains',
      'description',
      'owner',
      'typology',
    ];
    for (const key of required) {
      if (entry[key] === undefined || entry[key] === null || (key === 'domains' && !(entry.domains?.length))) {
        issues.push({
          message: `Missing ${String(key)} for record #${idx + 1}`,
          line: baseLine,
        });
      }
    }

    for (const d of entry.domains || []) {
      const lower = d.toLowerCase();
      if (domainSet.has(lower)) {
        issues.push({ message: `Duplicate domain ${d} (record #${idx + 1})`, line: baseLine });
      } else {
        domainSet.set(lower, idx);
      }
    }
  }

  for (const issue of issues) {
    console.error(`media_data.json:${issue.line}:${issue.message}`);
  }

  // ---- compare with kite_feeds.json -----
  const mediaDomains = new Set<string>();
  for (const e of entries) {
    for (const d of e.domains || []) {
      mediaDomains.add(d.toLowerCase());
    }
  }

  // collect only feeds added in this commit/PR
  let diffCmd = 'git diff -U0 --no-color ';
  if (process.env.GITHUB_EVENT_NAME === 'pull_request' && process.env.GITHUB_BASE_REF) {
    diffCmd += `${process.env.GITHUB_BASE_REF}...HEAD`;
  } else {
    diffCmd += 'HEAD~1';
  }
  diffCmd += ' -- kite_feeds.json';

  const diff = await import('node:child_process').then((m) => m.execSync(diffCmd, { encoding: 'utf8' }));
  const addedUrls: string[] = diff
    .split('\n')
    .filter((l) => l.startsWith('+') && l.includes('http'))
    .map((l) => l.replace(/^\+\s*",?/, '').replace(/",?$/, '').trim())
    .filter(Boolean);

  const unknownDomains = new Set<string>();
  for (const url of addedUrls) {
    try {
      const host = new URL(url).hostname.toLowerCase();
      if (!mediaDomains.has(host)) unknownDomains.add(host);
    } catch {}
  }

  for (const d of unknownDomains) {
    console.error(`kite_feeds.json:1: Domain ${d} missing in media_data.json`);
  }

  // exit code 0 (advisory)
}

main().catch((e) => {
  console.error('validator crashed', e);
}); 
```

## /src/app.css

```css path="/src/app.css" 
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant blue-light (&:where(.blue-light, .blue-light *));
@custom-variant blue-dark (&:where(.blue-dark, .blue-dark *));
@custom-variant rtl (&:where([dir="rtl"], [dir="rtl"] *));
@custom-variant ltr (&:where([dir="ltr"], [dir="ltr"] *));
@import 'overlayscrollbars/styles/overlayscrollbars.css';

@theme {
	/* Font Families */
	--font-lufga: 'lufga', system-ui, sans-serif;

	/* Colors */
	--color-graphite: #18181a;
	--color-graphite-25: #fafafb;
	--color-graphite-50: #f5f5f5;
	--color-graphite-70: #f3f3f3;
	--color-graphite-100: #e6e6e8;
	--color-graphite-200: #cfcfd1;
	--color-graphite-300: #acacaf;
	--color-graphite-400: #9f9fa5;
	--color-graphite-500: #87878e;
	--color-graphite-600: #707077;
	--color-graphite-700: #5a5a60;
	--color-graphite-800: #454549;
	--color-graphite-850: #39393c;
	--color-graphite-900: #2f2f31;
	--color-graphite-950: #232325;
	--color-graphite-1000: #18181a;

	--color-test: #131533;
	--color-test-25: #f8f9fc;
	--color-test-50: #eef0f7;
	--color-test-70: #e2e4f0;
	--color-test-100: #d5d7e7;
	--color-test-200: #b8bcdb;
	--color-test-300: #9ca1cc;
	--color-test-400: #8287be;
	--color-test-600: #555b98;
	--color-test-700: #434882;
	--color-test-800: #33376c;
	--color-test-850: #2a2e5d;
	--color-test-900: #23254e;
	--color-test-950: #1a1c40;

	--color-chrome: #fbfbfe;
	--color-chrome-25: #fbfbfe;
	--color-chrome-50: #f7f7fc;
	--color-chrome-70: #f3f3fc;
	--color-chrome-100: #e8e8f0;
	--color-chrome-200: #cfcfdb;
	--color-chrome-300: #b7b7c5;
	--color-chrome-400: #9fa0af;
	--color-chrome-500: #878898;
	--color-chrome-600: #707181;
	--color-chrome-700: #5a5a6a;
	--color-chrome-800: #454553;
	--color-chrome-850: #393947;
	--color-chrome-900: #2f2f3b;
	--color-chrome-950: #23232f;
	--color-chrome-1000: #181824;

	--color-purple-300: #c9c1ff;
	--color-purple-400: #c0b8ff;
	--color-purple-500: #9185e0;
	--color-purple-600: #6c5edc;
	--color-purple-800: #4d41a4;
	--color-purple-900: #3f3399;

	--color-white: #ffffff;
	--color-yellow: #ffb319;
	--color-warning: #ff964a;
	--color-info: #4A67C7;
	--color-danger: #ff5a0a;
	--color-success: #3F8611;
	--color-calm: #edf0f5;
	--color-blue: #224fc1;

	/* Primary Colors */
	--color-primary: var(--color-graphite-1000);
	--color-primary-25: var(--color-graphite-25);
	--color-primary-50: var(--color-graphite-50);
	--color-primary-70: var(--color-graphite-70);
	--color-primary-100: var(--color-graphite-100);
	--color-primary-200: var(--color-graphite-200);
	--color-primary-300: var(--color-graphite-300);
	--color-primary-400: var(--color-graphite-400);
	--color-primary-600: var(--color-graphite-600);
	--color-primary-700: var(--color-graphite-700);
	--color-primary-800: var(--color-graphite-800);
	--color-primary-850: var(--color-graphite-850);
	--color-primary-900: var(--color-graphite-900);
	--color-primary-950: var(--color-graphite-950);

	/* Backgrounds */
	--color-modal-bg: var(--color-white);
	--color-input-bg: var(--color-white);
	--color-app-bg: var(--color-white);
	--color-tooltip-bg: var(--color-graphite-800);
	--color-dark-bg: var(--color-graphite-950);

	/* Accents */
	--color-accent-links: var(--color-purple-600);
	--color-on-accent: var(--color-graphite-25);
	--color-accent-yellow: var(--color-yellow);
	--color-on-yellow: var(--color-graphite-1000);
	--color-accent-warning: var(--color-warning);
	--color-accent-alert: var(--color-danger);
	--color-accent-info: var(--color-info);
	--color-accent-success: var(--color-success);

	/* Hovers and Overlays */
	--color-overlay: rgba(24, 24, 26, 0.4);
	--color-general-hover-states: rgba(172, 172, 175, 0.2);
	--color-assistant-hover-state: var(--color-graphite-25);

	/* Focus Ring Colors */
	--color-focus-ring: #93c5fd; /* blue-300 for light mode */
	--color-ring-focus-ring: var(--color-focus-ring); /* For Tailwind ring-focus-ring class */
    
	/* Animations */
	--animate-fade: fadeIn 0.5s ease-in-out;

	/* Global animation settings */
	--transition-speed: 0.2s;
	--transition-easing: ease-in-out;

	/* Highlight colors - light mode default */
	--highlight-color: rgba(147, 51, 234, 0.2); /* Purple with opacity for light mode */
	--selection-color: rgba(147, 51, 234, 0.2); /* Purple with opacity for light mode */

	/* Skeleton loading colors - light mode */
	--skeleton-start: #eeedf6;
	--skeleton-middle: #d8d5eb;
	--skeleton-end: #fcfbfb;

	/* Z-index scale - Tailwind v4 utilities (use z-dropdown, z-modal, etc.) */
	--z-index-base: 0;
	--z-index-dropdown: 10;
	--z-index-sticky: 20;
	--z-index-fixed: 30;
	--z-index-drawer: 40;
	--z-index-modal-backdrop: 50;
	--z-index-modal: 60;
	--z-index-popover: 70;
	--z-index-tooltip: 80;
	--z-index-notification: 90;
	--z-index-max: 100;

	@keyframes fadeIn {
		from {
			opacity: 0;
		}
		to {
			opacity: 1;
		}
	}
}

/* Dark mode overrides */
.dark {
	--highlight-color: rgba(
		168,
		85,
		247,
		0.4
	); /* Brighter purple with higher opacity for dark mode */
	--selection-color: rgba(
		168,
		85,
		247,
		0.4
	); /* Brighter purple with higher opacity for dark mode */

	/* Skeleton loading colors - dark mode */
	--skeleton-start: #3d3a4f;
	--skeleton-middle: #2a2836;
	--skeleton-end: #4a4660;

	/* Dark mode primary color overrides */
	--color-primary: var(--color-graphite-70);
	--color-primary-25: var(--color-graphite-950);
	--color-primary-50: var(--color-graphite-900);
	--color-primary-70: var(--color-graphite-850);
	--color-primary-100: var(--color-graphite-800);
	--color-primary-200: var(--color-graphite-700);
	--color-primary-300: var(--color-graphite-600);
	--color-primary-400: var(--color-graphite-400);
	--color-primary-600: var(--color-graphite-300);
	--color-primary-700: var(--color-graphite-200);
	--color-primary-800: var(--color-graphite-100);
	--color-primary-850: var(--color-graphite-70);
	--color-primary-900: var(--color-graphite-50);
	--color-primary-950: var(--color-graphite-25);

	/* Dark mode test/blue color overrides */
	--color-test: var(--color-test-70);
	--color-test-25: var(--color-test-950);
	--color-test-50: var(--color-test-900);
	--color-test-70: var(--color-test-850);
	--color-test-100: var(--color-test-800);
	--color-test-200: var(--color-test-700);
	--color-test-300: var(--color-test-600);
	--color-test-600: var(--color-test-300);
	--color-test-700: var(--color-test-200);
	--color-test-800: var(--color-test-100);
	--color-test-850: var(--color-test-70);
	--color-test-900: var(--color-test-50);
	--color-test-950: var(--color-test-25);

	/* Dark mode semantic color overrides */
	--color-modal-bg: var(--color-graphite-950);
	--color-input-bg: var(--color-graphite-900);
	--color-app-bg: var(--color-graphite-950);
	--color-tooltip-bg: var(--color-primary-200);
	--color-dark-bg: var(--color-graphite-1000);

	/* Dark mode accent color overrides */
	--color-accent-links: var(--color-purple-300);
	--color-on-accent: var(--color-primary);

	/* Dark mode focus ring color override */
	--color-focus-ring: #2563eb; /* blue-600 for dark mode */
	--color-ring-focus-ring: var(--color-focus-ring); /* For Tailwind ring-focus-ring class */
}

/* Blue light theme overrides */
.blue-light {
	--color-primary: var(--color-test);
	--color-primary-25: var(--color-test-25);
	--color-primary-50: var(--color-test-50);
	--color-primary-70: var(--color-test-70);
	--color-primary-100: var(--color-test-100);
	--color-primary-200: var(--color-test-200);
	--color-primary-300: var(--color-test-300);
	--color-primary-400: var(--color-test-400);
	--color-primary-600: var(--color-test-600);
	--color-primary-700: var(--color-test-700);
	--color-primary-800: var(--color-test-800);
	--color-primary-850: var(--color-test-850);
	--color-primary-900: var(--color-test-900);
	--color-primary-950: var(--color-test-950);

	/* Blue light semantic color overrides */
	--color-tooltip-bg: var(--color-primary-800);
	--color-dark-bg: var(--color-test-950);

	/* Blue light accent color overrides */
	--color-on-accent: var(--color-primary-25);
	--color-accent-info: var(--color-blue);

	/* Blue light hovers and overlays overrides */
	--color-overlay: rgba(19, 21, 51, 0.4);
	--color-assistant-hover-state: var(--color-primary-25);
}

/* Blue dark theme overrides */
.blue-dark {
	--color-primary: var(--color-test-70);
	--color-primary-25: var(--color-test-950);
	--color-primary-50: var(--color-test-900);
	--color-primary-70: var(--color-test-850);
	--color-primary-100: var(--color-test-800);
	--color-primary-200: var(--color-test-700);
	--color-primary-300: var(--color-test-600);
	--color-primary-400: var(--color-test-400);
	--color-primary-600: var(--color-test-300);
	--color-primary-700: var(--color-test-200);
	--color-primary-800: var(--color-test-100);
	--color-primary-850: var(--color-test-70);
	--color-primary-900: var(--color-test-50);
	--color-primary-950: var(--color-test-25);

	/* Blue dark semantic color overrides */
	--color-modal-bg: var(--color-test-950);
	--color-input-bg: var(--color-test-900);
	--color-app-bg: var(--color-test);
	--color-tooltip-bg: var(--color-primary-200);
	--color-dark-bg: var(--color-test);

	/* Blue dark accent color overrides */
	--color-accent-links: var(--color-purple-300);
	--color-on-accent: var(--color-primary-25);
	--color-accent-info: var(--color-info);

	/* Blue dark hovers and overlays overrides */
	--color-overlay: rgba(19, 21, 51, 0.4);
	--color-assistant-hover-state: var(--color-primary-25);
}

/* Global selection style */
::selection {
	background-color: var(--selection-color) !important;
	color: inherit;
}

/* Global styles for text highlighting and transitions */
span[data-absolute-pos] {
	transition: all var(--transition-speed) var(--transition-easing);
}

span.highlight {
	background-color: var(--highlight-color);
	transition: background-color var(--transition-speed) var(--transition-easing);
}

@font-face {
	font-family: 'lufga';
	src:
		url('/fonts/lufga/lufga-regular-webfont.woff2') format('woff2'),
		url('/fonts/lufga/lufga-regular-webfont.woff') format('woff'),
		url('/fonts/lufga/lufga-regular-webfont.ttf') format('truetype');
	font-weight: normal;
	font-style: normal;
}

@font-face {
	font-family: 'lufga';
	src: url('/fonts/lufga/lufga-medium-webfont.ttf') format('truetype');
	font-weight: 500;
	font-style: normal;
	font-display: fallback;
}

@font-face {
	font-family: 'lufga';
	src:
		url('/fonts/lufga/lufga-semibold-webfont.woff2') format('woff2'),
		url('/fonts/lufga/lufga-semibold-webfont.woff') format('woff'),
		url('/fonts/lufga/lufga-semibold-webfont.ttf') format('truetype');
	font-weight: 600 700;
	font-style: normal;
	font-display: fallback;
}

@font-face {
	font-family: 'lufga';
	src: url('/fonts/lufga/lufga-light-webfont.ttf') format('truetype');
	font-weight: 300;
	font-style: normal;
	font-display: fallback;
}

select {
	background: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' style='color: %23808080' width='20'  height='20'  viewBox='0 0 24 24'  fill='currentColor'  class='icon icon-tabler icons-tabler-filled icon-tabler-caret-down'><path stroke='none' d='M0 0h24v24H0z' fill='none'/><path d='M18 9c.852 0 1.297 .986 .783 1.623l-.076 .084l-6 6a1 1 0 0 1 -1.32 .083l-.094 -.083l-6 -6l-.083 -.094l-.054 -.077l-.054 -.096l-.017 -.036l-.027 -.067l-.032 -.108l-.01 -.053l-.01 -.06l-.004 -.057v-.118l.005 -.058l.009 -.06l.01 -.052l.032 -.108l.027 -.067l.07 -.132l.065 -.09l.073 -.081l.094 -.083l.077 -.054l.096 -.054l.036 -.017l.067 -.027l.108 -.032l.053 -.01l.06 -.01l.057 -.004l12.059 -.002z' /></svg>")
		no-repeat right 10px center;
	appearance: none;
}

/* Text alignment highlight */
:global(span.highlight) {
	background-color: var(--highlight-color);
	transition: all 0.2s ease-in-out;
}

.tabler-icon {
	stroke-width: 1.5;
}

/* Universal Font Classes */
.font-universal {
	font-family: var(--font-universal);
	font-feature-settings:
		'kern' 1,
		'liga' 1,
		'ccmp' 1,
		'mark' 1,
		'mkmk' 1;
	font-variant-ligatures: common-ligatures;
	text-rendering: optimizeLegibility;
}

.font-universal-input {
	font-family: var(--font-universal);
	font-feature-settings:
		'kern' 1,
		'liga' 1,
		'ccmp' 1,
		'mark' 1,
		'mkmk' 1;
	font-variant-ligatures: common-ligatures;
	text-rendering: optimizeLegibility;
}

.font-universal-output {
	font-family: var(--font-universal);
	font-feature-settings:
		'kern' 1,
		'liga' 1,
		'ccmp' 1,
		'mark' 1,
		'mkmk' 1;
	font-variant-ligatures: common-ligatures;
	text-rendering: optimizeLegibility;
}

/* OverlayScrollbars theming - Light theme (default) */
.os-scrollbar {
  --os-handle-bg: rgba(0, 0, 0, 0.2);
  --os-handle-bg-hover: rgba(0, 0, 0, 0.3);
  --os-handle-bg-active: rgba(0, 0, 0, 0.4);
  --os-track-bg: transparent;
  --os-track-bg-hover: rgba(0, 0, 0, 0.05);
  --os-track-bg-active: rgba(0, 0, 0, 0.1);
}

/* OverlayScrollbars theming - Dark theme */
.dark .os-scrollbar {
  --os-handle-bg: rgba(255, 255, 255, 0.2);
  --os-handle-bg-hover: rgba(255, 255, 255, 0.3);
  --os-handle-bg-active: rgba(255, 255, 255, 0.4);
  --os-track-bg: transparent;
  --os-track-bg-hover: rgba(255, 255, 255, 0.05);
  --os-track-bg-active: rgba(255, 255, 255, 0.1);
}

/* Test Color Icon Class */
.icon-test-color {
	color: var(--color-primary-600);
	transition: color var(--transition-speed) var(--transition-easing);
}

/* Apply hover effect when parent button/link is hovered */
button:hover .icon-test-color,
a:hover .icon-test-color {
	color: var(--color-primary-800);
}

/* Icon Color Classes */
.icon-color {
	color: var(--color-primary-800);
}

.icon-color-hover {
	color: var(--color-primary-800);
	transition: color var(--transition-speed) var(--transition-easing);
}

.icon-color-hover:hover {
	color: var(--color-primary);
}

.icon-color-muted {
	color: var(--color-primary-600);
}

.icon-color-muted-hover {
	color: var(--color-primary-600);
	transition: color var(--transition-speed) var(--transition-easing);
}

.icon-color-muted-hover:hover {
	color: var(--color-primary-300);
}

.icon-color-disabled {
	color: var(--color-primary-400);
	opacity: 0.5;
}

/* Typography: text-balance for headings, text-pretty for body */
h1, h2, h3, h4, h5, h6 {
	text-wrap: balance;
}

p {
	text-wrap: pretty;
}

/* Scrollbar hiding utility - use class="hide-scrollbar" */
.hide-scrollbar {
	-ms-overflow-style: none;
	scrollbar-width: none;
}

.hide-scrollbar::-webkit-scrollbar {
	display: none;
}

/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
	*,
	*::before,
	*::after {
		animation-duration: 0.01ms !important;
		animation-iteration-count: 1 !important;
		transition-duration: 0.01ms !important;
		scroll-behavior: auto !important;
	}
}

```

## /src/app.html

```html path="/src/app.html" 
<!doctype html>
<html lang="en" class="%sveltekit.theme%">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, viewport-fit=cover"
    />
    <meta name="theme-color" content="#ffffff" id="theme-color" />
    <title>Kagi News</title>
    <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
    <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
    <link
      rel="apple-touch-icon"
      href="%sveltekit.assets%/apple-touch-icon.png"
    />

    <!-- Prevent flash of wrong theme - minimal script that only handles dark mode detection -->
    <script>
      (() => {
        try {
          // Check if we're in a browser environment
          if (typeof window === "undefined") return;

          // Try to get theme from localStorage with error handling
          let storedTheme = null;
          try {
            storedTheme = localStorage.getItem("theme");
          } catch (storageError) {
            // localStorage might be blocked in private mode or by security settings
            console.warn("localStorage not accessible:", storageError);
          }

          let darkModePreferred = false;

          if (storedTheme === "dark") {
            darkModePreferred = true;
          } else if (storedTheme === "system" || !storedTheme) {
            // Check system preference for 'system' theme or when no theme is stored
            const darkMediaQuery = window.matchMedia(
              "(prefers-color-scheme: dark)",
            );
            darkModePreferred = darkMediaQuery.matches;
          }
          // If storedTheme === 'light', darkModePreferred remains false

          // Apply dark mode class if needed
          if (darkModePreferred) {
            document.documentElement.classList.add("dark");
          }

          // Update theme-color meta tag immediately
          const themeColorMeta = document.getElementById("theme-color");
          if (themeColorMeta) {
            const newColor = darkModePreferred ? "#1f2937" : "#ffffff";
            themeColorMeta.setAttribute("content", newColor);
          }
        } catch (e) {
          console.error("Theme detection error:", e);
        }
      })();
    </script>

    %sveltekit.head%
  </head>
  <body
    data-sveltekit-preload-data="hover"
    class="dark:bg-dark-bg dark:text-dark-text min-h-dvh bg-white text-black"
  >
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

```

## /src/app.ts

```ts path="/src/app.ts" 
// Mock file for $app module in tests
export const browser = typeof window !== "undefined";
export const dev = process.env.NODE_ENV === "development";
export const building = false;
export const prerendering = false;

```

## /src/lib/client/localization.svelte.ts

```ts path="/src/lib/client/localization.svelte.ts" 
import Mustache from 'mustache';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { language } from '$lib/stores/language.svelte.js';

export function s(key: string, view?: Record<string, string>, strict?: false): string;
export function s(
	key: string,
	view: Record<string, string> | undefined,
	strict: true,
): string | undefined;
export function s(key: string, view?: Record<string, string>, strict = false): string | undefined {
	// Use server-side strings on server, client-side strings on client
	const strings = browser ? language.currentStrings : page.data.strings;
	let value = strings?.[key];

	if (typeof value === 'object') {
		value = value?.text;
	}

	if (!value) return strict ? undefined : key;

	return view ? Mustache.render(value, view) : value;
}

```

## /src/lib/client/pcm-processor.ts

```ts path="/src/lib/client/pcm-processor.ts" 
// @ts-nocheck
/*
 * This file is used as a raw string that gets passed to AudioWorklet.
 * It must be valid JavaScript that runs in the AudioWorklet context.
 * TypeScript checking is disabled for this file.
 */

class PCMProcessor extends AudioWorkletProcessor {
	constructor(options) {
		super(options);
		this.queue = [];
		this.hasSignaledPlaying = false;
		this.hasSignaledComplete = false;
		this.emptyFrameCount = 0;
		this.streamEnded = false; // Track whether the HTTP stream has fully arrived

		// Listen for incoming samples
		this.port.onmessage = (event) => {
			const { samples, command } = event.data;

			if (command === 'clear') {
				this.queue = [];
				this.currentBuffer = null;
				this.readIndex = 0;
				this.hasSignaledPlaying = false;
				this.hasSignaledComplete = false;
				this.emptyFrameCount = 0;
				this.streamEnded = false;
				console.log('[PCM Processor] Cleared queue');
				return;
			}

			if (command === 'ping') {
				console.log('[PCM Processor] Received ping');
				this.port.postMessage({ command: 'pong' });
				return;
			}

			if (command === 'force_play') {
				console.log('[PCM Processor] Force play requested');
				if (!this.hasSignaledPlaying && this.queue.length > 0) {
					console.log('[PCM Processor] Signaling play state');
					this.port.postMessage({ command: 'playing' });
					this.hasSignaledPlaying = true;
				}
				return;
			}

			// Stream has fully arrived - now we can allow completion when queue empties
			if (command === 'stream_ended') {
				console.log('[PCM Processor] Stream ended signal received');
				this.streamEnded = true;
				return;
			}

			this.hasSignaledComplete = false;

			if (samples && samples.length > 0) {
				console.log('[PCM Processor] Received samples:', samples.length);
				this.queue.push(samples);

				// Reset empty frame count when new samples arrive
				this.emptyFrameCount = 0;

				// Signal if we have data but haven't signaled yet
				if (!this.hasSignaledPlaying && this.queue.length > 0) {
					console.log('[PCM Processor] Auto-signaling play state after receiving data');
					this.port.postMessage({ command: 'playing' });
					this.hasSignaledPlaying = true;
				}
			}
		};
		this.currentBuffer = null;
		this.readIndex = 0;
		console.log('[PCM Processor] Initialized');
	}

	process(_inputs, outputs, _parameters) {
		const output = outputs[0];
		const channelData = output[0];

		if (!channelData) {
			return true; // Skip if no output channel available
		}

		let i = 0;
		let playing = false;

		while (i < channelData.length) {
			// If we don't have currentBuffer or we've exhausted it, get the next one
			if (!this.currentBuffer || this.readIndex >= this.currentBuffer.length) {
				this.currentBuffer = this.queue.shift();
				this.readIndex = 0;
				if (!this.currentBuffer) {
					// No data available: fill with silence
					while (i < channelData.length) {
						channelData[i++] = 0;
					}

					// Only signal completion when:
					// 1. We've started playing
					// 2. The HTTP stream has fully ended (explicit signal received)
					// 3. The audio queue is empty
					// 4. We haven't already signaled completion
					// This prevents premature completion during network delays
					if (
						this.hasSignaledPlaying &&
						this.streamEnded &&
						!this.hasSignaledComplete &&
						this.queue.length === 0
					) {
						this.emptyFrameCount++;

						// After 5 empty frames (~100ms), signal completion
						// We can use a shorter wait now since we KNOW the stream has ended
						if (this.emptyFrameCount > 5) {
							console.log(
								'[PCM Processor] Playback complete (stream ended + queue empty), signaling',
							);
							this.port.postMessage({ command: 'complete' });
							this.hasSignaledComplete = true;
							this.emptyFrameCount = 0;
						}
					}

					return true; // Keep running
				}

				if (!playing && !this.hasSignaledPlaying) {
					console.log('[PCM Processor] Started playback, signaling');
					this.port.postMessage({ command: 'playing' });
					this.hasSignaledPlaying = true;
					playing = true;
				}

				// Reset empty frame count when we get more data
				this.emptyFrameCount = 0;
			}

			// Copy samples from currentBuffer into output
			channelData[i++] = this.currentBuffer[this.readIndex++];
		}

		return true; // Keep alive
	}
}

registerProcessor('pcm-processor', PCMProcessor);

```

## /src/lib/client/rtl-detection.ts

```ts path="/src/lib/client/rtl-detection.ts" 
/**
 * List of RTL (Right-to-Left) language codes
 */
const RTL_LOCALES = [
	'ar', // Arabic
	'he', // Hebrew
	'fa', // Persian/Farsi
	'ur', // Urdu
	'yi', // Yiddish
	'ji', // Yiddish (alternative code)
	'iw', // Hebrew (old code)
	'ku', // Kurdish
	'arc', // Aramaic
	'az', // Azeri
	'dv', // Dhivehi/Maldivian
];

/**
 * Checks if a given locale is RTL
 * @param locale - The locale string (e.g., 'ar', 'ar-SA', 'he-IL')
 * @returns true if the locale is RTL, false otherwise
 */
export function isRtlLocale(locale: string | undefined | null): boolean {
	if (!locale) return false;

	// Extract the base language code (e.g., 'ar' from 'ar-SA')
	const baseLang = locale.split('-')[0].toLowerCase();

	return RTL_LOCALES.includes(baseLang);
}

```

## /src/lib/client/settings-backup.ts

```ts path="/src/lib/client/settings-backup.ts" 
import { browser } from '$app/environment';
import { settings } from '$lib/data/settings.svelte';

interface SettingsBackup {
	_comment: string;
	version: string;
	timestamp: string;
	device: string;
	settings: Record<string, unknown>;
}

/**
 * Create a JSON snapshot of all current settings (excluding sync-local settings).
 */
export function createSettingsBackup(): string {
	if (!browser) return '{}';

	const backup: SettingsBackup = {
		_comment: 'Settings backup from Kagi News (news.kagi.com)',
		version: '1.0',
		timestamp: new Date().toISOString(),
		device: navigator.userAgent,
		settings: {},
	};

	for (const [key, setting] of Object.entries(settings)) {
		if (setting.category === 'sync_local') continue;
		if (!setting.shouldStore()) continue;
		backup.settings[key] = setting.currentValue;
	}

	return JSON.stringify(backup, null, 2);
}

/**
 * Download a settings backup as a JSON file.
 * If backupData is not provided, creates a fresh backup.
 */
export function downloadSettingsBackup(backupData?: string): void {
	if (!browser) return;

	const data = backupData ?? createSettingsBackup();
	const blob = new Blob([data], { type: 'application/json' });
	const url = URL.createObjectURL(blob);
	const a = document.createElement('a');
	a.href = url;
	a.download = `kagi-news-settings-${new Date().toISOString().split('T')[0]}.json`;
	document.body.appendChild(a);
	a.click();
	document.body.removeChild(a);
	URL.revokeObjectURL(url);
}

/**
 * Import settings from a backup JSON string.
 */
export function importSettingsBackup(jsonString: string): { success: boolean; message: string } {
	try {
		const backup: SettingsBackup = JSON.parse(jsonString);

		if (!backup.version || !backup.settings) {
			return { success: false, message: 'Invalid backup format' };
		}

		let importedCount = 0;
		const settingsMap = settings as Record<string, (typeof settings)[keyof typeof settings]>;

		for (const [key, value] of Object.entries(backup.settings)) {
			const setting = settingsMap[key];
			if (!setting) continue;
			if (setting.category === 'sync_local') continue;

			// biome-ignore lint/suspicious/noExplicitAny: backup values are untyped JSON
			(setting as any).currentValue = value;
			// biome-ignore lint/suspicious/noExplicitAny: backup values are untyped JSON
			(setting as any).originalValue = value;
			setting.save();
			importedCount++;
		}

		return { success: true, message: `Successfully imported ${importedCount} settings` };
	} catch (error) {
		return {
			success: false,
			message: error instanceof Error ? error.message : 'Failed to parse backup file',
		};
	}
}

/**
 * Open a file picker and import settings from the selected JSON file.
 */
export function handleSettingsImport(): Promise<{ success: boolean; message: string }> {
	return new Promise((resolve) => {
		if (!browser) {
			resolve({ success: false, message: 'Not available in server context' });
			return;
		}

		const input = document.createElement('input');
		input.type = 'file';
		input.accept = '.json';
		input.onchange = async () => {
			const file = input.files?.[0];
			if (!file) {
				resolve({ success: false, message: 'No file selected' });
				return;
			}
			try {
				const text = await file.text();
				resolve(importSettingsBackup(text));
			} catch {
				resolve({ success: false, message: 'Failed to read file' });
			}
		};
		input.click();
	});
}

```

## /src/lib/client/speech-utils.ts

```ts path="/src/lib/client/speech-utils.ts" 
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import pcmProcessorString from './pcm-processor.ts?raw';

// Voice options for the dropdown
export const voiceOptions = [
	{ value: 'alloy', label: 'Alloy', gender: 'F' },
	{ value: 'ash', label: 'Ash', gender: 'M' },
	{ value: 'ballad', label: 'Ballad', gender: 'M' },
	{ value: 'coral', label: 'Coral', gender: 'F' },
	{ value: 'echo', label: 'Echo', gender: 'M' },
	{ value: 'sage', label: 'Sage', gender: 'F' },
	{ value: 'shimmer', label: 'Shimmer', gender: 'F' },
	{ value: 'verse', label: 'Verse', gender: 'M' },
];

// Create a persistent store for the selected voice
function createSelectedVoiceStore() {
	// Default voice
	const DEFAULT_VOICE = 'coral';

	// Get initial value from localStorage or use default
	const initialValue = DEFAULT_VOICE;

	// Create the writable store
	const { subscribe, set, update } = writable(initialValue);

	// Return the store with a custom set function that persists to localStorage
	return {
		subscribe,
		set: (value: string) => {
			if (browser) {
				// Validate the voice
				const validVoices = voiceOptions.map((option) => option.value);
				const voiceToSet = validVoices.includes(value) ? value : DEFAULT_VOICE;

				// Save to localStorage and update the store
				set(voiceToSet);
			} else {
				set(value);
			}
		},
		update,
	};
}

// Export the store
export const selectedVoiceStore = createSelectedVoiceStore();

export type SpeechState = {
	status: 'idle' | 'loading' | 'playing';
	bufferPromise: Promise<Float32Array> | null;
	downloadLoading?: boolean;
	currentAudioInputContext: AudioContext | null;
};

export async function playSpeech(
	text: string,
	language: string,
	voice: string,
	state: SpeechState,
	updateState: (updates: Partial<SpeechState>) => void,
): Promise<void> {
	console.log('[Speech] Starting playback process');
	updateState({ status: 'loading' });

	if (state.currentAudioInputContext) {
		console.log('[Speech] Closing existing audio context');
		try {
			state.currentAudioInputContext.suspend();
			state.currentAudioInputContext.close();
		} catch (e) {
			console.error('[Speech] Error closing existing audio context:', e);
		}
		updateState({ currentAudioInputContext: null });
	}

	const audioInputContext = new AudioContext({ sampleRate: 24000 });

	// Fix for iOS silent switch issue - set audio session to "playback" type
	// This ensures audio plays even when the iPhone's ringer is off
	try {
		// @ts-expect-error - navigator.audioSession is only available in newer iOS versions
		if (typeof navigator !== 'undefined' && navigator.audioSession) {
			// @ts-expect-error
			navigator.audioSession.type = 'playback';
			console.log('[Speech] Set audio session type to playback for iOS');
		}
	} catch {
		console.log('[Speech] Audio session API not available, using fallback for iOS silent switch');
	}
	console.log('[Speech] Created new AudioContext, state:', audioInputContext.state);
	updateState({ currentAudioInputContext: audioInputContext });

	// Properly resume the AudioContext and wait for it
	if (audioInputContext.state === 'suspended') {
		try {
			await audioInputContext.resume();
			console.log('[Speech] Audio context resumed, state:', audioInputContext.state);
		} catch (e) {
			console.error('[Speech] Failed to resume audio context:', e);
			// If resume fails, clean up and return
			updateState({
				status: 'idle',
				currentAudioInputContext: null,
			});
			return;
		}
	}

	// Create worklet module URL and clean it up after loading
	const workletBlob = new Blob([pcmProcessorString], { type: 'application/javascript' });
	const workletUrl = URL.createObjectURL(workletBlob);
	try {
		await audioInputContext.audioWorklet.addModule(workletUrl);
		console.log('[Speech] AudioWorklet module added');
	} catch (e) {
		console.error('[Speech] Failed to add AudioWorklet module:', e);
		URL.revokeObjectURL(workletUrl);
		try {
			audioInputContext.close();
		} catch (err) {
			console.error('[Speech] Error closing audio context after worklet failure:', err);
		}
		updateState({
			status: 'idle',
			currentAudioInputContext: null,
		});
		return;
	} finally {
		// Clean up the object URL to prevent memory leak
		URL.revokeObjectURL(workletUrl);
	}

	const workletNode = new AudioWorkletNode(audioInputContext, 'pcm-processor');
	workletNode.connect(audioInputContext.destination);
	console.log('[Speech] Worklet node created and connected');

	// CACHING DISABLED - always fetch fresh audio
	// if (state.bufferPromise) {
	// 	console.log('[Speech] Using cached buffer');
	// 	let buffer: Float32Array;
	//
	// 	try {
	// 		buffer = await state.bufferPromise;
	// 	} catch (e) {
	// 		console.error('[Speech] Cached buffer promise was rejected:', e);
	// 		// Clear the rejected promise and clean up
	// 		updateState({ bufferPromise: null });
	// 		try {
	// 			workletNode.disconnect();
	// 			workletNode.port.close();
	// 		} catch (err) {
	// 			console.error('[Speech] Error disconnecting worklet:', err);
	// 		}
	// 		try {
	// 			audioInputContext.close();
	// 		} catch (err) {
	// 			console.error('[Speech] Error closing audio context:', err);
	// 		}
	// 		updateState({
	// 			status: 'idle',
	// 			currentAudioInputContext: null
	// 		});
	// 		return;
	// 	}
	//
	// 	console.log('[Speech] Buffer length:', buffer.length);
	//
	// 	// Debug: Check for corrupted samples in cached buffer
	// 	let corruptedCount = 0;
	// 	for (let i = 0; i < Math.min(1000, buffer.length); i++) {
	// 		if (Math.abs(buffer[i]) > 0.99) {
	// 			corruptedCount++;
	// 		}
	// 	}
	// 	if (corruptedCount > 100) {
	// 		console.error('[Speech] CACHED BUFFER CORRUPTED - ', corruptedCount, 'clipped samples in first 1000');
	// 		console.log('[Speech] First 20 cached samples:', Array.from(buffer.slice(0, 20)));
	// 	} else {
	// 		console.log('[Speech] Cached buffer looks clean, first 20 samples:', Array.from(buffer.slice(0, 20)));
	// 	}
	//
	// 	workletNode.port.postMessage({ samples: buffer });
	// 	console.log('[Speech] Sent buffer to worklet');
	//
	// 	// Create a new, simplified message handler that only handles essential events
	// 	workletNode.port.onmessage = (e) => {
	// 		console.log('[Speech] Received message from worklet:', e.data);
	//
	// 		if (e.data.command === 'playing') {
	// 			console.log('[Speech] Worklet playing - setting status to PLAYING');
	// 			updateState({ status: 'playing' });
	// 		} else if (e.data.command === 'complete') {
	// 			console.log('[Speech] Worklet completed - setting status to IDLE');
	//
	// 			// Disconnect and clean up worklet
	// 			try {
	// 				workletNode.disconnect();
	// 				workletNode.port.close();
	// 			} catch (e) {
	// 				console.error('[Speech] Error disconnecting worklet:', e);
	// 			}
	//
	// 			// Close the audio context
	// 			try {
	// 				audioInputContext.suspend();
	// 				audioInputContext.close();
	// 			} catch (e) {
	// 				console.error('[Speech] Error closing audio context:', e);
	// 			}
	//
	// 			// Directly set status to idle - IMPORTANT: This must be executed
	// 			updateState({
	// 				status: 'idle',
	// 				currentAudioInputContext: null
	// 			});
	//
	// 			console.log('[Speech] Status should now be IDLE');
	// 		}
	// 	};
	//
	// 	return;
	// }

	console.log('[Speech] Fetching fresh audio (caching disabled)');
	let bufferResolve: (buffer: Float32Array) => void;
	let bufferReject: (error: Error) => void;
	// Promise is used to resolve/reject buffer from streaming callback
	new Promise<Float32Array>((resolve, reject) => {
		bufferResolve = resolve;
		bufferReject = reject;
	});
	// Don't save to state - caching disabled
	// updateState({ bufferPromise });

	const chunks: Float32Array[] = [];
	let totalLength = 0;

	const url = new URL('/api/speech', window.location.origin);

	// Enable debug mode to log raw API data (set to false in production)
	const DEBUG_TTS = true; // Set to true to enable server-side logging

	console.log('[Speech] Fetching audio via POST, text length:', text.length);
	const res = await fetch(url.href, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({
			text,
			language,
			voice,
			raw: true,
			debug: DEBUG_TTS,
		}),
	});
	console.log('[Speech] Fetch response received');

	try {
		// Check for HTTP errors
		if (!res.ok) {
			throw new Error(`HTTP error! status: ${res.status}`);
		}

		if (!res.body) {
			throw new Error('Response body is null');
		}

		// Set up the message handler before starting to stream
		workletNode.port.onmessage = (e) => {
			console.log('[Speech] Received message from worklet in streaming mode:', e.data);

			if (e.data.command === 'playing') {
				console.log('[Speech] Worklet playing - setting status to PLAYING');
				updateState({ status: 'playing' });
			} else if (e.data.command === 'complete') {
				console.log('[Speech] Worklet completed - setting status to IDLE');

				// Disconnect and clean up worklet
				try {
					workletNode.disconnect();
					workletNode.port.close();
				} catch (e) {
					console.error('[Speech] Error disconnecting worklet:', e);
				}

				// Close the audio context
				try {
					audioInputContext.suspend();
					audioInputContext.close();
				} catch (e) {
					console.error('[Speech] Error closing audio context:', e);
				}

				// Directly set status to idle - IMPORTANT: This must be executed
				updateState({
					status: 'idle',
					currentAudioInputContext: null,
				});

				console.log('[Speech] Status should now be IDLE');
			}
		};

		// Force worklet to respond when samples are received
		workletNode.port.postMessage({ command: 'ping' });

		const reader = res.body.getReader();
		console.log('[Speech] Got reader from response body');

		let leftoverByte = null;
		let length = 0;
		let chunkCount = 0;
		let hasStartedPlayback = false;

		let startTime = 0;

		while (true) {
			const { value, done } = await reader.read();
			if (done) {
				console.log('[Speech] Stream reading complete, processed', chunkCount, 'chunks');
				break;
			}

			if (!value || value.byteLength === 0) {
				console.warn('[Speech] Received empty chunk, skipping');
				continue;
			}

			chunkCount++;

			if (!startTime) {
				console.log(
					'[Speech] First audio chunk received (chunk size:',
					value?.byteLength || 0,
					'bytes)',
				);
				// Log first bytes to detect UTF-8 corruption
				console.log('[Speech] First 40 raw bytes from stream:', Array.from(value.slice(0, 40)));
				startTime = audioInputContext.currentTime;
			}

			// Only log occasionally to avoid console spam
			if (chunkCount % 10 === 0) {
				console.log(
					`[Speech] Processing chunk #${chunkCount}, size:`,
					value?.byteLength || 0,
					'bytes',
				);
			}

			let buffer: Uint8Array;

			if (leftoverByte !== null) {
				// Prepend the leftover byte to the current chunk
				buffer = new Uint8Array(value.byteLength + 1);
				buffer[0] = leftoverByte;
				buffer.set(value, 1);
				leftoverByte = null;
			} else {
				buffer = value;
			}

			// Check if there's an odd number of bytes
			if (buffer.byteLength % 2 !== 0) {
				// Save the last byte for the next iteration
				leftoverByte = buffer[buffer.byteLength - 1];
				buffer = buffer.slice(0, -1); // Remove the last byte
			}

			length += buffer.byteLength;

			// Convert PCM16 to Float32 safely without alignment issues
			// Read bytes directly and combine them into 16-bit samples
			const floatSamples = new Float32Array(buffer.byteLength / 2);
			let hasClipping = false;
			let clipCount = 0;

			for (let i = 0; i < floatSamples.length; i++) {
				const byteIndex = i * 2;
				// Read two bytes and combine into signed 16-bit integer (little-endian)
				const int16 = buffer[byteIndex] | (buffer[byteIndex + 1] << 8);
				// Convert to signed value (-32768 to 32767)
				const signed = int16 > 32767 ? int16 - 65536 : int16;
				// Normalize to -1.0 to 1.0
				floatSamples[i] = signed / 32768;

				// Detect clipping/corruption
				if (Math.abs(floatSamples[i]) > 0.99) {
					clipCount++;
					if (clipCount > 100 && i < 1000) {
						hasClipping = true;
					}
				}
			}

			// Log if chunk looks corrupted (lots of clipping early on)
			if (hasClipping && chunkCount <= 5) {
				console.error(
					`[Speech] Chunk #${chunkCount} appears corrupted - ${clipCount} clipped samples`,
				);
				console.log('[Speech] Raw bytes (first 40):', Array.from(buffer.slice(0, 40)));
				console.log(
					'[Speech] Converted samples (first 20):',
					Array.from(floatSamples.slice(0, 20)),
				);
			}

			// Collect chunks efficiently
			chunks.push(floatSamples);
			totalLength += floatSamples.length;

			// Send samples to worklet (use local audioInputContext reference to avoid reactivity issues)
			workletNode.port.postMessage({ samples: floatSamples });

			// Start playing immediately after first chunk with substantial data
			if (!hasStartedPlayback && floatSamples.length > 1000) {
				console.log(
					`[Speech] Starting streaming playback on chunk #${chunkCount} with ${floatSamples.length} samples`,
				);
				updateState({ status: 'playing' });
				hasStartedPlayback = true;
			}

			// Only log occasionally to avoid console spam
			if (chunkCount % 20 === 0) {
				console.log(
					`[Speech] Streaming progress: chunk #${chunkCount}, total samples:`,
					totalLength,
				);
			}
		}

		// Handle leftover byte at end of stream
		if (leftoverByte !== null) {
			console.warn('[Speech] Stream ended with leftover byte, audio may be truncated by 1 sample');
		}

		// Efficiently concatenate all chunks into final buffer
		const totalBuffer = new Float32Array(totalLength);
		let offset = 0;
		for (const chunk of chunks) {
			totalBuffer.set(chunk, offset);
			offset += chunk.length;
		}

		// @ts-expect-error - bufferResolve is guaranteed to be defined at this point
		bufferResolve(totalBuffer);
		console.log('[Speech] All chunks processed. Total samples:', totalBuffer.length);

		const duration = length / 24000 / 2;
		console.log('[Speech] Calculated audio duration:', duration.toFixed(2), 'seconds');

		// Signal to the worklet that the stream has fully arrived
		// This allows the worklet to complete only after ALL audio has been received
		// preventing premature completion during network delays
		console.log('[Speech] Signaling stream end to worklet');
		workletNode.port.postMessage({ command: 'stream_ended' });

		console.log('[Speech] Audio streaming completed');

		// Note: No need for a timeout - the worklet will signal when playback is complete
	} catch (e) {
		console.error('[Speech] Error during playback:', e);

		// Reject the buffer promise if it exists
		// @ts-expect-error - bufferReject is guaranteed to be defined if we're in streaming mode
		if (bufferReject) {
			bufferReject(e instanceof Error ? e : new Error(String(e)));
		}

		// Clean up on error - disconnect worklet first
		try {
			workletNode.disconnect();
			workletNode.port.close();
		} catch (err) {
			console.error('[Speech] Error disconnecting worklet on error:', err);
		}

		try {
			audioInputContext.suspend();
			audioInputContext.close();
		} catch (err) {
			console.error('[Speech] Error closing audio context on error:', err);
		}

		updateState({
			bufferPromise: null,
			status: 'idle',
			currentAudioInputContext: null,
		});
	}
}

export async function downloadAudio(
	text: string,
	language: string,
	voice: string,
	_state: SpeechState,
	updateState: (updates: Partial<SpeechState>) => void,
): Promise<void> {
	updateState({ downloadLoading: true });

	try {
		// CACHING DISABLED - always fetch fresh
		// if (state.bufferPromise) {
		// 	// If we already have the audio data, download it immediately
		// 	try {
		// 		const buffer = await state.bufferPromise;
		// 		await triggerDownload(buffer, language, voice);
		// 		return;
		// 	} catch (e) {
		// 		console.error('[Download] Cached buffer promise was rejected:', e);
		// 		// Clear the rejected promise and continue to fetch fresh
		// 		updateState({ bufferPromise: null });
		// 		// Fall through to fetch new audio
		// 	}
		// }

		// Fetch the audio data without playing it
		const url = new URL('/api/speech', window.location.origin);

		const res = await fetch(url.href, {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json',
			},
			body: JSON.stringify({
				text,
				language,
				voice,
				raw: true,
			}),
		});

		if (!res.ok) {
			throw new Error(`HTTP error! status: ${res.status}`);
		}

		if (!res.body) {
			throw new Error('Response body is null');
		}

		const reader = res.body.getReader();
		const chunks: Float32Array[] = [];
		let totalLength = 0;
		let leftoverByte = null;

		while (true) {
			const { value, done } = await reader.read();
			if (done) break;

			if (!value || value.byteLength === 0) {
				console.warn('[Download] Received empty chunk, skipping');
				continue;
			}

			let buffer: Uint8Array;

			if (leftoverByte !== null) {
				buffer = new Uint8Array(value.byteLength + 1);
				buffer[0] = leftoverByte;
				buffer.set(value, 1);
				leftoverByte = null;
			} else {
				buffer = value;
			}

			if (buffer.byteLength % 2 !== 0) {
				leftoverByte = buffer[buffer.byteLength - 1];
				buffer = buffer.slice(0, -1);
			}

			// Convert PCM16 to Float32 safely without alignment issues
			// Read bytes directly and combine them into 16-bit samples
			const floatSamples = new Float32Array(buffer.byteLength / 2);
			for (let i = 0; i < floatSamples.length; i++) {
				const byteIndex = i * 2;
				// Read two bytes and combine into signed 16-bit integer (little-endian)
				const int16 = buffer[byteIndex] | (buffer[byteIndex + 1] << 8);
				// Convert to signed value (-32768 to 32767)
				const signed = int16 > 32767 ? int16 - 65536 : int16;
				// Normalize to -1.0 to 1.0
				floatSamples[i] = signed / 32768;
			}

			chunks.push(floatSamples);
			totalLength += floatSamples.length;
		}

		// Handle leftover byte at end of stream
		if (leftoverByte !== null) {
			console.warn(
				'[Download] Stream ended with leftover byte, audio may be truncated by 1 sample',
			);
		}

		// Efficiently concatenate all chunks
		const totalBuffer = new Float32Array(totalLength);
		let offset = 0;
		for (const chunk of chunks) {
			totalBuffer.set(chunk, offset);
			offset += chunk.length;
		}

		// CACHING DISABLED - don't save buffer
		// const bufferPromise = Promise.resolve(totalBuffer);
		// updateState({ bufferPromise });

		// Trigger download
		await triggerDownload(totalBuffer, language, voice);
	} catch (error) {
		console.error('Error downloading audio:', error);
		throw error;
	} finally {
		updateState({ downloadLoading: false });
	}
}

export async function triggerDownload(
	buffer: Float32Array,
	language: string,
	voice: string,
): Promise<void> {
	const audioContext = new AudioContext({ sampleRate: 24000 });

	try {
		const audioBuffer = audioContext.createBuffer(1, buffer.length, 24000);

		// Copy buffer to audio channel
		// Create a new Float32Array backed by a regular ArrayBuffer to satisfy TypeScript
		const channelData = new Float32Array(buffer.length);
		channelData.set(buffer);
		audioBuffer.copyToChannel(channelData, 0);

		// Convert to WAV
		const wavBuffer = bufferToWav(audioBuffer);

		// Create download link
		const blob = new Blob([wavBuffer], { type: 'audio/wav' });
		const url = URL.createObjectURL(blob);

		// Create and click download link
		const a = document.createElement('a');
		a.href = url;
		a.download = `speech-${language}-${voice}-${Date.now()}.wav`;
		document.body.appendChild(a);
		a.click();

		// Clean up
		setTimeout(() => {
			document.body.removeChild(a);
			URL.revokeObjectURL(url);
		}, 100);
	} catch (error) {
		console.error('Error creating download:', error);
		throw error;
	} finally {
		// Always close the AudioContext to prevent memory leak
		try {
			audioContext.close();
		} catch (e) {
			console.error('Error closing AudioContext in triggerDownload:', e);
		}
	}
}

// Function to convert AudioBuffer to WAV
function bufferToWav(audioBuffer: AudioBuffer): ArrayBuffer {
	const numOfChan = audioBuffer.numberOfChannels;
	const length = audioBuffer.length * numOfChan * 2;
	const buffer = new ArrayBuffer(44 + length);
	const view = new DataView(buffer);
	const channels: Float32Array[] = [];
	let sample: number;
	let pos = 0;

	// Write WAV header
	writeString(view, 0, 'RIFF');
	view.setUint32(4, 36 + length, true);
	writeString(view, 8, 'WAVE');
	writeString(view, 12, 'fmt ');
	view.setUint32(16, 16, true);
	view.setUint16(20, 1, true);
	view.setUint16(22, numOfChan, true);
	view.setUint32(24, audioBuffer.sampleRate, true);
	view.setUint32(28, audioBuffer.sampleRate * 2 * numOfChan, true);
	view.setUint16(32, numOfChan * 2, true);
	view.setUint16(34, 16, true);
	writeString(view, 36, 'data');
	view.setUint32(40, length, true);

	// Write interleaved data
	for (let i = 0; i < numOfChan; i++) {
		channels.push(audioBuffer.getChannelData(i));
	}

	pos = 44;
	while (pos < 44 + length) {
		for (let i = 0; i < numOfChan; i++) {
			sample = Math.max(-1, Math.min(1, channels[i][(pos - 44) / 2 / numOfChan]));
			sample = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
			view.setInt16(pos, sample, true);
			pos += 2;
		}
	}

	return buffer;
}

function writeString(view: DataView, offset: number, string: string): void {
	for (let i = 0; i < string.length; i++) {
		view.setUint8(offset + i, string.charCodeAt(i));
	}
}

```

## /src/lib/client/storyLocalization.svelte.ts

```ts path="/src/lib/client/storyLocalization.svelte.ts" 
import Mustache from 'mustache';
import { browser } from '$app/environment';
import { s } from '$lib/client/localization.svelte';
import { SUPPORTED_LANGUAGES } from '$lib/constants/languages';
import { dataLanguage } from '$lib/stores/dataLanguage.svelte.js';
import { language, type SupportedLanguage } from '$lib/stores/language.svelte.js';

// Simple cache for locale data - all locales loaded at startup
const localeCache: Map<string, Record<string, unknown>> = new Map();

/**
 * Create a story-specific localization function
 * Auto-syncs to content language when UI language is set to "default"
 */
export function createStoryLocalizer(
	isExpanded: boolean,
	storyLanguage?: string,
): {
	(key: string, view?: Record<string, string>, strict?: false): string;
	(key: string, view: Record<string, string> | undefined, strict: true): string | undefined;
} {
	// Determine the actual content language to use
	let contentLanguage: string;

	if (dataLanguage.current === 'default') {
		// When data language is "default", use the story's source language
		contentLanguage = storyLanguage || 'en';
	} else {
		// When a specific data language is selected, use that
		contentLanguage = dataLanguage.current;
	}

	// Check if we should sync (UI language is "default" and story is expanded)
	const shouldSync = language.current === 'default' && isExpanded && contentLanguage;

	function localizer(key: string, view?: Record<string, string>, strict?: false): string;
	function localizer(
		key: string,
		view: Record<string, string> | undefined,
		strict: true,
	): string | undefined;
	function localizer(
		key: string,
		view?: Record<string, string> | undefined,
		strict = false,
	): string | undefined {
		// Only sync if conditions are met
		if (!shouldSync) {
			// Use conditional call to match overload signatures
			return strict ? s(key, view, true) : s(key, view);
		}

		// Use content language strings for story UI when expanded
		const strings = localeCache.get(contentLanguage) || language.currentStrings;
		let value = strings?.[key];

		if (typeof value === 'object' && value !== null) {
			value = (value as { text?: string })?.text;
		}

		if (!value || typeof value !== 'string') return strict ? undefined : key;

		return view ? Mustache.render(value, view) : value;
	}

	return localizer;
}

/**
 * Preload all locales when UI language is "default"
 * This ensures story UI elements can be shown in the story's language
 */
export async function preloadAllLocales() {
	if (!browser) return;

	// Get all real language codes (exclude "default" and "source")
	const languages = SUPPORTED_LANGUAGES.filter(
		(lang) => lang.code !== 'default' && lang.code !== 'source',
	).map((lang) => lang.code as SupportedLanguage);

	// Only log if we're actually going to load something
	const languagesToLoad = languages.filter((lang) => !localeCache.has(lang));
	if (languagesToLoad.length === 0) return;

	console.log(`📚 Preloading ${languagesToLoad.length} locales for default UI mode...`);

	// Load all missing locales in parallel
	await Promise.all(
		languagesToLoad.map(async (lang) => {
			try {
				const response = await fetch(`/api/locale/${lang}`);
				if (response.ok) {
					const data = await response.json();
					localeCache.set(lang, data.strings);
				}
			} catch (error) {
				console.warn(`Failed to preload locale for ${lang}:`, error);
			}
		}),
	);

	console.log('✅ All locales preloaded');
}

```

## /src/lib/client/sync-settings-watcher.svelte.ts

```ts path="/src/lib/client/sync-settings-watcher.svelte.ts" 
/**
 * Self-contained sync watcher for settings
 * Tracks setting changes and syncs them when appropriate
 */

import { browser } from '$app/environment';
import { settings } from '$lib/data/settings.svelte';
import { syncManager } from './sync-manager';

export class SyncSettingsWatcher {
	// Keep a copy of last synced values
	private lastSyncedValues = new Map<string, unknown>();
	private initialized = false;

	constructor() {
		if (browser) {
			// Don't auto-initialize - wait for explicit initialization
		}
	}

	initialize() {
		// Reset and load initial values into our tracking map
		this.lastSyncedValues.clear();
		Object.values(settings).forEach((setting) => {
			// Skip sync_local settings (they don't sync)
			if (setting.category !== 'sync_local') {
				// Deep clone the value to avoid reference issues
				try {
					const clonedValue = JSON.parse(JSON.stringify(setting.currentValue));
					this.lastSyncedValues.set(setting.key, clonedValue);
				} catch {
					// If JSON serialization fails, just store the value directly
					this.lastSyncedValues.set(setting.key, setting.currentValue);
				}
			}
		});
		this.initialized = true;
	}

	/**
	 * Check for changes and sync them
	 * This should be called after settings are saved
	 */
	checkForChanges() {
		console.log('[SyncWatcher] checkForChanges called, initialized:', this.initialized);
		if (!this.initialized) return;

		// Check if sync is enabled
		const syncEnabled = settings.syncSettings.currentValue;
		console.log('[SyncWatcher] syncEnabled:', syncEnabled);
		if (!syncEnabled) {
			console.log('[SyncWatcher] Sync disabled, skipping');
			return;
		}

		// Detect and sync changes
		const changes = this.detectAndGatherChanges();
		console.log('[SyncWatcher] Detected changes:', changes);

		// If we have changes, sync them
		if (changes.length > 0) {
			console.log('[SyncWatcher] Syncing changes:', changes);

			// Track each changed setting ONCE
			changes.forEach((change) => {
				console.log('[SyncWatcher] Tracking change:', change);
				syncManager.trackChange('setting', 'update', {
					key: change.key,
					value: change.value,
				});
			});

			// Reset our baseline after tracking changes
			this.initialize();
		} else {
			console.log('[SyncWatcher] No changes detected, skipping sync');
		}
	}

	/**
	 * Detect what settings have changed since last sync
	 */
	private detectAndGatherChanges(): Array<{ key: string; value: unknown }> {
		const changes: Array<{ key: string; value: unknown }> = [];
		const seen = new Set<string>(); // Track what we've already added

		Object.values(settings).forEach((setting) => {
			// Skip sync_local settings (they don't sync)
			if (setting.category === 'sync_local') {
				return;
			}

			// Check if value changed from our tracking map
			const lastValue = this.lastSyncedValues.get(setting.key);
			const currentValue = setting.currentValue;

			// Deep comparison for objects
			let hasChanged = false;
			try {
				hasChanged = JSON.stringify(lastValue) !== JSON.stringify(currentValue);
			} catch {
				// If JSON serialization fails, do simple comparison
				hasChanged = lastValue !== currentValue;
			}

			if (hasChanged && !seen.has(setting.key)) {
				console.log(`[SyncWatcher] Setting changed: ${setting.key}`, {
					old: lastValue,
					new: currentValue,
				});

				// Check if this should be stored based on the storage strategy
				const shouldStore = setting.shouldStore();

				// Add to changes array ONLY if not already added
				// If shouldStore is false, we send null to delete from DB
				changes.push({
					key: setting.key,
					value: shouldStore ? currentValue : null,
				});
				seen.add(setting.key);

				// DON'T update our tracking map here - do it after sync
			}
		});

		return changes;
	}

	/**
	 * Force sync all settings (for initial sync when enabling)
	 */
	syncAllSettings() {
		console.log('[SyncWatcher] Force syncing all settings');

		const changes: Array<{ key: string; value: unknown }> = [];

		Object.values(settings).forEach((setting) => {
			// Skip sync_local settings
			if (setting.category === 'sync_local') {
				return;
			}

			// Check if this should be stored
			const shouldStore = setting.shouldStore();

			if (shouldStore) {
				changes.push({
					key: setting.key,
					value: setting.currentValue,
				});
			}
		});

		console.log('[SyncWatcher] Syncing all settings:', changes.length, 'settings');

		// Track all settings
		changes.forEach((change) => {
			syncManager.trackChange('setting', 'update', {
				key: change.key,
				value: change.value,
			});
		});

		// Update our baseline
		this.initialize();
	}
}

// Create singleton instance
export const syncSettingsWatcher = browser ? new SyncSettingsWatcher() : null;

```

## /src/lib/client/utils/device-id.ts

```ts path="/src/lib/client/utils/device-id.ts" 
/**
 * Device ID management utilities
 */

import { v4 as uuidv4 } from 'uuid';
import { browser } from '$app/environment';
import { safeGetItem, safeSetItem } from './safe-storage';

/**
 * Get or create a persistent device ID
 * This is called on app initialization to ensure we always have a device ID
 */
export function getOrCreateDeviceId(): string {
	if (!browser) return '';

	let deviceId = safeGetItem('deviceId');
	if (!deviceId) {
		deviceId = uuidv4();
		if (!safeSetItem('deviceId', deviceId)) {
			// If we couldn't save, return a temporary ID
			console.warn('Could not persist device ID, using temporary ID');
			return `temp-${deviceId}`;
		}
	}
	return deviceId;
}

/**
 * Get the current device ID (assumes it exists)
 */
export function getDeviceId(): string {
	if (!browser) return '';
	return safeGetItem('deviceId') || '';
}

/**
 * Initialize device ID on app startup
 * This should be called in the root layout
 */
export function initializeDeviceId(): void {
	if (browser) {
		getOrCreateDeviceId();
	}
}

```

## /src/lib/components/BetaLabel.svelte

```svelte path="/src/lib/components/BetaLabel.svelte" 
<script lang="ts">
import { s } from '$lib/client/localization.svelte';
</script>

<span class="flex items-center justify-center rounded-lg bg-yellow px-2 py-0.5">
	<span class="text-graphite-1000 text-sm font-bold uppercase">{s('app.beta')}</span>
</span>

```

## /src/lib/components/search/index.ts

```ts path="/src/lib/components/search/index.ts" 
export { default as SearchInput } from './SearchInput.svelte';
export { default as SearchModal } from './SearchModal.svelte';
export { default as SearchResults } from './SearchResults.svelte';

```


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.
Copied!