diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c188faf..32cefe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,3 +229,73 @@ jobs: - uses: actions/checkout@v4 - name: Build all images run: NODE_VERSION=$(cat .nvmrc) docker compose build + + # ── 6. Bundle-size budget (size-limit on the built frontend) ───────────────── + bundle-size: + name: Bundle Size Budget + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - name: Build frontend + run: npm run -w frontend build + - name: Check bundle-size budget + run: npm run -w frontend size + + # ── 7. Lighthouse benchmarks (SEO / a11y / best-practices / perf gate) ─────── + lighthouse: + name: Lighthouse Benchmarks + needs: e2e + runs-on: ubuntu-latest + env: + JWT_ACCESS_SECRET: e2e_ci_access_secret_32chars_long + JWT_REFRESH_SECRET: e2e_ci_refresh_secret_32chars_long + LH_BASE_URL: http://localhost:18080 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + + - name: Start Dockerized stack + env: + FRONTEND_PORT: "18080" + WEB_ORIGIN: "http://localhost:18080" + run: | + NODE_VERSION=$(cat .nvmrc) docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d --build --wait + timeout-minutes: 15 + + - name: Dump stack logs on failure + if: failure() + run: | + NODE_VERSION=$(cat .nvmrc) FRONTEND_PORT=18080 WEB_ORIGIN=http://localhost:18080 \ + docker compose -f docker-compose.yml -f docker-compose.e2e.yml ps -a || true + docker logs tweeter-backend-1 --tail 400 2>&1 || true + + - name: Run database migrations + run: | + docker exec -w /app/backend tweeter-backend-1 \ + npx typeorm migration:run -d dist/infra/database/data-source.js + + - name: Seed public content for Lighthouse + run: node scripts/seed-lighthouse.mjs + + - name: Run Lighthouse CI + run: npx lhci autorun + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: lighthouse-results + path: .lighthouseci/ + + - name: Tear down stack + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml down -v diff --git a/.gitignore b/.gitignore index 06273f8..247fba7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,11 @@ tests/e2e/tour/.auth/ playwright-report/ test-results/ tests/node_modules + +# Lighthouse CI run artifacts +.lighthouseci/ +.lighthouse-urls.json + +# Local verification artifacts +.playwright-mcp/ +a11y-*.png diff --git a/README.md b/README.md index 2417c8d..462e93c 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,17 @@ make test-e2e # boots the stack, runs migrations, runs Playwright, te # Per-package, if you prefer: npm run test -w backend # npm run test:coverage -w backend for coverage npm run test -w frontend + +# SEO / quality benchmarks (Lighthouse + bundle-size) +npm run -w frontend size # bundle-size budget (size-limit) +node scripts/seed-lighthouse.mjs # seed a public profile + post (stack must be up) +npx lhci autorun # Lighthouse: SEO / a11y / best-practices / perf ``` Coverage is gated at **80% lines** in CI. The pipeline runs the layers in order — -`lint → unit → integration → e2e → build` (see [.github/workflows/ci.yml](.github/workflows/ci.yml)). +`lint → unit → integration → e2e → build`, plus a **bundle-size** budget and a +**Lighthouse** gate (SEO 100, a11y/best-practices ≥ 90, performance reported) — see +[.github/workflows/ci.yml](.github/workflows/ci.yml) and [ADR-0011](docs/adr/0011-seo-benchmark-gates.md). ## 🗄️ Migrations @@ -150,6 +157,7 @@ The load-bearing engineering decisions are recorded as ADRs (see the [decision l - **Secure sessions** — a short-lived access JWT (in memory) plus a **rotating** refresh token in an httpOnly/Secure/SameSite cookie, with a Redis denylist enforced in the auth guard so logout revokes immediately. → [ADR-0003](docs/adr/0003-auth-strategy.md), [ADR-0009](docs/adr/0009-session-revocation-denylist.md) - **Media pipeline** — presigned direct-to-storage uploads (MinIO/S3), async processing into variants, then attachment to posts. - **Search without extra infrastructure** — PostgreSQL full-text search + `pg_trgm` fuzzy matching behind a `SearchPort`, swappable for a dedicated engine later. → [ADR-0005](docs/adr/0005-search-approach.md) +- **SEO without an SSR rewrite** — public profiles/posts are crawlable; nginx routes social scrapers (which don't run JS) to a backend **dynamic-rendering** layer that emits Open Graph / Twitter Card / JSON-LD, plus a DB-backed `sitemap.xml` and host-aware `robots.txt`. Per-route metadata for the human/Googlebot path uses **React 19 native metadata** (no extra dependency). → [ADR-0010](docs/adr/0010-seo-crawlability-and-dynamic-rendering.md) ## 📂 Project structure diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8a46beb..796b18f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -19,6 +19,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { SearchModule } from './modules/search/search.module'; import { HashtagsModule } from './modules/hashtags/hashtags.module'; import { ReportsModule } from './modules/reports/reports.module'; +import { SeoModule } from './modules/seo/seo.module'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; import { NOTIFICATION_PORT } from './modules/users/notification.port'; @@ -56,6 +57,7 @@ import { ViewerFlagsAdapter } from './modules/engagement/viewer-flags.adapter'; HashtagsModule, SearchModule, ReportsModule, + SeoModule, ], providers: [ { diff --git a/backend/src/modules/seo/seo.controller.ts b/backend/src/modules/seo/seo.controller.ts new file mode 100644 index 0000000..c17e261 --- /dev/null +++ b/backend/src/modules/seo/seo.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Headers, Res } from '@nestjs/common'; +import type { FastifyReply } from 'fastify'; +import { SeoService } from './seo.service'; + +/** + * SeoController — crawler-facing endpoints. + * + * GET /api/v1/seo/prerender ← nginx routes bot requests for public content + * routes here (X-Original-URI carries the path) + * GET /api/v1/seo/sitemap.xml ← nginx maps /sitemap.xml here + * GET /api/v1/seo/robots.txt ← nginx maps /robots.txt here + * + * No auth guard: these are public by design and only ever reached via nginx. + */ +@Controller('seo') +export class SeoController { + constructor(private readonly seoService: SeoService) {} + + private baseUrl( + proto: string | undefined, + fwdHost: string | undefined, + host: string | undefined, + ): string { + const scheme = (proto ?? 'http').split(',')[0].trim() || 'http'; + const h = (fwdHost ?? host ?? 'localhost').split(',')[0].trim() || 'localhost'; + return `${scheme}://${h}`; + } + + @Get('prerender') + async prerender( + @Headers('x-original-uri') originalUri: string | undefined, + @Headers('x-forwarded-proto') proto: string | undefined, + @Headers('x-forwarded-host') fwdHost: string | undefined, + @Headers('host') host: string | undefined, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + const baseUrl = this.baseUrl(proto, fwdHost, host); + const { html, status } = await this.seoService.renderPath(originalUri ?? '/', baseUrl); + void reply.status(status).type('text/html; charset=utf-8'); + return html; + } + + @Get('sitemap.xml') + async sitemap( + @Headers('x-forwarded-proto') proto: string | undefined, + @Headers('x-forwarded-host') fwdHost: string | undefined, + @Headers('host') host: string | undefined, + @Res({ passthrough: true }) reply: FastifyReply, + ): Promise { + const baseUrl = this.baseUrl(proto, fwdHost, host); + const xml = await this.seoService.buildSitemap(baseUrl); + void reply.type('application/xml; charset=utf-8'); + return xml; + } + + @Get('robots.txt') + robots( + @Headers('x-forwarded-proto') proto: string | undefined, + @Headers('x-forwarded-host') fwdHost: string | undefined, + @Headers('host') host: string | undefined, + @Res({ passthrough: true }) reply: FastifyReply, + ): string { + const baseUrl = this.baseUrl(proto, fwdHost, host); + void reply.type('text/plain; charset=utf-8'); + return this.seoService.buildRobots(baseUrl); + } +} diff --git a/backend/src/modules/seo/seo.module.ts b/backend/src/modules/seo/seo.module.ts new file mode 100644 index 0000000..68bd6da --- /dev/null +++ b/backend/src/modules/seo/seo.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { PostsModule } from '../posts/posts.module'; +import { UsersModule } from '../users/users.module'; +import { SeoController } from './seo.controller'; +import { SeoService } from './seo.service'; + +/** + * SeoModule — server-rendered crawler artifacts (dynamic rendering for + * social scrapers, sitemap.xml, robots.txt). + * + * Imports PostsModule + UsersModule for PostsService/UsersService and the + * re-exported Post/User repositories (via their exported TypeOrmModule). + */ +@Module({ + imports: [PostsModule, UsersModule], + controllers: [SeoController], + providers: [SeoService], +}) +export class SeoModule {} diff --git a/backend/src/modules/seo/seo.service.ts b/backend/src/modules/seo/seo.service.ts new file mode 100644 index 0000000..011ff80 --- /dev/null +++ b/backend/src/modules/seo/seo.service.ts @@ -0,0 +1,383 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PostsService } from '../posts/posts.service'; +import { UsersService } from '../users/users.service'; +import { Post } from '../posts/post.entity'; +import { User } from '../users/user.entity'; +import type { PostDto } from '../posts/dto/post.dto'; +import type { ProfileDto } from '../users/dto/profile.dto'; + +const SITE_NAME = 'PULSE'; +const DEFAULT_OG_IMAGE = '/og-default.png'; +const SITEMAP_MAX_URLS = 2000; + +interface MetaDocument { + title: string; + description: string; + canonical: string; + image: string; + type: 'website' | 'article' | 'profile'; + card: 'summary' | 'summary_large_image'; + jsonLd?: Record; + bodyHtml: string; + noindex?: boolean; +} + +/** + * SeoService — renders crawler-facing artifacts that the SPA cannot + * provide to non-JS clients: server-rendered Open Graph / Twitter Card / + * JSON-LD documents for public posts & profiles (dynamic rendering), plus + * a DB-backed sitemap.xml and a host-aware robots.txt. + */ +@Injectable() +export class SeoService { + private readonly logger = new Logger(SeoService.name); + + constructor( + private readonly postsService: PostsService, + private readonly usersService: UsersService, + @InjectRepository(Post) private readonly postRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + ) {} + + // ── HTML escaping ────────────────────────────────────────────────────────── + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private truncate(text: string, max = 160): string { + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length <= max) return clean; + return clean.slice(0, max - 1).trimEnd() + '…'; + } + + /** Make a possibly-relative asset URL absolute against the request origin. */ + private absoluteUrl(baseUrl: string, maybeUrl: string | null | undefined): string { + if (!maybeUrl) return `${baseUrl}${DEFAULT_OG_IMAGE}`; + if (maybeUrl.startsWith('http://') || maybeUrl.startsWith('https://')) return maybeUrl; + return `${baseUrl}${maybeUrl.startsWith('/') ? '' : '/'}${maybeUrl}`; + } + + // ── Document renderer ─────────────────────────────────────────────────────── + + private renderDocument(doc: MetaDocument): string { + const t = this.escapeHtml(doc.title); + const d = this.escapeHtml(doc.description); + const robots = doc.noindex ? 'noindex, nofollow' : 'index, follow'; + const jsonLdScript = doc.jsonLd + ? `` + : ''; + + return ` + + + + +${t} + + + + + + + + + + + + + +${jsonLdScript} + + +${doc.bodyHtml} +

View on ${SITE_NAME}

+ + +`; + } + + // ── Profile rendering ──────────────────────────────────────────────────────── + + async renderProfile(handle: string, baseUrl: string): Promise<{ html: string; status: number }> { + const clean = handle.startsWith('@') ? handle.slice(1) : handle; + let profile: ProfileDto; + try { + const result = await this.usersService.getProfile(clean, null); + profile = result.user; + } catch { + return { html: this.renderNotFound(baseUrl, `@${clean}`), status: 404 }; + } + + const canonical = `${baseUrl}/@${profile.handle}`; + const title = `${profile.displayName} (@${profile.handle})`; + const description = profile.bio + ? this.truncate(profile.bio) + : `The latest posts from ${profile.displayName} (@${profile.handle}) on ${SITE_NAME}.`; + const image = this.absoluteUrl(baseUrl, profile.avatarUrl); + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'ProfilePage', + dateCreated: profile.createdAt, + mainEntity: { + '@type': 'Person', + name: profile.displayName, + alternateName: `@${profile.handle}`, + ...(profile.bio ? { description: profile.bio } : {}), + image, + url: canonical, + interactionStatistic: [ + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/FollowAction', + userInteractionCount: profile.counts.followers, + }, + ], + }, + }; + + const bodyHtml = `
+

${this.escapeHtml(profile.displayName)} @${this.escapeHtml(profile.handle)}

+${profile.bio ? `

${this.escapeHtml(profile.bio)}

` : ''} +

${profile.counts.followers} Followers · ${profile.counts.following} Following · ${profile.counts.posts} Posts

+
`; + + return { + html: this.renderDocument({ + title, + description, + canonical, + image, + type: 'profile', + card: 'summary', + jsonLd, + bodyHtml, + noindex: profile.isPrivate, + }), + status: 200, + }; + } + + // ── Post rendering ──────────────────────────────────────────────────────────── + + async renderPost(postId: string, baseUrl: string): Promise<{ html: string; status: number }> { + let post: PostDto; + try { + post = await this.postsService.findOne(postId, null); + } catch { + return { html: this.renderNotFound(baseUrl, 'this post'), status: 404 }; + } + if (post.deleted) { + return { html: this.renderNotFound(baseUrl, 'this post'), status: 404 }; + } + + const canonical = `${baseUrl}/@${post.author.handle}/status/${post.id}`; + const title = `${post.author.displayName} on ${SITE_NAME}`; + const description = post.text + ? this.truncate(post.text) + : `A post by ${post.author.displayName} (@${post.author.handle}) on ${SITE_NAME}.`; + + const readyMedia = post.media.find((m) => m.status === 'ready'); + const mediaUrl = + readyMedia?.variants.large ?? readyMedia?.variants.medium ?? readyMedia?.variants.poster; + const image = this.absoluteUrl(baseUrl, mediaUrl ?? post.author.avatarUrl); + const card: 'summary' | 'summary_large_image' = mediaUrl ? 'summary_large_image' : 'summary'; + + const jsonLd: Record = { + '@context': 'https://schema.org', + '@type': 'SocialMediaPosting', + url: canonical, + datePublished: post.createdAt, + ...(post.text ? { articleBody: post.text, headline: this.truncate(post.text, 110) } : {}), + author: { + '@type': 'Person', + name: post.author.displayName, + alternateName: `@${post.author.handle}`, + url: `${baseUrl}/@${post.author.handle}`, + }, + ...(mediaUrl ? { image } : {}), + interactionStatistic: [ + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/LikeAction', + userInteractionCount: post.counts.likes, + }, + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/ShareAction', + userInteractionCount: post.counts.reposts, + }, + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/ReplyAction', + userInteractionCount: post.counts.replies, + }, + ], + }; + + const bodyHtml = `
+
+

${this.escapeHtml(post.author.displayName)} @${this.escapeHtml(post.author.handle)}

+${post.text ? `

${this.escapeHtml(post.text)}

` : ''} +

+

${post.counts.likes} Likes · ${post.counts.reposts} Reposts · ${post.counts.replies} Replies

+
+
`; + + return { + html: this.renderDocument({ + title, + description, + canonical, + image, + type: 'article', + card, + jsonLd, + bodyHtml, + }), + status: 200, + }; + } + + private renderNotFound(baseUrl: string, what: string): string { + return this.renderDocument({ + title: `Not found / ${SITE_NAME}`, + description: `We couldn't find ${what} on ${SITE_NAME}.`, + canonical: baseUrl, + image: `${baseUrl}${DEFAULT_OG_IMAGE}`, + type: 'website', + card: 'summary', + bodyHtml: `

Not found

We couldn't find ${this.escapeHtml(what)}.

`, + noindex: true, + }); + } + + // ── Dynamic-rendering dispatcher ─────────────────────────────────────────────── + + /** + * Given the original request path (from nginx `X-Original-URI`), render the + * matching crawler document. Falls back to a generic site document. + */ + async renderPath( + originalPath: string, + baseUrl: string, + ): Promise<{ html: string; status: number }> { + // Strip query string and trailing slash. + const path = originalPath.split('?')[0].replace(/\/+$/, '') || '/'; + const segments = path.split('/').filter(Boolean); + + // /:handle/status/:postId (handle may be url-encoded "@handle") + const statusIdx = segments.indexOf('status'); + if (statusIdx === 1 && segments[2]) { + return this.renderPost(decodeURIComponent(segments[2]), baseUrl); + } + // /:handle (and tabs: /:handle/replies, /:handle/media, …) + if (segments.length >= 1 && segments[0] !== 'status') { + const handle = decodeURIComponent(segments[0]); + // Only treat as a profile when it looks like a handle (skip app routes). + if (/^@?[A-Za-z0-9_]{1,30}$/.test(handle)) { + return this.renderProfile(handle, baseUrl); + } + } + + return { + html: this.renderDocument({ + title: 'PULSE — Microblogging, in real time', + description: + 'PULSE is a real-time microblogging platform. Follow people, share posts, and join the conversation as it happens.', + canonical: baseUrl + path, + image: `${baseUrl}${DEFAULT_OG_IMAGE}`, + type: 'website', + card: 'summary_large_image', + bodyHtml: `

${SITE_NAME}

Microblogging, in real time.

`, + }), + status: 200, + }; + } + + // ── sitemap.xml ───────────────────────────────────────────────────────────── + + async buildSitemap(baseUrl: string): Promise { + const [users, posts] = await Promise.all([ + this.userRepo.find({ + where: { isPrivate: false }, + order: { createdAt: 'DESC' }, + take: SITEMAP_MAX_URLS, + select: { handle: true, updatedAt: true }, + }), + this.postRepo + .createQueryBuilder('p') + .innerJoin('p.author', 'a') + .select('p.id', 'id') + .addSelect('p.created_at', 'createdAt') + .addSelect('a.handle', 'handle') + .where('p.deleted_at IS NULL') + .andWhere('a.deleted_at IS NULL') + .andWhere('a.is_private = false') + .andWhere('p.reply_to_id IS NULL') + .andWhere('p.repost_of_id IS NULL') + .orderBy('p.created_at', 'DESC') + .limit(SITEMAP_MAX_URLS) + .getRawMany<{ id: string; createdAt: Date; handle: string }>(), + ]); + + const urls: string[] = [`${baseUrl}/`]; + const lastmods: (string | null)[] = [null]; + + for (const u of users) { + urls.push(`${baseUrl}/@${u.handle}`); + lastmods.push(u.updatedAt ? new Date(u.updatedAt).toISOString() : null); + } + for (const p of posts) { + urls.push(`${baseUrl}/@${p.handle}/status/${p.id}`); + lastmods.push(p.createdAt ? new Date(p.createdAt).toISOString() : null); + } + + const entries = urls + .map((loc, i) => { + const lm = lastmods[i]; + return ` \n ${this.escapeXml(loc)}${lm ? `\n ${lm}` : ''}\n `; + }) + .join('\n'); + + return `\n\n${entries}\n\n`; + } + + // ── robots.txt ──────────────────────────────────────────────────────────────── + + buildRobots(baseUrl: string): string { + return [ + '# robots.txt for PULSE', + 'User-agent: *', + 'Allow: /', + 'Disallow: /api/', + 'Disallow: /compose', + 'Disallow: /messages', + 'Disallow: /notifications', + 'Disallow: /bookmarks', + 'Disallow: /settings', + 'Disallow: /search', + 'Disallow: /verify-email', + '', + `Sitemap: ${baseUrl}/sitemap.xml`, + '', + ].join('\n'); + } +} diff --git a/backend/tests/unit/seo/seo.service.spec.ts b/backend/tests/unit/seo/seo.service.spec.ts new file mode 100644 index 0000000..dc08e51 --- /dev/null +++ b/backend/tests/unit/seo/seo.service.spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SeoService } from '../../../src/modules/seo/seo.service'; +import type { PostsService } from '../../../src/modules/posts/posts.service'; +import type { UsersService } from '../../../src/modules/users/users.service'; +import type { PostDto } from '../../../src/modules/posts/dto/post.dto'; +import type { ProfileDto } from '../../../src/modules/users/dto/profile.dto'; + +const BASE = 'https://pulse.example'; + +function makePost(overrides: Partial = {}): PostDto { + return { + id: '123', + author: { + id: 'u1', + handle: 'ada', + displayName: 'Ada Lovelace', + avatarUrl: '/media/ada.jpg', + isVerified: true, + isPrivate: false, + }, + text: 'Hello world from the analytical engine', + createdAt: '2026-01-01T00:00:00.000Z', + entities: { mentions: [], hashtags: [], urls: [] } as unknown as PostDto['entities'], + media: [], + counts: { replies: 2, reposts: 3, likes: 10, bookmarks: 1 }, + viewer: { liked: false, reposted: false, bookmarked: false }, + replyToId: null, + replyPolicy: 'everyone', + quoteOf: null, + repostOf: null, + repostedBy: null, + deleted: false, + ...overrides, + }; +} + +function makeProfile(overrides: Partial = {}): ProfileDto { + return { + id: 'u1', + handle: 'ada', + displayName: 'Ada Lovelace', + bio: 'First programmer', + location: 'London', + website: null, + avatarUrl: '/media/ada.jpg', + bannerUrl: null, + isVerified: true, + isPrivate: false, + counts: { followers: 100, following: 50, posts: 42 }, + viewer: null, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } as ProfileDto; +} + +describe('SeoService', () => { + let postsService: { findOne: ReturnType }; + let usersService: { getProfile: ReturnType }; + let postRepo: { createQueryBuilder: ReturnType }; + let userRepo: { find: ReturnType }; + let service: SeoService; + + beforeEach(() => { + postsService = { findOne: vi.fn() }; + usersService = { getProfile: vi.fn() }; + postRepo = { createQueryBuilder: vi.fn() }; + userRepo = { find: vi.fn() }; + service = new SeoService( + postsService as unknown as PostsService, + usersService as unknown as UsersService, + postRepo as never, + userRepo as never, + ); + }); + + describe('renderPost', () => { + it('renders OG, Twitter Card, canonical, and JSON-LD for a public post', async () => { + postsService.findOne.mockResolvedValue(makePost()); + const { html, status } = await service.renderPost('123', BASE); + + expect(status).toBe(200); + expect(html).toContain(''); + expect(html).toContain('og:title'); + expect(html).toContain('Hello world from the analytical engine'); + expect(html).toContain(``); + expect(html).toContain('"@type":"SocialMediaPosting"'); + expect(html).toContain('"interactionType":"https://schema.org/LikeAction"'); + expect(html).toContain('content="index, follow"'); + }); + + it('uses summary_large_image and media URL when the post has ready media', async () => { + postsService.findOne.mockResolvedValue( + makePost({ + media: [ + { + id: 'm1', + type: 'image', + status: 'ready', + variants: { large: '/media/large.jpg' }, + altText: null, + width: 1200, + height: 630, + }, + ], + }), + ); + const { html } = await service.renderPost('123', BASE); + expect(html).toContain('content="summary_large_image"'); + expect(html).toContain(`${BASE}/media/large.jpg`); + }); + + it('returns 404 + noindex for a deleted post', async () => { + postsService.findOne.mockResolvedValue(makePost({ deleted: true })); + const { html, status } = await service.renderPost('123', BASE); + expect(status).toBe(404); + expect(html).toContain('noindex'); + }); + + it('returns 404 when the post is not found', async () => { + postsService.findOne.mockRejectedValue(new Error('not found')); + const { status } = await service.renderPost('999', BASE); + expect(status).toBe(404); + }); + + it('escapes HTML in post text to prevent injection', async () => { + postsService.findOne.mockResolvedValue(makePost({ text: '' })); + const { html } = await service.renderPost('123', BASE); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + }); + + describe('renderProfile', () => { + it('renders profile meta + ProfilePage JSON-LD', async () => { + usersService.getProfile.mockResolvedValue({ user: makeProfile() }); + const { html, status } = await service.renderProfile('@ada', BASE); + expect(status).toBe(200); + expect(html).toContain('Ada Lovelace (@ada)'); + expect(html).toContain('First programmer'); + expect(html).toContain('"@type":"ProfilePage"'); + expect(html).toContain(``); + }); + + it('sets noindex for a private profile', async () => { + usersService.getProfile.mockResolvedValue({ user: makeProfile({ isPrivate: true }) }); + const { html } = await service.renderProfile('ada', BASE); + expect(html).toContain('noindex'); + }); + + it('returns 404 when the profile does not exist', async () => { + usersService.getProfile.mockRejectedValue(new Error('not found')); + const { status } = await service.renderProfile('ghost', BASE); + expect(status).toBe(404); + }); + }); + + describe('renderPath', () => { + it('dispatches /@handle/status/:id to the post renderer', async () => { + postsService.findOne.mockResolvedValue(makePost()); + await service.renderPath('/@ada/status/123', BASE); + expect(postsService.findOne).toHaveBeenCalledWith('123', null); + }); + + it('dispatches /@handle to the profile renderer', async () => { + usersService.getProfile.mockResolvedValue({ user: makeProfile() }); + await service.renderPath('/@ada', BASE); + expect(usersService.getProfile).toHaveBeenCalledWith('ada', null); + }); + + it('dispatches a profile tab (/@handle/replies) to the profile renderer', async () => { + usersService.getProfile.mockResolvedValue({ user: makeProfile() }); + await service.renderPath('/@ada/replies', BASE); + expect(usersService.getProfile).toHaveBeenCalledWith('ada', null); + }); + + it('falls back to a generic site document for the root path', async () => { + const { html, status } = await service.renderPath('/', BASE); + expect(status).toBe(200); + expect(html).toContain('PULSE — Microblogging'); + expect(postsService.findOne).not.toHaveBeenCalled(); + expect(usersService.getProfile).not.toHaveBeenCalled(); + }); + }); + + describe('buildSitemap', () => { + it('emits a valid urlset with profile and post URLs', async () => { + userRepo.find.mockResolvedValue([ + { handle: 'ada', updatedAt: new Date('2026-01-02T00:00:00Z') }, + ]); + const qb = { + innerJoin: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + addSelect: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + andWhere: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + getRawMany: vi + .fn() + .mockResolvedValue([ + { id: '123', createdAt: new Date('2026-01-01T00:00:00Z'), handle: 'ada' }, + ]), + }; + postRepo.createQueryBuilder.mockReturnValue(qb); + + const xml = await service.buildSitemap(BASE); + expect(xml).toContain(''); + expect(xml).toContain('${BASE}/@ada`); + expect(xml).toContain(`${BASE}/@ada/status/123`); + expect(xml).toContain(''); + }); + }); + + describe('buildRobots', () => { + it('allows crawling, disallows private routes, and points to the sitemap', () => { + const robots = service.buildRobots(BASE); + expect(robots).toContain('User-agent: *'); + expect(robots).toContain('Allow: /'); + expect(robots).toContain('Disallow: /messages'); + expect(robots).toContain('Disallow: /settings'); + expect(robots).toContain(`Sitemap: ${BASE}/sitemap.xml`); + }); + }); +}); diff --git a/docs/adr/0010-seo-crawlability-and-dynamic-rendering.md b/docs/adr/0010-seo-crawlability-and-dynamic-rendering.md new file mode 100644 index 0000000..ffbdf36 --- /dev/null +++ b/docs/adr/0010-seo-crawlability-and-dynamic-rendering.md @@ -0,0 +1,54 @@ +# ADR-0010: SEO Crawlability — Public Read Mode + Bot Dynamic Rendering, React 19 Native Metadata + +- **Date**: 2026-06-11 +- **Status**: Accepted +- **Deciders**: SEO initiative, lead architect, human gate + +## Context and Problem Statement + +PULSE shipped as a pure client-side React SPA in which **every content route sat behind `AuthGuard`** and redirected unauthenticated visitors to `/login`. Combined with CSR, this meant search engines and social scrapers saw `frontend` over an empty `
` and then a login redirect. For a microblogging platform whose primary distribution channel is *shared links*, this is the worst possible posture: zero organic indexing and zero rich link previews (Twitter/Slack/iMessage/Facebook). An SEO audit found no document metadata, no `robots.txt`/`sitemap.xml`, no structured data, and no benchmark tooling. + +Two problems had to be solved together: (1) making content **crawlable** at all, and (2) producing **correct metadata for non-JS scrapers**, which never execute the SPA's JavaScript and therefore cannot benefit from any client-rendered `` tags. + +## Decision Drivers + +- Social scrapers (Twitterbot, facebookexternalhit, Slackbot, LinkedInBot, Discordbot, WhatsApp) **do not run JavaScript**. Client-injected OG tags are invisible to them — link previews require server-rendered HTML. +- Googlebot renders JS but with delay and budget; server-rendered metadata is strictly better for it too. +- The product is a Twitter/X-style platform where public posts/profiles are the natural model (confirmed with the product owner). Private accounts must remain protected. +- The existing backend already exposed public reads: `GET /posts/:id`, `GET /users/:handle`, etc. all use `OptionalAuthGuard` (returns public data with no token, personalizes when authed). The crawlability blocker was purely the **frontend** `AuthGuard` redirect. +- Introducing full SSR/SSG to a mature CSR app is a large, risky re-architecture; the team wanted the SEO win without rewriting the rendering model. +- React 19 is already in use and natively hoists ``/`<meta>`/`<link>` to `<head>` — per-route metadata needs no new dependency. + +## Considered Options + +| Option | Pros | Cons | Verdict | +|--------|------|------|---------| +| **Dynamic rendering**: nginx routes known bot user-agents to a backend that returns server-rendered OG/JSON-LD HTML; humans keep the SPA | No SPA re-architecture; leverages existing `PostsService`/`UsersService`; correct previews for non-JS scrapers; isolated, low blast radius | Two render paths to maintain; UA list needs occasional updates; bot/human divergence must stay honest (same canonical content) | **Selected** | +| Full SSR (e.g. migrate to a React framework with server rendering) | Single render path; best-in-class SEO | Large rewrite of a working CSR app; high risk; weeks of work; out of scope | Rejected | +| Build-time prerendering (SSG) | Simple static output | Content is dynamic and per-user; cannot prerender arbitrary posts/profiles | Rejected | +| Client-only meta via React 19 (no server layer) | Zero infra | Invisible to non-JS social scrapers — fails the single highest-value SEO feature (link previews) | Rejected as sole solution (kept as a complement) | + +## Decision Outcome + +**Chosen**: A two-layer approach. + +1. **Public read mode (frontend).** Profile and post routes (`/:handle`, its tabs, `/:handle/status/:postId`, the photo route) are moved out from under the hard auth redirect into an **`AdaptiveShell`** layout element that renders `AppShell` for authenticated users, a lightweight crawlable `PublicShell` (sign-in CTAs, no realtime socket) for guests on routes marked `handle.public`, and redirects guests to `/login` on every other (private) route. Keeping all routes under one layout element avoids remounting the shell when an authed user opens a modal. Privacy is preserved: `ProfilePage` already renders a "protected" lock for private accounts to non-followers, and DMs/notifications/bookmarks/settings stay auth-gated. No backend authorization change was required — the read endpoints were already `OptionalAuthGuard`. + +2. **Dynamic rendering (backend + nginx).** A new `SeoModule` exposes `GET /api/v1/seo/prerender` (reads the original path from nginx's `X-Original-URI`), `GET /api/v1/seo/sitemap.xml` (DB-backed), and `GET /api/v1/seo/robots.txt` (host-aware absolute Sitemap URL). nginx detects crawler/social user-agents via a `map $http_user_agent $is_bot` and, in the SPA-fallback `@app` location, rewrites bot requests to an internal `/__prerender` location proxied to the backend; humans get `index.html`. The prerender document carries full Open Graph + Twitter Card (`summary_large_image`) + JSON-LD (`SocialMediaPosting` for posts, `ProfilePage`+`Person` for profiles) with absolute image URLs and HTML-escaped content. + +3. **Client metadata (complement).** A `<Seo>` component uses React 19 native metadata hoisting to set per-route `<title>`/description/canonical/OG/Twitter for the human + Googlebot-JS path. Helpers live in `lib/seo.ts` so the component file stays fast-refresh clean. + +**Positive Consequences**: +- Shared post/profile links render rich previews everywhere, including non-JS scrapers. +- Public content is indexable and present in a real, DB-backed `sitemap.xml`. +- No rewrite of the CSR rendering model; the change is additive and isolated. +- Private content remains protected; only explicitly public routes are crawlable. + +**Negative Consequences / Risks**: +- Two render paths (SPA + prerender) must stay content-consistent; mitigated by both deriving from the same services and canonical URLs. +- The bot user-agent list is a maintenance item; unknown bots fall back to the SPA shell (degraded but not broken). +- `prerender` adds backend load for crawler traffic; bounded and cacheable at the edge if needed. + +## Follow-ups +- Benchmark enforcement is covered by [ADR-0011](0011-seo-benchmark-gates.md). +- Consider edge-caching `/__prerender` and `/sitemap.xml` responses if crawler volume grows. diff --git a/docs/adr/0011-seo-benchmark-gates.md b/docs/adr/0011-seo-benchmark-gates.md new file mode 100644 index 0000000..f0f4ab8 --- /dev/null +++ b/docs/adr/0011-seo-benchmark-gates.md @@ -0,0 +1,54 @@ +# ADR-0011: SEO Benchmark Gates — Lighthouse CI + Bundle-Size Budget + +- **Date**: 2026-06-11 +- **Status**: Accepted +- **Deciders**: SEO initiative, lead architect, human gate + +## Context and Problem Statement + +"Improve all benchmarks" presupposes benchmarks exist. PULSE had **none**: no Lighthouse CI, no web-vitals collection, no bundle-size budget, no SEO scoring of any kind. Without a measured, enforced baseline, the SEO/quality work done in [ADR-0010](0010-seo-crawlability-and-dynamic-rendering.md) (plus performance, header, and accessibility fixes) would silently regress on the next feature PR. The product owner asked that the benchmarks **gate the build** (fail on regression), not merely report. + +## Decision Drivers + +- The four Lighthouse categories — Performance, Accessibility, Best Practices, SEO — are the industry-standard web-quality benchmark and run on the rendered page. +- The gate must be **reproducible in CI**. Lighthouse needs a real, crawlable URL; PULSE content is dynamic, so the run requires deterministic seeded content. +- Performance scores in CI containers are **noisy** (shared-CPU throttling varies run to run); gating hard on a flaky metric produces false failures and erodes trust in the gate. +- SEO is the category most under our direct control (meta, robots, sitemap, semantic HTML) and our headline deliverable — it should be the hardest gate. +- A separate, cheap **bundle-size budget** catches JS-weight regressions without needing the full stack. + +## Considered Options + +| Option | Pros | Cons | Verdict | +|--------|------|------|---------| +| Lighthouse CI (`@lhci/cli`) against the dockerized stack with seeded content + `size-limit` budget, gating in CI | Standard tooling; runs the real prod-like stack; per-category thresholds; artifacts on every run | Adds a full stack boot to CI (~minutes); needs a seed step | **Selected** | +| Report-only (collect scores as artifacts, never block) | Simplest; no flakiness | No enforcement — regressions merge silently; fails the product owner's explicit "gate the build" requirement | Rejected | +| Gate every category hard at 100/100/95/80 | Maximal strictness | Perf flakiness in CI containers → frequent false failures; brittle gate gets disabled in practice | Rejected (see threshold split below) | + +## Decision Outcome + +**Chosen**: Lighthouse CI via a JS config (`lighthouserc.cjs`) plus a `size-limit` budget, both enforced as CI jobs. + +**Reproducibility**: A `lighthouse` CI job boots the same dockerized stack as the e2e job (`docker-compose.e2e.yml`, `FRONTEND_PORT=18080`), runs migrations, then `scripts/seed-lighthouse.mjs` registers a demo user and one public post via the API and writes the crawlable URLs to `.lighthouse-urls.json`. `lighthouserc.cjs` reads that file (falling back to `/login`) and audits `/login`, the public profile, and the public post. The **desktop preset** is used to reduce performance-metric variance. + +**Threshold split** (the core of this decision): + +| Category | Level | minScore | Rationale | +|----------|-------|----------|-----------| +| SEO | `error` (gate) | 1.00 | Fully under our control; the headline deliverable | +| Accessibility | `error` (gate) | 0.90 | Strong gate, with margin for single-audit variance | +| Best Practices | `error` (gate) | 0.90 | Strong gate, with margin | +| Performance | `warn` | 0.80 | Container CPU variance makes a hard gate flaky; reported, not blocking | + +Gating the deterministic categories (SEO/A11y/Best Practices) honours "gate the build" while warning on the genuinely noisy metric (Performance) keeps the gate trustworthy rather than flaky. Expected real scores are higher than the gate floors (≈100 SEO, ≈100 BP, ≈95 A11y, ≈85 Perf desktop); the floors carry operational margin and can be tightened once CI baselines are observed. Results upload as artifacts (`.lighthouseci/`) on every run. + +**Bundle-size budget**: `size-limit` (`@size-limit/file`) enforces brotli budgets on the always-loaded chunks — app entry ≤ 25 kB and `react-vendor` ≤ 95 kB — in a standalone `bundle-size` CI job (no stack boot). Vendor chunking (see Vite `manualChunks`) keeps framework code in long-cacheable chunks separate from app code. + +**Positive Consequences**: +- SEO/quality posture cannot silently regress; PRs that drop a gated category fail CI. +- Performance is tracked without inflicting flaky failures. +- Bundle weight is bounded cheaply and independently of the Lighthouse job. + +**Negative Consequences / Risks**: +- The `lighthouse` job adds a full stack boot to CI; mitigated by running it in parallel with `build` (both `needs: e2e`). +- `@lhci/cli` is a dev-only dependency with transitive advisories (old `inquirer`/`tmp`); production deps remain clean (`npm audit --omit=dev`) and `tmp` is pinned via a root `overrides` to clear the high-severity advisory. +- Seeded content is created fresh per run (unique handle), so scores are reproducible without depending on prior DB state. diff --git a/docs/adr/README.md b/docs/adr/README.md index dd5396b..71afb4d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,3 +13,5 @@ One file per significant decision (MADR format). Produced by the `adr` agent dur | [ADR-0007](0007-nestjs-dev-compiler.md) | NestJS Dev-Container Compiler — nest start --watch Instead of tsx watch | Accepted | Smoke-test retrospective, lead architect | 2026-06-08 | | [ADR-0008](0008-redis-eviction-policy.md) | Redis Eviction Policy — noeviction Instead of allkeys-lru | Accepted | Smoke-test retrospective, lead architect | 2026-06-08 | | [ADR-0009](0009-session-revocation-denylist.md) | Session Revocation on Logout — Redis Denylist Checked in AuthGuard | Accepted | Smoke-test retrospective, lead architect | 2026-06-08 | +| [ADR-0010](0010-seo-crawlability-and-dynamic-rendering.md) | SEO Crawlability — Public Read Mode + Bot Dynamic Rendering, React 19 Native Metadata | Accepted | SEO initiative, lead architect, human gate | 2026-06-11 | +| [ADR-0011](0011-seo-benchmark-gates.md) | SEO Benchmark Gates — Lighthouse CI + Bundle-Size Budget | Accepted | SEO initiative, lead architect, human gate | 2026-06-11 | diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..b4f4c19 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,44 @@ <html lang="en"> <head> <meta charset="UTF-8" /> - <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>frontend + + + PULSE — Microblogging, in real time + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 05345ea..81b6ef0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,3 +1,11 @@ +# Crawler / social-scraper detection. conf.d/*.conf is included in the http +# context, so this map is valid here. Bots get server-rendered OG/JSON-LD +# (dynamic rendering); humans get the SPA. +map $http_user_agent $is_bot { + default 0; + "~*(googlebot|bingbot|yandex|duckduckbot|baiduspider|twitterbot|facebookexternalhit|facebot|linkedinbot|slackbot|telegrambot|discordbot|whatsapp|embedly|quora link preview|pinterest|redditbot|applebot|skypeuripreview|vkshare|outbrain|nuzzel|bitlybot|google page speed|developers\.google\.com/\+/web/snippet)" 1; +} + server { listen 8080; server_name _; @@ -20,15 +28,24 @@ server { application/atom+xml image/svg+xml; - # Cache hashed assets aggressively (Vite includes hash in filenames) - location /assets/ { + # Cache hashed assets aggressively (Vite includes hash in filenames). + # `^~` stops the regex static-asset location below from overriding this. + location ^~ /assets/ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } - # Cache public static files (favicon, manifest, etc.) for a shorter period - location /public/ { + # PWA manifest — explicit content-type (not always in mime.types). + location = /site.webmanifest { + default_type application/manifest+json; + expires 7d; + add_header Cache-Control "public"; + } + + # Root-level static assets (favicon, icons, OG image, fonts) emitted by Vite + # from public/ at the web root. Cache for a week. + location ~* \.(?:png|svg|ico|jpe?g|gif|webp|avif|woff2?)$ { expires 7d; add_header Cache-Control "public"; try_files $uri =404; @@ -41,6 +58,21 @@ server { add_header Content-Type text/plain; } + # SEO discovery files — served dynamically by the backend so the sitemap is + # DB-backed and both files carry a host-aware absolute Sitemap URL. + location = /sitemap.xml { + proxy_pass http://backend:3000/api/v1/seo/sitemap.xml; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + location = /robots.txt { + proxy_pass http://backend:3000/api/v1/seo/robots.txt; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + # API proxy -> backend (same-origin keeps SameSite cookies + avoids CORS) location /api/ { proxy_pass http://backend:3000; @@ -64,18 +96,51 @@ server { proxy_read_timeout 86400; } - # SPA fallback — all other paths serve index.html - # This allows React Router's history API to handle client-side navigation + # Internal: server-rendered crawler document (Open Graph / Twitter / JSON-LD). + # Reached only via the @app rewrite for bot user-agents. + location /__prerender { + internal; + proxy_pass http://backend:3000/api/v1/seo/prerender; + proxy_set_header Host $host; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + } + + # SPA fallback. Real files are served directly; unknown paths fall through to + # @app, which serves index.html to humans and the prerender to crawlers. location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/ @app; + } + + location @app { + # Crawlers/social scrapers get server-rendered metadata instead of the + # empty SPA shell. rewrite-in-if is one of nginx's safe `if` uses. + if ($is_bot) { + rewrite ^ /__prerender last; + } # Prevent caching the entry point so browsers always get the latest version add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma no-cache; add_header Expires 0; + # Security headers — repeated here because nginx suppresses inherited + # add_header directives when a location defines its own. This is the + # document Lighthouse audits, so CSP + HSTS live here. + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" always; + add_header Cross-Origin-Opener-Policy same-origin always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: blob: https: http:; media-src 'self' blob: https: http:; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; connect-src 'self' ws: wss:; form-action 'self'" always; + try_files /index.html =404; } - # Security headers - add_header X-Content-Type-Options nosniff; - add_header X-Frame-Options DENY; - add_header Referrer-Policy strict-origin-when-cross-origin; + # Security headers for proxied + non-@app responses (API, socket, sitemap, + # robots, prerender). The @app HTML document repeats these and adds CSP/HSTS. + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), browsing-topics=()" always; + add_header Cross-Origin-Opener-Policy same-origin always; } diff --git a/frontend/package.json b/frontend/package.json index cf3d1a1..a78f639 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,21 @@ "lint": "eslint . --max-warnings 0", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "size": "size-limit" }, + "size-limit": [ + { + "name": "App entry (always loaded)", + "path": "dist/assets/index-*.js", + "limit": "25 kB" + }, + { + "name": "React framework vendor", + "path": "dist/assets/react-vendor-*.js", + "limit": "95 kB" + } + ], "dependencies": { "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.101.0", @@ -27,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@size-limit/file": "^12.1.0", "@tailwindcss/vite": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -44,6 +58,7 @@ "globals": "^17.6.0", "jsdom": "^29.1.1", "prettier": "^3.8.3", + "size-limit": "^12.1.0", "tailwindcss": "^4.3.0", "typescript": "~6.0.2", "typescript-eslint": "^8.60.1", diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..b0b2151 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..7195797 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..073ad35 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/og-default.png b/frontend/public/og-default.png new file mode 100644 index 0000000..20d2577 Binary files /dev/null and b/frontend/public/og-default.png differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..a3f811e --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,22 @@ +# robots.txt for PULSE +# Public content (profiles, posts) is crawlable. Private app surfaces +# and the API are disallowed. A dynamic, host-aware version of this file +# (with an absolute Sitemap URL) is served by the backend for crawlers; +# this static copy is the build-time fallback. + +User-agent: * +Allow: / +Disallow: /api/ +Disallow: /compose +Disallow: /compose/ +Disallow: /messages +Disallow: /messages/ +Disallow: /notifications +Disallow: /notifications/ +Disallow: /bookmarks +Disallow: /settings +Disallow: /settings/ +Disallow: /search +Disallow: /verify-email + +Sitemap: /sitemap.xml diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..01ac8b5 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "PULSE", + "short_name": "PULSE", + "description": "PULSE is a real-time microblogging platform. Follow people, share posts, and join the conversation as it happens.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0e0e0f", + "theme_color": "#0e0e0f", + "icons": [ + { "src": "/favicon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" }, + { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" }, + { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" } + ] +} diff --git a/frontend/src/app/AdaptiveShell.tsx b/frontend/src/app/AdaptiveShell.tsx new file mode 100644 index 0000000..a58b417 --- /dev/null +++ b/frontend/src/app/AdaptiveShell.tsx @@ -0,0 +1,46 @@ +// ============================================================ +// AdaptiveShell — chooses the layout based on auth + route +// +// • Authenticated → AppShell (full nav, compose, realtime) +// • Guest on public route → PublicShell (crawlable, sign-in CTAs) +// • Guest on private route → redirect to /login?returnTo=… +// +// Public routes are marked with `handle: { public: true }` in the +// router. Keeping every route under one layout element means the +// shell does not remount when an authenticated user navigates +// between a profile and a modal route (compose, photo). +// ============================================================ + +import { Navigate, useLocation, useMatches } from 'react-router-dom' +import { useAuthStore, selectIsAuthenticated, selectIsInitialized } from '@/lib/auth/store' +import { FullPageSpinner } from '@/components/FullPageSpinner' +import { AppShell } from './AppShell' +import { PublicShell } from './PublicShell' + +interface RouteHandle { + public?: boolean +} + +export function AdaptiveShell() { + const isAuthenticated = useAuthStore(selectIsAuthenticated) + const isInitialized = useAuthStore(selectIsInitialized) + const location = useLocation() + const matches = useMatches() + + if (!isInitialized) { + return + } + + if (isAuthenticated) { + return + } + + // Guest: allow only routes explicitly marked public. + const isPublicRoute = matches.some((m) => (m.handle as RouteHandle | undefined)?.public) + if (isPublicRoute) { + return + } + + const returnTo = encodeURIComponent(location.pathname + location.search) + return +} diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index b295bed..f058aa1 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -142,7 +142,7 @@ function ComposeButton({ compact }: { compact?: boolean }) { width: '44px', height: '44px', borderRadius: 'var(--radius-full)', - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', display: 'flex', alignItems: 'center', @@ -166,7 +166,7 @@ function ComposeButton({ compact }: { compact?: boolean }) { width: '100%', padding: '0.75rem 1rem', borderRadius: 'var(--radius-full)', - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', fontFamily: 'var(--font-body)', fontWeight: 'var(--font-weight-semibold)', @@ -338,7 +338,7 @@ function NavRail({ compact }: { compact: boolean }) { minWidth: '16px', height: '16px', borderRadius: 'var(--radius-full)', - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', fontSize: '10px', fontWeight: 700, @@ -659,7 +659,7 @@ function MobileTopBar() { width: '36px', height: '36px', borderRadius: 'var(--radius-full)', - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', display: 'flex', alignItems: 'center', @@ -827,6 +827,9 @@ export function AppShell() { return ( <> + + Skip to content + {/* Desktop & tablet layout */}
+ pulse + + ) +} + +export function PublicShell() { + const location = useLocation() + const returnTo = encodeURIComponent(location.pathname + location.search) + const loginHref = `/login?returnTo=${returnTo}` + + return ( + <> + + Skip to content + + +
+
+ + + +
+ +
+ +
+ + +
+ + ) +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index a55a3d5..5e4e3af 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -6,8 +6,7 @@ import React, { Suspense } from 'react' import { createBrowserRouter, Outlet } from 'react-router-dom' -import { AppShell } from './AppShell' -import { AuthGuard } from './AuthGuard' +import { AdaptiveShell } from './AdaptiveShell' import { GuestGuard } from './GuestGuard' import { RouteErrorBoundary } from './RouteErrorBoundary' import { FullPageSpinner } from '@/components/FullPageSpinner' @@ -67,13 +66,12 @@ export const router = createBrowserRouter([ errorElement: , }, - // ── App shell (auth-protected) ───────────────────────── + // ── App shell ────────────────────────────────────────── + // AdaptiveShell renders AppShell for authed users, PublicShell for + // guests on routes marked `handle.public`, and redirects guests to + // /login on every other (private) route. { - element: ( - - - - ), + element: , errorElement: , children: [ { @@ -156,9 +154,10 @@ export const router = createBrowserRouter([ ), }, - // ── Profile routes ────────────────────────────────── + // ── Profile routes (public) ───────────────────────── { path: '/:handle', + handle: { public: true }, element: ( @@ -167,6 +166,7 @@ export const router = createBrowserRouter([ }, { path: '/:handle/replies', + handle: { public: true }, element: ( @@ -175,6 +175,7 @@ export const router = createBrowserRouter([ }, { path: '/:handle/media', + handle: { public: true }, element: ( @@ -183,6 +184,7 @@ export const router = createBrowserRouter([ }, { path: '/:handle/likes', + handle: { public: true }, element: ( @@ -191,6 +193,7 @@ export const router = createBrowserRouter([ }, { path: '/:handle/followers', + handle: { public: true }, element: ( @@ -199,24 +202,27 @@ export const router = createBrowserRouter([ }, { path: '/:handle/following', + handle: { public: true }, element: ( ), }, - // ── Post / thread ─────────────────────────────────── + // ── Post / thread (public) ────────────────────────── { path: '/:handle/status/:postId', + handle: { public: true }, element: ( ), }, - // ── Photo lightbox (modal route) ──────────────────── + // ── Photo lightbox (modal route, public) ──────────── { path: '/:handle/status/:postId/photo/:idx', + handle: { public: true }, element: ( diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx index ba297cc..300bb48 100644 --- a/frontend/src/components/Badge.tsx +++ b/frontend/src/components/Badge.tsx @@ -33,7 +33,7 @@ const VARIANT_COLORS: Record = { color: 'var(--color-warning)', }, accent: { - bg: 'var(--color-accent)', + bg: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', }, } diff --git a/frontend/src/components/Button.test.tsx b/frontend/src/components/Button.test.tsx index bb0acd9..112c578 100644 --- a/frontend/src/components/Button.test.tsx +++ b/frontend/src/components/Button.test.tsx @@ -12,7 +12,8 @@ describe('Button', () => { it('applies primary variant by default', () => { render() const btn = screen.getByTestId('btn') - expect(btn).toHaveStyle({ background: 'var(--color-accent)' }) + // Primary uses the deeper accent surface so the light label meets WCAG AA. + expect(btn).toHaveStyle({ background: 'var(--color-accent-strong)' }) }) it('applies secondary variant', () => { diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index fcc5d70..39c114f 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -23,7 +23,7 @@ export interface ButtonProps extends ButtonHTMLAttributes { const VARIANT_STYLES: Record = { primary: { - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', border: 'none', }, @@ -145,7 +145,7 @@ export const Button = forwardRef(function Button pointer-events: none; } .pulse-btn:not(:disabled):hover.variant-primary { - background: var(--color-accent-hover); + background: var(--color-accent-strong-hover); } .pulse-btn:not(:disabled):hover.variant-secondary { background: var(--color-surface-raised); diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index fed6bb1..828860f 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -104,7 +104,7 @@ export function ConfirmDialog({ style={{ padding: '0.5rem 1.25rem', borderRadius: 'var(--radius-full)', - background: danger ? 'var(--color-danger)' : 'var(--color-accent)', + background: danger ? 'var(--color-danger)' : 'var(--color-accent-strong)', color: danger ? '#fff' : 'var(--color-accent-contrast)', fontFamily: 'var(--font-body)', fontSize: 'var(--text-sm)', diff --git a/frontend/src/components/FollowButton.tsx b/frontend/src/components/FollowButton.tsx index dda63c8..8aa5c86 100644 --- a/frontend/src/components/FollowButton.tsx +++ b/frontend/src/components/FollowButton.tsx @@ -280,7 +280,7 @@ export function FollowButton({ disabled={isLoading} style={{ ...btnBase, - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', border: 'none', opacity: isLoading ? 0.7 : 1, diff --git a/frontend/src/components/IconButton.tsx b/frontend/src/components/IconButton.tsx index 535d421..3f0fe75 100644 --- a/frontend/src/components/IconButton.tsx +++ b/frontend/src/components/IconButton.tsx @@ -38,7 +38,7 @@ const VARIANT_STYLES: Record = { border: '1px solid var(--color-border)', }, accent: { - background: 'var(--color-accent)', + background: 'var(--color-accent-strong)', color: 'var(--color-accent-contrast)', border: 'none', }, diff --git a/frontend/src/components/MediaGrid.tsx b/frontend/src/components/MediaGrid.tsx index eb84b7a..1ce09b3 100644 --- a/frontend/src/components/MediaGrid.tsx +++ b/frontend/src/components/MediaGrid.tsx @@ -198,7 +198,7 @@ function MediaItem({ > {altText} setLoadFailed(true)} width={item.width ?? undefined} diff --git a/frontend/src/components/PostCard.tsx b/frontend/src/components/PostCard.tsx index 7956091..3ca7e40 100644 --- a/frontend/src/components/PostCard.tsx +++ b/frontend/src/components/PostCard.tsx @@ -366,7 +366,6 @@ export function PostCard({ post, noNavigate = false, embedded = false }: PostCar