Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e768a75f | ||
|
|
71d6915dd0 | ||
|
|
1b9c278662 | ||
|
|
6a908f2efe | ||
|
|
38c28b6adf |
@@ -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/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+73
-20
@@ -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() {
|
||||
|
||||
+17
-3
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 -----------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "&")
|
||||
@@ -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 "B" & <C>");
|
||||
});
|
||||
|
||||
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 & <B></h1>");
|
||||
expect(body).toContain("x "y"");
|
||||
});
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user