From 71a2a891e93026c65ae12f5453b769a13b3a7735 Mon Sep 17 00:00:00 2001
From: flavius
Date: Sun, 12 Jul 2026 16:34:22 +0300
Subject: [PATCH 1/2] feat: SEO & AI visibility
---
.../src/components/MiezOnboarding.test.tsx | 2 +-
apps/web/src/components/MiezOnboarding.tsx | 2 +-
apps/web/src/components/bookmark-button.tsx | 2 +-
apps/web/src/components/feed/event-card.tsx | 15 +-
apps/web/src/components/header.tsx | 4 +-
.../src/components/layout/MobileTabBar.tsx | 6 +-
apps/web/src/components/quiz-hidden.test.tsx | 2 +-
apps/web/src/lib/feature-flags.ts | 2 +-
apps/web/src/lib/i18n/getLocaleFromMatches.ts | 8 +-
apps/web/src/lib/seo.ts | 40 +-
apps/web/src/routeTree.gen.ts | 42 +
apps/web/src/router.tsx | 2 +-
apps/web/src/routes/__root.tsx | 10 +-
apps/web/src/routes/activitate.tsx | 4 +-
apps/web/src/routes/event.$slug.tsx | 64 +-
apps/web/src/routes/feed.tsx | 954 +-----------------
apps/web/src/routes/index.tsx | 936 ++++++++++++++++-
apps/web/src/routes/llms[.]txt.ts | 2 +-
apps/web/src/routes/news-sitemap[.]xml.ts | 94 ++
apps/web/src/routes/quiz.tsx | 4 +-
apps/web/src/routes/robots[.]txt.ts | 1 +
apps/web/src/routes/rss[.]xml.ts | 108 ++
apps/web/src/routes/salvate.tsx | 2 +-
apps/web/src/routes/sitemap[.]xml.ts | 2 +-
apps/web/src/routes/source.$sourceId.tsx | 6 +-
docs/seo-batch-2-runbook.md | 80 ++
packages/backend/convex/events.ts | 41 +
packages/backend/convex/sitemap.ts | 3 +-
packages/i18n/src/strings.ts | 4 +-
29 files changed, 1458 insertions(+), 984 deletions(-)
create mode 100644 apps/web/src/routes/news-sitemap[.]xml.ts
create mode 100644 apps/web/src/routes/rss[.]xml.ts
create mode 100644 docs/seo-batch-2-runbook.md
diff --git a/apps/web/src/components/MiezOnboarding.test.tsx b/apps/web/src/components/MiezOnboarding.test.tsx
index 3069656..e90098b 100644
--- a/apps/web/src/components/MiezOnboarding.test.tsx
+++ b/apps/web/src/components/MiezOnboarding.test.tsx
@@ -63,7 +63,7 @@ describe("MiezOnboarding (MIEZ-8)", () => {
test("the CTA dismisses and navigates into the feed", () => {
renderOnboarding();
fireEvent.click(screen.getByText(getString("ro", "onboarding.miez.cta")));
- expect(navigate).toHaveBeenCalledWith({ to: "/feed" });
+ expect(navigate).toHaveBeenCalledWith({ to: "/" });
expect(window.localStorage.getItem(KEY)).toBe("1");
});
diff --git a/apps/web/src/components/MiezOnboarding.tsx b/apps/web/src/components/MiezOnboarding.tsx
index 04e3cdf..5468d8e 100644
--- a/apps/web/src/components/MiezOnboarding.tsx
+++ b/apps/web/src/components/MiezOnboarding.tsx
@@ -123,7 +123,7 @@ export function MiezOnboarding() {
className="w-full"
onClick={() => {
dismiss("cta");
- void navigate({ to: "/feed" });
+ void navigate({ to: "/" });
}}
>
{t("onboarding.miez.cta")}
diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx
index 5532885..ad81696 100644
--- a/apps/web/src/components/bookmark-button.tsx
+++ b/apps/web/src/components/bookmark-button.tsx
@@ -30,7 +30,7 @@ export default function BookmarkButton({
interactionContext,
size = "default",
className,
- redirectTo = "/feed",
+ redirectTo = "/",
}: BookmarkButtonProps) {
const t = useT();
const { isAuthenticated } = useConvexAuth();
diff --git a/apps/web/src/components/feed/event-card.tsx b/apps/web/src/components/feed/event-card.tsx
index f5127f7..5c93d29 100644
--- a/apps/web/src/components/feed/event-card.tsx
+++ b/apps/web/src/components/feed/event-card.tsx
@@ -292,7 +292,20 @@ const EventCard = ({
{
+ // SEO-8: record "came from feed" client-side instead of via a
+ // ?returnToFeed URL param, so the crawlable href stays the
+ // clean canonical /event/$slug.
+ try {
+ window.sessionStorage.setItem("miez-return-to-feed", "1");
+ } catch {
+ // Ignore unavailable/blocked sessionStorage.
+ }
+ }
+ : undefined
+ }
className="group block min-w-0 flex-1 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
>
{rowContent}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 32407ba..7846571 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -24,7 +24,7 @@ import {
} from "@/components/ui/sheet";
const allLinks = [
- { to: "/feed", key: "tabs.feed", icon: Newspaper },
+ { to: "/", key: "tabs.feed", icon: Newspaper },
{ to: "/quiz", key: "tabs.quiz", icon: BrainCircuit },
{ to: "/salvate", key: "tabs.saved", icon: Bookmark },
{ to: "/activitate", key: "tabs.activity", icon: LayoutDashboard },
@@ -55,7 +55,7 @@ export default function Header() {
{/* Logo */}
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx
index 2a7fc26..1ed9d37 100644
--- a/apps/web/src/components/layout/MobileTabBar.tsx
+++ b/apps/web/src/components/layout/MobileTabBar.tsx
@@ -22,13 +22,11 @@ type TabDefinition = TabItem & { key: TabKey };
const allTabDefinitions: readonly TabDefinition[] = [
{
- to: "/feed",
+ to: "/",
key: "tabs.feed",
icon: Newspaper,
isActive: (pathname: string) =>
- pathname === "/feed" ||
- pathname.startsWith("/feed/") ||
- pathname.startsWith("/event/"),
+ pathname === "/" || pathname.startsWith("/event/"),
},
{ to: "/quiz", key: "tabs.quiz", icon: BrainCircuit },
{ to: "/salvate", key: "tabs.saved", icon: Bookmark },
diff --git a/apps/web/src/components/quiz-hidden.test.tsx b/apps/web/src/components/quiz-hidden.test.tsx
index d9d1ad0..17da509 100644
--- a/apps/web/src/components/quiz-hidden.test.tsx
+++ b/apps/web/src/components/quiz-hidden.test.tsx
@@ -52,7 +52,7 @@ describe("quiz feature flag (BIV-802)", () => {
const redirectTo =
(thrown as { options?: { to?: string }; to?: string }).options?.to ??
(thrown as { to?: string }).to;
- expect(redirectTo).toBe("/feed");
+ expect(redirectTo).toBe("/");
}
});
});
diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/feature-flags.ts
index 117b429..060b535 100644
--- a/apps/web/src/lib/feature-flags.ts
+++ b/apps/web/src/lib/feature-flags.ts
@@ -20,6 +20,6 @@ export const FEATURE_FLAGS = {
*/
export function guardQuizRoute() {
if (!FEATURE_FLAGS.quiz) {
- throw redirect({ to: "/feed", replace: true });
+ throw redirect({ to: "/", replace: true });
}
}
diff --git a/apps/web/src/lib/i18n/getLocaleFromMatches.ts b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
index c91373b..45c9c06 100644
--- a/apps/web/src/lib/i18n/getLocaleFromMatches.ts
+++ b/apps/web/src/lib/i18n/getLocaleFromMatches.ts
@@ -5,13 +5,17 @@ const SUPPORTED_LOCALES = ["ro", "en"] as const satisfies readonly Locale[];
export function getLocaleFromMatches(
matches: ReadonlyArray<{ context?: unknown }>,
): Locale {
+ // Romanian-first product (see resolveLocale): when the root context has not
+ // resolved a locale — e.g. a first-hit crawler with no cookie/?lang — meta
+ // must default to Romanian to match and the content, not
+ // leak English titles/descriptions on indexable pages (SEO-2).
const rootContext = matches[0]?.context;
if (!rootContext || typeof rootContext !== "object") {
- return "en";
+ return "ro";
}
const locale =
"locale" in rootContext ? (rootContext.locale as string | undefined) : null;
- return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "en";
+ return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "ro";
}
diff --git a/apps/web/src/lib/seo.ts b/apps/web/src/lib/seo.ts
index 5a875c6..a0d5805 100644
--- a/apps/web/src/lib/seo.ts
+++ b/apps/web/src/lib/seo.ts
@@ -18,6 +18,9 @@ export const SITE = {
title: getString("en", "seo.siteTitle"),
description: getString("en", "seo.siteDescription"),
ogImage: "https://www.miez.news/og-image.jpg",
+ // Romanian alt for the default share card (SEO-2). Event pages override with
+ // their own imageAlt when a per-event photo is present.
+ ogImageAlt: "Miez - știri din ambele tabere",
ogImageType: "image/jpeg",
ogImageWidth: 1200,
ogImageHeight: 630,
@@ -27,6 +30,39 @@ export function absoluteSiteUrl(pathname: string): string {
return new URL(pathname, SITE.url).toString();
}
+/**
+ * Truncate text at a word boundary, never mid-word, appending an ellipsis when
+ * (and only when) the text was actually cut. Trailing whitespace/punctuation
+ * before the ellipsis is stripped so descriptions never read "…word ,…" or end
+ * with a dangling space before the closing quote (SEO-6). Whitespace is also
+ * collapsed so multi-line summaries render as a single clean meta line.
+ */
+export function truncateAtWordBoundary(text: string, maxLen: number): string {
+ const normalized = text.replace(/\s+/g, " ").trim();
+ if (normalized.length <= maxLen) return normalized;
+ const slice = normalized.slice(0, maxLen);
+ const lastSpace = slice.lastIndexOf(" ");
+ const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
+ // Drop any trailing space or dangling punctuation before the ellipsis.
+ const trimmed = cut.replace(/[\s.,;:!?…·•\-–—]+$/u, "");
+ return `${trimmed}…`;
+}
+
+/**
+ * Short, stable canonical headline for
/og:title/twitter:title, RSS
+ * items and the news sitemap (SEO-5). Event titles are concatenated source
+ * headlines (200+ chars); take the first segment before the " / " join and cap
+ * it at ~65 chars on a word boundary. The long compound title stays on the
+ * page as the . Falls back to the raw title when there is no separator.
+ */
+export function deriveShortTitle(title: string, maxLen = 65): string {
+ const firstSegment = title.split(/\s*\/\s*/)[0]?.trim();
+ return truncateAtWordBoundary(
+ firstSegment && firstSegment.length > 0 ? firstSegment : title,
+ maxLen,
+ );
+}
+
/**
* Official social/entity profiles for JSON-LD `sameAs`. Extend as profiles
* are created; never list a profile that doesn't exist yet.
@@ -45,7 +81,9 @@ function organizationEntity(): JsonLd {
"@type": "NewsMediaOrganization",
name: SITE.name,
url: SITE.url,
- logo: absoluteSiteUrl("/favicon.svg"),
+ // Structured-data logo must be a raster image (Google rejects SVG for the
+ // publisher logo); logo-mark.png is 512×512 (SEO-7).
+ logo: absoluteSiteUrl("/logo-mark.png"),
...(SOCIAL_PROFILES.length > 0 ? { sameAs: SOCIAL_PROFILES } : {}),
};
}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 207b01e..ee31c69 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -15,6 +15,7 @@ import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre'
import { Route as SurseRouteImport } from './routes/surse'
import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as SalvateRouteImport } from './routes/salvate'
+import { Route as RssDotxmlRouteImport } from './routes/rss[.]xml'
import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as QuizRouteImport } from './routes/quiz'
@@ -22,6 +23,7 @@ import { Route as PublishersRouteImport } from './routes/publishers'
import { Route as ProfilRouteImport } from './routes/profil'
import { Route as PoliticaConfidentialitateRouteImport } from './routes/politica-confidentialitate'
import { Route as ParteneriRouteImport } from './routes/parteneri'
+import { Route as NewsSitemapDotxmlRouteImport } from './routes/news-sitemap[.]xml'
import { Route as MetodologieRouteImport } from './routes/metodologie'
import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt'
import { Route as FinantareRouteImport } from './routes/finantare'
@@ -73,6 +75,11 @@ const SalvateRoute = SalvateRouteImport.update({
path: '/salvate',
getParentRoute: () => rootRouteImport,
} as any)
+const RssDotxmlRoute = RssDotxmlRouteImport.update({
+ id: '/rss.xml',
+ path: '/rss.xml',
+ getParentRoute: () => rootRouteImport,
+} as any)
const RobotsDottxtRoute = RobotsDottxtRouteImport.update({
id: '/robots.txt',
path: '/robots.txt',
@@ -109,6 +116,11 @@ const ParteneriRoute = ParteneriRouteImport.update({
path: '/parteneri',
getParentRoute: () => rootRouteImport,
} as any)
+const NewsSitemapDotxmlRoute = NewsSitemapDotxmlRouteImport.update({
+ id: '/news-sitemap.xml',
+ path: '/news-sitemap.xml',
+ getParentRoute: () => rootRouteImport,
+} as any)
const MetodologieRoute = MetodologieRouteImport.update({
id: '/metodologie',
path: '/metodologie',
@@ -223,6 +235,7 @@ export interface FileRoutesByFullPath {
'/finantare': typeof FinantareRoute
'/llms.txt': typeof LlmsDottxtRoute
'/metodologie': typeof MetodologieRoute
+ '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute
'/parteneri': typeof ParteneriRoute
'/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
'/profil': typeof ProfilRoute
@@ -230,6 +243,7 @@ export interface FileRoutesByFullPath {
'/quiz': typeof QuizRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/rss.xml': typeof RssDotxmlRoute
'/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/surse': typeof SurseRoute
@@ -258,6 +272,7 @@ export interface FileRoutesByTo {
'/finantare': typeof FinantareRoute
'/llms.txt': typeof LlmsDottxtRoute
'/metodologie': typeof MetodologieRoute
+ '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute
'/parteneri': typeof ParteneriRoute
'/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
'/profil': typeof ProfilRoute
@@ -265,6 +280,7 @@ export interface FileRoutesByTo {
'/quiz': typeof QuizRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/rss.xml': typeof RssDotxmlRoute
'/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/surse': typeof SurseRoute
@@ -294,6 +310,7 @@ export interface FileRoutesById {
'/finantare': typeof FinantareRoute
'/llms.txt': typeof LlmsDottxtRoute
'/metodologie': typeof MetodologieRoute
+ '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute
'/parteneri': typeof ParteneriRoute
'/politica-confidentialitate': typeof PoliticaConfidentialitateRoute
'/profil': typeof ProfilRoute
@@ -301,6 +318,7 @@ export interface FileRoutesById {
'/quiz': typeof QuizRoute
'/reset-password': typeof ResetPasswordRoute
'/robots.txt': typeof RobotsDottxtRoute
+ '/rss.xml': typeof RssDotxmlRoute
'/salvate': typeof SalvateRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/surse': typeof SurseRoute
@@ -331,6 +349,7 @@ export interface FileRouteTypes {
| '/finantare'
| '/llms.txt'
| '/metodologie'
+ | '/news-sitemap.xml'
| '/parteneri'
| '/politica-confidentialitate'
| '/profil'
@@ -338,6 +357,7 @@ export interface FileRouteTypes {
| '/quiz'
| '/reset-password'
| '/robots.txt'
+ | '/rss.xml'
| '/salvate'
| '/sitemap.xml'
| '/surse'
@@ -366,6 +386,7 @@ export interface FileRouteTypes {
| '/finantare'
| '/llms.txt'
| '/metodologie'
+ | '/news-sitemap.xml'
| '/parteneri'
| '/politica-confidentialitate'
| '/profil'
@@ -373,6 +394,7 @@ export interface FileRouteTypes {
| '/quiz'
| '/reset-password'
| '/robots.txt'
+ | '/rss.xml'
| '/salvate'
| '/sitemap.xml'
| '/surse'
@@ -401,6 +423,7 @@ export interface FileRouteTypes {
| '/finantare'
| '/llms.txt'
| '/metodologie'
+ | '/news-sitemap.xml'
| '/parteneri'
| '/politica-confidentialitate'
| '/profil'
@@ -408,6 +431,7 @@ export interface FileRouteTypes {
| '/quiz'
| '/reset-password'
| '/robots.txt'
+ | '/rss.xml'
| '/salvate'
| '/sitemap.xml'
| '/surse'
@@ -437,6 +461,7 @@ export interface RootRouteChildren {
FinantareRoute: typeof FinantareRoute
LlmsDottxtRoute: typeof LlmsDottxtRoute
MetodologieRoute: typeof MetodologieRoute
+ NewsSitemapDotxmlRoute: typeof NewsSitemapDotxmlRoute
ParteneriRoute: typeof ParteneriRoute
PoliticaConfidentialitateRoute: typeof PoliticaConfidentialitateRoute
ProfilRoute: typeof ProfilRoute
@@ -444,6 +469,7 @@ export interface RootRouteChildren {
QuizRoute: typeof QuizRoute
ResetPasswordRoute: typeof ResetPasswordRoute
RobotsDottxtRoute: typeof RobotsDottxtRoute
+ RssDotxmlRoute: typeof RssDotxmlRoute
SalvateRoute: typeof SalvateRoute
SitemapDotxmlRoute: typeof SitemapDotxmlRoute
SurseRoute: typeof SurseRoute
@@ -504,6 +530,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SalvateRouteImport
parentRoute: typeof rootRouteImport
}
+ '/rss.xml': {
+ id: '/rss.xml'
+ path: '/rss.xml'
+ fullPath: '/rss.xml'
+ preLoaderRoute: typeof RssDotxmlRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/robots.txt': {
id: '/robots.txt'
path: '/robots.txt'
@@ -553,6 +586,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ParteneriRouteImport
parentRoute: typeof rootRouteImport
}
+ '/news-sitemap.xml': {
+ id: '/news-sitemap.xml'
+ path: '/news-sitemap.xml'
+ fullPath: '/news-sitemap.xml'
+ preLoaderRoute: typeof NewsSitemapDotxmlRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/metodologie': {
id: '/metodologie'
path: '/metodologie'
@@ -709,6 +749,7 @@ const rootRouteChildren: RootRouteChildren = {
FinantareRoute: FinantareRoute,
LlmsDottxtRoute: LlmsDottxtRoute,
MetodologieRoute: MetodologieRoute,
+ NewsSitemapDotxmlRoute: NewsSitemapDotxmlRoute,
ParteneriRoute: ParteneriRoute,
PoliticaConfidentialitateRoute: PoliticaConfidentialitateRoute,
ProfilRoute: ProfilRoute,
@@ -716,6 +757,7 @@ const rootRouteChildren: RootRouteChildren = {
QuizRoute: QuizRoute,
ResetPasswordRoute: ResetPasswordRoute,
RobotsDottxtRoute: RobotsDottxtRoute,
+ RssDotxmlRoute: RssDotxmlRoute,
SalvateRoute: SalvateRoute,
SitemapDotxmlRoute: SitemapDotxmlRoute,
SurseRoute: SurseRoute,
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 861dedf..ee7066e 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -26,7 +26,7 @@ function NotFound() {
return (
{t("router.notFound")}
-
+
{t("router.backToFeed")}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 6c52a57..01dc94f 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -1,6 +1,6 @@
import { lazy, Suspense } from "react";
import { Toaster } from "@/components/ui/sonner";
-import { SITE } from "@/lib/seo";
+import { SITE, absoluteSiteUrl } from "@/lib/seo";
import { Footer } from "@/components/layout/Footer";
import { MobileTabBar } from "@/components/layout/MobileTabBar";
import { MiezOnboarding } from "@/components/MiezOnboarding";
@@ -127,6 +127,14 @@ export const Route = createRootRouteWithContext()({
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "manifest", href: "/manifest.webmanifest" },
+ // Site-wide RSS discovery (SEO-3): feed readers and crawlers pick this
+ // up from any page.
+ {
+ rel: "alternate",
+ type: "application/rss+xml",
+ title: SITE.name,
+ href: absoluteSiteUrl("/rss.xml"),
+ },
// SVG mark first (modern browsers); PNG kept as the legacy fallback.
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
{ rel: "icon", type: "image/png", href: "/logo-mark.png" },
diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx
index de540ad..b351a23 100644
--- a/apps/web/src/routes/activitate.tsx
+++ b/apps/web/src/routes/activitate.tsx
@@ -315,7 +315,7 @@ function AuthorizedDashboard({
-
+
{t("tabs.feed")}
@@ -413,7 +413,7 @@ function AuthorizedDashboard({
{/* Quick Actions */}
diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx
index 568f2d1..5173a2f 100644
--- a/apps/web/src/routes/event.$slug.tsx
+++ b/apps/web/src/routes/event.$slug.tsx
@@ -4,11 +4,10 @@ import {
notFound,
useNavigate,
} from "@tanstack/react-router";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@news-app/backend/convex/_generated/api";
import { useConvexMutation } from "@convex-dev/react-query";
-import { z } from "zod";
import { Button } from "@/components/ui/button";
import ArticlesList from "@/components/feed/articles-list";
import { EventDetailTabs } from "@/components/feed/event-detail-tabs";
@@ -29,11 +28,18 @@ import {
type Locale,
type StringKey,
} from "@/lib/i18n/strings";
-import { SITE, absoluteSiteUrl, jsonLdScript } from "@/lib/seo";
+import {
+ SITE,
+ absoluteSiteUrl,
+ deriveShortTitle,
+ jsonLdScript,
+ truncateAtWordBoundary,
+} from "@/lib/seo";
-const searchSchema = z.object({
- returnToFeed: z.string().optional(),
-});
+// SEO-8: "came from feed" is carried in sessionStorage (set on the feed link
+// click), not a ?returnToFeed URL param, so crawlers only ever see the clean
+// canonical /event/$slug URL with no parameter variants.
+const RETURN_TO_FEED_KEY = "miez-return-to-feed";
function getPluralizedCountLabel(
locale: Locale,
@@ -59,7 +65,6 @@ function getPluralizedCountLabel(
}
export const Route = createFileRoute("/event/$slug")({
- validateSearch: searchSchema,
loader: async ({ context, params }) => {
const httpClient = context.convexQueryClient.serverHttpClient;
let data;
@@ -93,13 +98,21 @@ export const Route = createFileRoute("/event/$slug")({
notFoundComponent: EventNotFound,
head: ({ loaderData, params, matches }) => {
const locale = getLocaleFromMatches(matches);
- const title = loaderData?.event?.title
- ? `${loaderData.event.title} | ${SITE.name}`
+ // Short, single-headline canonical title for
/og/twitter cards
+ // (SEO-5); the full compound title stays as the on-page .
+ const shortTitle = loaderData?.event?.title
+ ? deriveShortTitle(loaderData.event.title)
+ : null;
+ const title = shortTitle
+ ? `${shortTitle} | ${SITE.name}`
: getString(locale, "event.metaTitle");
- const description =
- loaderData?.event?.perspectiveSummaries?.neutral?.slice(0, 155) ??
- loaderData?.event?.globalImpact?.slice(0, 155) ??
- getString(locale, "event.metaDescription");
+ // Word-boundary truncation, never mid-word, single ellipsis (SEO-6).
+ const rawDescription =
+ loaderData?.event?.perspectiveSummaries?.neutral?.trim() ||
+ loaderData?.event?.globalImpact?.trim();
+ const description = rawDescription
+ ? truncateAtWordBoundary(rawDescription, 155)
+ : getString(locale, "event.metaDescription");
const imageUrl =
loaderData?.event?.shareImageUrl ?? loaderData?.event?.imageUrl;
@@ -219,7 +232,8 @@ export const Route = createFileRoute("/event/$slug")({
"@type": "NewsMediaOrganization",
name: SITE.name,
url: SITE.url,
- logo: absoluteSiteUrl("/favicon.svg"),
+ // Raster logo (512×512) — Google rejects an SVG here (SEO-7).
+ logo: absoluteSiteUrl("/logo-mark.png"),
},
...(loaderData.event.imageUrl
? { image: [loaderData.event.imageUrl] }
@@ -244,7 +258,7 @@ function EventNotFound() {
{t("event.notFound")}
{t("event.notFoundBody")}
- {t("event.backToFeed")}
+ {t("event.backToFeed")}
@@ -256,7 +270,6 @@ function EventDetailPage() {
const t = useT();
const { slug } = Route.useParams();
const loaderData = Route.useLoaderData();
- const search = Route.useSearch();
const eventData = useQuery(api.events.getEventBySlug, { slug }) ?? loaderData;
// L4 — per-sentence source attribution for the summary tabs.
const grounding = useQuery(
@@ -266,15 +279,28 @@ function EventDetailPage() {
const { isAuthenticated } = useConvexAuth();
const logInteractionFn = useConvexMutation(api.interactions.logInteraction);
const navigate = useNavigate();
- const returnToFeed = search.returnToFeed === "1";
+ // Set by the feed card on click (SEO-8). Read once and clear, so a reload of
+ // a directly-shared event URL falls back to navigating to the feed instead of
+ // popping unrelated history.
+ const [cameFromFeed, setCameFromFeed] = useState(false);
+ useEffect(() => {
+ try {
+ if (window.sessionStorage.getItem(RETURN_TO_FEED_KEY) === "1") {
+ setCameFromFeed(true);
+ window.sessionStorage.removeItem(RETURN_TO_FEED_KEY);
+ }
+ } catch {
+ // Ignore unavailable/blocked sessionStorage.
+ }
+ }, [slug]);
const handleBackToFeed = () => {
- if (returnToFeed && window.history.length > 1) {
+ if (cameFromFeed && window.history.length > 1) {
window.history.back();
return;
}
- void navigate({ to: "/feed" });
+ void navigate({ to: "/" });
};
useEffect(() => {
diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx
index ad11755..4a6b358 100644
--- a/apps/web/src/routes/feed.tsx
+++ b/apps/web/src/routes/feed.tsx
@@ -1,936 +1,26 @@
-import {
- useEffect,
- useMemo,
- useRef,
- useState,
- type ComponentProps,
-} from "react";
-import { createFileRoute, Link, notFound } from "@tanstack/react-router";
-import { z } from "zod";
-import { api } from "@news-app/backend/convex/_generated/api";
-import type { Id } from "@news-app/backend/convex/_generated/dataModel";
-import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react";
-import { buildFeedQueryArgs } from "@/lib/feed-query";
-import { CheckIcon, ChevronDownIcon, FilterIcon, XIcon } from "lucide-react";
-import { QuizCta } from "@/components/quiz-cta";
-import EventCard from "@/components/feed/event-card";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { SectionTitle } from "@/components/ui/section-title";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command";
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { useIsMobile } from "@/components/ui/use-mobile";
-import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
-import { useT } from "@/lib/i18n/LocaleContext";
-import { getString } from "@/lib/i18n/strings";
-import { cn } from "@/lib/utils";
-import {
- SITE,
- absoluteSiteUrl,
- jsonLdScript,
- organizationJsonLd,
- softwareApplicationJsonLd,
-} from "@/lib/seo";
-
-// The event shape EventCard consumes. Loader/query results feed straight
-// into it; annotating the .map callbacks explicitly keeps the typecheck
-// stable even when the generated loader types resolve loosely in CI.
-type FeedEventCardData = ComponentProps["event"];
-
-// Crawl archive: fixed pages behind ?page=N so a no-JS crawler can reach
-// every published event through real anchors (Googlebot does not scroll).
-const ARCHIVE_PAGE_SIZE = 20;
-// Matches the client-side default page size so hydration swaps the SSR list
-// for the live subscription without a visible jump.
-const SSR_FEED_PAGE_SIZE = 6;
-
-const feedSearchSchema = z.object({
- page: z.coerce.number().int().min(1).optional().catch(undefined),
-});
+import { createFileRoute } from "@tanstack/react-router";
+
+// SEO-1: the feed now renders at the root URL (/). /feed is kept only as a
+// permanent (308) redirect so old links, bookmarks and previously indexed
+// URLs resolve to the canonical root. Query params (e.g. the ?page=N archive)
+// are preserved: /feed?page=2 -> /?page=2. 308 (not 307/301) keeps the method
+// and tells crawlers the move is permanent so they transfer signals to /.
+function buildRedirectResponse(url: string) {
+ const search = new URL(url).search;
+ return new Response(null, {
+ status: 308,
+ headers: {
+ location: `/${search}`,
+ "cache-control": "public, max-age=3600",
+ },
+ });
+}
export const Route = createFileRoute("/feed")({
- validateSearch: feedSearchSchema,
- loaderDeps: ({ search }) => ({ page: search.page }),
- loader: async ({ context, deps }) => {
- const client =
- context.convexQueryClient.serverHttpClient ?? context.convexClient;
-
- if (deps.page !== undefined) {
- let archive;
- try {
- archive = await client.query(api.events.getPublishedEventsArchivePage, {
- page: deps.page,
- pageSize: ARCHIVE_PAGE_SIZE,
- });
- } catch (error) {
- console.error(
- `[Route loader] Failed to load feed archive page ${deps.page}:`,
- error,
- );
- return { archive: null };
- }
- if (archive.events.length === 0 && deps.page > 1) {
- // Out-of-range page numbers must 404, not mirror another page.
- throw notFound();
- }
- return { archive };
- }
-
- // Interactive feed: server-render the first page of events so the feed
- // is not a loading shell for crawlers; the live subscription takes over
- // after hydration. The anonymous trending snapshot keeps this cheap.
- try {
- const first = await client.query(api.events.getPublishedEvents, {
- ...buildFeedQueryArgs("all", "trending"),
- paginationOpts: { numItems: SSR_FEED_PAGE_SIZE, cursor: null },
- });
- return { initialEvents: first.page };
- } catch (error) {
- console.error("[Route loader] Failed to load initial feed page:", error);
- return { initialEvents: [] };
- }
- },
- head: ({ matches, loaderData }) => {
- const locale = getLocaleFromMatches(matches);
- const archivePage =
- loaderData && "archive" in loaderData
- ? (loaderData.archive?.page ?? null)
- : null;
- const baseTitle = getString(locale, "feed.meta.title");
- const title = archivePage
- ? `${baseTitle} — ${getString(locale, "feed.archive.page").replace("{page}", String(archivePage))}`
- : baseTitle;
- const description = getString(locale, "feed.meta.description");
- // Paginated archive pages self-canonicalize.
- const canonicalPath = archivePage ? `/feed?page=${archivePage}` : "/feed";
-
- return {
- meta: [
- { title },
- { name: "description", content: description },
- { property: "og:title", content: title },
- { property: "og:description", content: description },
- { property: "og:site_name", content: SITE.name },
- { name: "twitter:title", content: title },
- { name: "twitter:description", content: description },
- { property: "og:image", content: SITE.ogImage },
- { name: "twitter:image", content: SITE.ogImage },
- { property: "og:url", content: absoluteSiteUrl(canonicalPath) },
- { property: "og:type", content: "website" },
- { name: "twitter:card", content: "summary_large_image" },
- { property: "og:locale", content: locale === "ro" ? "ro_RO" : "en_US" },
- ],
- links: [{ rel: "canonical", href: absoluteSiteUrl(canonicalPath) }],
- // Org + app schema only on the de-facto landing page (/ redirects
- // here), not on every archive page.
- scripts: archivePage
- ? []
- : [
- jsonLdScript(organizationJsonLd()),
- jsonLdScript(softwareApplicationJsonLd(description)),
- ],
- };
+ server: {
+ handlers: {
+ HEAD: ({ request }) => buildRedirectResponse(request.url),
+ GET: ({ request }) => buildRedirectResponse(request.url),
+ },
},
- component: FeedComponent,
});
-
-function TopicFilterContent({
- topics,
- selectedTopic,
- onSelect,
-}: {
- topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined;
- selectedTopic: Id<"topics"> | "all";
- onSelect: (topic: Id<"topics"> | "all") => void;
-}) {
- const t = useT();
-
- return (
-
-
-
- {t("feed.topic.empty")}
-
- onSelect("all")}
- className="flex items-center justify-between gap-2"
- >
- {t("feed.topic.all")}
- {selectedTopic === "all" && (
-
- )}
-
- {topics?.map((topic) => (
- onSelect(topic._id)}
- className="flex items-center justify-between gap-2"
- >
- {topic.displayName}
- {selectedTopic === topic._id && (
-
- )}
-
- ))}
-
-
-
- );
-}
-
-function TopicFilter({
- topics,
- selectedTopic,
- onSelect,
-}: {
- topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined;
- selectedTopic: Id<"topics"> | "all";
- onSelect: (topic: Id<"topics"> | "all") => void;
-}) {
- const t = useT();
- const [open, setOpen] = useState(false);
- const isMobile = useIsMobile();
-
- const selectedLabel = useMemo(() => {
- if (selectedTopic === "all") return t("feed.topic.all");
- return (
- topics?.find((topic) => topic._id === selectedTopic)?.displayName ??
- t("feed.topic.single")
- );
- }, [selectedTopic, t, topics]);
-
- const handleSelect = (topic: Id<"topics"> | "all") => {
- onSelect(topic);
- setOpen(false);
- };
-
- const triggerButton = (
-
-
-
- {selectedLabel}
-
-
-
- );
-
- if (isMobile) {
- return (
-
-
- {triggerButton}
-
-
-
-
- {t("feed.topic.drawerTitle")}
-
- {t("feed.topic.drawerBody")}
-
-
-
-
-
- {t("feed.close")}
-
-
-
-
-
-
-
-
-
-
- {selectedTopic !== "all" && (
-
onSelect("all")}
- aria-label={t("feed.filter.clear")}
- >
-
-
- )}
-
- );
- }
-
- return (
-
-
- {triggerButton}
-
-
-
-
-
- {selectedTopic !== "all" && (
-
onSelect("all")}
- aria-label={t("feed.filter.clear")}
- >
-
-
- )}
-
- );
-}
-
-function useTopicNamesById() {
- const topics = useQuery(api.topics.getTopics);
- const topicNamesById = useMemo(() => {
- const map: Record = {};
- topics?.forEach((topic) => {
- map[topic._id] = topic.displayName;
- });
- return map;
- }, [topics]);
- return { topics, topicNamesById };
-}
-
-function FeedComponent() {
- const { page } = Route.useSearch();
- if (page !== undefined) {
- return ;
- }
- return ;
-}
-
-/**
- * Static, crawlable slice of the feed (/feed?page=N): server-rendered event
- * list in stable recent order with real previous/next anchors. Infinite
- * scroll on /feed stays the interactive experience layered on top.
- */
-function FeedArchive() {
- const t = useT();
- const loaderData = Route.useLoaderData();
- const { topicNamesById } = useTopicNamesById();
- const archive =
- loaderData && "archive" in loaderData ? loaderData.archive : null;
-
- if (!archive) {
- return (
-
-
- {t("feed.loading")}
-
-
- );
- }
-
- return (
-
-
-
-
-
- {archive.events.length === 0 ? (
-
- {t("feed.archive.empty")}
-
- ) : (
-
- {archive.events.map((event: FeedEventCardData) => (
-
-
-
- ))}
-
- )}
-
-
- {archive.page > 1 ? (
-
- ← {t("feed.archive.prev")}
-
- ) : (
-
- ← {t("feed.archive.backToFeed")}
-
- )}
- {archive.hasMore && (
-
- {t("feed.archive.next")} →
-
- )}
-
-
-
-
- );
-}
-
-function FeedContent() {
- const t = useT();
- const loaderData = Route.useLoaderData();
- const initialEvents =
- loaderData && "initialEvents" in loaderData ? loaderData.initialEvents : [];
- const { isAuthenticated } = useConvexAuth();
- const { topics, topicNamesById } = useTopicNamesById();
- const currentUser = useQuery(
- api.user.getCurrentUser,
- isAuthenticated ? {} : "skip",
- );
- const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig);
- const rawPageSize = Number(runtimeConfig?.feedPageSize);
- const MAX_FEED_PAGE_SIZE = 50;
- const pageSize = Number.isFinite(rawPageSize)
- ? Math.min(MAX_FEED_PAGE_SIZE, Math.max(1, Math.floor(rawPageSize)))
- : 6;
-
- const [selectedTopic, setSelectedTopic] = useState | "all">(
- "all",
- );
- const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending");
- const [searchInput, setSearchInput] = useState("");
- const [debouncedSearch, setDebouncedSearch] = useState("");
- const isSearching = debouncedSearch.length >= 2;
- const [isSearchFocused, setIsSearchFocused] = useState(false);
- const [recentSearches, setRecentSearches] = useState([]);
- const searchInputRef = useRef(null);
- const loadMoreTriggerRef = useRef(null);
- const isLoadingMoreRef = useRef(false);
-
- useEffect(() => {
- try {
- const raw = window.localStorage.getItem("miez-recent-event-searches");
- if (!raw) return;
- const parsed = JSON.parse(raw);
- if (Array.isArray(parsed)) {
- setRecentSearches(
- parsed
- .filter((value): value is string => typeof value === "string")
- .slice(0, 5),
- );
- }
- } catch {
- // Ignore malformed localStorage.
- }
- }, []);
-
- useEffect(() => {
- const timeout = window.setTimeout(() => {
- setDebouncedSearch(searchInput.trim());
- }, 250);
-
- return () => window.clearTimeout(timeout);
- }, [searchInput]);
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key !== "/") return;
- if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
- return;
- }
-
- const target = event.target as HTMLElement | null;
- if (
- target instanceof HTMLInputElement ||
- target instanceof HTMLTextAreaElement ||
- target?.isContentEditable
- ) {
- return;
- }
-
- event.preventDefault();
- searchInputRef.current?.focus();
- };
-
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, []);
-
- const {
- results: liveEvents,
- status,
- loadMore,
- } = usePaginatedQuery(
- api.events.getPublishedEvents,
- buildFeedQueryArgs(selectedTopic, feedSort),
- { initialNumItems: pageSize },
- );
- // Until the live subscription delivers its first page, fall back to the
- // loader's server-fetched events so the initial (and crawler-visible)
- // HTML contains real content instead of a loading shell.
- const events =
- status === "LoadingFirstPage" && liveEvents.length === 0
- ? initialEvents
- : liveEvents;
- const searchResults = useQuery(
- api.events.searchPublishedEvents,
- isSearching
- ? {
- query: debouncedSearch,
- limit: pageSize,
- topicId: selectedTopic === "all" ? undefined : selectedTopic,
- }
- : "skip",
- );
- const canLoadMore = !isSearching && status === "CanLoadMore";
- const isLoadingMore = !isSearching && status === "LoadingMore";
- const loadMoreRef = useRef(loadMore);
-
- const preferredTopicIds = useMemo(() => {
- if (!topics || !currentUser?.privateContext?.interests?.length) {
- return [];
- }
-
- const preferredNames = currentUser.privateContext.interests.map(
- (interest) => interest.trim().toLowerCase(),
- );
-
- return topics
- .filter((topic) => {
- const candidates = [topic.displayName, ...(topic.aliases ?? [])].map(
- (value) => value.trim().toLowerCase(),
- );
- return candidates.some((candidate) =>
- preferredNames.includes(candidate),
- );
- })
- .map((topic) => topic._id);
- }, [currentUser?.privateContext?.interests, topics]);
-
- const fallbackEvents = useQuery(
- api.events.getPublishedEventsByTopicIds,
- isSearching &&
- searchResults !== undefined &&
- searchResults.length === 0 &&
- preferredTopicIds.length > 0
- ? { topicIds: preferredTopicIds, limit: 5 }
- : "skip",
- );
-
- useEffect(() => {
- loadMoreRef.current = loadMore;
- }, [loadMore]);
-
- useEffect(() => {
- if (status !== "LoadingMore") {
- isLoadingMoreRef.current = false;
- }
- }, [status]);
-
- useEffect(() => {
- if (!canLoadMore) {
- return;
- }
-
- const target = loadMoreTriggerRef.current;
- if (!target) {
- return;
- }
-
- const observer = new IntersectionObserver(
- (entries) => {
- const entry = entries[0];
- if (!entry?.isIntersecting || isLoadingMoreRef.current) {
- return;
- }
-
- isLoadingMoreRef.current = true;
- loadMoreRef.current(pageSize);
- },
- {
- rootMargin: "1200px 0px",
- },
- );
-
- observer.observe(target);
-
- return () => observer.disconnect();
- }, [canLoadMore, pageSize]);
-
- useEffect(() => {
- if (
- debouncedSearch.length < 2 ||
- searchResults === undefined ||
- searchResults.length === 0
- ) {
- return;
- }
-
- const next = [
- debouncedSearch,
- ...recentSearches.filter(
- (entry) => entry.toLowerCase() !== debouncedSearch.toLowerCase(),
- ),
- ].slice(0, 5);
- const isUnchanged =
- next.length === recentSearches.length &&
- next.every((value, index) => value === recentSearches[index]);
- if (isUnchanged) {
- return;
- }
- setRecentSearches(() => next);
- window.localStorage.setItem(
- "miez-recent-event-searches",
- JSON.stringify(next),
- );
- }, [debouncedSearch, recentSearches, searchResults]);
-
- const featuredEvent = events?.[0];
- const remainingEvents = featuredEvent ? events.slice(1) : events;
- const featuredSearchEvent = searchResults?.[0];
- const remainingSearchEvents = featuredSearchEvent
- ? searchResults.slice(1)
- : searchResults;
- const shouldShowThresholdHint =
- isSearchFocused && searchInput.trim().length < 2;
- const shouldShowRecentSearches =
- isSearchFocused &&
- searchInput.trim().length === 0 &&
- recentSearches.length > 0;
-
- return (
-
-
-
- {/* Feed controls: flat, in-flow — no floating glass, no
- scroll-linked motion (BIV-807, native DESIGN_LOG). */}
-
-
- setSearchInput(event.target.value)}
- onFocus={() => setIsSearchFocused(true)}
- onBlur={() => {
- window.setTimeout(() => setIsSearchFocused(false), 100);
- }}
- placeholder={t("feed.search.placeholder")}
- className="h-10 pr-11 text-base"
- aria-label={t("feed.search.label")}
- />
- {searchInput.length > 0 && (
- {
- setSearchInput("");
- setDebouncedSearch("");
- }}
- aria-label={t("feed.search.clear")}
- >
-
-
- )}
-
-
-
-
-
- {!isSearching && (
- /* Plain-text segmented control: weight + color, not pills. */
-
- setFeedSort("recent")}
- aria-pressed={feedSort === "recent"}
- >
- {t("feed.sort.recent")}
-
- setFeedSort("trending")}
- aria-pressed={feedSort === "trending"}
- >
- {t("feed.sort.trending")}
-
-
- )}
-
- {shouldShowThresholdHint && (
-
- {t("feed.search.threshold")}
-
- )}
- {shouldShowRecentSearches && (
-
- {recentSearches.map((recentSearch) => (
- event.preventDefault()}
- onClick={() => {
- setSearchInput(recentSearch);
- setDebouncedSearch(recentSearch);
- searchInputRef.current?.focus();
- }}
- >
- {recentSearch}
-
- ))}
-
- )}
- {isSearching && (
-
- {t("feed.search.indexed").replace("{query}", debouncedSearch)}
-
- )}
-
-
- {!isSearching &&
}
-
-
- {isSearching && searchResults === undefined && (
-
- {t("feed.searching")}
-
- )}
- {status === "LoadingFirstPage" && events.length === 0 && (
-
- {t("feed.loading")}
-
- )}
-
- {!isSearching && featuredEvent ? (
-
-
- {feedSort === "recent"
- ? t("feed.leadStory")
- : t("feed.trendingStory")}
-
-
-
- ) : null}
-
- {isSearching && featuredSearchEvent ? (
-
- {t("feed.topSearch")}
-
-
- ) : null}
-
- {(!isSearching && remainingEvents && remainingEvents.length > 0) ||
- (isSearching &&
- remainingSearchEvents &&
- remainingSearchEvents.length > 0) ? (
-
-
- {isSearching ? t("feed.moreSearch") : t("feed.moreEvents")}
-
-
- {(isSearching ? remainingSearchEvents : remainingEvents)?.map(
- (event: FeedEventCardData) => (
-
-
-
- ),
- )}
-
-
- ) : null}
-
- {isSearching && searchResults?.length === 0 && (
-
-
-
{t("feed.noMatch").replace("{query}", debouncedSearch)}
-
{t("feed.tryFewer")}
-
- {fallbackEvents && fallbackEvents.length > 0 && (
-
-
{t("feed.preferredTopics")}
-
- {fallbackEvents.map((event) => (
-
-
-
- ))}
-
-
- )}
-
- )}
-
- {!isSearching &&
- status !== "LoadingFirstPage" &&
- (!events || events.length === 0) && (
-
- {t("feed.none")}
-
- )}
-
-
- {!isSearching && (canLoadMore || isLoadingMore) && (
-
-
- {isLoadingMore && (
-
- {t("feed.loading")}
-
- )}
-
- )}
-
- {/* Crawlable entry into the paginated archive: a real anchor a
- no-JS crawler can follow, since it cannot trigger the
- infinite-scroll observer above. */}
- {!isSearching && (
-
-
- {t("feed.archive.browse")} →
-
-
- )}
-
-
-
- );
-}
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 3d8ce5b..31582b9 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -1,7 +1,937 @@
-import { createFileRoute, redirect } from "@tanstack/react-router";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentProps,
+} from "react";
+import { createFileRoute, Link, notFound } from "@tanstack/react-router";
+import { z } from "zod";
+import { api } from "@news-app/backend/convex/_generated/api";
+import type { Id } from "@news-app/backend/convex/_generated/dataModel";
+import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react";
+import { buildFeedQueryArgs } from "@/lib/feed-query";
+import { CheckIcon, ChevronDownIcon, FilterIcon, XIcon } from "lucide-react";
+import { QuizCta } from "@/components/quiz-cta";
+import EventCard from "@/components/feed/event-card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { SectionTitle } from "@/components/ui/section-title";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useIsMobile } from "@/components/ui/use-mobile";
+import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches";
+import { useT } from "@/lib/i18n/LocaleContext";
+import { getString } from "@/lib/i18n/strings";
+import { cn } from "@/lib/utils";
+import {
+ SITE,
+ absoluteSiteUrl,
+ jsonLdScript,
+ organizationJsonLd,
+ softwareApplicationJsonLd,
+} from "@/lib/seo";
+
+// The event shape EventCard consumes. Loader/query results feed straight
+// into it; annotating the .map callbacks explicitly keeps the typecheck
+// stable even when the generated loader types resolve loosely in CI.
+type FeedEventCardData = ComponentProps["event"];
+
+// Crawl archive: fixed pages behind ?page=N so a no-JS crawler can reach
+// every published event through real anchors (Googlebot does not scroll).
+const ARCHIVE_PAGE_SIZE = 20;
+// Matches the client-side default page size so hydration swaps the SSR list
+// for the live subscription without a visible jump.
+const SSR_FEED_PAGE_SIZE = 6;
+
+const feedSearchSchema = z.object({
+ page: z.coerce.number().int().min(1).optional().catch(undefined),
+});
export const Route = createFileRoute("/")({
- beforeLoad: () => {
- throw redirect({ to: "/feed", replace: true });
+ validateSearch: feedSearchSchema,
+ loaderDeps: ({ search }) => ({ page: search.page }),
+ loader: async ({ context, deps }) => {
+ const client =
+ context.convexQueryClient.serverHttpClient ?? context.convexClient;
+
+ if (deps.page !== undefined) {
+ let archive;
+ try {
+ archive = await client.query(api.events.getPublishedEventsArchivePage, {
+ page: deps.page,
+ pageSize: ARCHIVE_PAGE_SIZE,
+ });
+ } catch (error) {
+ console.error(
+ `[Route loader] Failed to load feed archive page ${deps.page}:`,
+ error,
+ );
+ return { archive: null };
+ }
+ if (archive.events.length === 0 && deps.page > 1) {
+ // Out-of-range page numbers must 404, not mirror another page.
+ throw notFound();
+ }
+ return { archive };
+ }
+
+ // Interactive feed: server-render the first page of events so the feed
+ // is not a loading shell for crawlers; the live subscription takes over
+ // after hydration. The anonymous trending snapshot keeps this cheap.
+ try {
+ const first = await client.query(api.events.getPublishedEvents, {
+ ...buildFeedQueryArgs("all", "trending"),
+ paginationOpts: { numItems: SSR_FEED_PAGE_SIZE, cursor: null },
+ });
+ return { initialEvents: first.page };
+ } catch (error) {
+ console.error("[Route loader] Failed to load initial feed page:", error);
+ return { initialEvents: [] };
+ }
+ },
+ head: ({ matches, loaderData }) => {
+ const locale = getLocaleFromMatches(matches);
+ const archivePage =
+ loaderData && "archive" in loaderData
+ ? (loaderData.archive?.page ?? null)
+ : null;
+ const baseTitle = getString(locale, "feed.meta.title");
+ const title = archivePage
+ ? `${baseTitle} — ${getString(locale, "feed.archive.page").replace("{page}", String(archivePage))}`
+ : baseTitle;
+ const description = getString(locale, "feed.meta.description");
+ // The feed is served at the root URL; paginated archive pages
+ // self-canonicalize to /?page=N.
+ const canonicalPath = archivePage ? `/?page=${archivePage}` : "/";
+
+ return {
+ meta: [
+ { title },
+ { name: "description", content: description },
+ { property: "og:title", content: title },
+ { property: "og:description", content: description },
+ { property: "og:site_name", content: SITE.name },
+ { name: "twitter:title", content: title },
+ { name: "twitter:description", content: description },
+ { property: "og:image", content: SITE.ogImage },
+ { property: "og:image:alt", content: SITE.ogImageAlt },
+ { name: "twitter:image", content: SITE.ogImage },
+ { property: "og:url", content: absoluteSiteUrl(canonicalPath) },
+ { property: "og:type", content: "website" },
+ { name: "twitter:card", content: "summary_large_image" },
+ { property: "og:locale", content: locale === "ro" ? "ro_RO" : "en_US" },
+ ],
+ links: [{ rel: "canonical", href: absoluteSiteUrl(canonicalPath) }],
+ // Org + app schema only on the landing page (the feed root), not on
+ // every paginated archive page.
+ scripts: archivePage
+ ? []
+ : [
+ jsonLdScript(organizationJsonLd()),
+ jsonLdScript(softwareApplicationJsonLd(description)),
+ ],
+ };
},
+ component: FeedComponent,
});
+
+function TopicFilterContent({
+ topics,
+ selectedTopic,
+ onSelect,
+}: {
+ topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined;
+ selectedTopic: Id<"topics"> | "all";
+ onSelect: (topic: Id<"topics"> | "all") => void;
+}) {
+ const t = useT();
+
+ return (
+
+
+
+ {t("feed.topic.empty")}
+
+ onSelect("all")}
+ className="flex items-center justify-between gap-2"
+ >
+ {t("feed.topic.all")}
+ {selectedTopic === "all" && (
+
+ )}
+
+ {topics?.map((topic) => (
+ onSelect(topic._id)}
+ className="flex items-center justify-between gap-2"
+ >
+ {topic.displayName}
+ {selectedTopic === topic._id && (
+
+ )}
+
+ ))}
+
+
+
+ );
+}
+
+function TopicFilter({
+ topics,
+ selectedTopic,
+ onSelect,
+}: {
+ topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined;
+ selectedTopic: Id<"topics"> | "all";
+ onSelect: (topic: Id<"topics"> | "all") => void;
+}) {
+ const t = useT();
+ const [open, setOpen] = useState(false);
+ const isMobile = useIsMobile();
+
+ const selectedLabel = useMemo(() => {
+ if (selectedTopic === "all") return t("feed.topic.all");
+ return (
+ topics?.find((topic) => topic._id === selectedTopic)?.displayName ??
+ t("feed.topic.single")
+ );
+ }, [selectedTopic, t, topics]);
+
+ const handleSelect = (topic: Id<"topics"> | "all") => {
+ onSelect(topic);
+ setOpen(false);
+ };
+
+ const triggerButton = (
+
+
+
+ {selectedLabel}
+
+
+
+ );
+
+ if (isMobile) {
+ return (
+
+
+ {triggerButton}
+
+
+
+
+ {t("feed.topic.drawerTitle")}
+
+ {t("feed.topic.drawerBody")}
+
+
+
+
+
+ {t("feed.close")}
+
+
+
+
+
+
+
+
+
+
+ {selectedTopic !== "all" && (
+
onSelect("all")}
+ aria-label={t("feed.filter.clear")}
+ >
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+ {triggerButton}
+
+
+
+
+
+ {selectedTopic !== "all" && (
+
onSelect("all")}
+ aria-label={t("feed.filter.clear")}
+ >
+
+
+ )}
+
+ );
+}
+
+function useTopicNamesById() {
+ const topics = useQuery(api.topics.getTopics);
+ const topicNamesById = useMemo(() => {
+ const map: Record = {};
+ topics?.forEach((topic) => {
+ map[topic._id] = topic.displayName;
+ });
+ return map;
+ }, [topics]);
+ return { topics, topicNamesById };
+}
+
+function FeedComponent() {
+ const { page } = Route.useSearch();
+ if (page !== undefined) {
+ return ;
+ }
+ return ;
+}
+
+/**
+ * Static, crawlable slice of the feed (/?page=N): server-rendered event
+ * list in stable recent order with real previous/next anchors. Infinite
+ * scroll on / stays the interactive experience layered on top.
+ */
+function FeedArchive() {
+ const t = useT();
+ const loaderData = Route.useLoaderData();
+ const { topicNamesById } = useTopicNamesById();
+ const archive =
+ loaderData && "archive" in loaderData ? loaderData.archive : null;
+
+ if (!archive) {
+ return (
+
+
+ {t("feed.loading")}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {archive.events.length === 0 ? (
+
+ {t("feed.archive.empty")}
+
+ ) : (
+
+ {archive.events.map((event: FeedEventCardData) => (
+
+
+
+ ))}
+
+ )}
+
+
+ {archive.page > 1 ? (
+
+ ← {t("feed.archive.prev")}
+
+ ) : (
+
+ ← {t("feed.archive.backToFeed")}
+
+ )}
+ {archive.hasMore && (
+
+ {t("feed.archive.next")} →
+
+ )}
+
+
+
+
+ );
+}
+
+function FeedContent() {
+ const t = useT();
+ const loaderData = Route.useLoaderData();
+ const initialEvents =
+ loaderData && "initialEvents" in loaderData ? loaderData.initialEvents : [];
+ const { isAuthenticated } = useConvexAuth();
+ const { topics, topicNamesById } = useTopicNamesById();
+ const currentUser = useQuery(
+ api.user.getCurrentUser,
+ isAuthenticated ? {} : "skip",
+ );
+ const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig);
+ const rawPageSize = Number(runtimeConfig?.feedPageSize);
+ const MAX_FEED_PAGE_SIZE = 50;
+ const pageSize = Number.isFinite(rawPageSize)
+ ? Math.min(MAX_FEED_PAGE_SIZE, Math.max(1, Math.floor(rawPageSize)))
+ : 6;
+
+ const [selectedTopic, setSelectedTopic] = useState | "all">(
+ "all",
+ );
+ const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending");
+ const [searchInput, setSearchInput] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const isSearching = debouncedSearch.length >= 2;
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
+ const [recentSearches, setRecentSearches] = useState([]);
+ const searchInputRef = useRef(null);
+ const loadMoreTriggerRef = useRef(null);
+ const isLoadingMoreRef = useRef(false);
+
+ useEffect(() => {
+ try {
+ const raw = window.localStorage.getItem("miez-recent-event-searches");
+ if (!raw) return;
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ setRecentSearches(
+ parsed
+ .filter((value): value is string => typeof value === "string")
+ .slice(0, 5),
+ );
+ }
+ } catch {
+ // Ignore malformed localStorage.
+ }
+ }, []);
+
+ useEffect(() => {
+ const timeout = window.setTimeout(() => {
+ setDebouncedSearch(searchInput.trim());
+ }, 250);
+
+ return () => window.clearTimeout(timeout);
+ }, [searchInput]);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "/") return;
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
+ return;
+ }
+
+ const target = event.target as HTMLElement | null;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target?.isContentEditable
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ const {
+ results: liveEvents,
+ status,
+ loadMore,
+ } = usePaginatedQuery(
+ api.events.getPublishedEvents,
+ buildFeedQueryArgs(selectedTopic, feedSort),
+ { initialNumItems: pageSize },
+ );
+ // Until the live subscription delivers its first page, fall back to the
+ // loader's server-fetched events so the initial (and crawler-visible)
+ // HTML contains real content instead of a loading shell.
+ const events =
+ status === "LoadingFirstPage" && liveEvents.length === 0
+ ? initialEvents
+ : liveEvents;
+ const searchResults = useQuery(
+ api.events.searchPublishedEvents,
+ isSearching
+ ? {
+ query: debouncedSearch,
+ limit: pageSize,
+ topicId: selectedTopic === "all" ? undefined : selectedTopic,
+ }
+ : "skip",
+ );
+ const canLoadMore = !isSearching && status === "CanLoadMore";
+ const isLoadingMore = !isSearching && status === "LoadingMore";
+ const loadMoreRef = useRef(loadMore);
+
+ const preferredTopicIds = useMemo(() => {
+ if (!topics || !currentUser?.privateContext?.interests?.length) {
+ return [];
+ }
+
+ const preferredNames = currentUser.privateContext.interests.map(
+ (interest) => interest.trim().toLowerCase(),
+ );
+
+ return topics
+ .filter((topic) => {
+ const candidates = [topic.displayName, ...(topic.aliases ?? [])].map(
+ (value) => value.trim().toLowerCase(),
+ );
+ return candidates.some((candidate) =>
+ preferredNames.includes(candidate),
+ );
+ })
+ .map((topic) => topic._id);
+ }, [currentUser?.privateContext?.interests, topics]);
+
+ const fallbackEvents = useQuery(
+ api.events.getPublishedEventsByTopicIds,
+ isSearching &&
+ searchResults !== undefined &&
+ searchResults.length === 0 &&
+ preferredTopicIds.length > 0
+ ? { topicIds: preferredTopicIds, limit: 5 }
+ : "skip",
+ );
+
+ useEffect(() => {
+ loadMoreRef.current = loadMore;
+ }, [loadMore]);
+
+ useEffect(() => {
+ if (status !== "LoadingMore") {
+ isLoadingMoreRef.current = false;
+ }
+ }, [status]);
+
+ useEffect(() => {
+ if (!canLoadMore) {
+ return;
+ }
+
+ const target = loadMoreTriggerRef.current;
+ if (!target) {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry?.isIntersecting || isLoadingMoreRef.current) {
+ return;
+ }
+
+ isLoadingMoreRef.current = true;
+ loadMoreRef.current(pageSize);
+ },
+ {
+ rootMargin: "1200px 0px",
+ },
+ );
+
+ observer.observe(target);
+
+ return () => observer.disconnect();
+ }, [canLoadMore, pageSize]);
+
+ useEffect(() => {
+ if (
+ debouncedSearch.length < 2 ||
+ searchResults === undefined ||
+ searchResults.length === 0
+ ) {
+ return;
+ }
+
+ const next = [
+ debouncedSearch,
+ ...recentSearches.filter(
+ (entry) => entry.toLowerCase() !== debouncedSearch.toLowerCase(),
+ ),
+ ].slice(0, 5);
+ const isUnchanged =
+ next.length === recentSearches.length &&
+ next.every((value, index) => value === recentSearches[index]);
+ if (isUnchanged) {
+ return;
+ }
+ setRecentSearches(() => next);
+ window.localStorage.setItem(
+ "miez-recent-event-searches",
+ JSON.stringify(next),
+ );
+ }, [debouncedSearch, recentSearches, searchResults]);
+
+ const featuredEvent = events?.[0];
+ const remainingEvents = featuredEvent ? events.slice(1) : events;
+ const featuredSearchEvent = searchResults?.[0];
+ const remainingSearchEvents = featuredSearchEvent
+ ? searchResults.slice(1)
+ : searchResults;
+ const shouldShowThresholdHint =
+ isSearchFocused && searchInput.trim().length < 2;
+ const shouldShowRecentSearches =
+ isSearchFocused &&
+ searchInput.trim().length === 0 &&
+ recentSearches.length > 0;
+
+ return (
+
+
+
+ {/* Feed controls: flat, in-flow — no floating glass, no
+ scroll-linked motion (BIV-807, native DESIGN_LOG). */}
+
+
+ setSearchInput(event.target.value)}
+ onFocus={() => setIsSearchFocused(true)}
+ onBlur={() => {
+ window.setTimeout(() => setIsSearchFocused(false), 100);
+ }}
+ placeholder={t("feed.search.placeholder")}
+ className="h-10 pr-11 text-base"
+ aria-label={t("feed.search.label")}
+ />
+ {searchInput.length > 0 && (
+ {
+ setSearchInput("");
+ setDebouncedSearch("");
+ }}
+ aria-label={t("feed.search.clear")}
+ >
+
+
+ )}
+
+
+
+
+
+ {!isSearching && (
+ /* Plain-text segmented control: weight + color, not pills. */
+
+ setFeedSort("recent")}
+ aria-pressed={feedSort === "recent"}
+ >
+ {t("feed.sort.recent")}
+
+ setFeedSort("trending")}
+ aria-pressed={feedSort === "trending"}
+ >
+ {t("feed.sort.trending")}
+
+
+ )}
+
+ {shouldShowThresholdHint && (
+
+ {t("feed.search.threshold")}
+
+ )}
+ {shouldShowRecentSearches && (
+
+ {recentSearches.map((recentSearch) => (
+ event.preventDefault()}
+ onClick={() => {
+ setSearchInput(recentSearch);
+ setDebouncedSearch(recentSearch);
+ searchInputRef.current?.focus();
+ }}
+ >
+ {recentSearch}
+
+ ))}
+
+ )}
+ {isSearching && (
+
+ {t("feed.search.indexed").replace("{query}", debouncedSearch)}
+
+ )}
+
+
+ {!isSearching &&
}
+
+
+ {isSearching && searchResults === undefined && (
+
+ {t("feed.searching")}
+
+ )}
+ {status === "LoadingFirstPage" && events.length === 0 && (
+
+ {t("feed.loading")}
+
+ )}
+
+ {!isSearching && featuredEvent ? (
+
+
+ {feedSort === "recent"
+ ? t("feed.leadStory")
+ : t("feed.trendingStory")}
+
+
+
+ ) : null}
+
+ {isSearching && featuredSearchEvent ? (
+
+ {t("feed.topSearch")}
+
+
+ ) : null}
+
+ {(!isSearching && remainingEvents && remainingEvents.length > 0) ||
+ (isSearching &&
+ remainingSearchEvents &&
+ remainingSearchEvents.length > 0) ? (
+
+
+ {isSearching ? t("feed.moreSearch") : t("feed.moreEvents")}
+
+
+ {(isSearching ? remainingSearchEvents : remainingEvents)?.map(
+ (event: FeedEventCardData) => (
+
+
+
+ ),
+ )}
+
+
+ ) : null}
+
+ {isSearching && searchResults?.length === 0 && (
+
+
+
{t("feed.noMatch").replace("{query}", debouncedSearch)}
+
{t("feed.tryFewer")}
+
+ {fallbackEvents && fallbackEvents.length > 0 && (
+
+
{t("feed.preferredTopics")}
+
+ {fallbackEvents.map((event) => (
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {!isSearching &&
+ status !== "LoadingFirstPage" &&
+ (!events || events.length === 0) && (
+
+ {t("feed.none")}
+
+ )}
+
+
+ {!isSearching && (canLoadMore || isLoadingMore) && (
+
+
+ {isLoadingMore && (
+
+ {t("feed.loading")}
+
+ )}
+
+ )}
+
+ {/* Crawlable entry into the paginated archive: a real anchor a
+ no-JS crawler can follow, since it cannot trigger the
+ infinite-scroll observer above. */}
+ {!isSearching && (
+
+
+ {t("feed.archive.browse")} →
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/llms[.]txt.ts b/apps/web/src/routes/llms[.]txt.ts
index 04d2938..f614094 100644
--- a/apps/web/src/routes/llms[.]txt.ts
+++ b/apps/web/src/routes/llms[.]txt.ts
@@ -12,7 +12,7 @@ const LLMS_TXT = `# ${BRAND_NAME}
## Key pages
-- [News feed](${absoluteSiteUrl("/feed")}): Live stream of clustered news events; each event aggregates and summarizes multiple sources.
+- [News feed](${absoluteSiteUrl("/")}): Live stream of clustered news events; each event aggregates and summarizes multiple sources.
- [How it works](${absoluteSiteUrl("/cum-functioneaza")}): How ${BRAND_NAME} clusters articles into events and produces balanced, multi-perspective summaries.
- [Methodology](${absoluteSiteUrl("/metodologie")}): Source-rating and bias-balancing methodology.
- [Our sources](${absoluteSiteUrl("/surse")}): The Romanian publications ${BRAND_NAME} monitors.
diff --git a/apps/web/src/routes/news-sitemap[.]xml.ts b/apps/web/src/routes/news-sitemap[.]xml.ts
new file mode 100644
index 0000000..cfabb32
--- /dev/null
+++ b/apps/web/src/routes/news-sitemap[.]xml.ts
@@ -0,0 +1,94 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ConvexHttpClient } from "convex/browser";
+import { api } from "@news-app/backend/convex/_generated/api";
+import { SITE, absoluteSiteUrl, deriveShortTitle } from "@/lib/seo";
+
+const convexUrl = process.env.VITE_CONVEX_URL!;
+
+// Google News only wants articles from roughly the last two days.
+const NEWS_WINDOW_MS = 48 * 60 * 60 * 1000;
+const NEWS_ITEM_LIMIT = 100;
+
+function escapeXml(value: string) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function buildNewsSitemapHeaders() {
+ return {
+ "content-type": "application/xml; charset=utf-8",
+ "cache-control": "public, max-age=600, s-maxage=600",
+ };
+}
+
+type SyndicationEvent = {
+ slug: string;
+ title: string;
+ summary: string;
+ firstPublishedAt: number;
+ lastUpdatedAt: number;
+};
+
+function buildNewsSitemapXml(events: SyndicationEvent[]) {
+ const cutoff = Date.now() - NEWS_WINDOW_MS;
+ const urls = events
+ .filter((event) => event.firstPublishedAt >= cutoff)
+ .map((event) => {
+ const loc = absoluteSiteUrl(`/event/${event.slug}`);
+ const title = deriveShortTitle(event.title);
+ const publicationDate = new Date(event.firstPublishedAt).toISOString();
+ return [
+ "",
+ `${escapeXml(loc)} `,
+ "",
+ "",
+ `${escapeXml(SITE.name)} `,
+ "ro ",
+ " ",
+ `${publicationDate} `,
+ `${escapeXml(title)} `,
+ " ",
+ " ",
+ ].join("");
+ })
+ .join("");
+
+ return [
+ '',
+ '',
+ urls,
+ " ",
+ ].join("");
+}
+
+export const Route = createFileRoute("/news-sitemap.xml")({
+ server: {
+ handlers: {
+ HEAD: async () =>
+ new Response(null, {
+ status: 200,
+ headers: buildNewsSitemapHeaders(),
+ }),
+ GET: async () => {
+ const client = new ConvexHttpClient(convexUrl);
+ let events: SyndicationEvent[] = [];
+ try {
+ events = await client.query(api.events.getSyndicationEvents, {
+ limit: NEWS_ITEM_LIMIT,
+ });
+ } catch (error) {
+ console.error("Failed to load events for /news-sitemap.xml:", error);
+ }
+
+ return new Response(buildNewsSitemapXml(events), {
+ status: 200,
+ headers: buildNewsSitemapHeaders(),
+ });
+ },
+ },
+ },
+});
diff --git a/apps/web/src/routes/quiz.tsx b/apps/web/src/routes/quiz.tsx
index 412c3d9..6755a20 100644
--- a/apps/web/src/routes/quiz.tsx
+++ b/apps/web/src/routes/quiz.tsx
@@ -161,7 +161,7 @@ function QuizRoute() {
- {t("quiz.empty.action")}
+ {t("quiz.empty.action")}
@@ -510,7 +510,7 @@ function QuizExperience({
{t("quiz.share.action")}
- {t("quiz.backToFeed")}
+ {t("quiz.backToFeed")}
diff --git a/apps/web/src/routes/robots[.]txt.ts b/apps/web/src/routes/robots[.]txt.ts
index 12e0e61..3bc7649 100644
--- a/apps/web/src/routes/robots[.]txt.ts
+++ b/apps/web/src/routes/robots[.]txt.ts
@@ -16,6 +16,7 @@ Disallow: /api/
Disallow: /unsubscribe
Sitemap: ${absoluteSiteUrl("/sitemap.xml")}
+Sitemap: ${absoluteSiteUrl("/news-sitemap.xml")}
`;
function buildRobotsHeaders() {
diff --git a/apps/web/src/routes/rss[.]xml.ts b/apps/web/src/routes/rss[.]xml.ts
new file mode 100644
index 0000000..8bb1047
--- /dev/null
+++ b/apps/web/src/routes/rss[.]xml.ts
@@ -0,0 +1,108 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ConvexHttpClient } from "convex/browser";
+import { api } from "@news-app/backend/convex/_generated/api";
+import {
+ SITE,
+ absoluteSiteUrl,
+ deriveShortTitle,
+ truncateAtWordBoundary,
+} from "@/lib/seo";
+import { getString } from "@/lib/i18n/strings";
+
+const convexUrl = process.env.VITE_CONVEX_URL!;
+
+// Latest N summarized events (thin-page gated in Convex).
+const RSS_ITEM_LIMIT = 50;
+
+function escapeXml(value: string) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function buildRssHeaders() {
+ return {
+ "content-type": "application/rss+xml; charset=utf-8",
+ // Short cache: feed readers/crawlers poll frequently; keep it fresh.
+ "cache-control": "public, max-age=600, s-maxage=600",
+ };
+}
+
+type SyndicationEvent = {
+ slug: string;
+ title: string;
+ summary: string;
+ firstPublishedAt: number;
+ lastUpdatedAt: number;
+};
+
+function buildRssXml(events: SyndicationEvent[]) {
+ const feedTitle = SITE.name;
+ const feedDescription = getString("ro", "feed.meta.description");
+ const selfHref = absoluteSiteUrl("/rss.xml");
+ const homeHref = absoluteSiteUrl("/");
+ const lastBuildDate = new Date(
+ events[0]?.firstPublishedAt ?? Date.now(),
+ ).toUTCString();
+
+ const items = events
+ .map((event) => {
+ const link = absoluteSiteUrl(`/event/${event.slug}`);
+ const title = deriveShortTitle(event.title);
+ const description = truncateAtWordBoundary(event.summary, 400);
+ const pubDate = new Date(event.firstPublishedAt).toUTCString();
+ return [
+ "- ",
+ `
${escapeXml(title)} `,
+ ` ${escapeXml(link)}`,
+ `${escapeXml(link)} `,
+ `${pubDate} `,
+ `${escapeXml(description)} `,
+ " ",
+ ].join("");
+ })
+ .join("");
+
+ return [
+ '',
+ '',
+ "",
+ `${escapeXml(feedTitle)} `,
+ ` ${escapeXml(homeHref)}`,
+ `${escapeXml(feedDescription)} `,
+ "ro ",
+ ` `,
+ `${lastBuildDate} `,
+ items,
+ " ",
+ " ",
+ ].join("");
+}
+
+export const Route = createFileRoute("/rss.xml")({
+ server: {
+ handlers: {
+ HEAD: async () =>
+ new Response(null, { status: 200, headers: buildRssHeaders() }),
+ GET: async () => {
+ const client = new ConvexHttpClient(convexUrl);
+ let events: SyndicationEvent[] = [];
+ try {
+ events = await client.query(api.events.getSyndicationEvents, {
+ limit: RSS_ITEM_LIMIT,
+ });
+ } catch (error) {
+ console.error("Failed to load events for /rss.xml:", error);
+ }
+
+ return new Response(buildRssXml(events), {
+ status: 200,
+ headers: buildRssHeaders(),
+ });
+ },
+ },
+ },
+});
diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx
index d7e1a88..3957d6f 100644
--- a/apps/web/src/routes/salvate.tsx
+++ b/apps/web/src/routes/salvate.tsx
@@ -117,7 +117,7 @@ function SalvateContent() {
- {t("saved.browseFeed")}
+ {t("saved.browseFeed")}
) : (
diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts
index 4b96176..55f8b2c 100644
--- a/apps/web/src/routes/sitemap[.]xml.ts
+++ b/apps/web/src/routes/sitemap[.]xml.ts
@@ -30,7 +30,7 @@ function buildSitemapHeaders() {
}
function buildFallbackSitemapXml() {
- const entries = [toSitemapUrl("/"), toSitemapUrl("/feed")];
+ const entries = [toSitemapUrl("/")];
return [
'',
'',
diff --git a/apps/web/src/routes/source.$sourceId.tsx b/apps/web/src/routes/source.$sourceId.tsx
index bf59f56..bbde896 100644
--- a/apps/web/src/routes/source.$sourceId.tsx
+++ b/apps/web/src/routes/source.$sourceId.tsx
@@ -144,7 +144,7 @@ function SourceNotFound() {
{t("source.notFound")}
{t("source.notFoundBody")}
- {t("source.backToFeed")}
+ {t("source.backToFeed")}
@@ -212,7 +212,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {
@@ -431,7 +431,7 @@ function InvalidSourceId() {
{t("source.notFound")}
{t("source.invalidBody")}
- {t("source.backToFeed")}
+ {t("source.backToFeed")}
diff --git a/docs/seo-batch-2-runbook.md b/docs/seo-batch-2-runbook.md
new file mode 100644
index 0000000..6527a40
--- /dev/null
+++ b/docs/seo-batch-2-runbook.md
@@ -0,0 +1,80 @@
+# SEO batch 2 (SEO-1..11) — runbook & verification
+
+Branch: `feat/seo-batch-2`. Implements SEO-1, 2, 5, 6, 3, 4, 7, 8, 10, 11.
+**SEO-9 (per-event dynamic OG images) was intentionally skipped** — it conflicts
+with the standing "custom event share/OG images stay OFF" decision (event OG
+keeps falling back to the original event photo).
+
+## What changed (per ticket)
+
+- **SEO-1** — Feed now renders at `/` (moved from `feed.tsx` into `index.tsx`,
+ incl. the `?page=N` crawl archive). `/feed` is a **308** redirect to `/` that
+ preserves query params (`/feed?page=2 → /?page=2`). Every internal
+ `to="/feed"` link, the header/mobile nav, `bookmark-button` default,
+ `feature-flags` guard, `llms.txt`, and the sitemap fallback now point to `/`.
+- **SEO-2** — Root cause of English meta on a `lang="ro"` site: `getLocaleFromMatches`
+ defaulted to `"en"` while the product default is `"ro"`. Fixed the fallback to
+ `"ro"` (fixes meta on **every** route at once). Homepage title/description/
+ og:image:alt set to the Romanian launch copy.
+- **SEO-5** — `deriveShortTitle()` in `lib/seo.ts`: first headline segment before
+ the ` / ` join, capped ~65 chars on a word boundary. Used in event
+ ``/og:title/twitter:title, RSS items, and the news sitemap. The long
+ compound title stays as the on-page ``.
+- **SEO-6** — `truncateAtWordBoundary()` in `lib/seo.ts`: ~155-char word-boundary
+ cut, single trailing `…`, trailing whitespace/punctuation stripped. Applied to
+ the event meta/og description and the NewsArticle JSON-LD description.
+- **SEO-3** — `/rss.xml` (RSS 2.0, latest ~50 summarized events, short titles,
+ neutral-summary descriptions, RFC-822 `pubDate`, permalink guid). Declared
+ site-wide via ` ` in `__root`.
+ `content-type: application/rss+xml`, 10-min cache.
+- **SEO-4** — `/news-sitemap.xml` (Google News namespace, only `<48h` events,
+ `Miez`/`ro` publication, short titles). Added to `robots.txt` alongside the
+ main sitemap.
+- **SEO-7** — Structured-data logo switched from `favicon.svg` to the existing
+ 512×512 `logo-mark.png` (raster) in both `organizationEntity` and the event
+ NewsArticle `publisher.logo`.
+- **SEO-8** — Removed `?returnToFeed=1` from event hrefs. "Came from feed" is now
+ carried in `sessionStorage` (set on the feed card click, read+cleared on the
+ event page), so crawlers only see the clean canonical `/event/$slug`.
+- **SEO-10** — `manifest.webmanifest` already existed, is linked, and ships a
+ 512×512 PNG icon → installability already satisfied. No change needed.
+
+## Local verification (dev server, `feat/seo-batch-2`)
+
+- `curl -sI /feed` → `308`, `location: /`
+- `curl /feed?page=2` → `308 → /?page=2`
+- `curl -sI /` → `200`
+- Homepage meta: title/description/og/twitter/og:image:alt all Romanian;
+ `canonical = https://www.miez.news/`; RSS alternate link present.
+- `robots.txt` lists both `sitemap.xml` and `news-sitemap.xml`.
+- `llms.txt` "News feed" → `/` (no `/feed`).
+- `/rss.xml` → 200 `application/rss+xml`, valid channel; `/news-sitemap.xml` → 200
+ `application/xml`, valid news namespace.
+- Event page: `` 70 chars (≤75), description ends on a word boundary with
+ `…`, `publisher.logo = /logo-mark.png`, NewsArticle JSON-LD intact.
+- `logo-mark.png` → `image/png`; `manifest.webmanifest` → `application/manifest+json`.
+- Typecheck clean; 83/83 unit tests pass; production build succeeds.
+
+> RSS/news feeds returned **0 items** locally only because the new Convex query
+> `events.getSyndicationEvents` is not deployed to the dev deployment yet (the
+> routes catch the error and return a valid empty feed). See deploy steps.
+
+## Deploy-time dependencies (do in order)
+
+1. **Deploy `packages/backend` to prod Convex** — adds `events.getSyndicationEvents`
+ (consumed by `/rss.xml` + `/news-sitemap.xml`) and removes `/feed` from the
+ sitemap `STATIC_PATHS`. ⚠️ Watch the Convex CLI cwd gotcha — deploy from the
+ backend package dir; a wrong-cwd push can wipe dev functions.
+2. **Rebuild the public sitemap snapshot** (`internal.sitemap.rebuildPublicSitemapSnapshot`)
+ so `/feed` drops out of the cached `sitemap.xml`. Until then the snapshot
+ still advertises `/feed` (which now 308s — harmless but not ideal).
+3. **Deploy web** so `/`, the 308, the two feeds, and the meta fixes go live.
+
+## SEO-11 close-out (manual, in Google Search Console — cannot be automated here)
+
+- [ ] Resubmit `sitemap.xml` and `news-sitemap.xml`.
+- [ ] URL Inspection → "Request indexing" on `/`, `/despre`, `/surse`, and 2–3 events.
+- [ ] Confirm both sitemaps report "Success" on the next read.
+- [ ] Rich Results Test on 3 event URLs → zero errors (logo now PNG).
+- [ ] Re-run the prod audit: root `200`, `/feed` `308`, zero English meta,
+ RSS valid, `grep -c returnToFeed` on homepage HTML = 0.
diff --git a/packages/backend/convex/events.ts b/packages/backend/convex/events.ts
index 4d3624a..7432d14 100644
--- a/packages/backend/convex/events.ts
+++ b/packages/backend/convex/events.ts
@@ -366,6 +366,47 @@ export const getSitemapPublishedEvents = query({
},
});
+/**
+ * Recent published events for syndication feeds (/rss.xml and
+ * /news-sitemap.xml). Newest first by publication time, thin-page gated: an
+ * event with no neutral AI summary is mostly third-party RSS text and must not
+ * be syndicated (mirrors the sitemap discipline). Returns the fields the feeds
+ * need — the web layer derives the short headline and truncates copy.
+ */
+export const getSyndicationEvents = query({
+ args: { limit: v.optional(v.number()) },
+ handler: async (ctx, args) => {
+ const safeLimit = Math.min(Math.max(Math.floor(args.limit ?? 50), 1), 100);
+ // Over-fetch, then keep only summarized events until we have `safeLimit`.
+ const rows = await ctx.db
+ .query("publicEventPreviews")
+ .withIndex("by_first_published_at")
+ .order("desc")
+ .take(safeLimit * 4);
+
+ const events: Array<{
+ slug: string;
+ title: string;
+ summary: string;
+ firstPublishedAt: number;
+ lastUpdatedAt: number;
+ }> = [];
+ for (const event of rows) {
+ const neutral = event.perspectiveSummaries?.neutral?.trim();
+ if (!neutral) continue;
+ events.push({
+ slug: event.slug,
+ title: event.title,
+ summary: neutral,
+ firstPublishedAt: event.firstPublishedAt,
+ lastUpdatedAt: event.lastUpdatedAt,
+ });
+ if (events.length >= safeLimit) break;
+ }
+ return events;
+ },
+});
+
export const backfillPublicPreviewReadModels = internalMutation({
args: {
cursor: v.optional(v.string()),
diff --git a/packages/backend/convex/sitemap.ts b/packages/backend/convex/sitemap.ts
index d98f927..4e2503e 100644
--- a/packages/backend/convex/sitemap.ts
+++ b/packages/backend/convex/sitemap.ts
@@ -12,8 +12,9 @@ const SITEMAP_PAGE_SIZE = 1000;
// Public indexable routes without per-row lastmod.
const STATIC_PATHS = [
+ // The feed is served at the root (SEO-1); /feed only 308-redirects here, so
+ // it must not appear as its own indexable URL.
"/",
- "/feed",
"/surse",
"/cum-functioneaza",
"/sursele-noastre",
diff --git a/packages/i18n/src/strings.ts b/packages/i18n/src/strings.ts
index f07f9d2..e8e3c71 100644
--- a/packages/i18n/src/strings.ts
+++ b/packages/i18n/src/strings.ts
@@ -292,9 +292,9 @@ const ro = {
// MIEZ-7: methodology + funding transparency pages.
"footer.methodology": "Metodologie",
"footer.funding": "Cine finanțează Miez",
- "feed.meta.title": `Feed — ${BRAND_NAME}`,
+ "feed.meta.title": `${BRAND_NAME} - știri din ambele tabere`,
"feed.meta.description":
- "Explorează subiectele importante ale zilei din perspective multiple. Filtrează după topic și urmărește același eveniment în mai multe surse.",
+ "Urmărește știrile zilei din ambele tabere, reformistă și suveranistă. Același eveniment, toate sursele, fără cont.",
"feed.topic.search": "Caută topicuri...",
"feed.topic.empty": "Nu am găsit topicuri.",
"feed.topic.all": "Toate topicurile",
From c1c43e790126947f9b3fbc0d6ed6bd44514090b4 Mon Sep 17 00:00:00 2001
From: flavius
Date: Sun, 12 Jul 2026 16:53:14 +0300
Subject: [PATCH 2/2] fix: address SEO batch-2 review findings
- MobileTabBar: keep Feed tab inactive on the /?page=N archive and route
taps back to a clean "/" instead of a scroll-to-top no-op
- event.$slug: reset cameFromFeed at the start of the slug effect so
client-side jumps don't inherit the previous event's flag
- feed archive: show an error state with retry (router.invalidate) when
the loader query throws, instead of a permanent loading spinner
- search no-results: add role=status/aria-live=polite
- dedupe escapeXml + SyndicationEvent into @/lib/syndication across the
rss/sitemap/news-sitemap routes
- feed.meta.title: use em dash to match the other meta titles
Co-Authored-By: Claude Opus 4.8
---
.../src/components/layout/MobileTabBar.tsx | 28 +++++++++++---
apps/web/src/lib/syndication.ts | 22 +++++++++++
apps/web/src/routes/event.$slug.tsx | 3 ++
apps/web/src/routes/index.tsx | 38 ++++++++++++++-----
apps/web/src/routes/news-sitemap[.]xml.ts | 18 +--------
apps/web/src/routes/rss[.]xml.ts | 18 +--------
apps/web/src/routes/sitemap[.]xml.ts | 10 +----
packages/i18n/src/strings.ts | 6 ++-
8 files changed, 84 insertions(+), 59 deletions(-)
create mode 100644 apps/web/src/lib/syndication.ts
diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx
index 1ed9d37..9145640 100644
--- a/apps/web/src/components/layout/MobileTabBar.tsx
+++ b/apps/web/src/components/layout/MobileTabBar.tsx
@@ -58,8 +58,18 @@ function matchesPath(pathname: string, to: string, allowPrefix = false) {
*/
export function MobileTabBar() {
const t = useT();
- const pathname = useRouterState({
- select: (state) => state.location.pathname,
+ // Track the archive variant of the feed (/?page=N) separately: its pathname
+ // is still "/", but it's not the live Feed, so the Feed tab must stay inactive
+ // there and tapping it must navigate back to a clean "/" rather than no-op.
+ const { pathname, isArchiveRoot } = useRouterState({
+ select: (state) => {
+ const search = state.location.search as { page?: unknown };
+ return {
+ pathname: state.location.pathname,
+ isArchiveRoot:
+ state.location.pathname === "/" && search.page !== undefined,
+ };
+ },
});
const handleTabClick =
@@ -84,10 +94,16 @@ export function MobileTabBar() {
{tabDefinitions.map(
({ to, key, icon: Icon, isActive: customIsActive }) => {
- const isActive = customIsActive
- ? customIsActive(pathname)
- : matchesPath(pathname, to);
- const isSameDestination = pathname === to;
+ const onArchiveRoot = to === "/" && isArchiveRoot;
+ const isActive = onArchiveRoot
+ ? false
+ : customIsActive
+ ? customIsActive(pathname)
+ : matchesPath(pathname, to);
+ // On the archive (/?page=N) the Feed link still points at "/", so
+ // treat it as a real navigation (clean "/") instead of a same-page
+ // scroll-to-top no-op.
+ const isSameDestination = pathname === to && !onArchiveRoot;
const label = t(key);
return (
diff --git a/apps/web/src/lib/syndication.ts b/apps/web/src/lib/syndication.ts
new file mode 100644
index 0000000..8049a07
--- /dev/null
+++ b/apps/web/src/lib/syndication.ts
@@ -0,0 +1,22 @@
+// Shared helpers for the web syndication routes (rss.xml, sitemap.xml,
+// news-sitemap.xml). Kept in one place so the XML-escaping order and the
+// event shape stay identical across feeds.
+
+/** Escape the five XML special characters. Order matters: `&` first. */
+export function escapeXml(value: string) {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+/** Shape returned by `api.events.getSyndicationEvents`. */
+export type SyndicationEvent = {
+ slug: string;
+ title: string;
+ summary: string;
+ firstPublishedAt: number;
+ lastUpdatedAt: number;
+};
diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx
index 5173a2f..b110a96 100644
--- a/apps/web/src/routes/event.$slug.tsx
+++ b/apps/web/src/routes/event.$slug.tsx
@@ -284,6 +284,9 @@ function EventDetailPage() {
// popping unrelated history.
const [cameFromFeed, setCameFromFeed] = useState(false);
useEffect(() => {
+ // Start each event navigation clean so a client-side jump to an event that
+ // wasn't opened from the feed doesn't inherit the previous event's flag.
+ setCameFromFeed(false);
try {
if (window.sessionStorage.getItem(RETURN_TO_FEED_KEY) === "1") {
setCameFromFeed(true);
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 31582b9..1b202b0 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -5,7 +5,12 @@ import {
useState,
type ComponentProps,
} from "react";
-import { createFileRoute, Link, notFound } from "@tanstack/react-router";
+import {
+ createFileRoute,
+ Link,
+ notFound,
+ useRouter,
+} from "@tanstack/react-router";
import { z } from "zod";
import { api } from "@news-app/backend/convex/_generated/api";
import type { Id } from "@news-app/backend/convex/_generated/dataModel";
@@ -356,21 +361,32 @@ function FeedComponent() {
*/
function FeedArchive() {
const t = useT();
+ const router = useRouter();
const loaderData = Route.useLoaderData();
const { topicNamesById } = useTopicNamesById();
const archive =
loaderData && "archive" in loaderData ? loaderData.archive : null;
+ // The loader returns { archive: null } only when the Convex query threw, so
+ // this is an error state (not a first paint) — surface it with a retry that
+ // re-runs the route loader instead of a spinner that never resolves.
if (!archive) {
return (
-
- {t("feed.loading")}
-
+
+
+ {t("feed.archive.error")}
+
+
{
+ void router.invalidate();
+ }}
+ >
+ {t("feed.archive.retry")}
+
+
);
}
@@ -857,7 +873,11 @@ function FeedContent() {
) : null}
{isSearching && searchResults?.length === 0 && (
-
+
{t("feed.noMatch").replace("{query}", debouncedSearch)}
{t("feed.tryFewer")}
diff --git a/apps/web/src/routes/news-sitemap[.]xml.ts b/apps/web/src/routes/news-sitemap[.]xml.ts
index cfabb32..6fae49f 100644
--- a/apps/web/src/routes/news-sitemap[.]xml.ts
+++ b/apps/web/src/routes/news-sitemap[.]xml.ts
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@news-app/backend/convex/_generated/api";
import { SITE, absoluteSiteUrl, deriveShortTitle } from "@/lib/seo";
+import { escapeXml, type SyndicationEvent } from "@/lib/syndication";
const convexUrl = process.env.VITE_CONVEX_URL!;
@@ -9,15 +10,6 @@ const convexUrl = process.env.VITE_CONVEX_URL!;
const NEWS_WINDOW_MS = 48 * 60 * 60 * 1000;
const NEWS_ITEM_LIMIT = 100;
-function escapeXml(value: string) {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
function buildNewsSitemapHeaders() {
return {
"content-type": "application/xml; charset=utf-8",
@@ -25,14 +17,6 @@ function buildNewsSitemapHeaders() {
};
}
-type SyndicationEvent = {
- slug: string;
- title: string;
- summary: string;
- firstPublishedAt: number;
- lastUpdatedAt: number;
-};
-
function buildNewsSitemapXml(events: SyndicationEvent[]) {
const cutoff = Date.now() - NEWS_WINDOW_MS;
const urls = events
diff --git a/apps/web/src/routes/rss[.]xml.ts b/apps/web/src/routes/rss[.]xml.ts
index 8bb1047..de3f606 100644
--- a/apps/web/src/routes/rss[.]xml.ts
+++ b/apps/web/src/routes/rss[.]xml.ts
@@ -8,21 +8,13 @@ import {
truncateAtWordBoundary,
} from "@/lib/seo";
import { getString } from "@/lib/i18n/strings";
+import { escapeXml, type SyndicationEvent } from "@/lib/syndication";
const convexUrl = process.env.VITE_CONVEX_URL!;
// Latest N summarized events (thin-page gated in Convex).
const RSS_ITEM_LIMIT = 50;
-function escapeXml(value: string) {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
function buildRssHeaders() {
return {
"content-type": "application/rss+xml; charset=utf-8",
@@ -31,14 +23,6 @@ function buildRssHeaders() {
};
}
-type SyndicationEvent = {
- slug: string;
- title: string;
- summary: string;
- firstPublishedAt: number;
- lastUpdatedAt: number;
-};
-
function buildRssXml(events: SyndicationEvent[]) {
const feedTitle = SITE.name;
const feedDescription = getString("ro", "feed.meta.description");
diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts
index 55f8b2c..bbf5fba 100644
--- a/apps/web/src/routes/sitemap[.]xml.ts
+++ b/apps/web/src/routes/sitemap[.]xml.ts
@@ -2,18 +2,10 @@ import { createFileRoute } from "@tanstack/react-router";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@news-app/backend/convex/_generated/api";
import { absoluteSiteUrl } from "@/lib/seo";
+import { escapeXml } from "@/lib/syndication";
const convexUrl = process.env.VITE_CONVEX_URL!;
-function escapeXml(value: string) {
- return value
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
function toSitemapUrl(pathname: string, lastModifiedAt?: number) {
const url = absoluteSiteUrl(pathname);
const lastmod = lastModifiedAt
diff --git a/packages/i18n/src/strings.ts b/packages/i18n/src/strings.ts
index e8e3c71..e61ecbc 100644
--- a/packages/i18n/src/strings.ts
+++ b/packages/i18n/src/strings.ts
@@ -292,7 +292,7 @@ const ro = {
// MIEZ-7: methodology + funding transparency pages.
"footer.methodology": "Metodologie",
"footer.funding": "Cine finanțează Miez",
- "feed.meta.title": `${BRAND_NAME} - știri din ambele tabere`,
+ "feed.meta.title": `${BRAND_NAME} — știri din ambele tabere`,
"feed.meta.description":
"Urmărește știrile zilei din ambele tabere, reformistă și suveranistă. Același eveniment, toate sursele, fără cont.",
"feed.topic.search": "Caută topicuri...",
@@ -369,6 +369,8 @@ const ro = {
"feed.archive.backToFeed": "Înapoi la feed",
"feed.archive.browse": "Răsfoiește arhiva completă a știrilor",
"feed.archive.empty": "Nu există evenimente pe această pagină.",
+ "feed.archive.error": "Nu am putut încărca arhiva.",
+ "feed.archive.retry": "Încearcă din nou",
"event.general": "General",
"event.summaryPending": "Rezumatul se pregătește...",
"event.coveragePreview":
@@ -1174,6 +1176,8 @@ const en: { [K in keyof BaseStrings]: string } = {
"feed.archive.backToFeed": "Back to feed",
"feed.archive.browse": "Browse the full news archive",
"feed.archive.empty": "No events on this page.",
+ "feed.archive.error": "We couldn't load the archive.",
+ "feed.archive.retry": "Try again",
"event.general": "General",
"event.summaryPending": "Summary is on the way...",
"event.coveragePreview":