```
├── .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.
[](https://choosealicense.com/licenses/mit/)
[](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(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/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.