Files
Stirling-PDF/frontend/editor/scripts/generate-og-metadata.mjs
T

488 lines
18 KiB
JavaScript

// Generates Open Graph (OG) / SEO social-preview metadata from the single
// source of truth in the frontend (tool ids, URL aliases, translated English
// strings) plus the actual images in public/og_images.
//
// Outputs:
// 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)
//
// Why a generator instead of hand-maintained JSON: tool ids, URL aliases and
// English copy already live in the codebase. Regenerating keeps OG metadata in
// lockstep with the tool registry and surfaces tools that have no art.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8");
const SITE_NAME = "Stirling PDF";
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 = {
merge: "mergePdfs",
crop: "cropPdf",
getPdfInfo: "get-all-info-on-pdf",
validateSignature: "validate-pdf-signature",
replaceColor: "replace-and-invert-color",
scalePages: "adjust-page-size-scale",
adjustContrast: "adjust-colors-contrast",
autoRename: "auto-rename-pdf-file",
removeBlanks: "remove-blank-pages",
removePages: "remove",
scannerImageSplit: "detect-split-scanned-photos",
};
// --- parse tool ids ---------------------------------------------------------
const idSrc = read("src/core/types/toolId.ts");
function idArray(name) {
const m = idSrc.match(
new RegExp("export const " + name + " = \\[([\\s\\S]*?)\\] as const"),
);
return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
}
const regularIds = idArray("CORE_REGULAR_TOOL_IDS");
const superIds = idArray("CORE_SUPER_TOOL_IDS");
const linkIds = idArray("CORE_LINK_TOOL_IDS");
const allIds = [...regularIds, ...superIds, ...linkIds];
// --- parse URL aliases ------------------------------------------------------
const mapSrc = read("src/core/utils/urlMapping.ts");
const urlToTool = {};
for (const m of mapSrc.matchAll(/"([^"]+)":\s*"([^"]+)"/g))
urlToTool[m[1]] = m[2];
// --- parse English title/description fallbacks ------------------------------
const regSrc = read("src/core/data/useTranslatedToolRegistry.tsx");
const titleById = {};
const descById = {};
const STR = '"((?:[^"\\\\]|\\\\.)*)"';
for (const m of regSrc.matchAll(
new RegExp('t\\(\\s*"home\\.([A-Za-z0-9_]+)\\.title"\\s*,\\s*' + STR, "g"),
))
titleById[m[1]] = m[2];
for (const m of regSrc.matchAll(
new RegExp('t\\(\\s*"home\\.([A-Za-z0-9_]+)\\.desc"\\s*,\\s*' + STR, "g"),
))
descById[m[1]] = m[2];
// --- available images -------------------------------------------------------
const imageDir = "public/og_images";
const images = new Set(
fs
.readdirSync(path.join(ROOT, imageDir))
.filter((f) => f.endsWith(".png"))
.map((f) => f.replace(/\.png$/, "")),
);
const canonicalPath = (id) => "/" + id.replace(/([A-Z])/g, "-$1").toLowerCase();
const aliasesByTool = {};
for (const [p, id] of Object.entries(urlToTool))
(aliasesByTool[id] ??= []).push(p);
function resolveImage(id) {
const override = LEGACY_IMAGE_OVERRIDES[id];
if (override) return images.has(override) ? override : null;
const candidates = [
id,
canonicalPath(id).slice(1),
...(aliasesByTool[id] || []).map((a) => a.slice(1)),
];
for (const c of candidates) if (images.has(c)) return c;
return null;
}
const humanize = (id) =>
id
.replace(/([A-Z]+)/g, " $1")
.replace(/^./, (c) => c.toUpperCase())
.replace(/\s+/g, " ")
.trim();
const titleFor = (id) => `${titleById[id] || humanize(id)} - ${SITE_NAME}`;
const descFor = (id) => descById[id] || SITE_DESC;
// --- build outputs ----------------------------------------------------------
const ogImageMap = {}; // toolId -> basename (only tools with art)
const byTool = {};
const missing = [];
for (const id of allIds) {
const img = resolveImage(id);
if (img) ogImageMap[id] = img;
else missing.push(id);
byTool[id] = {
image: `/og_images/${img || DEFAULT_IMAGE_BASENAME}.png`,
title: titleFor(id),
description: descFor(id),
};
}
// 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;
for (const [p, id] of Object.entries(urlToTool)) byPath[p] = id;
// --- non-tool application routes --------------------------------------------
// Every other URL the SPA serves also gets OG: auth, the file manager, the
// mobile scanner, and each settings section. These have no bespoke art (default
// image) but carry a page-specific title so shared links are labelled correctly.
// Keyed by path (tool ids never start with "/", so there is no collision).
const navKeys = (
read("src/core/components/shared/config/types.ts")
.match(/export const VALID_NAV_KEYS = \[([\s\S]*?)\] as const/)?.[1]
.match(/"([^"]+)"/g) || []
).map((s) => s.replace(/"/g, ""));
const humanizeLabel = (s) =>
s
.replace(/[-_]/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
const pageTitles = {
"/login": "Sign In",
"/signup": "Sign Up",
"/mobile-scanner": "Mobile Scanner",
"/files": "Files",
"/settings": "Settings",
};
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`,
title: SITE_TITLE,
description: SITE_DESC,
},
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 = 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 " +
stale.join("\n "),
);
process.exit(1);
}
console.log("OG metadata is up to date.");
} else {
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 -----------------------------------------------------------------
console.log(
`\nTools with OG image: ${allIds.length - missing.length}/${allIds.length}`,
);
console.log(
`Tools using the DEFAULT image (${DEFAULT_IMAGE_BASENAME}.png) - no bespoke art: ${missing.length}`,
);
for (const id of missing) {
const kind = superIds.includes(id)
? "super"
: linkIds.includes(id)
? "link"
: "regular";
console.log(` ${kind.padEnd(8)} ${id.padEnd(20)} ${canonicalPath(id)}`);
}
const used = new Set(Object.values(ogImageMap));
const orphans = [...images]
.filter((i) => !used.has(i) && i !== DEFAULT_IMAGE_BASENAME)
.sort();
console.log(
`\nUnused images in ${imageDir} (${orphans.length}): ${orphans.join(", ")}`,
);