Compare commits

...
13 changed files with 1942 additions and 182 deletions
+1
View File
@@ -60,6 +60,7 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/og-metadata.json
app/core/src/main/resources/static/sitemap.xml
app/core/src/main/resources/static/sw-folder-retry.js
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
+2
View File
@@ -345,6 +345,8 @@ tasks.register('cleanFrontendAssets', Delete) {
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
// Nested prerendered route pages (e.g. settings/people.html)
delete new File(resourcesStaticDir, 'settings')
// Generated SEO sitemap (only emitted when a canonical domain is set at build).
delete new File(resourcesStaticDir, 'sitemap.xml')
}
tasks.register('copyApiLandingPage', Copy) {
@@ -5,6 +5,8 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
@@ -16,6 +18,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
@@ -31,6 +34,14 @@ public class ReactRoutingController {
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
// Clean URL segment: no dots, slashes or traversal - matches the prerendered
// file naming (e.g. compress -> compress.html, settings/people).
private static final Pattern SAFE_SEGMENT = Pattern.compile("[A-Za-z0-9_-]+");
// Sentinel cached for routes with no prerendered file, so misses don't re-hit disk.
private static final String NO_PRERENDER = "no-prerendered-page";
// Clean-URL path -> processed prerendered HTML (or NO_PRERENDER). Bounded by the
// finite set of SPA routes; populated lazily on first request.
private final Map<String, String> prerenderedCache = new ConcurrentHashMap<>();
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -115,22 +126,7 @@ public class ReactRoutingController {
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
BASE_HREF_PATTERN
.matcher(html)
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
return applyContextPath(html);
}
} catch (Exception ex) {
if (!loggedMissingIndex) {
@@ -141,6 +137,59 @@ public class ReactRoutingController {
}
}
// Apply the deploy's context path to a built HTML shell: fill %BASE_URL%,
// rewrite the baked <base href>, and expose the API base to the SPA.
private String applyContextPath(String html) {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
html =
BASE_HREF_PATTERN
.matcher(html)
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
return html.replace("</head>", contextPathScript + "</head>");
}
// Serve the prerendered per-route HTML (e.g. compress.html) for a clean URL so
// crawlers and link unfurlers get the route's title/OG/canonical/JSON-LD. Falls
// back to the generic index.html shell when no prerendered page exists.
private ResponseEntity<String> servePrerenderedOrIndex(
HttpServletRequest request, String... segments) {
String html = lookupPrerendered(segments);
if (html != null) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(html);
}
return serveIndexHtml(request);
}
private String lookupPrerendered(String... segments) {
if (segments == null || segments.length == 0) {
return null;
}
for (String segment : segments) {
if (segment == null || !SAFE_SEGMENT.matcher(segment).matches()) {
return null;
}
}
String key = String.join("/", segments);
String cached = prerenderedCache.get(key);
if (cached != null) {
return NO_PRERENDER.equals(cached) ? null : cached;
}
String html = readStaticHtml(key + ".html");
if (html == null) {
prerenderedCache.put(key, NO_PRERENDER);
return null;
}
String processed = applyContextPath(html);
prerenderedCache.put(key, processed);
return processed;
}
private Resource getIndexHtmlResource() {
// Check external location first
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
@@ -245,15 +294,19 @@ public class ReactRoutingController {
// excluded by the leading `api` token in the same regex.)
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
public ResponseEntity<String> forwardRootPaths(
HttpServletRequest request, @PathVariable("path") String path) throws IOException {
return servePrerenderedOrIndex(request, path);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
public ResponseEntity<String> forwardNestedPaths(
HttpServletRequest request,
@PathVariable("path") String path,
@PathVariable("subpath") String subpath)
throws IOException {
return serveIndexHtml(request);
return servePrerenderedOrIndex(request, path, subpath);
}
private String buildFallbackHtml() {
@@ -156,10 +156,23 @@ class ReactRoutingControllerTest {
// --- forwarding routes ---
@Test
void forwardRootPaths_servesIndexHtml() throws Exception {
void forwardRootPaths_noPrerenderedFile_fallsBackToIndexHtml() throws Exception {
controller.init();
ResponseEntity<String> response = controller.forwardRootPaths(request);
// No prerendered compress.html on the test classpath -> generic shell.
ResponseEntity<String> response = controller.forwardRootPaths(request, "compress");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().contains("Stirling PDF"));
}
@Test
void forwardRootPaths_unsafeSegment_fallsBackToIndexHtml() throws Exception {
controller.init();
// A dotted segment must never be turned into a file lookup.
ResponseEntity<String> response = controller.forwardRootPaths(request, "e.tag");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
@@ -169,7 +182,8 @@ class ReactRoutingControllerTest {
void forwardNestedPaths_servesIndexHtml() throws Exception {
controller.init();
ResponseEntity<String> response = controller.forwardNestedPaths(request);
ResponseEntity<String> response =
controller.forwardNestedPaths(request, "settings", "people");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
+1
View File
@@ -12,6 +12,7 @@ editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
editor/src/core/data/urlSeoOverrides.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
File diff suppressed because it is too large Load Diff
+265 -12
View File
@@ -3,8 +3,9 @@
// strings) plus the actual images in public/og_images.
//
// Outputs:
// src/core/data/ogImageMap.json - { toolId: imageBasename } (imported by the client)
// public/og-metadata.json - { default, byTool, byPath } (read by the backend at startup)
// src/core/data/ogImageMap.json - { toolId: imageBasename } (imported by the client)
// src/core/data/urlSeoOverrides.json - { path: { title, description } } (client per-URL meta)
// public/og-metadata.json - { default, byTool, byPath, navLinks } (read by the build-time OG prerender)
//
// Run: `node scripts/generate-og-metadata.mjs` (writes files)
// `node scripts/generate-og-metadata.mjs --check` (CI drift guard: fails if stale)
@@ -26,6 +27,174 @@ const SITE_TITLE = "Stirling PDF";
const SITE_DESC = "The Free Adobe Acrobat alternative (10M+ Downloads)";
const DEFAULT_IMAGE_BASENAME = "home";
// Keyword-targeted landing copy for the convert tool's URL aliases. Every
// /pdf-to-x and /x-to-pdf path routes to the single `convert` tool, so without
// this they would all share the generic "Convert - Stirling PDF" title and
// description - duplicate content that ranks for nothing. Each entry gets its
// own crawlable title/description (prerendered) and a client-side override so
// the SPA keeps the same title after hydration. `name` is the keyword phrase;
// the app suffix (" - Stirling PDF" / the instance name) is added per surface.
const CONVERT_SEO_PAGES = {
"/pdf-to-word": {
name: "PDF to Word Converter",
description:
"Convert PDF files into editable Microsoft Word (DOCX) documents. Free, fast, and private - no signup or email required.",
},
"/pdf-to-xlsx": {
name: "PDF to Excel Converter",
description:
"Convert PDF tables into editable Microsoft Excel (XLSX) spreadsheets. Free, fast, and private.",
},
"/pdf-to-csv": {
name: "PDF to CSV Converter",
description:
"Extract tables from PDF files into CSV data. Free, fast, and private - no signup required.",
},
"/pdf-to-img": {
name: "PDF to Image Converter",
description:
"Convert PDF pages into high-quality JPG or PNG images. Free and private, right in your browser.",
},
"/pdf-to-presentation": {
name: "PDF to PowerPoint Converter",
description:
"Convert PDF files into editable PowerPoint (PPTX) presentations. Free, fast, and private.",
},
"/pdf-to-text": {
name: "PDF to Text Converter",
description:
"Extract plain text from PDF documents. Free, fast, and private - no signup or email required.",
},
"/pdf-to-markdown": {
name: "PDF to Markdown Converter",
description:
"Convert PDF documents into clean Markdown text. Free and private, right in your browser.",
},
"/pdf-to-html": {
name: "PDF to HTML Converter",
description:
"Convert PDF documents into HTML web pages. Free and private, right in your browser.",
},
"/pdf-to-xml": {
name: "PDF to XML Converter",
description:
"Convert PDF documents into structured XML. Free, fast, and private.",
},
"/pdf-to-pdfa": {
name: "PDF to PDF/A Converter",
description:
"Convert PDFs to the PDF/A archival standard for long-term preservation. Free and private.",
},
"/img-to-pdf": {
name: "Image to PDF Converter",
description:
"Convert JPG, PNG, and other images into a single PDF document. Free, fast, and private.",
},
"/html-to-pdf": {
name: "HTML to PDF Converter",
description:
"Convert HTML files and web pages into PDF documents. Free, fast, and private.",
},
"/markdown-to-pdf": {
name: "Markdown to PDF Converter",
description:
"Convert Markdown files into polished PDF documents. Free and private, right in your browser.",
},
"/eml-to-pdf": {
name: "Email (EML) to PDF Converter",
description:
"Convert email (EML) files into PDF documents. Free, fast, and private - no signup required.",
},
"/file-to-pdf": {
name: "File to PDF Converter",
description:
"Convert Word, Excel, PowerPoint, and more into PDF documents. Free, fast, and private.",
},
"/cbr-to-pdf": {
name: "CBR to PDF Converter",
description:
"Convert CBR comic book archives into PDF documents. Free and private, right in your browser.",
},
"/pdf-to-cbr": {
name: "PDF to CBR Converter",
description:
"Convert PDF documents into CBR comic book archives. Free and private.",
},
"/cbz-to-pdf": {
name: "CBZ to PDF Converter",
description:
"Convert CBZ comic book archives into PDF documents. Free and private.",
},
"/pdf-to-cbz": {
name: "PDF to CBZ Converter",
description:
"Convert PDF documents into CBZ comic book archives. Free and private.",
},
};
// Keyword titles for base tools. The in-app label ("Compress") is the UI name,
// not the phrase people search ("Compress PDF"). Keyed by tool id; overrides the
// prerendered <title>, the crawlable H1, and the nav-hub label, and is fed to the
// running SPA (per alias) so the hydrated title matches. The in-app UI name is
// unaffected - it comes from the translation registry, not this file. The tool's
// own description is kept. Convert aliases are handled by CONVERT_SEO_PAGES.
const TOOL_SEO_TITLES = {
compress: "Compress PDF",
merge: "Merge PDF",
split: "Split PDF",
rotate: "Rotate PDF",
convert: "PDF Converter",
ocr: "OCR PDF",
watermark: "Add Watermark to PDF",
sign: "Sign PDF",
certSign: "Sign PDF with a Certificate",
redact: "Redact PDF",
crop: "Crop PDF",
flatten: "Flatten PDF",
repair: "Repair PDF",
compare: "Compare PDF Files",
sanitize: "Sanitize PDF",
addPassword: "Password Protect PDF",
removePassword: "Remove PDF Password",
changePermissions: "Change PDF Permissions",
addPageNumbers: "Add Page Numbers to PDF",
addText: "Add Text to PDF",
addImage: "Add Image to PDF",
addStamp: "Add Stamp to PDF",
addAttachments: "Add Attachments to PDF",
extractPages: "Extract PDF Pages",
removePages: "Delete Pages from PDF",
removeBlanks: "Remove Blank Pages from PDF",
removeImage: "Remove Images from PDF",
removeAnnotations: "Remove Annotations from PDF",
reorganizePages: "Organize PDF Pages",
extractImages: "Extract Images from PDF",
pageLayout: "PDF Multi-Page Layout",
scalePages: "Resize PDF Pages",
pdfToSinglePage: "Combine PDF into a Single Page",
adjustContrast: "Adjust PDF Colors and Contrast",
replaceColor: "Replace and Invert PDF Colors",
changeMetadata: "Edit PDF Metadata",
getPdfInfo: "PDF Info and Metadata Viewer",
overlayPdfs: "Overlay PDFs",
editTableOfContents: "Edit PDF Bookmarks",
pdfTextEditor: "Edit PDF Text",
formFill: "Fill PDF Forms",
multiTool: "PDF Page Editor",
read: "PDF Viewer",
annotate: "Annotate PDF",
automate: "PDF Workflow Automation",
scannerEffect: "Make a PDF Look Scanned",
scannerImageSplit: "Split Scanned Photos",
autoRename: "Auto Rename PDF Files",
bookletImposition: "Create a PDF Booklet",
unlockPDFForms: "Unlock PDF Forms",
removeCertSign: "Remove PDF Certificate Signature",
validateSignature: "Validate PDF Signature",
timestampPdf: "Timestamp PDF",
showJS: "Show JavaScript in PDF",
};
// Tools whose art exists under a legacy v1 filename that does not match the
// tool id or any current URL slug. Verified against public/og_images contents.
const LEGACY_IMAGE_OVERRIDES = {
@@ -125,6 +294,15 @@ for (const id of allIds) {
};
}
// Upgrade base-tool titles to their search phrases (see TOOL_SEO_TITLES).
for (const id of Object.keys(TOOL_SEO_TITLES)) {
if (!byTool[id]) {
console.warn(`[og] TOOL_SEO_TITLES: unknown tool id "${id}" - skipped`);
continue;
}
byTool[id].title = `${TOOL_SEO_TITLES[id]} - ${SITE_NAME}`;
}
// path -> toolId for every canonical path and every alias
const byPath = {};
for (const id of allIds) byPath[canonicalPath(id)] = id;
@@ -159,15 +337,80 @@ const pageTitles = {
for (const key of navKeys)
pageTitles[`/settings/${key}`] = `${humanizeLabel(key)} Settings`;
// Auth, file manager, scanner and settings are app surfaces, not landing pages:
// mark them noindex so crawlers keep them out of the index (and the sitemap).
for (const [routePath, label] of Object.entries(pageTitles)) {
byTool[routePath] = {
image: `/og_images/${DEFAULT_IMAGE_BASENAME}.png`,
title: `${label} - ${SITE_NAME}`,
description: SITE_DESC,
noindex: true,
};
byPath[routePath] = routePath;
}
// --- convert-alias SEO landing pages -----------------------------------------
// Give each convert alias its own keyword title/description instead of the
// generic "Convert" entry. Self-key in byPath so the prerender treats it as a
// distinct (self-canonical) page. `urlSeoOverrides` feeds the same copy to the
// running SPA so the hydrated title matches what crawlers first see.
const convertImage =
byTool.convert?.image ?? `/og_images/${DEFAULT_IMAGE_BASENAME}.png`;
const urlSeoOverrides = {};
for (const [routePath, seo] of Object.entries(CONVERT_SEO_PAGES)) {
byTool[routePath] = {
image: convertImage,
title: `${seo.name} - ${SITE_NAME}`,
description: seo.description,
};
byPath[routePath] = routePath;
urlSeoOverrides[routePath] = {
title: seo.name,
description: seo.description,
};
}
// Base-tool keyword copy for the running SPA, on every alias of the tool, so the
// hydrated <title> matches the prerendered one. Convert pages set above win (they
// have more specific per-alias keywords), hence the guard.
for (const [id, name] of Object.entries(TOOL_SEO_TITLES)) {
if (!byTool[id]) continue;
for (const p of [canonicalPath(id), ...(aliasesByTool[id] || [])])
if (!(p in urlSeoOverrides))
urlSeoOverrides[p] = { title: name, description: descFor(id) };
}
// --- crawlable internal-link hub --------------------------------------------
// One routable link per tool + each convert SEO landing page. The in-app
// toolbar uses onClick buttons (not <a href>), and the prerendered body is
// otherwise empty, so these baked links are the only tool links a crawler can
// follow - they spread link equity and help discovery. Paths are the first
// alias for each tool (guaranteed routable by parseToolRoute). Dev/link tools
// are external redirects, not landing pages, so they are excluded.
const stripSuffix = (t) =>
t.endsWith(` - ${SITE_NAME}`) ? t.slice(0, -` - ${SITE_NAME}`.length) : t;
const primaryPathByTool = {};
for (const [p, id] of Object.entries(urlToTool))
if (!(id in primaryPathByTool)) primaryPathByTool[id] = p;
const navLinks = [];
const seenNav = new Set();
const pushNav = (p, label) => {
if (p && !seenNav.has(p)) {
seenNav.add(p);
navLinks.push({ path: p, label });
}
};
for (const id of allIds) {
if (linkIds.includes(id)) continue;
pushNav(
primaryPathByTool[id] || canonicalPath(id),
stripSuffix(byTool[id].title),
);
}
for (const [p, seo] of Object.entries(CONVERT_SEO_PAGES)) pushNav(p, seo.name);
navLinks.sort((a, b) => a.label.localeCompare(b.label));
const manifest = {
default: {
image: `/og_images/${DEFAULT_IMAGE_BASENAME}.png`,
@@ -176,23 +419,30 @@ const manifest = {
},
byTool,
byPath,
navLinks,
};
const mapJson = JSON.stringify(ogImageMap, null, 2) + "\n";
const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
const seoOverridesJson = JSON.stringify(urlSeoOverrides, null, 2) + "\n";
const mapPath = "src/core/data/ogImageMap.json";
const manifestPath = "public/og-metadata.json";
const seoOverridesPath = "src/core/data/urlSeoOverrides.json";
const outputs = [
[mapPath, mapJson],
[manifestPath, manifestJson],
[seoOverridesPath, seoOverridesJson],
];
const check = process.argv.includes("--check");
if (check) {
const stale = [];
if (!fs.existsSync(path.join(ROOT, mapPath)) || read(mapPath) !== mapJson)
stale.push(mapPath);
if (
!fs.existsSync(path.join(ROOT, manifestPath)) ||
read(manifestPath) !== manifestJson
)
stale.push(manifestPath);
const stale = outputs
.filter(
([p, content]) =>
!fs.existsSync(path.join(ROOT, p)) || read(p) !== content,
)
.map(([p]) => p);
if (stale.length) {
console.error(
"OG metadata is stale. Run `node scripts/generate-og-metadata.mjs`:\n " +
@@ -202,12 +452,15 @@ if (check) {
}
console.log("OG metadata is up to date.");
} else {
fs.writeFileSync(path.join(ROOT, mapPath), mapJson);
fs.writeFileSync(path.join(ROOT, manifestPath), manifestJson);
for (const [p, content] of outputs)
fs.writeFileSync(path.join(ROOT, p), content);
console.log(
`Wrote ${mapPath} (${Object.keys(ogImageMap).length} tools with art)`,
);
console.log(`Wrote ${manifestPath} (${Object.keys(byPath).length} paths)`);
console.log(
`Wrote ${seoOverridesPath} (${Object.keys(urlSeoOverrides).length} SEO landing pages)`,
);
}
// --- report -----------------------------------------------------------------
+31 -1
View File
@@ -4,21 +4,47 @@ export interface OgEntry {
image: string;
title: string;
description: string;
noindex?: boolean;
}
export interface OgInjectOptions {
ogBase?: string;
pageUrlPath?: string | null;
canonicalPath?: string | null;
noindex?: boolean;
siteRoot?: string | null;
isHome?: boolean;
pathPrefix?: string;
}
export interface OgNavLink {
path: string;
label: string;
}
export interface OgManifest {
default: OgEntry;
byTool: Record<string, OgEntry>;
byPath: Record<string, string>;
navLinks?: OgNavLink[];
}
export function escapeHtml(value: string): string;
export function buildOgTags(entry: OgEntry, opts?: OgInjectOptions): string;
export function buildOgTags(
entry: OgEntry,
opts?: { ogBase?: string; pageUrlPath?: string | null; pathPrefix?: string },
): string;
export function buildRobotsTag(noindex: boolean): string;
export function buildCanonicalTag(canonicalUrl: string | null): string | null;
export function buildJsonLd(
entry: OgEntry,
opts: { siteRoot: string; pageUrl: string; isHome: boolean },
): string;
export function buildBodyContent(
entry: OgEntry,
opts?: { navLinks?: OgNavLink[]; heading?: string | null },
): string;
export function injectBody(html: string, content: string): string;
export function injectOg(
html: string,
entry: OgEntry,
@@ -30,3 +56,7 @@ export function prerenderOg(args: {
ogBase?: string;
baseHref?: string;
}): Promise<number>;
export function buildSitemap(
manifest: OgManifest,
opts: { ogBase: string; pathPrefix?: string },
): string | null;
+262 -19
View File
@@ -1,10 +1,18 @@
// Pure helpers for baking Open Graph / Twitter Card tags into prerendered HTML.
// Kept separate from vite.config so the logic is unit-testable without a full build.
// Pure helpers for baking SEO metadata into prerendered HTML: Open Graph /
// Twitter Card tags, a canonical link, a robots directive, and JSON-LD
// structured data - plus an XML sitemap builder. Kept separate from
// vite.config so the logic is unit-testable without a full build.
// Used by the `prerender-og` Vite plugin (see vite.config.ts).
import fs from "node:fs/promises";
import path from "node:path";
const SITE_NAME = "Stirling PDF";
const APP_SUFFIX = ` - ${SITE_NAME}`;
// Public project home - a safe, verifiable sameAs signal for structured data.
const GITHUB_URL = "https://github.com/Stirling-Tools/Stirling-PDF";
const LOGO_PATH = "/modern-logo/logo512.png";
export const escapeHtml = (value) =>
String(value)
.replace(/&/g, "&amp;")
@@ -17,12 +25,17 @@ const absolute = (urlPath, ogBase) => (ogBase ? ogBase + urlPath : urlPath);
/**
* Build the OG/Twitter <meta> block for one route.
* @param {{image:string,title:string,description:string}} entry
* @param {{ogBase?:string, pageUrlPath?:string|null}} opts
* @param {{ogBase?:string, pageUrlPath?:string|null, pathPrefix?:string}} opts
*/
export function buildOgTags(entry, { ogBase = "", pageUrlPath = null } = {}) {
export function buildOgTags(
entry,
{ ogBase = "", pageUrlPath = null, pathPrefix = "" } = {},
) {
const title = escapeHtml(entry.title);
const description = escapeHtml(entry.description);
const imageUrl = absolute(entry.image, ogBase);
// entry.image is root-relative (/og_images/x.png); the assets deploy under the
// sub-path too, so the absolute form must carry pathPrefix like canonical/logo.
const imageUrl = ogBase ? ogBase + pathPrefix + entry.image : entry.image;
const image = escapeHtml(imageUrl);
const pageUrl = pageUrlPath
? escapeHtml(absolute(pageUrlPath, ogBase))
@@ -41,17 +54,121 @@ export function buildOgTags(entry, { ogBase = "", pageUrlPath = null } = {}) {
'<meta property="og:image:type" content="image/png" />',
'<meta property="og:image:width" content="1200" />',
'<meta property="og:image:height" content="630" />',
`<meta property="og:image:alt" content="${title}" />`,
'<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="${image}" />`,
"<!-- og:end -->",
].filter(Boolean);
return lines.join("\n ") + "\n ";
return lines.join("\n ");
}
/** Inject route-specific <title>, description and OG/Twitter tags into an HTML shell. */
/** Robots directive - keep app/auth pages out of the index, follow everywhere. */
export function buildRobotsTag(noindex) {
return `<meta name="robots" content="${noindex ? "noindex, follow" : "index, follow"}" />`;
}
/** Canonical link (absolute). Null when no canonical origin is known. */
export function buildCanonicalTag(canonicalUrl) {
return canonicalUrl
? `<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`
: null;
}
/**
* Build a JSON-LD structured-data block. Home gets WebSite + Organization;
* tool pages get a free WebApplication plus a Home > Tool breadcrumb. Needs an
* absolute origin, so callers only invoke it when a canonical base is known.
* @param {{title:string,description:string}} entry
* @param {{siteRoot:string, pageUrl:string, isHome:boolean}} opts
*/
export function buildJsonLd(entry, { siteRoot, pageUrl, isHome }) {
const name = entry.title.endsWith(APP_SUFFIX)
? entry.title.slice(0, -APP_SUFFIX.length)
: entry.title;
const root = siteRoot.replace(/\/+$/, "");
const organization = {
"@type": "Organization",
name: SITE_NAME,
url: siteRoot,
logo: root + LOGO_PATH,
sameAs: [GITHUB_URL],
};
const graph = isHome
? [
{
"@type": "WebSite",
name: SITE_NAME,
url: siteRoot,
description: entry.description,
},
organization,
]
: [
{
"@type": "WebApplication",
name,
description: entry.description,
url: pageUrl,
applicationCategory: "BusinessApplication",
operatingSystem: "All",
browserRequirements: "Requires JavaScript. Requires HTML5.",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
isPartOf: { "@type": "WebSite", name: SITE_NAME, url: siteRoot },
publisher: organization,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: siteRoot },
{ "@type": "ListItem", position: 2, name },
],
},
];
const json = JSON.stringify({
"@context": "https://schema.org",
"@graph": graph,
});
// Escape `<` so a value can never close the script element early.
return `<script type="application/ld+json">${json.replace(/</g, "\\u003c")}</script>`;
}
/**
* Inject route-specific SEO into an HTML shell: <title>, description, OG/Twitter
* tags, a robots directive, and (when a canonical origin is known) a canonical
* link plus JSON-LD structured data.
* @param {object} entry
* @param {{ogBase?:string, pageUrlPath?:string|null, canonicalPath?:string|null,
* noindex?:boolean, siteRoot?:string|null, isHome?:boolean, pathPrefix?:string}} opts
*/
export function injectOg(html, entry, opts = {}) {
const {
ogBase = "",
pageUrlPath = null,
canonicalPath = null,
noindex = false,
siteRoot = null,
isHome = false,
pathPrefix = "",
} = opts;
const canonicalUrl = ogBase
? absolute(canonicalPath ?? pageUrlPath ?? "/", ogBase)
: null;
const resolvedSiteRoot = siteRoot ?? (ogBase ? `${ogBase}/` : "/");
const blocks = [
buildOgTags(entry, { ogBase, pageUrlPath, pathPrefix }),
buildRobotsTag(noindex),
buildCanonicalTag(canonicalUrl),
ogBase
? buildJsonLd(entry, {
siteRoot: resolvedSiteRoot,
pageUrl: canonicalUrl,
isHome,
})
: null,
].filter(Boolean);
const head = blocks.join("\n ") + "\n ";
return html
.replace(
/<title>[\s\S]*?<\/title>/i,
@@ -62,11 +179,76 @@ export function injectOg(html, entry, opts = {}) {
() =>
`<meta name="description" content="${escapeHtml(entry.description)}" />`,
)
.replace("</head>", ` ${buildOgTags(entry, opts)}</head>`);
.replace("</head>", ` ${head}</head>`);
}
// Scoped styling for the prerendered body content so the pre-hydration paint
// (what crawlers see and the first visible paint for users) looks intentional.
// It lives inside #root and is wiped when React mounts.
const SEO_STYLE =
"<style>.spdf-seo{max-width:760px;margin:0 auto;padding:56px 24px;" +
"font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a}" +
".spdf-seo h1{font-size:2rem;line-height:1.2;margin:0 0 .75rem}" +
".spdf-seo p{font-size:1.05rem;line-height:1.6;color:#444;margin:0 0 1rem}" +
".spdf-seo nav{margin-top:2.5rem}.spdf-seo nav h2{font-size:.95rem;" +
"text-transform:uppercase;letter-spacing:.04em;color:#666;margin:0 0 .5rem}" +
".spdf-seo nav a{display:inline-block;margin:0 .75rem .35rem 0;" +
"color:#0a58ca;text-decoration:none;font-size:.92rem}" +
"@media(prefers-color-scheme:dark){.spdf-seo{color:#e8e8e8}" +
".spdf-seo p{color:#b8b8b8}.spdf-seo nav a{color:#6ea8fe}}</style>";
/**
* Build the crawlable body content injected into #root: an H1, the page
* description, and a hub of real <a href> links to every tool. React (createRoot)
* replaces it on mount, so it only ever shows on a full page load - i.e. to
* crawlers and as the first paint (which also helps LCP). Links are relative so
* they resolve against <base href> under any deploy (root, /app, context path).
* @param {{title:string,description:string}} entry
* @param {{navLinks?:{path:string,label:string}[], heading?:string|null}} opts
*/
export function buildBodyContent(
entry,
{ navLinks = [], heading = null } = {},
) {
const name =
heading ||
(entry.title.endsWith(APP_SUFFIX)
? entry.title.slice(0, -APP_SUFFIX.length)
: entry.title);
const links = navLinks
.map(
(l) =>
`<a href="${escapeHtml(l.path.replace(/^\//, ""))}">${escapeHtml(l.label)}</a>`,
)
.join("\n ");
const nav = links
? `\n <nav aria-label="All PDF tools">\n <h2>All PDF tools</h2>\n ${links}\n </nav>`
: "";
return (
`<div class="spdf-seo">${SEO_STYLE}\n ` +
`<h1>${escapeHtml(name)}</h1>\n ` +
`<p>${escapeHtml(entry.description)}</p>${nav}\n </div>`
);
}
const ROOT_RE = /<div id="root">\s*<\/div>/;
/** Inject crawlable content into the (otherwise empty) React mount point. */
export function injectBody(html, content) {
return html.replace(ROOT_RE, `<div id="root">${content}</div>`);
}
const BASE_HREF_RE = /<base\s+href="[^"]*"\s*\/?>/i;
// Clean single/multi-segment route (e.g. /compress, /settings/people); rejects
// traversal and dotted names. Returns the segment list or null.
function cleanSegments(routePath) {
const segments = routePath.replace(/^\//, "").split("/");
if (!segments.length || !segments.every((s) => /^[A-Za-z0-9_-]+$/.test(s)))
return null;
return segments;
}
/**
* Write the root index.html (home preview) plus one file per route in the
* manifest: flat for single-segment routes (e.g. dist/compress.html) and nested
@@ -76,7 +258,8 @@ const BASE_HREF_RE = /<base\s+href="[^"]*"\s*\/?>/i;
* `baseHref` is the absolute deploy base ("/" for a root deploy). Nested files
* need it because a relative `<base href="./">` would resolve their assets
* against the sub-path (e.g. /settings/) and 404; flat files and the root keep
* the build's relative base.
* the build's relative base. The path prefix derived from it is also woven into
* canonical/OG/JSON-LD URLs so a sub-path deploy (RUN_SUBPATH) stays consistent.
* @returns {Promise<number>}
*/
export async function prerenderOg({
@@ -86,26 +269,47 @@ export async function prerenderOg({
baseHref = "/",
}) {
const template = await fs.readFile(path.join(distDir, "index.html"), "utf8");
const pathPrefix = baseHref.replace(/\/+$/, ""); // "" or "/app"
const siteRoot = ogBase ? `${ogBase}${pathPrefix}/` : "/";
const homePath = `${pathPrefix}/`;
const navLinks = manifest.navLinks || [];
await fs.writeFile(
path.join(distDir, "index.html"),
injectOg(template, manifest.default, {
ogBase,
pageUrlPath: ogBase ? "/" : null,
let home = injectOg(template, manifest.default, {
ogBase,
pageUrlPath: ogBase ? homePath : null,
canonicalPath: homePath,
siteRoot,
isHome: true,
pathPrefix,
});
// Home <title> stays the brand ("Stirling PDF"); the H1 targets the keyword.
home = injectBody(
home,
buildBodyContent(manifest.default, {
navLinks,
heading: "Free Online PDF Tools",
}),
);
await fs.writeFile(path.join(distDir, "index.html"), home);
let count = 0;
for (const [routePath, id] of Object.entries(manifest.byPath || {})) {
const segments = routePath.replace(/^\//, "").split("/");
// Clean segments only - no traversal, no dots (e.g. /compress, /settings/people).
if (!segments.length || !segments.every((s) => /^[A-Za-z0-9_-]+$/.test(s)))
continue;
const segments = cleanSegments(routePath);
if (!segments) continue;
const entry = manifest.byTool[id] ?? manifest.default;
const pageUrlPath = pathPrefix + routePath;
let html = injectOg(template, entry, {
ogBase,
pageUrlPath: ogBase ? routePath : null,
pageUrlPath: ogBase ? pageUrlPath : null,
canonicalPath: pageUrlPath, // self-canonical: never points at a maybe-404 alias
noindex: !!entry.noindex,
siteRoot,
isHome: false,
pathPrefix,
});
// App/auth pages (noindex) stay the bare shell - no crawlable landing copy.
if (!entry.noindex)
html = injectBody(html, buildBodyContent(entry, { navLinks }));
const nested = segments.length > 1;
if (nested)
html = html.replace(BASE_HREF_RE, `<base href="${baseHref}" />`);
@@ -116,3 +320,42 @@ export async function prerenderOg({
}
return count;
}
/**
* Build an XML sitemap of every indexable route. Requires an absolute origin
* (sitemaps must use absolute URLs), so returns null when none is known - e.g.
* self-hosted builds with no canonical domain. Noindex routes are excluded.
* @param {object} manifest
* @param {{ogBase:string, pathPrefix?:string}} opts
* @returns {string|null}
*/
export function buildSitemap(manifest, { ogBase, pathPrefix = "" }) {
if (!ogBase) return null;
const base = ogBase + pathPrefix;
const locs = new Set([`${base}/`]);
for (const [routePath, id] of Object.entries(manifest.byPath || {})) {
if (!cleanSegments(routePath)) continue;
const entry = manifest.byTool[id] ?? manifest.default;
if (entry.noindex) continue;
locs.add(base + routePath);
}
const body = [...locs]
.sort()
.map((loc) => {
const priority = loc === `${base}/` ? "1.0" : "0.8";
return (
` <url>\n` +
` <loc>${escapeHtml(loc)}</loc>\n` +
` <changefreq>weekly</changefreq>\n` +
` <priority>${priority}</priority>\n` +
` </url>`
);
})
.join("\n");
return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
`${body}\n` +
`</urlset>\n`
);
}
@@ -6,7 +6,10 @@ import { getToolOgImage } from "@app/data/ogImage";
// Build tooling (plain ESM, node:fs only) - import the helpers for coverage.
// eslint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
import {
buildBodyContent,
buildOgTags,
buildSitemap,
injectBody,
injectOg,
prerenderOg,
} from "../../../scripts/og-prerender.mjs";
@@ -102,6 +105,229 @@ describe("injectOg (build-time prerender)", () => {
);
expect(tags).toContain("A &quot;B&quot; &amp; &lt;C&gt;");
});
it("weaves the sub-path prefix into the absolute image URL", () => {
const tags = buildOgTags(entry, {
ogBase: "https://stirling.com",
pageUrlPath: "/app/compress",
pathPrefix: "/app",
});
// image lives under the sub-path too, like canonical/og:url/logo
expect(tags).toContain(
'<meta property="og:image" content="https://stirling.com/app/og_images/compress.png" />',
);
expect(tags).toContain(
'<meta name="twitter:image" content="https://stirling.com/app/og_images/compress.png" />',
);
});
});
describe("injectOg SEO extras (robots, canonical, JSON-LD)", () => {
const entry = {
image: "/og_images/compress.png",
title: "Compress - Stirling PDF",
description: "Compress PDFs to reduce their file size.",
};
it("always emits a robots directive, indexable by default", () => {
const out = injectOg(TEMPLATE, entry, {});
expect(out).toContain('<meta name="robots" content="index, follow" />');
});
it("emits noindex when the entry is flagged", () => {
const out = injectOg(TEMPLATE, entry, { noindex: true });
expect(out).toContain('<meta name="robots" content="noindex, follow" />');
});
it("omits canonical + JSON-LD when no canonical origin is known", () => {
const out = injectOg(TEMPLATE, entry, { ogBase: "", pageUrlPath: "/x" });
expect(out).not.toContain('rel="canonical"');
expect(out).not.toContain("application/ld+json");
});
it("emits an absolute self-canonical and WebApplication JSON-LD with a base", () => {
const out = injectOg(TEMPLATE, entry, {
ogBase: "https://stirling.com",
pageUrlPath: "/compress",
canonicalPath: "/compress",
});
expect(out).toContain(
'<link rel="canonical" href="https://stirling.com/compress" />',
);
expect(out).toContain('<script type="application/ld+json">');
expect(out).toContain('"@type":"WebApplication"');
expect(out).toContain('"@type":"BreadcrumbList"');
});
it("escapes '<' inside JSON-LD so a value cannot close the script early", () => {
const out = injectOg(
TEMPLATE,
{ ...entry, title: "A <script> B - Stirling PDF" },
{ ogBase: "https://stirling.com", pageUrlPath: "/x" },
);
const ldStart = out.indexOf("application/ld+json");
const ld = out.slice(ldStart, out.indexOf("</script>", ldStart));
// '<' is escaped (breakout-proof); '>' need not be.
expect(ld).not.toContain("<script");
expect(ld).toContain("\\u003cscript");
});
it("canonical can point somewhere other than the page URL (alias dedupe)", () => {
const out = injectOg(TEMPLATE, entry, {
ogBase: "https://stirling.com",
pageUrlPath: "/compress-pdf",
canonicalPath: "/compress",
});
expect(out).toContain(
'<link rel="canonical" href="https://stirling.com/compress" />',
);
});
});
describe("buildSitemap", () => {
const manifest = {
default: {
image: "/og_images/home.png",
title: "Stirling PDF",
description: "d",
},
byTool: {
compress: {
image: "/og_images/compress.png",
title: "Compress - Stirling PDF",
description: "c",
},
"/settings/people": {
image: "/og_images/home.png",
title: "People Settings - Stirling PDF",
description: "p",
noindex: true,
},
},
byPath: {
"/compress": "compress",
"/settings/people": "/settings/people",
},
};
it("returns null without a canonical origin (sitemaps need absolute URLs)", () => {
expect(buildSitemap(manifest, { ogBase: "" })).toBeNull();
});
it("lists indexable routes as absolute URLs and excludes noindex ones", () => {
const xml = buildSitemap(manifest, { ogBase: "https://stirling.com" });
expect(xml).toContain("<loc>https://stirling.com/</loc>");
expect(xml).toContain("<loc>https://stirling.com/compress</loc>");
expect(xml).not.toContain("/settings/people");
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<urlset");
});
it("weaves a sub-path prefix into every URL", () => {
const xml = buildSitemap(manifest, {
ogBase: "https://stirling.com",
pathPrefix: "/app",
});
expect(xml).toContain("<loc>https://stirling.com/app/</loc>");
expect(xml).toContain("<loc>https://stirling.com/app/compress</loc>");
});
});
describe("buildBodyContent + injectBody (crawlable landing content)", () => {
const entry = {
image: "/og_images/compress.png",
title: "PDF to Word Converter - Stirling PDF",
description: "Convert PDF files into editable Word documents.",
};
const navLinks = [
{ path: "/compress", label: "Compress" },
{ path: "/merge", label: "Merge" },
];
it("emits an H1 (keyword, suffix stripped), the description, and relative tool links", () => {
const body = buildBodyContent(entry, { navLinks });
expect(body).toContain("<h1>PDF to Word Converter</h1>");
expect(body).toContain(
"<p>Convert PDF files into editable Word documents.</p>",
);
// links are relative (no leading slash) so they resolve against <base href>
expect(body).toContain('<a href="compress">Compress</a>');
expect(body).toContain('<a href="merge">Merge</a>');
expect(body).not.toContain('href="/compress"');
});
it("uses an explicit heading override for the H1 when given", () => {
const body = buildBodyContent(entry, {
navLinks,
heading: "Free Online PDF Tools",
});
expect(body).toContain("<h1>Free Online PDF Tools</h1>");
expect(body).not.toContain("<h1>PDF to Word Converter</h1>");
});
it("escapes HTML in the H1/description", () => {
const body = buildBodyContent(
{ ...entry, title: "A & <B>", description: 'x "y"' },
{ navLinks: [] },
);
expect(body).toContain("<h1>A &amp; &lt;B&gt;</h1>");
expect(body).toContain("x &quot;y&quot;");
});
it("injectBody fills the empty React mount point", () => {
const out = injectBody(
'<body><div id="root"></div><script></script></body>',
"<h1>hi</h1>",
);
expect(out).toContain('<div id="root"><h1>hi</h1></div>');
});
it("prerenderOg injects landing content on indexable pages but not noindex ones", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "og-body-"));
await fs.writeFile(path.join(dir, "index.html"), TEMPLATE);
const manifest = {
default: {
image: "/og_images/home.png",
title: "Stirling PDF",
description: "home",
},
byTool: {
compress: {
image: "/og_images/compress.png",
title: "Compress - Stirling PDF",
description: "c",
},
"/settings/people": {
image: "/og_images/home.png",
title: "People Settings - Stirling PDF",
description: "p",
noindex: true,
},
},
byPath: {
"/compress": "compress",
"/settings/people": "/settings/people",
},
navLinks: [{ path: "/compress", label: "Compress" }],
};
await prerenderOg({ distDir: dir, manifest, ogBase: "", baseHref: "/" });
const compress = await fs.readFile(path.join(dir, "compress.html"), "utf8");
expect(compress).toContain('<div id="root"><div class="spdf-seo">');
expect(compress).toContain("<h1>Compress</h1>");
const settings = await fs.readFile(
path.join(dir, "settings", "people.html"),
"utf8",
);
expect(settings).toContain('<div id="root"></div>'); // noindex: bare shell
const home = await fs.readFile(path.join(dir, "index.html"), "utf8");
expect(home).toContain("spdf-seo");
await fs.rm(dir, { recursive: true, force: true });
});
});
describe("prerenderOg (flat + nested route files)", () => {
@@ -155,4 +381,43 @@ describe("prerenderOg (flat + nested route files)", () => {
await fs.rm(dir, { recursive: true, force: true });
});
it("gives the home shell WebSite JSON-LD and flags noindex routes with a base", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "og-prerender-"));
await fs.writeFile(path.join(dir, "index.html"), TEMPLATE);
const manifest = {
default: {
image: "/og_images/home.png",
title: "Stirling PDF",
description: "home",
},
byTool: {
"/login": {
image: "/og_images/home.png",
title: "Sign In - Stirling PDF",
description: "l",
noindex: true,
},
},
byPath: { "/login": "/login" },
};
await prerenderOg({
distDir: dir,
manifest,
ogBase: "https://stirling.com",
baseHref: "/",
});
const home = await fs.readFile(path.join(dir, "index.html"), "utf8");
expect(home).toContain('"@type":"WebSite"');
expect(home).toContain(
'<link rel="canonical" href="https://stirling.com/"',
);
const login = await fs.readFile(path.join(dir, "login.html"), "utf8");
expect(login).toContain('content="noindex, follow"');
await fs.rm(dir, { recursive: true, force: true });
});
});
@@ -0,0 +1,430 @@
{
"/pdf-to-word": {
"title": "PDF to Word Converter",
"description": "Convert PDF files into editable Microsoft Word (DOCX) documents. Free, fast, and private - no signup or email required."
},
"/pdf-to-xlsx": {
"title": "PDF to Excel Converter",
"description": "Convert PDF tables into editable Microsoft Excel (XLSX) spreadsheets. Free, fast, and private."
},
"/pdf-to-csv": {
"title": "PDF to CSV Converter",
"description": "Extract tables from PDF files into CSV data. Free, fast, and private - no signup required."
},
"/pdf-to-img": {
"title": "PDF to Image Converter",
"description": "Convert PDF pages into high-quality JPG or PNG images. Free and private, right in your browser."
},
"/pdf-to-presentation": {
"title": "PDF to PowerPoint Converter",
"description": "Convert PDF files into editable PowerPoint (PPTX) presentations. Free, fast, and private."
},
"/pdf-to-text": {
"title": "PDF to Text Converter",
"description": "Extract plain text from PDF documents. Free, fast, and private - no signup or email required."
},
"/pdf-to-markdown": {
"title": "PDF to Markdown Converter",
"description": "Convert PDF documents into clean Markdown text. Free and private, right in your browser."
},
"/pdf-to-html": {
"title": "PDF to HTML Converter",
"description": "Convert PDF documents into HTML web pages. Free and private, right in your browser."
},
"/pdf-to-xml": {
"title": "PDF to XML Converter",
"description": "Convert PDF documents into structured XML. Free, fast, and private."
},
"/pdf-to-pdfa": {
"title": "PDF to PDF/A Converter",
"description": "Convert PDFs to the PDF/A archival standard for long-term preservation. Free and private."
},
"/img-to-pdf": {
"title": "Image to PDF Converter",
"description": "Convert JPG, PNG, and other images into a single PDF document. Free, fast, and private."
},
"/html-to-pdf": {
"title": "HTML to PDF Converter",
"description": "Convert HTML files and web pages into PDF documents. Free, fast, and private."
},
"/markdown-to-pdf": {
"title": "Markdown to PDF Converter",
"description": "Convert Markdown files into polished PDF documents. Free and private, right in your browser."
},
"/eml-to-pdf": {
"title": "Email (EML) to PDF Converter",
"description": "Convert email (EML) files into PDF documents. Free, fast, and private - no signup required."
},
"/file-to-pdf": {
"title": "File to PDF Converter",
"description": "Convert Word, Excel, PowerPoint, and more into PDF documents. Free, fast, and private."
},
"/cbr-to-pdf": {
"title": "CBR to PDF Converter",
"description": "Convert CBR comic book archives into PDF documents. Free and private, right in your browser."
},
"/pdf-to-cbr": {
"title": "PDF to CBR Converter",
"description": "Convert PDF documents into CBR comic book archives. Free and private."
},
"/cbz-to-pdf": {
"title": "CBZ to PDF Converter",
"description": "Convert CBZ comic book archives into PDF documents. Free and private."
},
"/pdf-to-cbz": {
"title": "PDF to CBZ Converter",
"description": "Convert PDF documents into CBZ comic book archives. Free and private."
},
"/compress": {
"title": "Compress PDF",
"description": "Compress PDFs to reduce their file size."
},
"/compress-pdf": {
"title": "Compress PDF",
"description": "Compress PDFs to reduce their file size."
},
"/merge": {
"title": "Merge PDF",
"description": "Merge multiple PDFs into a single document"
},
"/merge-pdfs": {
"title": "Merge PDF",
"description": "Merge multiple PDFs into a single document"
},
"/split": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/split-pdfs": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/auto-split-pdf": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/auto-size-split-pdf": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/split-by-size-or-count": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/split-pdf-by-sections": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/split-pdf-by-chapters": {
"title": "Split PDF",
"description": "Split PDFs into multiple documents"
},
"/rotate": {
"title": "Rotate PDF",
"description": "Easily rotate your PDFs."
},
"/rotate-pdf": {
"title": "Rotate PDF",
"description": "Easily rotate your PDFs."
},
"/convert": {
"title": "PDF Converter",
"description": "Convert files to and from PDF format"
},
"/convert-pdf": {
"title": "PDF Converter",
"description": "Convert files to and from PDF format"
},
"/ocr": {
"title": "OCR PDF",
"description": "Extract text from scanned PDFs using Optical Character Recognition"
},
"/ocr-pdf": {
"title": "OCR PDF",
"description": "Extract text from scanned PDFs using Optical Character Recognition"
},
"/watermark": {
"title": "Add Watermark to PDF",
"description": "Add a custom watermark to your PDF document."
},
"/add-watermark": {
"title": "Add Watermark to PDF",
"description": "Add a custom watermark to your PDF document."
},
"/sign": {
"title": "Sign PDF",
"description": "Adds signature to PDF by drawing, text or image"
},
"/cert-sign": {
"title": "Sign PDF with a Certificate",
"description": "Sign PDF documents using digital certificates"
},
"/manage-signatures": {
"title": "Sign PDF with a Certificate",
"description": "Sign PDF documents using digital certificates"
},
"/redact": {
"title": "Redact PDF",
"description": "Permanently remove sensitive information from PDF documents"
},
"/auto-redact": {
"title": "Redact PDF",
"description": "Permanently remove sensitive information from PDF documents"
},
"/crop": {
"title": "Crop PDF",
"description": "Crop a PDF to reduce its size (maintains text!)"
},
"/flatten": {
"title": "Flatten PDF",
"description": "Remove all interactive elements and forms from a PDF"
},
"/repair": {
"title": "Repair PDF",
"description": "Repair corrupted or damaged PDF files"
},
"/compare": {
"title": "Compare PDF Files",
"description": "Compare two PDF documents and highlight differences"
},
"/sanitize": {
"title": "Sanitize PDF",
"description": "Remove potentially harmful elements from PDF files"
},
"/sanitize-pdf": {
"title": "Sanitize PDF",
"description": "Remove potentially harmful elements from PDF files"
},
"/add-password": {
"title": "Password Protect PDF",
"description": "Add password protection and restrictions to PDF files"
},
"/remove-password": {
"title": "Remove PDF Password",
"description": "Remove password protection from PDF documents"
},
"/change-permissions": {
"title": "Change PDF Permissions",
"description": "Change document restrictions and permissions"
},
"/add-page-numbers": {
"title": "Add Page Numbers to PDF",
"description": "Add Page numbers throughout a document in a set location"
},
"/add-text": {
"title": "Add Text to PDF",
"description": "Add custom text anywhere in your PDF"
},
"/add-image": {
"title": "Add Image to PDF",
"description": "Add images anywhere in your PDF"
},
"/add-stamp": {
"title": "Add Stamp to PDF",
"description": "Add text or add image stamps at set locations"
},
"/stamp": {
"title": "Add Stamp to PDF",
"description": "Add text or add image stamps at set locations"
},
"/add-attachments": {
"title": "Add Attachments to PDF",
"description": "Add or remove embedded files (attachments) to/from a PDF"
},
"/extract-pages": {
"title": "Extract PDF Pages",
"description": "Extract specific pages from a PDF document"
},
"/extract-page": {
"title": "Extract PDF Pages",
"description": "Extract specific pages from a PDF document"
},
"/remove-pages": {
"title": "Delete Pages from PDF",
"description": "Remove specific pages from a PDF document"
},
"/remove-blanks": {
"title": "Remove Blank Pages from PDF",
"description": "Remove blank pages from PDF documents"
},
"/remove-image": {
"title": "Remove Images from PDF",
"description": "Remove all images from a PDF document"
},
"/remove-image-pdf": {
"title": "Remove Images from PDF",
"description": "Remove all images from a PDF document"
},
"/remove-annotations": {
"title": "Remove Annotations from PDF",
"description": "Remove annotations and comments from PDF documents"
},
"/reorganize-pages": {
"title": "Organize PDF Pages",
"description": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"/pdf-organizer": {
"title": "Organize PDF Pages",
"description": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"/extract-images": {
"title": "Extract Images from PDF",
"description": "Extract images from PDF documents"
},
"/page-layout": {
"title": "PDF Multi-Page Layout",
"description": "Merge multiple pages of a PDF document into a single page"
},
"/multi-page-layout": {
"title": "PDF Multi-Page Layout",
"description": "Merge multiple pages of a PDF document into a single page"
},
"/scale-pages": {
"title": "Resize PDF Pages",
"description": "Change the size/scale of a page and/or its contents."
},
"/pdf-to-single-page": {
"title": "Combine PDF into a Single Page",
"description": "Merges all PDF pages into one large single page"
},
"/single-large-page": {
"title": "Combine PDF into a Single Page",
"description": "Merges all PDF pages into one large single page"
},
"/adjust-contrast": {
"title": "Adjust PDF Colors and Contrast",
"description": "Adjust colors and contrast of PDF documents"
},
"/replace-color": {
"title": "Replace and Invert PDF Colors",
"description": "Replace or invert colors in PDF documents"
},
"/replace-color-pdf": {
"title": "Replace and Invert PDF Colors",
"description": "Replace or invert colors in PDF documents"
},
"/replace-and-invert-color-pdf": {
"title": "Replace and Invert PDF Colors",
"description": "Replace or invert colors in PDF documents"
},
"/change-metadata": {
"title": "Edit PDF Metadata",
"description": "Change/Remove/Add metadata from a PDF document"
},
"/get-pdf-info": {
"title": "PDF Info and Metadata Viewer",
"description": "Grabs any and all information possible on PDFs"
},
"/get-info-on-pdf": {
"title": "PDF Info and Metadata Viewer",
"description": "Grabs any and all information possible on PDFs"
},
"/overlay-pdfs": {
"title": "Overlay PDFs",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/overlay-pdf": {
"title": "Overlay PDFs",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/edit-table-of-contents": {
"title": "Edit PDF Bookmarks",
"description": "Add or edit bookmarks and table of contents in PDF documents"
},
"/pdf-text-editor": {
"title": "Edit PDF Text",
"description": "Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
},
"/form-fill": {
"title": "Fill PDF Forms",
"description": "Fill PDF form fields interactively with a visual editor"
},
"/multi-tool": {
"title": "PDF Page Editor",
"description": "Use multiple tools on a single PDF document"
},
"/read": {
"title": "PDF Viewer",
"description": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"/view-pdf": {
"title": "PDF Viewer",
"description": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"/annotate": {
"title": "Annotate PDF",
"description": "Highlight, draw, add notes, and shapes directly in the viewer"
},
"/annotations": {
"title": "Annotate PDF",
"description": "Highlight, draw, add notes, and shapes directly in the viewer"
},
"/automate": {
"title": "PDF Workflow Automation",
"description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"/pipeline": {
"title": "PDF Workflow Automation",
"description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"/scanner-effect": {
"title": "Make a PDF Look Scanned",
"description": "Create a PDF that looks like it was scanned"
},
"/fake-scan": {
"title": "Make a PDF Look Scanned",
"description": "Create a PDF that looks like it was scanned"
},
"/scanner-image-split": {
"title": "Split Scanned Photos",
"description": "Detect and split scanned photos into separate pages"
},
"/extract-image-scans": {
"title": "Split Scanned Photos",
"description": "Detect and split scanned photos into separate pages"
},
"/auto-rename": {
"title": "Auto Rename PDF Files",
"description": "Automatically rename PDF files based on their content"
},
"/booklet-imposition": {
"title": "Create a PDF Booklet",
"description": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"/unlock-p-d-f-forms": {
"title": "Unlock PDF Forms",
"description": "Remove read-only property of form fields in a PDF document."
},
"/unlock-pdf-forms": {
"title": "Unlock PDF Forms",
"description": "Remove read-only property of form fields in a PDF document."
},
"/remove-cert-sign": {
"title": "Remove PDF Certificate Signature",
"description": "Remove digital signature from PDF documents"
},
"/remove-certificate-sign": {
"title": "Remove PDF Certificate Signature",
"description": "Remove digital signature from PDF documents"
},
"/validate-signature": {
"title": "Validate PDF Signature",
"description": "Verify digital signatures and certificates in PDF documents"
},
"/timestamp-pdf": {
"title": "Timestamp PDF",
"description": "Add an RFC 3161 document timestamp to prove when your PDF existed"
},
"/show-j-s": {
"title": "Show JavaScript in PDF",
"description": "Extract and display JavaScript code from PDF documents"
},
"/show-js": {
"title": "Show JavaScript in PDF",
"description": "Extract and display JavaScript code from PDF documents"
},
"/show-javascript": {
"title": "Show JavaScript in PDF",
"description": "Extract and display JavaScript code from PDF documents"
}
}
+27 -16
View File
@@ -5,6 +5,7 @@ import { Group } from "@mantine/core";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getToolOgImage } from "@app/data/ogImage";
import urlSeoOverrides from "@app/data/urlSeoOverrides.json";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile, useIsTouch } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -292,25 +293,35 @@ export default function HomePage() {
const baseUrl = useBaseUrl();
// Update document meta when tool changes
// Update document meta when tool changes. Convert aliases (e.g. /pdf-to-word)
// all share the one `convert` tool, so prefer a per-URL SEO override when the
// path has one - this keeps the hydrated title/description matching the
// keyword-targeted copy that crawlers see in the prerendered HTML.
const appName = config?.appNameNavbar || "Stirling PDF";
const seoOverride = (
urlSeoOverrides as Record<string, { title: string; description: string }>
)[location.pathname];
const defaultDescription = t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
);
const metaTitle = seoOverride
? `${seoOverride.title} - ${appName}`
: selectedTool
? `${selectedTool.name} - ${appName}`
: appName;
const metaDescription =
seoOverride?.description || selectedTool?.description || defaultDescription;
useDocumentMeta({
title: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
description:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: selectedTool ? `${selectedTool.name} - ${appName}` : appName,
ogDescription:
selectedTool?.description ||
t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
title: metaTitle,
description: metaDescription,
ogTitle: metaTitle,
ogDescription: metaDescription,
ogImage: getToolOgImage(baseUrl, selectedToolKey),
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl,
ogUrl:
seoOverride || selectedTool
? `${baseUrl}${window.location.pathname}`
: baseUrl,
});
// Note: File selection limits are now handled directly by individual tools
+24 -1
View File
@@ -93,7 +93,8 @@ function prerenderOgPlugin(): PluginOption {
name: "prerender-og",
apply: "build" as const,
async closeBundle() {
const { prerenderOg } = await import("./scripts/og-prerender.mjs");
const { prerenderOg, buildSitemap } =
await import("./scripts/og-prerender.mjs");
const ogBase = (
process.env.VITE_OG_BASE_URL ||
process.env.CF_PAGES_URL ||
@@ -102,6 +103,7 @@ function prerenderOgPlugin(): PluginOption {
// Absolute deploy base for nested routes' <base href> (matches vite `base`).
const subpath = (process.env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, "");
const baseHref = subpath ? `/${subpath}/` : "/";
const pathPrefix = baseHref.replace(/\/+$/, ""); // "" or "/app"
let manifest;
try {
manifest = JSON.parse(
@@ -125,6 +127,27 @@ function prerenderOgPlugin(): PluginOption {
? ` (absolute URLs, base=${ogBase})`
: " (root-relative URLs)"),
);
// Sitemaps need absolute URLs, so only emit when a canonical origin is
// known (custom domain or Cloudflare Pages). Self-hosted builds skip it.
const sitemap = buildSitemap(manifest, { ogBase, pathPrefix });
if (sitemap) {
await fs.writeFile(path.join(distDir, "sitemap.xml"), sitemap);
// Point robots.txt at the sitemap (best-effort; robots.txt may be absent).
const robotsPath = path.join(distDir, "robots.txt");
try {
let robots = await fs.readFile(robotsPath, "utf8");
if (!/^\s*Sitemap:/im.test(robots)) {
robots =
robots.replace(/\s*$/, "\n") +
`Sitemap: ${ogBase}${pathPrefix}/sitemap.xml\n`;
await fs.writeFile(robotsPath, robots);
}
} catch {
// no robots.txt in dist - nothing to link
}
console.log(`[prerender-og] wrote sitemap.xml (base=${ogBase})`);
}
},
};
}