mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
import type { Locale } from "@reactive-resume/utils/locale";
|
|
import { existsSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
import { env } from "@reactive-resume/env/server";
|
|
import { templateSchema } from "@reactive-resume/schema/templates";
|
|
import { defaultLocale, getLocaleAlternates, isLocale, localizedUrl } from "@reactive-resume/utils/locale";
|
|
|
|
function resolveWebDistPath() {
|
|
const candidates = [
|
|
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
|
|
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
|
|
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
|
|
fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
|
];
|
|
const [fallback] = candidates;
|
|
if (!fallback) throw new Error("Could not resolve web dist path");
|
|
|
|
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
|
|
}
|
|
|
|
const staticRoot = resolveWebDistPath();
|
|
const indexHtmlPath = `${staticRoot}/index.html`;
|
|
// The marketing pages prerendered per locale by the web build (apps/web/vite.config.ts), as <page>/<locale>.html, kept
|
|
// beside dist/ so they're never served at an address of their own.
|
|
const prerenderRoot = `${staticRoot}-prerender`;
|
|
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
|
/**
|
|
* Marketing pages the SPA owns that search engines should index.
|
|
*
|
|
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
|
|
* server serves the shell for anything, so this failure only ever shows up once deployed.
|
|
*/
|
|
const indexableAppPaths = new Set(["/ats-checker"]);
|
|
const reservedPublicResumeSegments = new Set([
|
|
"api",
|
|
"mcp",
|
|
".well-known",
|
|
"uploads",
|
|
"auth",
|
|
"dashboard",
|
|
"builder",
|
|
"agent",
|
|
"templates",
|
|
"ats-checker",
|
|
]);
|
|
|
|
function isAssetPath(pathname: string): boolean {
|
|
return pathname.split("/").pop()?.includes(".") ?? false;
|
|
}
|
|
|
|
function getPathSegments(pathname: string) {
|
|
return pathname.split("/").filter(Boolean);
|
|
}
|
|
|
|
function isNoindexShellPath(pathname: string): boolean {
|
|
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
}
|
|
|
|
function isPublicResumePath(pathname: string): boolean {
|
|
const segments = getPathSegments(pathname);
|
|
const [firstSegment] = segments;
|
|
|
|
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
|
}
|
|
|
|
const BASE_SECURITY_HEADERS = {
|
|
"X-Frame-Options": "DENY",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
// `wasm-unsafe-eval` lets the PDF engine (Forme, WebAssembly) start in the browser.
|
|
"Content-Security-Policy-Report-Only":
|
|
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
|
};
|
|
|
|
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
|
|
// The English copy, for a build without prerendered pages; a prerendered page carries its own locale's title and
|
|
// description, and the social cards reuse them.
|
|
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
|
const ROOT_DESCRIPTION =
|
|
"Free, open-source resume builder. Create, update, and share your resume, with PDF and Word downloads, no ads and no paywall.";
|
|
const ATS_CHECKER_TITLE = "Free ATS resume checker — Reactive Resume";
|
|
const ATS_CHECKER_DESCRIPTION =
|
|
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
|
|
|
|
type StructuredData = Record<string, unknown>;
|
|
|
|
/** Who makes Reactive Resume, referenced by id from every page's structured data. */
|
|
function organization(origin: string): StructuredData {
|
|
return {
|
|
"@type": "Organization",
|
|
"@id": `${origin}/#organization`,
|
|
name: "Reactive Resume",
|
|
url: `${origin}/`,
|
|
contactPoint: { "@type": "ContactPoint", contactType: "support", email: "hello@amruthpillai.com" },
|
|
logo: { "@type": "ImageObject", url: `${origin}/pwa-512x512.png`, width: 512, height: 512 },
|
|
sameAs: [
|
|
githubUrl,
|
|
"https://www.linkedin.com/company/reactive-resume",
|
|
"https://opencollective.com/reactive-resume",
|
|
"https://www.reddit.com/r/reactiveresume",
|
|
"https://discord.gg/aSyA5ZSxpb",
|
|
"https://crowdin.com/project/reactive-resume",
|
|
],
|
|
};
|
|
}
|
|
|
|
function homepageStructuredData(origin: string): StructuredData {
|
|
const rootUrl = `${origin}/`;
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@graph": [
|
|
organization(origin),
|
|
{
|
|
"@type": "WebSite",
|
|
"@id": `${origin}/#website`,
|
|
name: "Reactive Resume",
|
|
url: rootUrl,
|
|
publisher: { "@id": `${origin}/#organization` },
|
|
},
|
|
{
|
|
"@type": ["SoftwareApplication", "WebApplication"],
|
|
name: "Reactive Resume",
|
|
url: rootUrl,
|
|
description: ROOT_DESCRIPTION,
|
|
applicationCategory: "BusinessApplication",
|
|
operatingSystem: "Web",
|
|
isAccessibleForFree: true,
|
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
|
license: `${githubUrl}/blob/main/LICENSE`,
|
|
codeRepository: githubUrl,
|
|
publisher: { "@id": `${origin}/#organization` },
|
|
featureList: [
|
|
"Resume editor with a live page preview",
|
|
`${templateSchema.options.length} templates`,
|
|
"PDF, Word (DOCX), Markdown and JSON export",
|
|
"Import from PDF, LinkedIn, JSON Resume and Word",
|
|
"ATS readability checker",
|
|
"Public or password-protected sharing links",
|
|
"Cover letters and a job application tracker",
|
|
"Optional AI assistant with your own API key",
|
|
"Self-hosting with Docker",
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function atsCheckerStructuredData(origin: string): StructuredData {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@graph": [
|
|
organization(origin),
|
|
{
|
|
"@type": "WebApplication",
|
|
name: "ATS Checker",
|
|
url: `${origin}/ats-checker`,
|
|
description: ATS_CHECKER_DESCRIPTION,
|
|
applicationCategory: "BusinessApplication",
|
|
operatingSystem: "Web",
|
|
isAccessibleForFree: true,
|
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
|
isPartOf: { "@type": "WebSite", "@id": `${origin}/#website`, name: "Reactive Resume", url: `${origin}/` },
|
|
provider: { "@id": `${origin}/#organization` },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
type PageSeoOptions = {
|
|
/** The page's plain address: the canonical for the default locale, and the base of its hreflang alternates. */
|
|
canonicalUrl: string;
|
|
locale: Locale;
|
|
/** A `?locale=` request is canonical for its own language. */
|
|
requested: boolean;
|
|
/** Already HTML-escaped, as the page's own <title> and description are. */
|
|
title: string;
|
|
description: string;
|
|
imageUrl: string;
|
|
structuredData: StructuredData;
|
|
};
|
|
|
|
function createPageSeoMarkup(options: PageSeoOptions) {
|
|
const pageUrl = options.requested ? localizedUrl(options.canonicalUrl, options.locale) : options.canonicalUrl;
|
|
const alternates = getLocaleAlternates(options.canonicalUrl)
|
|
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${escapeAttribute(href)}">`)
|
|
.join("");
|
|
|
|
return `
|
|
<link rel="canonical" href="${escapeAttribute(pageUrl)}">
|
|
${alternates}
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:site_name" content="Reactive Resume">
|
|
<meta property="og:locale" content="${options.locale.replace("-", "_")}">
|
|
<meta property="og:title" content="${options.title}">
|
|
<meta property="og:description" content="${options.description}">
|
|
<meta property="og:url" content="${escapeAttribute(pageUrl)}">
|
|
<meta property="og:image" content="${options.imageUrl}">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="${options.title}">
|
|
<meta name="twitter:description" content="${options.description}">
|
|
<meta name="twitter:image" content="${options.imageUrl}">
|
|
<script type="application/ld+json">${JSON.stringify(options.structuredData)}</script>
|
|
`;
|
|
}
|
|
|
|
/** The title and description a page was built with, still HTML-escaped. */
|
|
function readPageMeta(html: string, fallback: { title: string; description: string }) {
|
|
return {
|
|
title: html.match(/<title>([^<]*)<\/title>/)?.[1] ?? fallback.title,
|
|
description: html.match(/<meta\s+name="description"\s+content="([^"]*)"/)?.[1] ?? fallback.description,
|
|
};
|
|
}
|
|
|
|
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
|
// HTML unescaped.
|
|
const escapeAttribute = (value: string) =>
|
|
value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
|
|
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
|
|
const [username, slug] = getPathSegments(pathname);
|
|
if (!username || !slug) return null;
|
|
|
|
// A card render must never take down the page: any lookup failure falls back to the plain shell.
|
|
const meta = await import("@reactive-resume/api/features/resume/social-meta")
|
|
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
|
|
.catch(() => null);
|
|
if (!meta) return null;
|
|
|
|
const canonicalUrl = `${origin}/${username}/${slug}`;
|
|
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
|
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
|
|
const title = escapeAttribute(meta.title);
|
|
const description = escapeAttribute(meta.description);
|
|
|
|
return {
|
|
pageTitle,
|
|
description,
|
|
markup: `
|
|
<link rel="canonical" href="${canonicalUrl}">
|
|
<meta property="og:type" content="profile">
|
|
<meta property="og:site_name" content="Reactive Resume">
|
|
<meta property="og:title" content="${title}">
|
|
<meta property="og:description" content="${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="${title}">
|
|
<meta name="twitter:description" content="${description}">
|
|
<meta name="twitter:image" content="${imageUrl}">
|
|
`,
|
|
};
|
|
}
|
|
|
|
export const serveWebDistStatic = env.CLOUDFLARE
|
|
? undefined
|
|
: serveStatic({
|
|
root: staticRoot,
|
|
onFound: (_path, context) => {
|
|
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
|
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
}
|
|
},
|
|
});
|
|
|
|
function getFallbackResponseHeaders(pathname: string) {
|
|
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
|
return {
|
|
"Content-Type": "text/html; charset=UTF-8",
|
|
"X-Robots-Tag": "noindex, follow",
|
|
"Cache-Control": "private, no-store",
|
|
...BASE_SECURITY_HEADERS,
|
|
};
|
|
}
|
|
if (pathname === "/" || indexableAppPaths.has(pathname)) {
|
|
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
|
|
}
|
|
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
|
return {
|
|
"Content-Type": "text/html; charset=UTF-8",
|
|
"X-Robots-Tag": "noindex, follow",
|
|
...BASE_SECURITY_HEADERS,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A prerendered page's language: a `?locale=` address first (its hreflang alternates), then the visitor's saved
|
|
* choice, in the order the app reads them (apps/web/src/libs/locale.ts).
|
|
*/
|
|
function getPageLocale(request: Request) {
|
|
const requested = new URL(request.url).searchParams.get("locale");
|
|
if (isLocale(requested)) return { locale: requested, requested: true };
|
|
|
|
const saved = request.headers.get("cookie")?.match(/(?:^|;\s*)locale=([^;]*)/)?.[1] ?? "";
|
|
return { locale: isLocale(saved) ? saved : defaultLocale, requested: false };
|
|
}
|
|
|
|
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
|
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
|
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
|
|
|
return new Response(options.head ? null : "Not Found", {
|
|
status: 404,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
const withMeta = (html: string, meta: { title: string; description: string }) =>
|
|
html
|
|
.replace(/<title>[^<]*<\/title>/, `<title>${meta.title}</title>`)
|
|
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${meta.description}">`);
|
|
|
|
/** The indexable pages the web build prerenders, by path. */
|
|
const prerenderedPages: Record<
|
|
string,
|
|
{
|
|
name: string;
|
|
meta: { title: string; description: string };
|
|
image: string;
|
|
structuredData: (origin: string) => StructuredData;
|
|
/** The page when the build has no prerendered copy: the app shell, titled for the page. */
|
|
fallback: (shell: string) => string;
|
|
}
|
|
> = {
|
|
"/": {
|
|
name: "home",
|
|
meta: { title: ROOT_TITLE, description: ROOT_DESCRIPTION },
|
|
image: "/opengraph/banner.jpg",
|
|
structuredData: homepageStructuredData,
|
|
fallback: (shell) => shell,
|
|
},
|
|
"/ats-checker": {
|
|
name: "ats-checker",
|
|
meta: { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION },
|
|
image: "/opengraph/ats-checker.png",
|
|
structuredData: atsCheckerStructuredData,
|
|
fallback: (shell) => withMeta(shell, { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION }),
|
|
},
|
|
};
|
|
|
|
// ponytail: GET and HEAD share the same routing logic; method determines body presence
|
|
export type ReadWebFile = (path: string) => Promise<string>;
|
|
|
|
export async function handleWebApp(request: Request, readFile: ReadWebFile = (path) => fs.readFile(path, "utf-8")) {
|
|
const isHead = request.method === "HEAD";
|
|
const pathname = new URL(request.url).pathname;
|
|
|
|
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
|
|
return new Response(isHead ? null : "Not Found", { status: 404 });
|
|
}
|
|
|
|
const headers = getFallbackResponseHeaders(pathname);
|
|
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
|
|
|
|
if (isHead) return new Response(null, { status: 200, headers });
|
|
|
|
const html = await readFile(indexHtmlPath);
|
|
|
|
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
|
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
|
// Root configuration never discloses a target in the HTML shell. The public API
|
|
// gates data and browser metadata; shell requests must not count extra views.
|
|
const shell = html
|
|
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
|
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
|
|
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
|
|
return new Response(
|
|
shell.replace("</head>", () => `${markup}</head>`),
|
|
{ headers },
|
|
);
|
|
}
|
|
|
|
const prerendered = prerenderedPages[pathname];
|
|
if (prerendered) {
|
|
const { locale, requested } = getPageLocale(request);
|
|
const origin = new URL(env.APP_URL).origin;
|
|
// Without a prerendered page (a build that skipped it), the app renders the page in the browser.
|
|
const page = await readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`).catch(() =>
|
|
prerendered.fallback(html),
|
|
);
|
|
const markup = createPageSeoMarkup({
|
|
canonicalUrl: new URL(pathname, env.APP_URL).toString(),
|
|
locale,
|
|
requested,
|
|
...readPageMeta(page, prerendered.meta),
|
|
imageUrl: `${origin}${prerendered.image}`,
|
|
structuredData: prerendered.structuredData(origin),
|
|
});
|
|
|
|
// The saved locale changes what this address shows, so caches have to key on the cookie.
|
|
return new Response(
|
|
page.replace("</head>", () => `${markup}</head>`),
|
|
{ headers: { ...headers, Vary: "Cookie" } },
|
|
);
|
|
}
|
|
|
|
if (isPublicResumePath(pathname)) {
|
|
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
|
|
if (resumeSeo) {
|
|
// The shell's generic title/description are replaced so shares and previews show the resume,
|
|
// not the marketing copy baked into index.html. Function replacers keep `$&`, `$'` etc. in user text literal.
|
|
const withTitle = html
|
|
.replace(/<title>[^<]*<\/title>/, () => `<title>${resumeSeo.pageTitle}</title>`)
|
|
.replace(
|
|
/<meta\s+name="description"[^>]*>/,
|
|
() => `<meta name="description" content="${resumeSeo.description}">`,
|
|
);
|
|
|
|
return new Response(
|
|
withTitle.replace("</head>", () => `${resumeSeo.markup}</head>`),
|
|
{ headers },
|
|
);
|
|
}
|
|
}
|
|
|
|
return new Response(html, { headers });
|
|
}
|