fix(server): emit initial homepage SEO metadata

This commit is contained in:
Amruth Pillai
2026-07-28 08:07:53 +02:00
parent 12407d473d
commit 418c7887ee
2 changed files with 172 additions and 5 deletions
+57 -1
View File
@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
}));
@@ -12,10 +16,21 @@ vi.mock("node:fs/promises", () => ({
}));
vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: vi.fn(() => vi.fn()),
serveStatic: mocks.serveStatic,
}));
type StaticOptions = {
onFound?: (
path: string,
context: {
req: { path: string };
header: (name: string, value: string) => void;
},
) => void | Promise<void>;
};
const { handleWebApp } = await import("./web");
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
describe("web app fallback classification", () => {
beforeEach(() => {
@@ -32,6 +47,47 @@ describe("web app fallback classification", () => {
expect(await response.text()).toBe("<html>app</html>");
});
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
vi.mocked(fs.readFile).mockResolvedValue(`
<!doctype html>
<html>
<head>
<title>Reactive Resume — A free and open-source resume builder</title>
<meta
name="description"
content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
>
</head>
<body><div id="app"></div></body>
</html>
`);
const response = await handleWebApp(new Request("https://example.com/?utm_source=search"));
const html = await response.text();
expect(html).toContain('<link rel="canonical" href="https://example.com/">');
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
expect(html).toContain('<meta property="og:url" content="https://example.com/">');
expect(html).toContain('<meta property="og:image" content="https://example.com/opengraph/banner.jpg">');
expect(html).toContain('id="reactive-resume-structured-data"');
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
expect(html).not.toContain("utm_source");
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
});
it("caches versioned homepage media immutably", async () => {
const headers = new Headers();
await staticOptions?.onFound?.("", {
req: { path: "/videos/timelapse-v1.mp4" },
header: (name, value) => headers.set(name, value),
});
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
});
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
+115 -4
View File
@@ -31,8 +31,6 @@ const reservedPublicResumeSegments = new Set([
"templates",
]);
export const serveWebDistStatic = serveStatic({ root: staticRoot });
function isAssetPath(pathname: string): boolean {
return pathname.split("/").pop()?.includes(".") ?? false;
}
@@ -60,6 +58,115 @@ const BASE_SECURITY_HEADERS = {
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
};
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
const ROOT_DESCRIPTION =
"Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.";
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
const ROOT_FAQ_ITEMS = [
{
question: "Is Reactive Resume really free?",
answer:
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
},
{
question: "How is my data protected?",
answer:
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
},
{
question: "Can I export my resume to PDF?",
answer:
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
},
{
question: "Is Reactive Resume available in multiple languages?",
answer:
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
},
{
question: "What makes Reactive Resume different from other resume builders?",
answer:
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
},
{
question: "How do I share my resume?",
answer:
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
},
] as const;
function createRootSeoMarkup(canonicalUrl: string) {
const origin = new URL(canonicalUrl).origin;
const imageUrl = `${origin}/opengraph/banner.jpg`;
const structuredData = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
name: "Reactive Resume",
url: canonicalUrl,
},
{
"@type": ["SoftwareApplication", "WebApplication"],
name: "Reactive Resume",
url: canonicalUrl,
description: ROOT_DESCRIPTION,
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
isAccessibleForFree: true,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
codeRepository: "https://github.com/amruthpillai/reactive-resume",
},
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
},
{
"@type": "FAQPage",
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
},
],
};
return `
<link rel="canonical" href="${canonicalUrl}">
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Reactive Resume">
<meta property="og:title" content="${ROOT_TITLE}">
<meta property="og:description" content="${ROOT_DESCRIPTION}">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${ROOT_TITLE}">
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
<meta name="twitter:image" content="${imageUrl}">
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
`;
}
export const serveWebDistStatic = serveStatic({
root: staticRoot,
onFound: (_path, context) => {
if (context.req.path.startsWith("/videos/")) {
context.header("Cache-Control", "public, max-age=31536000, immutable");
}
},
});
function getFallbackResponseHeaders(pathname: string) {
if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
@@ -86,7 +193,8 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
const pathname = new URL(request.url).pathname;
const requestUrl = new URL(request.url);
const pathname = requestUrl.pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
return new Response(isHead ? null : "Not Found", { status: 404 });
@@ -98,5 +206,8 @@ export async function handleWebApp(request: Request) {
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
return new Response(html, { headers });
const canonicalUrl = new URL("/", requestUrl.origin).toString();
const responseHtml = pathname === "/" ? html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`) : html;
return new Response(responseHtml, { headers });
}