diff --git a/apps/dashboard/public/manifest.json b/apps/dashboard/public/manifest.json index cf17062..a6fc19a 100644 --- a/apps/dashboard/public/manifest.json +++ b/apps/dashboard/public/manifest.json @@ -1,6 +1,7 @@ { "short_name": "DiffKit", "name": "DiffKit Dashboard", + "description": "GitHub dashboard for pull requests, issues, and code reviews.", "icons": [ { "src": "favicon.svg", @@ -23,8 +24,11 @@ "sizes": "512x512" } ], - "start_url": ".", + "id": "/", + "start_url": "/login", + "scope": "/", "display": "standalone", + "categories": ["developer tools", "productivity"], "theme_color": "#00C943", "background_color": "#ffffff" } diff --git a/apps/dashboard/public/robots.txt b/apps/dashboard/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/apps/dashboard/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/apps/dashboard/src/lib/seo.test.ts b/apps/dashboard/src/lib/seo.test.ts new file mode 100644 index 0000000..43495bc --- /dev/null +++ b/apps/dashboard/src/lib/seo.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { buildSeo, summarizeText, toAbsoluteUrl } from "./seo"; + +describe("summarizeText", () => { + it("strips basic markdown and whitespace", () => { + expect( + summarizeText( + "# Heading\n\nTrack [pull requests](https://example.com) with `fast` reviews.", + ), + ).toBe("Heading Track pull requests with reviews."); + }); + + it("falls back when the source is empty", () => { + expect(summarizeText(" ", "Fallback text")).toBe("Fallback text"); + }); +}); + +describe("toAbsoluteUrl", () => { + it("joins a site url and path", () => { + expect(toAbsoluteUrl("https://diffkit.app", "/login")).toBe( + "https://diffkit.app/login", + ); + }); +}); + +describe("buildSeo", () => { + it("returns canonical links and noindex robots directives", () => { + const seo = buildSeo({ + siteUrl: "https://diffkit.app", + path: "/pulls", + title: "Pull Requests | DiffKit", + description: "Private pull request dashboard.", + robots: "noindex", + }); + + expect(seo.links).toEqual([ + { rel: "canonical", href: "https://diffkit.app/pulls" }, + ]); + expect(seo.meta).toContainEqual({ + name: "robots", + content: "noindex, nofollow, noarchive", + }); + expect(seo.meta).toContainEqual({ + property: "og:url", + content: "https://diffkit.app/pulls", + }); + }); +}); diff --git a/apps/dashboard/src/lib/seo.ts b/apps/dashboard/src/lib/seo.ts new file mode 100644 index 0000000..ea5aaca --- /dev/null +++ b/apps/dashboard/src/lib/seo.ts @@ -0,0 +1,154 @@ +import { siteConfig } from "./site-config"; + +const MAX_DESCRIPTION_LENGTH = 160; +const SEO_ROBOTS_INDEX = + "index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"; +const SEO_ROBOTS_NOINDEX = "noindex, nofollow, noarchive"; + +export const PRIVATE_ROUTE_HEADERS = { + "X-Robots-Tag": SEO_ROBOTS_NOINDEX, +}; + +type SeoInput = { + siteUrl?: string; + path: string; + title: string; + description: string; + imagePath?: string; + imageAlt?: string; + robots?: "index" | "noindex"; + type?: "website" | "article"; + includeCanonical?: boolean; +}; + +type WebSiteSchemaInput = { + siteUrl?: string; + path?: string; +}; + +export function buildSeo({ + description, + imageAlt = `${siteConfig.name} preview`, + imagePath = siteConfig.socialImagePath, + includeCanonical = true, + path, + robots = "index", + siteUrl = siteConfig.url, + title, + type = "website", +}: SeoInput) { + const canonicalUrl = toAbsoluteUrl(siteUrl, path); + const imageUrl = toAbsoluteUrl(siteUrl, imagePath); + const normalizedDescription = summarizeText(description); + const robotsContent = + robots === "noindex" ? SEO_ROBOTS_NOINDEX : SEO_ROBOTS_INDEX; + + return { + links: includeCanonical + ? [{ rel: "canonical", href: canonicalUrl }] + : undefined, + meta: [ + { title }, + { name: "description", content: normalizedDescription }, + { name: "robots", content: robotsContent }, + { name: "googlebot", content: robotsContent }, + { property: "og:site_name", content: siteConfig.name }, + { property: "og:type", content: type }, + { property: "og:title", content: title }, + { property: "og:description", content: normalizedDescription }, + { property: "og:url", content: canonicalUrl }, + { property: "og:image", content: imageUrl }, + { property: "og:image:alt", content: imageAlt }, + { name: "twitter:card", content: "summary" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: normalizedDescription }, + { name: "twitter:image", content: imageUrl }, + ], + }; +} + +export function buildWebSiteSchema({ + path = "/", + siteUrl = siteConfig.url, +}: WebSiteSchemaInput) { + const siteRoot = toAbsoluteUrl(siteUrl, "/"); + + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + name: siteConfig.name, + url: siteRoot, + description: siteConfig.defaultDescription, + publisher: { + "@type": "Organization", + name: siteConfig.name, + url: siteRoot, + logo: { + "@type": "ImageObject", + url: toAbsoluteUrl(siteUrl, siteConfig.socialImagePath), + }, + sameAs: [siteConfig.githubRepositoryUrl], + }, + }, + { + "@type": "WebPage", + name: siteConfig.defaultTitle, + url: toAbsoluteUrl(siteUrl, path), + isPartOf: { + "@id": siteRoot, + }, + }, + ], + }; +} + +export function buildSoftwareApplicationSchema(siteUrl: string) { + return { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: siteConfig.name, + applicationCategory: "DeveloperApplication", + operatingSystem: "Web", + description: siteConfig.defaultDescription, + url: toAbsoluteUrl(siteUrl, "/login"), + image: toAbsoluteUrl(siteUrl, siteConfig.socialImagePath), + codeRepository: siteConfig.githubRepositoryUrl, + }; +} + +export function summarizeText( + input: string | null | undefined, + fallback = siteConfig.defaultDescription, +) { + if (!input) return fallback; + + const normalized = input + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/`{1,3}[^`]*`{1,3}/g, " ") + .replace(/[*_~>#-]+/g, " ") + .replace(/<\/?[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) return fallback; + if (normalized.length <= MAX_DESCRIPTION_LENGTH) return normalized; + + return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH - 1).trimEnd()}...`; +} + +export function formatPageTitle(value: string) { + return value.includes(siteConfig.name) + ? value + : `${value} | ${siteConfig.name}`; +} + +export function toAbsoluteUrl(siteUrl: string, path: string) { + return new URL(path, ensureTrailingSlash(siteUrl)).toString(); +} + +function ensureTrailingSlash(value: string) { + return value.endsWith("/") ? value : `${value}/`; +} diff --git a/apps/dashboard/src/lib/site-config.ts b/apps/dashboard/src/lib/site-config.ts new file mode 100644 index 0000000..f03f014 --- /dev/null +++ b/apps/dashboard/src/lib/site-config.ts @@ -0,0 +1,27 @@ +type SiteConfig = { + name: string; + domain: string; + url: string; + githubRepositoryUrl: string; + themeColor: string; + socialImagePath: string; + defaultTitle: string; + defaultDescription: string; + manifestName: string; + manifestCategories: string[]; +}; + +export const siteConfig: SiteConfig = { + name: "DiffKit", + domain: "diff-kit.com", + url: "https://diff-kit.com", + githubRepositoryUrl: "https://github.com/stylessh/diffkit", + themeColor: "#00C943", + socialImagePath: "/logo512.png", + defaultTitle: + "DiffKit | GitHub dashboard for pull requests, issues, and reviews", + defaultDescription: + "DiffKit helps developers track pull requests, issues, and code reviews across GitHub in one fast, focused dashboard.", + manifestName: "DiffKit Dashboard", + manifestCategories: ["developer tools", "productivity"], +}; diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index f8d9805..379592e 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -9,6 +9,8 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as ProtectedIndexRouteImport } from './routes/_protected/index' @@ -20,6 +22,16 @@ import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_pr import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' +const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ + id: '/sitemap.xml', + path: '/sitemap.xml', + getParentRoute: () => rootRouteImport, +} as any) +const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ + id: '/robots.txt', + path: '/robots.txt', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -76,6 +88,8 @@ const ProtectedOwnerRepoIssuesIssueIdRoute = export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -86,6 +100,8 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute '/reviews': typeof ProtectedReviewsRoute @@ -99,6 +115,8 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute '/_protected/reviews': typeof ProtectedReviewsRoute @@ -113,6 +131,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/robots.txt' + | '/sitemap.xml' | '/issues' | '/pulls' | '/reviews' @@ -123,6 +143,8 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/login' + | '/robots.txt' + | '/sitemap.xml' | '/issues' | '/pulls' | '/reviews' @@ -135,6 +157,8 @@ export interface FileRouteTypes { | '__root__' | '/_protected' | '/login' + | '/robots.txt' + | '/sitemap.xml' | '/_protected/issues' | '/_protected/pulls' | '/_protected/reviews' @@ -148,11 +172,27 @@ export interface FileRouteTypes { export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren LoginRoute: typeof LoginRoute + RobotsDottxtRoute: typeof RobotsDottxtRoute + SitemapDotxmlRoute: typeof SitemapDotxmlRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sitemap.xml': { + id: '/sitemap.xml' + path: '/sitemap.xml' + fullPath: '/sitemap.xml' + preLoaderRoute: typeof SitemapDotxmlRouteImport + parentRoute: typeof rootRouteImport + } + '/robots.txt': { + id: '/robots.txt' + path: '/robots.txt' + fullPath: '/robots.txt' + preLoaderRoute: typeof RobotsDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -253,6 +293,8 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, LoginRoute: LoginRoute, + RobotsDottxtRoute: RobotsDottxtRoute, + SitemapDotxmlRoute: SitemapDotxmlRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, } export const routeTree = rootRouteImport diff --git a/apps/dashboard/src/routes/__root.tsx b/apps/dashboard/src/routes/__root.tsx index 11a5200..e13ac63 100644 --- a/apps/dashboard/src/routes/__root.tsx +++ b/apps/dashboard/src/routes/__root.tsx @@ -10,23 +10,47 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Agentation } from "agentation"; import { ThemeProvider } from "next-themes"; import { ErrorScreen } from "#/components/layouts/error-screen"; +import { buildSeo, buildWebSiteSchema } from "#/lib/seo"; +import { siteConfig } from "#/lib/site-config"; import appCss from "../styles.css?url"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ - head: () => ({ - meta: [ - { charSet: "utf-8" }, - { name: "viewport", content: "width=device-width, initial-scale=1" }, - { title: "DiffKit Dashboard" }, - ], - links: [ - { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, - { rel: "stylesheet", href: appCss }, - ], - }), + head: ({ match }) => { + const defaultSeo = buildSeo({ + path: match.pathname, + title: siteConfig.defaultTitle, + description: siteConfig.defaultDescription, + includeCanonical: false, + }); + + return { + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { name: "application-name", content: siteConfig.name }, + { name: "apple-mobile-web-app-title", content: siteConfig.name }, + { name: "theme-color", content: siteConfig.themeColor }, + { name: "format-detection", content: "telephone=no" }, + ...defaultSeo.meta, + ], + links: [ + { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { rel: "manifest", href: "/manifest.json" }, + { rel: "stylesheet", href: appCss }, + ], + scripts: [ + { + type: "application/ld+json", + children: JSON.stringify( + buildWebSiteSchema({ path: match.pathname }), + ), + }, + ], + }; + }, component: RootComponent, errorComponent: ErrorScreen, shellComponent: RootDocument, diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx index 262ef60..a1d867d 100644 --- a/apps/dashboard/src/routes/_protected.tsx +++ b/apps/dashboard/src/routes/_protected.tsx @@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { DashboardLayout } from "#/components/layouts/dashboard-layout"; import { ErrorScreen } from "#/components/layouts/error-screen"; import { getSession } from "#/lib/auth.functions"; +import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; export const Route = createFileRoute("/_protected")({ beforeLoad: async ({ location }) => { @@ -14,6 +15,16 @@ export const Route = createFileRoute("/_protected")({ } return { user: session.user, session: session.session }; }, + headers: () => PRIVATE_ROUTE_HEADERS, + head: ({ match }) => { + return buildSeo({ + path: match.pathname, + title: formatPageTitle("Dashboard"), + description: + "Private GitHub workspace for tracking pull requests, issues, and review requests.", + robots: "noindex", + }); + }, component: DashboardLayout, errorComponent: ErrorScreen, }); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx index 6bdb3a9..d141b56 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { formatRelativeTime } from "#/components/pulls/pull-request-row"; import { githubIssuePageQueryOptions } from "#/lib/github.query"; import type { GitHubActor, IssueDetail } from "#/lib/github.types"; +import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; @@ -28,15 +29,30 @@ export const Route = createFileRoute( issueNumber, }); - const primeQuery = (options: { queryKey: readonly unknown[] }) => { - if (context.queryClient.getQueryData(options.queryKey) !== undefined) { - return Promise.resolve(); - } - - return context.queryClient.ensureQueryData(options); - }; + const cachedData = context.queryClient.getQueryData(pageOptions.queryKey); + if (cachedData !== undefined) { + return cachedData; + } - await Promise.all([primeQuery(pageOptions)]); + return context.queryClient.ensureQueryData(pageOptions); + }, + head: ({ loaderData, match, params }) => { + const issue = loaderData?.detail; + const issueTitle = issue + ? formatPageTitle(`Issue #${issue.number}: ${issue.title}`) + : formatPageTitle(`Issue #${params.issueId}`); + + return buildSeo({ + path: match.pathname, + title: issueTitle, + description: issue + ? summarizeText( + issue.body, + `Private GitHub issue #${issue.number} in ${params.owner}/${params.repo}.`, + ) + : `Private GitHub issue #${params.issueId} in ${params.owner}/${params.repo}.`, + robots: "noindex", + }); }, component: IssueDetailPage, }); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx index acb31c0..b6bbe9b 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx @@ -36,6 +36,7 @@ import type { PullStatus, } from "#/lib/github.types"; import { githubCachePolicy } from "#/lib/github-cache-policy"; +import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; @@ -49,15 +50,30 @@ export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({ pullNumber, }); - const primeQuery = (options: { queryKey: readonly unknown[] }) => { - if (context.queryClient.getQueryData(options.queryKey) !== undefined) { - return Promise.resolve(); - } - - return context.queryClient.ensureQueryData(options); - }; + const cachedData = context.queryClient.getQueryData(pageOptions.queryKey); + if (cachedData !== undefined) { + return cachedData; + } - await Promise.all([primeQuery(pageOptions)]); + return context.queryClient.ensureQueryData(pageOptions); + }, + head: ({ loaderData, match, params }) => { + const pull = loaderData?.detail; + const title = pull + ? formatPageTitle(`PR #${pull.number}: ${pull.title}`) + : formatPageTitle(`PR #${params.pullId}`); + + return buildSeo({ + path: match.pathname, + title, + description: pull + ? summarizeText( + pull.body, + `Private pull request #${pull.number} in ${params.owner}/${params.repo}.`, + ) + : `Private pull request #${params.pullId} in ${params.owner}/${params.repo}.`, + robots: "noindex", + }); }, component: PullDetailPage, }); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx index 7a34873..f7f722c 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx @@ -53,6 +53,7 @@ import type { PullFile, PullReviewComment, } from "#/lib/github.types"; +import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useRegisterTab } from "#/lib/use-register-tab"; @@ -86,19 +87,48 @@ export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")( const pullNumber = Number(params.pullId); const scope = { userId: context.user.id }; const input = { owner: params.owner, repo: params.repo, pullNumber }; + const pageOptions = githubPullPageQueryOptions(scope, input); + const filesOptions = githubPullFilesQueryOptions(scope, input); + const commentsOptions = githubPullReviewCommentsQueryOptions( + scope, + input, + ); + + const pageData = + context.queryClient.getQueryData(pageOptions.queryKey) ?? + (await context.queryClient.ensureQueryData(pageOptions)); + + if ( + context.queryClient.getQueryData(filesOptions.queryKey) === undefined + ) { + await context.queryClient.ensureQueryData(filesOptions); + } - const primeQuery = (options: { queryKey: readonly unknown[] }) => { - if (context.queryClient.getQueryData(options.queryKey) !== undefined) { - return Promise.resolve(); - } - return context.queryClient.ensureQueryData(options); - }; - - await Promise.all([ - primeQuery(githubPullPageQueryOptions(scope, input)), - primeQuery(githubPullFilesQueryOptions(scope, input)), - primeQuery(githubPullReviewCommentsQueryOptions(scope, input)), - ]); + if ( + context.queryClient.getQueryData(commentsOptions.queryKey) === undefined + ) { + await context.queryClient.ensureQueryData(commentsOptions); + } + + return pageData; + }, + head: ({ loaderData, match, params }) => { + const pull = loaderData?.detail; + const title = pull + ? formatPageTitle(`Review PR #${pull.number}: ${pull.title}`) + : formatPageTitle(`Review PR #${params.pullId}`); + + return buildSeo({ + path: match.pathname, + title, + description: pull + ? summarizeText( + pull.body, + `Private code review workspace for pull request #${pull.number} in ${params.owner}/${params.repo}.`, + ) + : `Private code review workspace for pull request #${params.pullId} in ${params.owner}/${params.repo}.`, + robots: "noindex", + }); }, component: ReviewPage, }, diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx index d71b813..1223bd5 100644 --- a/apps/dashboard/src/routes/_protected/index.tsx +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -8,6 +8,7 @@ import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, } from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/")({ @@ -19,6 +20,14 @@ export const Route = createFileRoute("/_protected/")({ context.queryClient.ensureQueryData(githubMyIssuesQueryOptions(scope)), ]); }, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Dashboard overview"), + description: + "Private overview of your open pull requests, assigned issues, and pending review requests across GitHub.", + robots: "noindex", + }), component: OverviewPage, }); diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index 7dcd533..a6485ba 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -13,6 +13,7 @@ import { IssueRow } from "#/components/issues/issue-row"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { githubMyIssuesQueryOptions } from "#/lib/github.query"; import type { IssueSummary, MyIssuesResult } from "#/lib/github.types"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/issues")({ @@ -22,6 +23,14 @@ export const Route = createFileRoute("/_protected/issues")({ githubMyIssuesQueryOptions(scope), ); }, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("GitHub issues"), + description: + "Private issue dashboard for assigned, authored, and mentioned GitHub issues across your repositories.", + robots: "noindex", + }), component: IssuesPage, }); diff --git a/apps/dashboard/src/routes/_protected/pulls.tsx b/apps/dashboard/src/routes/_protected/pulls.tsx index a9daec1..28951b8 100644 --- a/apps/dashboard/src/routes/_protected/pulls.tsx +++ b/apps/dashboard/src/routes/_protected/pulls.tsx @@ -19,6 +19,7 @@ import { DashboardContentLoading } from "#/components/layouts/dashboard-content- import { PullRequestRow } from "#/components/pulls/pull-request-row"; import { githubMyPullsQueryOptions } from "#/lib/github.query"; import type { MyPullsResult, PullSummary } from "#/lib/github.types"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/pulls")({ @@ -26,6 +27,14 @@ export const Route = createFileRoute("/_protected/pulls")({ const scope = { userId: context.user.id }; await context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)); }, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("GitHub pull requests"), + description: + "Private pull request dashboard for review requests, assigned work, authored PRs, mentions, and active branches.", + robots: "noindex", + }), component: PullRequestsPage, }); diff --git a/apps/dashboard/src/routes/_protected/reviews.tsx b/apps/dashboard/src/routes/_protected/reviews.tsx index e8dccc0..57619c3 100644 --- a/apps/dashboard/src/routes/_protected/reviews.tsx +++ b/apps/dashboard/src/routes/_protected/reviews.tsx @@ -5,6 +5,7 @@ import { useRef } from "react"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { PullRequestRow } from "#/components/pulls/pull-request-row"; import { githubMyPullsQueryOptions } from "#/lib/github.query"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/reviews")({ @@ -12,6 +13,14 @@ export const Route = createFileRoute("/_protected/reviews")({ const scope = { userId: context.user.id }; await context.queryClient.ensureQueryData(githubMyPullsQueryOptions(scope)); }, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("GitHub code reviews"), + description: + "Private review queue for pull requests requesting your feedback, with fast access to diffs and discussion.", + robots: "noindex", + }), component: ReviewsPage, }); diff --git a/apps/dashboard/src/routes/api/auth/$.ts b/apps/dashboard/src/routes/api/auth/$.ts index cc179b0..b027c11 100644 --- a/apps/dashboard/src/routes/api/auth/$.ts +++ b/apps/dashboard/src/routes/api/auth/$.ts @@ -1,7 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { getAuth } from "#/lib/auth.server"; +import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; export const Route = createFileRoute("/api/auth/$")({ + headers: () => PRIVATE_ROUTE_HEADERS, server: { handlers: { GET: async ({ request }) => { diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index 56adbec..8909ca4 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -4,12 +4,41 @@ import { Logo } from "@diffkit/ui/components/logo"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { getSession } from "#/lib/auth.functions"; import { signInWithGitHub } from "#/lib/auth-actions"; +import { + buildSeo, + buildSoftwareApplicationSchema, + formatPageTitle, +} from "#/lib/seo"; +import { siteConfig } from "#/lib/site-config"; export const Route = createFileRoute("/login")({ beforeLoad: async () => { const session = await getSession(); if (session) throw redirect({ to: "/" }); }, + head: () => { + const seo = buildSeo({ + path: "/login", + title: formatPageTitle( + "GitHub dashboard for pull requests, issues, and reviews", + ), + description: + "Track GitHub pull requests, assigned issues, and review requests in one focused dashboard built for developers.", + }); + + return { + links: seo.links, + meta: seo.meta, + scripts: [ + { + type: "application/ld+json", + children: JSON.stringify( + buildSoftwareApplicationSchema(siteConfig.url), + ), + }, + ], + }; + }, component: LoginPage, }); @@ -36,6 +65,11 @@ function LoginPage() {

Review your GitHub work in one place

+

+ DiffKit pulls together open pull requests, assigned issues, + and pending code reviews into one fast workspace so you can + move through GitHub work without tab sprawl. +

+ +
+

+ What DiffKit helps you do +

+ +
@@ -68,11 +120,34 @@ function LoginPage() {
-
-
@@ -80,3 +155,12 @@ function LoginPage() { ); } + +function FeaturePanel({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/dashboard/src/routes/robots[.]txt.ts b/apps/dashboard/src/routes/robots[.]txt.ts new file mode 100644 index 0000000..4d70bec --- /dev/null +++ b/apps/dashboard/src/routes/robots[.]txt.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { siteConfig } from "#/lib/site-config"; + +export const Route = createFileRoute("/robots.txt")({ + server: { + handlers: { + GET: async () => { + const lines = [ + "User-agent: *", + "Allow: /", + "Disallow: /api/", + `Sitemap: ${siteConfig.url}/sitemap.xml`, + ]; + + return new Response(lines.join("\n"), { + headers: { + "Cache-Control": "public, max-age=0, s-maxage=3600", + "Content-Type": "text/plain; charset=utf-8", + }, + }); + }, + }, + }, +}); diff --git a/apps/dashboard/src/routes/sitemap[.]xml.ts b/apps/dashboard/src/routes/sitemap[.]xml.ts new file mode 100644 index 0000000..e686ace --- /dev/null +++ b/apps/dashboard/src/routes/sitemap[.]xml.ts @@ -0,0 +1,40 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { siteConfig } from "#/lib/site-config"; + +export const Route = createFileRoute("/sitemap.xml")({ + server: { + handlers: { + GET: async () => { + const lastModified = new Date().toISOString(); + const pages = [ + { + loc: `${siteConfig.url}/login`, + changefreq: "weekly", + priority: "0.8", + }, + ]; + + const xml = ` + +${pages + .map( + (page) => ` + ${page.loc} + ${lastModified} + ${page.changefreq} + ${page.priority} + `, + ) + .join("\n")} +`; + + return new Response(xml, { + headers: { + "Cache-Control": "public, max-age=0, s-maxage=3600", + "Content-Type": "application/xml; charset=utf-8", + }, + }); + }, + }, + }, +});