diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index eb95dd0dcf..c5e2e10378 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -7,12 +7,12 @@ "byTool": { "certSign": { "image": "/og_images/certSign.png", - "title": "Certificate Sign - Stirling PDF", + "title": "Sign PDF with a Certificate - Stirling PDF", "description": "Sign PDF documents using digital certificates" }, "sign": { "image": "/og_images/sign.png", - "title": "Sign - Stirling PDF", + "title": "Sign PDF - Stirling PDF", "description": "Adds signature to PDF by drawing, text or image" }, "sharedSign": { @@ -22,122 +22,122 @@ }, "addText": { "image": "/og_images/add-text.png", - "title": "Add Text - Stirling PDF", + "title": "Add Text to PDF - Stirling PDF", "description": "Add custom text anywhere in your PDF" }, "addPassword": { "image": "/og_images/add-password.png", - "title": "Add Password - Stirling PDF", + "title": "Password Protect PDF - Stirling PDF", "description": "Add password protection and restrictions to PDF files" }, "removePassword": { "image": "/og_images/remove-password.png", - "title": "Remove Password - Stirling PDF", + "title": "Remove PDF Password - Stirling PDF", "description": "Remove password protection from PDF documents" }, "removePages": { "image": "/og_images/remove.png", - "title": "Remove Pages - Stirling PDF", + "title": "Delete Pages from PDF - Stirling PDF", "description": "Remove specific pages from a PDF document" }, "removeBlanks": { "image": "/og_images/remove-blank-pages.png", - "title": "Remove Blank Pages - Stirling PDF", + "title": "Remove Blank Pages from PDF - Stirling PDF", "description": "Remove blank pages from PDF documents" }, "removeAnnotations": { "image": "/og_images/remove-annotations.png", - "title": "Remove Annotations - Stirling PDF", + "title": "Remove Annotations from PDF - Stirling PDF", "description": "Remove annotations and comments from PDF documents" }, "removeImage": { "image": "/og_images/remove-image.png", - "title": "Remove Images - Stirling PDF", + "title": "Remove Images from PDF - Stirling PDF", "description": "Remove all images from a PDF document" }, "changePermissions": { "image": "/og_images/change-permissions.png", - "title": "Change Permissions - Stirling PDF", + "title": "Change PDF Permissions - Stirling PDF", "description": "Change document restrictions and permissions" }, "watermark": { "image": "/og_images/add-watermark.png", - "title": "Add Watermark - Stirling PDF", + "title": "Add Watermark to PDF - Stirling PDF", "description": "Add a custom watermark to your PDF document." }, "sanitize": { "image": "/og_images/sanitize.png", - "title": "Sanitize - Stirling PDF", + "title": "Sanitize PDF - Stirling PDF", "description": "Remove potentially harmful elements from PDF files" }, "split": { "image": "/og_images/split.png", - "title": "Split - Stirling PDF", + "title": "Split PDF - Stirling PDF", "description": "Split PDFs into multiple documents" }, "merge": { "image": "/og_images/mergePdfs.png", - "title": "Merge - Stirling PDF", + "title": "Merge PDF - Stirling PDF", "description": "Merge multiple PDFs into a single document" }, "convert": { "image": "/og_images/convert.png", - "title": "Convert - Stirling PDF", + "title": "PDF Converter - Stirling PDF", "description": "Convert files to and from PDF format" }, "ocr": { "image": "/og_images/ocr.png", - "title": "OCR - Stirling PDF", + "title": "OCR PDF - Stirling PDF", "description": "Extract text from scanned PDFs using Optical Character Recognition" }, "addImage": { "image": "/og_images/add-image.png", - "title": "Add Image - Stirling PDF", + "title": "Add Image to PDF - Stirling PDF", "description": "Add images anywhere in your PDF" }, "rotate": { "image": "/og_images/rotate.png", - "title": "Rotate - Stirling PDF", + "title": "Rotate PDF - Stirling PDF", "description": "Easily rotate your PDFs." }, "annotate": { "image": "/og_images/annotate.png", - "title": "Annotate - Stirling PDF", + "title": "Annotate PDF - Stirling PDF", "description": "Highlight, draw, add notes, and shapes directly in the viewer" }, "scannerImageSplit": { "image": "/og_images/detect-split-scanned-photos.png", - "title": "Detect & Split Scanned Photos - Stirling PDF", + "title": "Split Scanned Photos - Stirling PDF", "description": "Detect and split scanned photos into separate pages" }, "editTableOfContents": { "image": "/og_images/edit-table-of-contents.png", - "title": "Edit Table of Contents - Stirling PDF", + "title": "Edit PDF Bookmarks - Stirling PDF", "description": "Add or edit bookmarks and table of contents in PDF documents" }, "scannerEffect": { "image": "/og_images/scanner-effect.png", - "title": "Scanner Effect - Stirling PDF", + "title": "Make a PDF Look Scanned - Stirling PDF", "description": "Create a PDF that looks like it was scanned" }, "autoRename": { "image": "/og_images/auto-rename-pdf-file.png", - "title": "Auto Rename PDF File - Stirling PDF", + "title": "Auto Rename PDF Files - Stirling PDF", "description": "Automatically rename PDF files based on their content" }, "pageLayout": { "image": "/og_images/multi-page-layout.png", - "title": "Multi-Page Layout - Stirling PDF", + "title": "PDF Multi-Page Layout - Stirling PDF", "description": "Merge multiple pages of a PDF document into a single page" }, "scalePages": { "image": "/og_images/adjust-page-size-scale.png", - "title": "Adjust page size/scale - Stirling PDF", + "title": "Resize PDF Pages - Stirling PDF", "description": "Change the size/scale of a page and/or its contents." }, "adjustContrast": { "image": "/og_images/adjust-colors-contrast.png", - "title": "Adjust Colors/Contrast - Stirling PDF", + "title": "Adjust PDF Colors and Contrast - Stirling PDF", "description": "Adjust colors and contrast of PDF documents" }, "crop": { @@ -147,37 +147,37 @@ }, "pdfToSinglePage": { "image": "/og_images/single-large-page.png", - "title": "PDF to Single Large Page - Stirling PDF", + "title": "Combine PDF into a Single Page - Stirling PDF", "description": "Merges all PDF pages into one large single page" }, "repair": { "image": "/og_images/repair.png", - "title": "Repair - Stirling PDF", + "title": "Repair PDF - Stirling PDF", "description": "Repair corrupted or damaged PDF files" }, "compare": { "image": "/og_images/compare.png", - "title": "Compare - Stirling PDF", + "title": "Compare PDF Files - Stirling PDF", "description": "Compare two PDF documents and highlight differences" }, "addPageNumbers": { "image": "/og_images/add-page-numbers.png", - "title": "Add Page Numbers - Stirling PDF", + "title": "Add Page Numbers to PDF - Stirling PDF", "description": "Add Page numbers throughout a document in a set location" }, "redact": { "image": "/og_images/redact.png", - "title": "Redact - Stirling PDF", + "title": "Redact PDF - Stirling PDF", "description": "Permanently remove sensitive information from PDF documents" }, "flatten": { "image": "/og_images/flatten.png", - "title": "Flatten - Stirling PDF", + "title": "Flatten PDF - Stirling PDF", "description": "Remove all interactive elements and forms from a PDF" }, "removeCertSign": { "image": "/og_images/remove-certificate-sign.png", - "title": "Remove Certificate Sign - Stirling PDF", + "title": "Remove PDF Certificate Signature - Stirling PDF", "description": "Remove digital signature from PDF documents" }, "unlockPDFForms": { @@ -187,22 +187,22 @@ }, "compress": { "image": "/og_images/compress.png", - "title": "Compress - Stirling PDF", + "title": "Compress PDF - Stirling PDF", "description": "Compress PDFs to reduce their file size." }, "extractPages": { "image": "/og_images/extract-pages.png", - "title": "Extract Pages - Stirling PDF", + "title": "Extract PDF Pages - Stirling PDF", "description": "Extract specific pages from a PDF document" }, "reorganizePages": { "image": "/og_images/reorganize-pages.png", - "title": "Reorganize Pages - Stirling PDF", + "title": "Organize PDF Pages - Stirling PDF", "description": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control." }, "extractImages": { "image": "/og_images/extract-images.png", - "title": "Extract Images - Stirling PDF", + "title": "Extract Images from PDF - Stirling PDF", "description": "Extract images from PDF documents" }, "addStamp": { @@ -212,22 +212,22 @@ }, "addAttachments": { "image": "/og_images/add-attachments.png", - "title": "Add Attachments - Stirling PDF", + "title": "Add Attachments to PDF - Stirling PDF", "description": "Add or remove embedded files (attachments) to/from a PDF" }, "changeMetadata": { "image": "/og_images/change-metadata.png", - "title": "Change Metadata - Stirling PDF", + "title": "Edit PDF Metadata - Stirling PDF", "description": "Change/Remove/Add metadata from a PDF document" }, "overlayPdfs": { "image": "/og_images/overlay-pdfs.png", - "title": "Overlay Pdfs - Stirling PDF", + "title": "Overlay PDFs - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" }, "getPdfInfo": { "image": "/og_images/get-all-info-on-pdf.png", - "title": "Get ALL Info on PDF - Stirling PDF", + "title": "PDF Info and Metadata Viewer - Stirling PDF", "description": "Grabs any and all information possible on PDFs" }, "validateSignature": { @@ -242,42 +242,42 @@ }, "replaceColor": { "image": "/og_images/replace-and-invert-color.png", - "title": "Replace & Invert Color - Stirling PDF", + "title": "Replace and Invert PDF Colors - Stirling PDF", "description": "Replace or invert colors in PDF documents" }, "showJS": { "image": "/og_images/show-javascript.png", - "title": "Show JavaScript - Stirling PDF", + "title": "Show JavaScript in PDF - Stirling PDF", "description": "Extract and display JavaScript code from PDF documents" }, "bookletImposition": { "image": "/og_images/booklet-imposition.png", - "title": "Booklet Imposition - Stirling PDF", + "title": "Create a PDF Booklet - Stirling PDF", "description": "Create booklets with proper page ordering and multi-page layout for printing and binding" }, "pdfTextEditor": { "image": "/og_images/pdf-text-editor.png", - "title": "PDF Text Editor - Stirling PDF", + "title": "Edit PDF Text - Stirling PDF", "description": "Review and edit text and images in PDFs with grouped text editing and PDF regeneration" }, "formFill": { "image": "/og_images/form-fill.png", - "title": "Fill Form - Stirling PDF", + "title": "Fill PDF Forms - Stirling PDF", "description": "Fill PDF form fields interactively with a visual editor" }, "multiTool": { "image": "/og_images/multi-tool.png", - "title": "Multi-Tool - Stirling PDF", + "title": "PDF Page Editor - Stirling PDF", "description": "Use multiple tools on a single PDF document" }, "read": { "image": "/og_images/read.png", - "title": "Read - Stirling PDF", + "title": "PDF Viewer - Stirling PDF", "description": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." }, "automate": { "image": "/og_images/automate.png", - "title": "Automate - Stirling PDF", + "title": "PDF Workflow Automation - Stirling PDF", "description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." }, "devApi": { @@ -775,5 +775,303 @@ "/settings/help": "/settings/help", "/settings/legal": "/settings/legal", "/settings/payg": "/settings/payg" - } + }, + "navLinks": [ + { + "path": "/add-attachments", + "label": "Add Attachments to PDF" + }, + { + "path": "/add-image", + "label": "Add Image to PDF" + }, + { + "path": "/add-page-numbers", + "label": "Add Page Numbers to PDF" + }, + { + "path": "/add-stamp", + "label": "Add Stamp to PDF" + }, + { + "path": "/add-text", + "label": "Add Text to PDF" + }, + { + "path": "/watermark", + "label": "Add Watermark to PDF" + }, + { + "path": "/adjust-contrast", + "label": "Adjust PDF Colors and Contrast" + }, + { + "path": "/annotations", + "label": "Annotate PDF" + }, + { + "path": "/auto-rename", + "label": "Auto Rename PDF Files" + }, + { + "path": "/cbr-to-pdf", + "label": "CBR to PDF Converter" + }, + { + "path": "/cbz-to-pdf", + "label": "CBZ to PDF Converter" + }, + { + "path": "/change-permissions", + "label": "Change PDF Permissions" + }, + { + "path": "/single-large-page", + "label": "Combine PDF into a Single Page" + }, + { + "path": "/compare", + "label": "Compare PDF Files" + }, + { + "path": "/compress", + "label": "Compress PDF" + }, + { + "path": "/booklet-imposition", + "label": "Create a PDF Booklet" + }, + { + "path": "/crop", + "label": "Crop PDF" + }, + { + "path": "/remove-pages", + "label": "Delete Pages from PDF" + }, + { + "path": "/edit-table-of-contents", + "label": "Edit PDF Bookmarks" + }, + { + "path": "/change-metadata", + "label": "Edit PDF Metadata" + }, + { + "path": "/pdf-text-editor", + "label": "Edit PDF Text" + }, + { + "path": "/eml-to-pdf", + "label": "Email (EML) to PDF Converter" + }, + { + "path": "/extract-images", + "label": "Extract Images from PDF" + }, + { + "path": "/extract-pages", + "label": "Extract PDF Pages" + }, + { + "path": "/file-to-pdf", + "label": "File to PDF Converter" + }, + { + "path": "/form-fill", + "label": "Fill PDF Forms" + }, + { + "path": "/flatten", + "label": "Flatten PDF" + }, + { + "path": "/html-to-pdf", + "label": "HTML to PDF Converter" + }, + { + "path": "/img-to-pdf", + "label": "Image to PDF Converter" + }, + { + "path": "/fake-scan", + "label": "Make a PDF Look Scanned" + }, + { + "path": "/markdown-to-pdf", + "label": "Markdown to PDF Converter" + }, + { + "path": "/merge", + "label": "Merge PDF" + }, + { + "path": "/ocr", + "label": "OCR PDF" + }, + { + "path": "/reorganize-pages", + "label": "Organize PDF Pages" + }, + { + "path": "/overlay-pdfs", + "label": "Overlay PDFs" + }, + { + "path": "/add-password", + "label": "Password Protect PDF" + }, + { + "path": "/convert", + "label": "PDF Converter" + }, + { + "path": "/get-pdf-info", + "label": "PDF Info and Metadata Viewer" + }, + { + "path": "/page-layout", + "label": "PDF Multi-Page Layout" + }, + { + "path": "/multi-tool", + "label": "PDF Page Editor" + }, + { + "path": "/pdf-to-cbr", + "label": "PDF to CBR Converter" + }, + { + "path": "/pdf-to-cbz", + "label": "PDF to CBZ Converter" + }, + { + "path": "/pdf-to-csv", + "label": "PDF to CSV Converter" + }, + { + "path": "/pdf-to-xlsx", + "label": "PDF to Excel Converter" + }, + { + "path": "/pdf-to-html", + "label": "PDF to HTML Converter" + }, + { + "path": "/pdf-to-img", + "label": "PDF to Image Converter" + }, + { + "path": "/pdf-to-markdown", + "label": "PDF to Markdown Converter" + }, + { + "path": "/pdf-to-pdfa", + "label": "PDF to PDF/A Converter" + }, + { + "path": "/pdf-to-presentation", + "label": "PDF to PowerPoint Converter" + }, + { + "path": "/pdf-to-text", + "label": "PDF to Text Converter" + }, + { + "path": "/pdf-to-word", + "label": "PDF to Word Converter" + }, + { + "path": "/pdf-to-xml", + "label": "PDF to XML Converter" + }, + { + "path": "/read", + "label": "PDF Viewer" + }, + { + "path": "/automate", + "label": "PDF Workflow Automation" + }, + { + "path": "/redact", + "label": "Redact PDF" + }, + { + "path": "/remove-annotations", + "label": "Remove Annotations from PDF" + }, + { + "path": "/remove-blanks", + "label": "Remove Blank Pages from PDF" + }, + { + "path": "/remove-image", + "label": "Remove Images from PDF" + }, + { + "path": "/remove-certificate-sign", + "label": "Remove PDF Certificate Signature" + }, + { + "path": "/remove-password", + "label": "Remove PDF Password" + }, + { + "path": "/repair", + "label": "Repair PDF" + }, + { + "path": "/replace-color-pdf", + "label": "Replace and Invert PDF Colors" + }, + { + "path": "/scale-pages", + "label": "Resize PDF Pages" + }, + { + "path": "/rotate", + "label": "Rotate PDF" + }, + { + "path": "/sanitize", + "label": "Sanitize PDF" + }, + { + "path": "/shared-sign", + "label": "Shared Signing" + }, + { + "path": "/show-js", + "label": "Show JavaScript in PDF" + }, + { + "path": "/sign", + "label": "Sign PDF" + }, + { + "path": "/cert-sign", + "label": "Sign PDF with a Certificate" + }, + { + "path": "/split", + "label": "Split PDF" + }, + { + "path": "/scanner-image-split", + "label": "Split Scanned Photos" + }, + { + "path": "/timestamp-pdf", + "label": "Timestamp PDF" + }, + { + "path": "/unlock-pdf-forms", + "label": "Unlock PDF Forms" + }, + { + "path": "/validate-signature", + "label": "Validate PDF Signature" + } + ] } diff --git a/frontend/editor/scripts/generate-og-metadata.mjs b/frontend/editor/scripts/generate-og-metadata.mjs index 1b72876379..2cbaa61219 100644 --- a/frontend/editor/scripts/generate-og-metadata.mjs +++ b/frontend/editor/scripts/generate-og-metadata.mjs @@ -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) @@ -131,6 +132,69 @@ const CONVERT_SEO_PAGES = { }, }; +// 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 , 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 = { @@ -230,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; @@ -297,6 +370,47 @@ for (const [routePath, seo] of Object.entries(CONVERT_SEO_PAGES)) { }; } +// 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`, @@ -305,6 +419,7 @@ const manifest = { }, byTool, byPath, + navLinks, }; const mapJson = JSON.stringify(ogImageMap, null, 2) + "\n"; diff --git a/frontend/editor/scripts/og-prerender.d.mts b/frontend/editor/scripts/og-prerender.d.mts index 4f4f12e88f..2ba96efff3 100644 --- a/frontend/editor/scripts/og-prerender.d.mts +++ b/frontend/editor/scripts/og-prerender.d.mts @@ -14,18 +14,25 @@ export interface OgInjectOptions { 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?: { ogBase?: string; pageUrlPath?: string | null }, + opts?: { ogBase?: string; pageUrlPath?: string | null; pathPrefix?: string }, ): string; export function buildRobotsTag(noindex: boolean): string; export function buildCanonicalTag(canonicalUrl: string | null): string | null; @@ -33,6 +40,11 @@ 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, diff --git a/frontend/editor/scripts/og-prerender.mjs b/frontend/editor/scripts/og-prerender.mjs index b9d1043a8a..2e9c44c32c 100644 --- a/frontend/editor/scripts/og-prerender.mjs +++ b/frontend/editor/scripts/og-prerender.mjs @@ -25,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)) @@ -135,7 +140,7 @@ export function buildJsonLd(entry, { siteRoot, pageUrl, isHome }) { * 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}} opts + * noindex?:boolean, siteRoot?:string|null, isHome?:boolean, pathPrefix?:string}} opts */ export function injectOg(html, entry, opts = {}) { const { @@ -145,13 +150,14 @@ export function injectOg(html, entry, opts = {}) { 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 }), + buildOgTags(entry, { ogBase, pageUrlPath, pathPrefix }), buildRobotsTag(noindex), buildCanonicalTag(canonicalUrl), ogBase @@ -176,6 +182,62 @@ export function injectOg(html, entry, opts = {}) { .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 @@ -210,17 +272,25 @@ export async function prerenderOg({ 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 ? homePath : null, - canonicalPath: homePath, - siteRoot, - isHome: true, + 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 || {})) { @@ -235,7 +305,11 @@ export async function prerenderOg({ 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}" />`); diff --git a/frontend/editor/src/core/data/ogMetadata.test.ts b/frontend/editor/src/core/data/ogMetadata.test.ts index 1871d24d2f..2b21fc6e67 100644 --- a/frontend/editor/src/core/data/ogMetadata.test.ts +++ b/frontend/editor/src/core/data/ogMetadata.test.ts @@ -6,8 +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"; @@ -103,6 +105,21 @@ 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)", () => { @@ -216,6 +233,103 @@ describe("buildSitemap", () => { }); }); +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)", () => { it("writes a flat file per single-segment route and a nested file (absolute base) per sub-route", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "og-prerender-")); diff --git a/frontend/editor/src/core/data/urlSeoOverrides.json b/frontend/editor/src/core/data/urlSeoOverrides.json index ebcfae7d49..532d61d818 100644 --- a/frontend/editor/src/core/data/urlSeoOverrides.json +++ b/frontend/editor/src/core/data/urlSeoOverrides.json @@ -74,5 +74,357 @@ "/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" } }