Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/dashboard/public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"short_name": "DiffKit",
"name": "DiffKit Dashboard",
"description": "GitHub dashboard for pull requests, issues, and code reviews.",
"icons": [
{
"src": "favicon.svg",
Expand All @@ -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"
}
3 changes: 0 additions & 3 deletions apps/dashboard/public/robots.txt

This file was deleted.

48 changes: 48 additions & 0 deletions apps/dashboard/src/lib/seo.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
154 changes: 154 additions & 0 deletions apps/dashboard/src/lib/seo.ts
Original file line number Diff line number Diff line change
@@ -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}/`;
}
27 changes: 27 additions & 0 deletions apps/dashboard/src/lib/site-config.ts
Original file line number Diff line number Diff line change
@@ -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"],
};
42 changes: 42 additions & 0 deletions apps/dashboard/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -113,6 +131,8 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
| '/robots.txt'
| '/sitemap.xml'
| '/issues'
| '/pulls'
| '/reviews'
Expand All @@ -123,6 +143,8 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/robots.txt'
| '/sitemap.xml'
| '/issues'
| '/pulls'
| '/reviews'
Expand All @@ -135,6 +157,8 @@ export interface FileRouteTypes {
| '__root__'
| '/_protected'
| '/login'
| '/robots.txt'
| '/sitemap.xml'
| '/_protected/issues'
| '/_protected/pulls'
| '/_protected/reviews'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading