add prerendered Open Graph previews and OG card generator (#6661)
# Description of Changes add prerendered Open Graph previews and OG card generator so that /compresss etc shows a pre generated static html file (Since google etc doenst render javascript) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
@@ -46,6 +46,11 @@ app/core/storage/
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
# Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source.
|
||||
app/core/src/main/resources/static/*.html
|
||||
!app/core/src/main/resources/static/api-landing.html
|
||||
# Prerendered nested-route pages (e.g. settings/people.html)
|
||||
app/core/src/main/resources/static/settings/
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
@@ -53,6 +58,8 @@ app/core/src/main/resources/static/modern-logo/
|
||||
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/sw-folder-retry.js
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/pdfjs/
|
||||
|
||||
@@ -40,6 +40,21 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-icons.js
|
||||
|
||||
prepare:og:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate OG/social-preview metadata from the tool registry"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs
|
||||
sources:
|
||||
- editor/src/core/types/toolId.ts
|
||||
- editor/src/core/utils/urlMapping.ts
|
||||
- editor/src/core/data/useTranslatedToolRegistry.tsx
|
||||
- editor/public/og_images/*.png
|
||||
generates:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -49,6 +64,7 @@ tasks:
|
||||
- task: prepare:env
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -317,9 +333,17 @@ tasks:
|
||||
- task: format:check
|
||||
- task: test
|
||||
|
||||
og:check:
|
||||
desc: "Fail if committed OG/social-preview metadata is out of date"
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
|
||||
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
||||
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
||||
// api-landing.html is a real backend source file, not a generated artifact.
|
||||
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html'])
|
||||
// Nested prerendered route pages (e.g. settings/people.html)
|
||||
delete new File(resourcesStaticDir, 'settings')
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
|
||||
@@ -10,6 +10,9 @@ node_modules/
|
||||
editor/public/vendor/
|
||||
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||
portal/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/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
{
|
||||
"default": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"byTool": {
|
||||
"certSign": {
|
||||
"image": "/og_images/certSign.png",
|
||||
"title": "Certificate Sign - Stirling PDF",
|
||||
"description": "Sign PDF documents using digital certificates"
|
||||
},
|
||||
"sign": {
|
||||
"image": "/og_images/sign.png",
|
||||
"title": "Sign - Stirling PDF",
|
||||
"description": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"addText": {
|
||||
"image": "/og_images/add-text.png",
|
||||
"title": "Add Text - Stirling PDF",
|
||||
"description": "Add custom text anywhere in your PDF"
|
||||
},
|
||||
"addPassword": {
|
||||
"image": "/og_images/add-password.png",
|
||||
"title": "Add Password - Stirling PDF",
|
||||
"description": "Add password protection and restrictions to PDF files"
|
||||
},
|
||||
"removePassword": {
|
||||
"image": "/og_images/remove-password.png",
|
||||
"title": "Remove Password - Stirling PDF",
|
||||
"description": "Remove password protection from PDF documents"
|
||||
},
|
||||
"removePages": {
|
||||
"image": "/og_images/remove.png",
|
||||
"title": "Remove Pages - Stirling PDF",
|
||||
"description": "Remove specific pages from a PDF document"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"image": "/og_images/remove-blank-pages.png",
|
||||
"title": "Remove Blank Pages - Stirling PDF",
|
||||
"description": "Remove blank pages from PDF documents"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"image": "/og_images/remove-annotations.png",
|
||||
"title": "Remove Annotations - Stirling PDF",
|
||||
"description": "Remove annotations and comments from PDF documents"
|
||||
},
|
||||
"removeImage": {
|
||||
"image": "/og_images/remove-image.png",
|
||||
"title": "Remove Images - Stirling PDF",
|
||||
"description": "Remove all images from a PDF document"
|
||||
},
|
||||
"changePermissions": {
|
||||
"image": "/og_images/change-permissions.png",
|
||||
"title": "Change Permissions - Stirling PDF",
|
||||
"description": "Change document restrictions and permissions"
|
||||
},
|
||||
"watermark": {
|
||||
"image": "/og_images/add-watermark.png",
|
||||
"title": "Add Watermark - Stirling PDF",
|
||||
"description": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"sanitize": {
|
||||
"image": "/og_images/sanitize.png",
|
||||
"title": "Sanitize - Stirling PDF",
|
||||
"description": "Remove potentially harmful elements from PDF files"
|
||||
},
|
||||
"split": {
|
||||
"image": "/og_images/split.png",
|
||||
"title": "Split - Stirling PDF",
|
||||
"description": "Split PDFs into multiple documents"
|
||||
},
|
||||
"merge": {
|
||||
"image": "/og_images/mergePdfs.png",
|
||||
"title": "Merge - Stirling PDF",
|
||||
"description": "Merge multiple PDFs into a single document"
|
||||
},
|
||||
"convert": {
|
||||
"image": "/og_images/convert.png",
|
||||
"title": "Convert - Stirling PDF",
|
||||
"description": "Convert files to and from PDF format"
|
||||
},
|
||||
"ocr": {
|
||||
"image": "/og_images/ocr.png",
|
||||
"title": "OCR - Stirling PDF",
|
||||
"description": "Extract text from scanned PDFs using Optical Character Recognition"
|
||||
},
|
||||
"addImage": {
|
||||
"image": "/og_images/add-image.png",
|
||||
"title": "Add Image - Stirling PDF",
|
||||
"description": "Add images anywhere in your PDF"
|
||||
},
|
||||
"rotate": {
|
||||
"image": "/og_images/rotate.png",
|
||||
"title": "Rotate - Stirling PDF",
|
||||
"description": "Easily rotate your PDFs."
|
||||
},
|
||||
"annotate": {
|
||||
"image": "/og_images/annotate.png",
|
||||
"title": "Annotate - 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",
|
||||
"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",
|
||||
"description": "Add or edit bookmarks and table of contents in PDF documents"
|
||||
},
|
||||
"scannerEffect": {
|
||||
"image": "/og_images/scanner-effect.png",
|
||||
"title": "Scanner Effect - 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",
|
||||
"description": "Automatically rename PDF files based on their content"
|
||||
},
|
||||
"pageLayout": {
|
||||
"image": "/og_images/multi-page-layout.png",
|
||||
"title": "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",
|
||||
"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",
|
||||
"description": "Adjust colors and contrast of PDF documents"
|
||||
},
|
||||
"crop": {
|
||||
"image": "/og_images/cropPdf.png",
|
||||
"title": "Crop PDF - Stirling PDF",
|
||||
"description": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"image": "/og_images/single-large-page.png",
|
||||
"title": "PDF to Single Large Page - Stirling PDF",
|
||||
"description": "Merges all PDF pages into one large single page"
|
||||
},
|
||||
"repair": {
|
||||
"image": "/og_images/repair.png",
|
||||
"title": "Repair - Stirling PDF",
|
||||
"description": "Repair corrupted or damaged PDF files"
|
||||
},
|
||||
"compare": {
|
||||
"image": "/og_images/compare.png",
|
||||
"title": "Compare - Stirling PDF",
|
||||
"description": "Compare two PDF documents and highlight differences"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"image": "/og_images/add-page-numbers.png",
|
||||
"title": "Add Page Numbers - Stirling PDF",
|
||||
"description": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"redact": {
|
||||
"image": "/og_images/redact.png",
|
||||
"title": "Redact - Stirling PDF",
|
||||
"description": "Permanently remove sensitive information from PDF documents"
|
||||
},
|
||||
"flatten": {
|
||||
"image": "/og_images/flatten.png",
|
||||
"title": "Flatten - 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",
|
||||
"description": "Remove digital signature from PDF documents"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"image": "/og_images/unlock-pdf-forms.png",
|
||||
"title": "Unlock PDF Forms - Stirling PDF",
|
||||
"description": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"image": "/og_images/compress.png",
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
"description": "Extract specific pages from a PDF document"
|
||||
},
|
||||
"reorganizePages": {
|
||||
"image": "/og_images/reorganize-pages.png",
|
||||
"title": "Reorganize 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",
|
||||
"description": "Extract images from PDF documents"
|
||||
},
|
||||
"addStamp": {
|
||||
"image": "/og_images/add-stamp.png",
|
||||
"title": "Add Stamp to PDF - Stirling PDF",
|
||||
"description": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"addAttachments": {
|
||||
"image": "/og_images/add-attachments.png",
|
||||
"title": "Add Attachments - 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",
|
||||
"description": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"image": "/og_images/overlay-pdfs.png",
|
||||
"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",
|
||||
"description": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
"validateSignature": {
|
||||
"image": "/og_images/validate-pdf-signature.png",
|
||||
"title": "Validate PDF Signature - Stirling PDF",
|
||||
"description": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"timestampPdf": {
|
||||
"image": "/og_images/timestamp-pdf.png",
|
||||
"title": "Timestamp PDF - Stirling PDF",
|
||||
"description": "Add an RFC 3161 document timestamp to prove when your PDF existed"
|
||||
},
|
||||
"replaceColor": {
|
||||
"image": "/og_images/replace-and-invert-color.png",
|
||||
"title": "Replace & Invert Color - Stirling PDF",
|
||||
"description": "Replace or invert colors in PDF documents"
|
||||
},
|
||||
"showJS": {
|
||||
"image": "/og_images/show-javascript.png",
|
||||
"title": "Show JavaScript - Stirling PDF",
|
||||
"description": "Extract and display JavaScript code from PDF documents"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"image": "/og_images/booklet-imposition.png",
|
||||
"title": "Booklet Imposition - 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",
|
||||
"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",
|
||||
"description": "Fill PDF form fields interactively with a visual editor"
|
||||
},
|
||||
"multiTool": {
|
||||
"image": "/og_images/multi-tool.png",
|
||||
"title": "Multi-Tool - Stirling PDF",
|
||||
"description": "Use multiple tools on a single PDF document"
|
||||
},
|
||||
"read": {
|
||||
"image": "/og_images/read.png",
|
||||
"title": "Read - 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",
|
||||
"description": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
},
|
||||
"devApi": {
|
||||
"image": "/og_images/dev-api.png",
|
||||
"title": "API - Stirling PDF",
|
||||
"description": "Link to API documentation"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"image": "/og_images/dev-folder-scanning.png",
|
||||
"title": "Automated Folder Scanning - Stirling PDF",
|
||||
"description": "Link to automated folder scanning guide"
|
||||
},
|
||||
"devSsoGuide": {
|
||||
"image": "/og_images/dev-sso-guide.png",
|
||||
"title": "SSO Guide - Stirling PDF",
|
||||
"description": "Link to SSO guide"
|
||||
},
|
||||
"devAirgapped": {
|
||||
"image": "/og_images/dev-airgapped.png",
|
||||
"title": "Air-gapped Setup - Stirling PDF",
|
||||
"description": "Link to air-gapped setup guide"
|
||||
},
|
||||
"/login": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Sign In - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/signup": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Sign Up - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/mobile-scanner": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Mobile Scanner - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/files": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Files - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/preferences": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Preferences Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/notifications": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Notifications Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/connections": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Connections Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/account": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Account Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/general": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "General Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/people": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "People Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/teams": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Teams Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/security": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Security Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/identity": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Identity Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/plan": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Plan Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/payments": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payments Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/requests": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Requests Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/developer": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Developer Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/api-keys": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Api Keys Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/hotkeys": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Hotkeys Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminGeneral": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin General Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminSecurity": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Security Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminConnections": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Connections Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPrivacy": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Privacy Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminDatabase": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Database Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminAdvanced": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Advanced Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminLegal": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Legal Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPremium": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Premium Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminFeatures": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Features Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminPlan": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Plan Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminAudit": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Audit Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminUsage": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Usage Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminEndpoints": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Endpoints Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminStorageSharing": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Storage Sharing Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/adminMcp": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Admin Mcp Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/help": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Help Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/legal": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Legal Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
},
|
||||
"/settings/payg": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Payg Settings - Stirling PDF",
|
||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
}
|
||||
},
|
||||
"byPath": {
|
||||
"/cert-sign": "certSign",
|
||||
"/sign": "sign",
|
||||
"/add-text": "addText",
|
||||
"/add-password": "addPassword",
|
||||
"/remove-password": "removePassword",
|
||||
"/remove-pages": "removePages",
|
||||
"/remove-blanks": "removeBlanks",
|
||||
"/remove-annotations": "removeAnnotations",
|
||||
"/remove-image": "removeImage",
|
||||
"/change-permissions": "changePermissions",
|
||||
"/watermark": "watermark",
|
||||
"/sanitize": "sanitize",
|
||||
"/split": "split",
|
||||
"/merge": "merge",
|
||||
"/convert": "convert",
|
||||
"/ocr": "ocr",
|
||||
"/add-image": "addImage",
|
||||
"/rotate": "rotate",
|
||||
"/annotate": "annotate",
|
||||
"/scanner-image-split": "scannerImageSplit",
|
||||
"/edit-table-of-contents": "editTableOfContents",
|
||||
"/scanner-effect": "scannerEffect",
|
||||
"/auto-rename": "autoRename",
|
||||
"/page-layout": "pageLayout",
|
||||
"/scale-pages": "scalePages",
|
||||
"/adjust-contrast": "adjustContrast",
|
||||
"/crop": "crop",
|
||||
"/pdf-to-single-page": "pdfToSinglePage",
|
||||
"/repair": "repair",
|
||||
"/compare": "compare",
|
||||
"/add-page-numbers": "addPageNumbers",
|
||||
"/redact": "redact",
|
||||
"/flatten": "flatten",
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
"/add-stamp": "addStamp",
|
||||
"/add-attachments": "addAttachments",
|
||||
"/change-metadata": "changeMetadata",
|
||||
"/overlay-pdfs": "overlayPdfs",
|
||||
"/get-pdf-info": "getPdfInfo",
|
||||
"/validate-signature": "validateSignature",
|
||||
"/timestamp-pdf": "timestampPdf",
|
||||
"/replace-color": "replaceColor",
|
||||
"/show-j-s": "showJS",
|
||||
"/booklet-imposition": "bookletImposition",
|
||||
"/pdf-text-editor": "pdfTextEditor",
|
||||
"/form-fill": "formFill",
|
||||
"/multi-tool": "multiTool",
|
||||
"/read": "read",
|
||||
"/automate": "automate",
|
||||
"/dev-api": "devApi",
|
||||
"/dev-folder-scanning": "devFolderScanning",
|
||||
"/dev-sso-guide": "devSsoGuide",
|
||||
"/dev-airgapped": "devAirgapped",
|
||||
"/split-pdfs": "split",
|
||||
"/merge-pdfs": "merge",
|
||||
"/compress-pdf": "compress",
|
||||
"/rotate-pdf": "rotate",
|
||||
"/convert-pdf": "convert",
|
||||
"/file-to-pdf": "convert",
|
||||
"/eml-to-pdf": "convert",
|
||||
"/html-to-pdf": "convert",
|
||||
"/markdown-to-pdf": "convert",
|
||||
"/pdf-to-csv": "convert",
|
||||
"/pdf-to-xlsx": "convert",
|
||||
"/pdf-to-img": "convert",
|
||||
"/pdf-to-markdown": "convert",
|
||||
"/pdf-to-pdfa": "convert",
|
||||
"/pdf-to-word": "convert",
|
||||
"/pdf-to-xml": "convert",
|
||||
"/cbr-to-pdf": "convert",
|
||||
"/pdf-to-cbr": "convert",
|
||||
"/cbz-to-pdf": "convert",
|
||||
"/pdf-to-cbz": "convert",
|
||||
"/manage-signatures": "certSign",
|
||||
"/remove-certificate-sign": "removeCertSign",
|
||||
"/unlock-pdf-forms": "unlockPDFForms",
|
||||
"/sanitize-pdf": "sanitize",
|
||||
"/ocr-pdf": "ocr",
|
||||
"/add-watermark": "watermark",
|
||||
"/single-large-page": "pdfToSinglePage",
|
||||
"/auto-split-pdf": "split",
|
||||
"/auto-size-split-pdf": "split",
|
||||
"/annotations": "annotate",
|
||||
"/fake-scan": "scannerEffect",
|
||||
"/replace-color-pdf": "replaceColor",
|
||||
"/show-js": "showJS",
|
||||
"/pdf-organizer": "reorganizePages",
|
||||
"/multi-page-layout": "pageLayout",
|
||||
"/extract-page": "extractPages",
|
||||
"/img-to-pdf": "convert",
|
||||
"/pdf-to-presentation": "convert",
|
||||
"/pdf-to-text": "convert",
|
||||
"/pdf-to-html": "convert",
|
||||
"/auto-redact": "redact",
|
||||
"/stamp": "addStamp",
|
||||
"/view-pdf": "read",
|
||||
"/get-info-on-pdf": "getPdfInfo",
|
||||
"/remove-image-pdf": "removeImage",
|
||||
"/replace-and-invert-color-pdf": "replaceColor",
|
||||
"/pipeline": "automate",
|
||||
"/extract-image-scans": "scannerImageSplit",
|
||||
"/show-javascript": "showJS",
|
||||
"/split-by-size-or-count": "split",
|
||||
"/overlay-pdf": "overlayPdfs",
|
||||
"/split-pdf-by-sections": "split",
|
||||
"/split-pdf-by-chapters": "split",
|
||||
"/login": "/login",
|
||||
"/signup": "/signup",
|
||||
"/mobile-scanner": "/mobile-scanner",
|
||||
"/files": "/files",
|
||||
"/settings": "/settings",
|
||||
"/settings/preferences": "/settings/preferences",
|
||||
"/settings/notifications": "/settings/notifications",
|
||||
"/settings/connections": "/settings/connections",
|
||||
"/settings/account": "/settings/account",
|
||||
"/settings/general": "/settings/general",
|
||||
"/settings/people": "/settings/people",
|
||||
"/settings/teams": "/settings/teams",
|
||||
"/settings/security": "/settings/security",
|
||||
"/settings/identity": "/settings/identity",
|
||||
"/settings/plan": "/settings/plan",
|
||||
"/settings/payments": "/settings/payments",
|
||||
"/settings/requests": "/settings/requests",
|
||||
"/settings/developer": "/settings/developer",
|
||||
"/settings/api-keys": "/settings/api-keys",
|
||||
"/settings/hotkeys": "/settings/hotkeys",
|
||||
"/settings/adminGeneral": "/settings/adminGeneral",
|
||||
"/settings/adminSecurity": "/settings/adminSecurity",
|
||||
"/settings/adminConnections": "/settings/adminConnections",
|
||||
"/settings/adminPrivacy": "/settings/adminPrivacy",
|
||||
"/settings/adminDatabase": "/settings/adminDatabase",
|
||||
"/settings/adminAdvanced": "/settings/adminAdvanced",
|
||||
"/settings/adminLegal": "/settings/adminLegal",
|
||||
"/settings/adminPremium": "/settings/adminPremium",
|
||||
"/settings/adminFeatures": "/settings/adminFeatures",
|
||||
"/settings/adminPlan": "/settings/adminPlan",
|
||||
"/settings/adminAudit": "/settings/adminAudit",
|
||||
"/settings/adminUsage": "/settings/adminUsage",
|
||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||
"/settings/adminMcp": "/settings/adminMcp",
|
||||
"/settings/help": "/settings/help",
|
||||
"/settings/legal": "/settings/legal",
|
||||
"/settings/payg": "/settings/payg"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 212 KiB |
@@ -0,0 +1,413 @@
|
||||
// Generate branded Open Graph cards (1200x630) that match the existing
|
||||
// public/og_images tool cards. Renders an HTML template with Puppeteer.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/generate-og-image.mjs --missing # generate cards for every tool with no art
|
||||
// node scripts/generate-og-image.mjs --name "Add Text" --desc "Add custom text anywhere" \
|
||||
// --icon text-fields --out public/og_images/add-text.png
|
||||
// # theme overrides, e.g. a different accent:
|
||||
// node scripts/generate-og-image.mjs --name "Sign" --icon draw --bg-top "#1e3a5f" \
|
||||
// --bg-bottom "#3b6ea5" --rotate -3 --out /path/sign.png
|
||||
//
|
||||
// --icon is a material-symbols name (https://fonts.google.com/icons) resolved via iconify,
|
||||
// or a raw "<svg…>" string. Theme is fully customizable (see THEME / the CLI flags below).
|
||||
// After adding images, run `node scripts/generate-og-metadata.mjs` so they get picked up.
|
||||
|
||||
/* global document, getComputedStyle */ // used inside page.evaluate (browser context)
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "..");
|
||||
|
||||
// ---- theme (override any of these via CLI flags) ---------------------------
|
||||
export const THEME = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
// background red gradient (sampled from the originals)
|
||||
bgTop: "#7b2727",
|
||||
bgMid: "#b3352e",
|
||||
bgBottom: "#c64040",
|
||||
bgAngle: 162, // deg
|
||||
// white diagonal in the top-right; vertices (cutTopX,0) (W,0) (W,cutRightY)
|
||||
cutTopX: 527,
|
||||
cutRightY: 345,
|
||||
// the tilted "paper" card holding the icon + text
|
||||
cardLeft: 36,
|
||||
cardTop: 6,
|
||||
cardWidth: 580,
|
||||
cardHeight: 640,
|
||||
cardRadius: 5, // a tilted "sheet of paper": near-sharp corners, not a rounded box
|
||||
cardRotate: -4.5, // deg (counter-clockwise)
|
||||
cardPadLeft: 78,
|
||||
cardPadTop: 182,
|
||||
cardFill: "rgba(255,255,255,0.045)",
|
||||
cardBorder: "rgba(255,255,255,0.085)",
|
||||
// icon
|
||||
iconBox: 120,
|
||||
iconBoxRadius: 26,
|
||||
iconBoxFill: "rgba(0,0,0,0.07)",
|
||||
iconSize: 80,
|
||||
// text
|
||||
textColor: "#ffffff",
|
||||
titleSize: 70,
|
||||
titleWeight: 900,
|
||||
titleSpacing: -1,
|
||||
titleMaxWidth: 485, // title wrap width; long names wrap to 2 lines, then shrink
|
||||
descSize: 30,
|
||||
descWeight: 600,
|
||||
descMaxWidth: 455,
|
||||
gapIconTitle: 30,
|
||||
gapTitleDesc: 16,
|
||||
fontFamily: "Nunito",
|
||||
fontUrl:
|
||||
"https://fonts.googleapis.com/css2?family=Nunito:wght@600;700;900&display=swap",
|
||||
};
|
||||
|
||||
// ---- icon resolution (material-symbols via iconify) ------------------------
|
||||
function resolveIcon(icon) {
|
||||
if (!icon) return "";
|
||||
if (icon.trim().startsWith("<svg")) return icon; // raw svg passed through
|
||||
const { getIconData, iconToSVG } = require("@iconify/utils");
|
||||
const set = require("@iconify-json/material-symbols/icons.json");
|
||||
const data = getIconData(set, icon);
|
||||
if (!data) throw new Error(`icon not found in material-symbols: "${icon}"`);
|
||||
const { attributes, body } = iconToSVG(data);
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${attributes.viewBox}" style="color:#fff">${body}</svg>`;
|
||||
}
|
||||
|
||||
// ---- html template ---------------------------------------------------------
|
||||
async function buildHtml({ name, description, iconSvg, theme }) {
|
||||
const t = theme;
|
||||
const lockup = await fs.readFile(
|
||||
path.join(HERE, "og-assets/stirling-lockup.png"),
|
||||
);
|
||||
const lockupUri = `data:image/png;base64,${lockup.toString("base64")}`;
|
||||
return `<!doctype html><html><head><meta charset="utf-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="${t.fontUrl}" rel="stylesheet" />
|
||||
<style>
|
||||
* { margin: 0; box-sizing: border-box; }
|
||||
html, body { width: ${t.width}px; height: ${t.height}px; }
|
||||
.stage {
|
||||
position: relative; width: ${t.width}px; height: ${t.height}px; overflow: hidden;
|
||||
background: linear-gradient(${t.bgAngle}deg, ${t.bgTop} 0%, ${t.bgMid} 52%, ${t.bgBottom} 100%);
|
||||
font-family: "${t.fontFamily}", system-ui, sans-serif;
|
||||
}
|
||||
.whiteTri {
|
||||
position: absolute; inset: 0; background: #fff;
|
||||
clip-path: polygon(${t.cutTopX}px 0, ${t.width + 1}px 0, ${t.width + 1}px ${t.cutRightY}px);
|
||||
}
|
||||
.lockup { position: absolute; top: 42px; left: 895px; width: 295px; height: auto; }
|
||||
.cardGroup {
|
||||
position: absolute; left: ${t.cardLeft}px; top: ${t.cardTop}px;
|
||||
transform: rotate(${t.cardRotate}deg); transform-origin: 40% 50%;
|
||||
}
|
||||
.card {
|
||||
width: ${t.cardWidth}px; height: ${t.cardHeight}px; border-radius: ${t.cardRadius}px;
|
||||
background: ${t.cardFill}; border: 1px solid ${t.cardBorder};
|
||||
padding: ${t.cardPadTop}px 0 0 ${t.cardPadLeft}px;
|
||||
}
|
||||
.iconBox {
|
||||
width: ${t.iconBox}px; height: ${t.iconBox}px; border-radius: ${t.iconBoxRadius}px;
|
||||
background: ${t.iconBoxFill}; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.iconBox svg { width: ${t.iconSize}px; height: ${t.iconSize}px; }
|
||||
.title {
|
||||
margin-top: ${t.gapIconTitle}px; color: ${t.textColor};
|
||||
font-size: ${t.titleSize}px; font-weight: ${t.titleWeight};
|
||||
line-height: 1.06; letter-spacing: ${t.titleSpacing}px;
|
||||
max-width: ${t.titleMaxWidth}px;
|
||||
}
|
||||
.desc {
|
||||
margin-top: ${t.gapTitleDesc}px; color: ${t.textColor}; opacity: 0.96;
|
||||
font-size: ${t.descSize}px; font-weight: ${t.descWeight};
|
||||
line-height: 1.3; max-width: ${t.descMaxWidth}px;
|
||||
}
|
||||
</style></head>
|
||||
<body><div class="stage">
|
||||
<div class="whiteTri"></div>
|
||||
<img class="lockup" src="${lockupUri}" />
|
||||
<div class="cardGroup"><div class="card">
|
||||
<div class="iconBox">${iconSvg}</div>
|
||||
<div class="title">${escapeHtml(name)}</div>
|
||||
<div class="desc">${escapeHtml(description)}</div>
|
||||
</div></div>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
const escapeHtml = (s) =>
|
||||
String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// ---- render ----------------------------------------------------------------
|
||||
let _browser = null;
|
||||
async function getBrowser() {
|
||||
if (_browser) return _browser;
|
||||
const puppeteer = require("puppeteer");
|
||||
_browser = await puppeteer.launch({
|
||||
headless: "new",
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
return _browser;
|
||||
}
|
||||
|
||||
export async function renderOgCard({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
outFile,
|
||||
theme = THEME,
|
||||
}) {
|
||||
const iconSvg = resolveIcon(icon);
|
||||
const html = await buildHtml({ name, description, iconSvg, theme });
|
||||
const browser = await getBrowser();
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({
|
||||
width: theme.width,
|
||||
height: theme.height,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||
const fit = await page.evaluate(
|
||||
async (family, weight, lineH, maxLines, minSize) => {
|
||||
const el = document.querySelector(".title");
|
||||
// Force the actual web font (this exact weight) to load before measuring,
|
||||
// else wrapping/height is computed against the fallback font.
|
||||
try {
|
||||
await document.fonts.load(`${weight} 80px "${family}"`);
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
/* best-effort font preload */
|
||||
}
|
||||
// The title wraps within its max-width. Shrink only if it would exceed
|
||||
// maxLines lines, so long names wrap to 2 lines instead of spilling off.
|
||||
let size = parseFloat(getComputedStyle(el).fontSize);
|
||||
while (size > minSize && el.offsetHeight > maxLines * lineH * size + 2) {
|
||||
size -= 1;
|
||||
el.style.fontSize = size + "px";
|
||||
}
|
||||
return { size, lines: Math.round(el.offsetHeight / (lineH * size)) };
|
||||
},
|
||||
theme.fontFamily,
|
||||
theme.titleWeight,
|
||||
1.06,
|
||||
2,
|
||||
40,
|
||||
);
|
||||
if (process.env.OG_DEBUG) console.log(" fit:", JSON.stringify(fit));
|
||||
await fs.mkdir(path.dirname(outFile), { recursive: true });
|
||||
await page.screenshot({ path: outFile, type: "png" });
|
||||
await page.close();
|
||||
return outFile;
|
||||
}
|
||||
|
||||
export async function closeBrowser() {
|
||||
if (_browser) await _browser.close();
|
||||
_browser = null;
|
||||
}
|
||||
|
||||
// ---- batch: generate cards for tools that currently have no bespoke art ----
|
||||
// material-symbols icon per tool (the white glyph shown in the card).
|
||||
const MISSING_TOOL_ICONS = {
|
||||
addText: "title",
|
||||
annotate: "draw",
|
||||
timestampPdf: "schedule",
|
||||
bookletImposition: "menu-book-outline",
|
||||
pdfTextEditor: "edit-document-outline",
|
||||
formFill: "ballot-outline",
|
||||
devApi: "api",
|
||||
devFolderScanning: "folder-open-outline",
|
||||
devSsoGuide: "key-outline",
|
||||
devAirgapped: "cloud-off-outline",
|
||||
};
|
||||
|
||||
const kebab = (id) => id.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||
|
||||
// English name/description live next to each tool as the `t(key, fallback)` default.
|
||||
function readRegistryStrings() {
|
||||
const src = require("node:fs").readFileSync(
|
||||
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const STR = '"((?:[^"\\\\]|\\\\.)*)"';
|
||||
const titles = {},
|
||||
descs = {};
|
||||
for (const m of src.matchAll(
|
||||
new RegExp('t\\(\\s*"home\\.([A-Za-z0-9_]+)\\.title"\\s*,\\s*' + STR, "g"),
|
||||
))
|
||||
titles[m[1]] = m[2];
|
||||
for (const m of src.matchAll(
|
||||
new RegExp('t\\(\\s*"home\\.([A-Za-z0-9_]+)\\.desc"\\s*,\\s*' + STR, "g"),
|
||||
))
|
||||
descs[m[1]] = m[2];
|
||||
return { titles, descs };
|
||||
}
|
||||
|
||||
export async function generateMissing(theme = THEME) {
|
||||
const { titles, descs } = readRegistryStrings();
|
||||
const results = [];
|
||||
for (const [id, icon] of Object.entries(MISSING_TOOL_ICONS)) {
|
||||
const out = path.join(ROOT, `public/og_images/${kebab(id)}.png`);
|
||||
await renderOgCard({
|
||||
name: titles[id] || id,
|
||||
description: descs[id] || "",
|
||||
icon,
|
||||
outFile: out,
|
||||
theme,
|
||||
});
|
||||
results.push(`${id} -> public/og_images/${kebab(id)}.png (${icon})`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Each tool's app icon lives as `icon="<material-symbol>"` just before its
|
||||
// `name: t("home.<id>.title", …)`. Pair each title with the closest preceding icon.
|
||||
function readRegistryIcons() {
|
||||
const src = require("node:fs").readFileSync(
|
||||
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const icons = [...src.matchAll(/icon="([^"]+)"/g)].map((m) => ({
|
||||
pos: m.index,
|
||||
name: m[1],
|
||||
}));
|
||||
const byId = {};
|
||||
for (const m of src.matchAll(
|
||||
/name:\s*t\(\s*"home\.([A-Za-z0-9_]+)\.title"/g,
|
||||
)) {
|
||||
let best = null;
|
||||
for (const ic of icons)
|
||||
if (ic.pos < m.index && (!best || ic.pos > best.pos)) best = ic;
|
||||
if (best) byId[m[1]] = best.name;
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
function iconExists(name) {
|
||||
if (!name) return false;
|
||||
try {
|
||||
const { getIconData } = require("@iconify/utils");
|
||||
return !!getIconData(
|
||||
require("@iconify-json/material-symbols/icons.json"),
|
||||
name,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// First candidate that resolves; also tries dropping a "-rounded" suffix.
|
||||
function firstResolvableIcon(candidates) {
|
||||
for (const c of candidates) {
|
||||
if (iconExists(c)) return c;
|
||||
const alt = c && c.replace(/-rounded$/, "");
|
||||
if (alt && alt !== c && iconExists(alt)) return alt;
|
||||
}
|
||||
return "description-outline";
|
||||
}
|
||||
|
||||
const humanizeId = (id) =>
|
||||
id
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
// Regenerate a card for EVERY tool that has an image, so the whole set is one
|
||||
// consistent style. Writes to each tool's existing filename (from ogImageMap).
|
||||
export async function generateAll(theme = THEME) {
|
||||
const { titles, descs } = readRegistryStrings();
|
||||
const regIcons = readRegistryIcons();
|
||||
const ogMap = JSON.parse(
|
||||
require("node:fs").readFileSync(
|
||||
path.join(ROOT, "src/core/data/ogImageMap.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const results = [];
|
||||
for (const [id, basename] of Object.entries(ogMap)) {
|
||||
const icon = firstResolvableIcon([regIcons[id], MISSING_TOOL_ICONS[id]]);
|
||||
await renderOgCard({
|
||||
name: titles[id] || humanizeId(id),
|
||||
description: descs[id] || "",
|
||||
icon,
|
||||
outFile: path.join(ROOT, `public/og_images/${basename}.png`),
|
||||
theme,
|
||||
});
|
||||
results.push(`${id.padEnd(20)} -> ${basename}.png (${icon})`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- CLI -------------------------------------------------------------------
|
||||
function parseArgs(argv) {
|
||||
const a = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i].startsWith("--")) {
|
||||
const k = argv[i].slice(2);
|
||||
const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true;
|
||||
a[k] = v;
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
if (
|
||||
import.meta.url === `file://${process.argv[1]}` ||
|
||||
process.argv[1]?.endsWith("generate-og-image.mjs")
|
||||
) {
|
||||
const a = parseArgs(process.argv.slice(2));
|
||||
const theme = { ...THEME };
|
||||
for (const [flag, key] of Object.entries({
|
||||
"bg-top": "bgTop",
|
||||
"bg-mid": "bgMid",
|
||||
"bg-bottom": "bgBottom",
|
||||
rotate: "cardRotate",
|
||||
"title-size": "titleSize",
|
||||
font: "fontFamily",
|
||||
})) {
|
||||
if (a[flag] != null) theme[key] = isNaN(+a[flag]) ? a[flag] : +a[flag];
|
||||
}
|
||||
const run = async () => {
|
||||
if (a.all) {
|
||||
const results = await generateAll(theme);
|
||||
console.log(
|
||||
`Regenerated ${results.length} tool cards:\n ` + results.join("\n "),
|
||||
);
|
||||
} else if (a.missing) {
|
||||
const results = await generateMissing(theme);
|
||||
console.log(
|
||||
"Generated cards for tools with no bespoke art:\n " +
|
||||
results.join("\n "),
|
||||
);
|
||||
} else if (a.name) {
|
||||
const out =
|
||||
a.out ||
|
||||
`public/og_images/${a.name.toLowerCase().replace(/\s+/g, "-")}.png`;
|
||||
await renderOgCard({
|
||||
name: a.name,
|
||||
description: a.desc || "",
|
||||
icon: a.icon,
|
||||
outFile: path.resolve(ROOT, out),
|
||||
theme,
|
||||
});
|
||||
console.log("wrote", out);
|
||||
} else {
|
||||
console.error(
|
||||
"Provide --missing, or --name (and --desc, --icon, --out). See header for usage.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await closeBrowser();
|
||||
};
|
||||
run().catch(async (e) => {
|
||||
console.error(e);
|
||||
await closeBrowser();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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)
|
||||
// public/og-metadata.json - { default, byTool, byPath } (read by the backend at startup)
|
||||
//
|
||||
// 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";
|
||||
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
// 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`;
|
||||
|
||||
for (const [routePath, label] of Object.entries(pageTitles)) {
|
||||
byTool[routePath] = {
|
||||
image: `/og_images/${DEFAULT_IMAGE_BASENAME}.png`,
|
||||
title: `${label} - ${SITE_NAME}`,
|
||||
description: SITE_DESC,
|
||||
};
|
||||
byPath[routePath] = routePath;
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
default: {
|
||||
image: `/og_images/${DEFAULT_IMAGE_BASENAME}.png`,
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESC,
|
||||
},
|
||||
byTool,
|
||||
byPath,
|
||||
};
|
||||
|
||||
const mapJson = JSON.stringify(ogImageMap, null, 2) + "\n";
|
||||
const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
|
||||
const mapPath = "src/core/data/ogImageMap.json";
|
||||
const manifestPath = "public/og-metadata.json";
|
||||
|
||||
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);
|
||||
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 {
|
||||
fs.writeFileSync(path.join(ROOT, mapPath), mapJson);
|
||||
fs.writeFileSync(path.join(ROOT, manifestPath), manifestJson);
|
||||
console.log(
|
||||
`Wrote ${mapPath} (${Object.keys(ogImageMap).length} tools with art)`,
|
||||
);
|
||||
console.log(`Wrote ${manifestPath} (${Object.keys(byPath).length} paths)`);
|
||||
}
|
||||
|
||||
// --- 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(", ")}`,
|
||||
);
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,32 @@
|
||||
// Type declarations for og-prerender.mjs (plain ESM build helper).
|
||||
|
||||
export interface OgEntry {
|
||||
image: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OgInjectOptions {
|
||||
ogBase?: string;
|
||||
pageUrlPath?: string | null;
|
||||
}
|
||||
|
||||
export interface OgManifest {
|
||||
default: OgEntry;
|
||||
byTool: Record<string, OgEntry>;
|
||||
byPath: Record<string, string>;
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string;
|
||||
export function buildOgTags(entry: OgEntry, opts?: OgInjectOptions): string;
|
||||
export function injectOg(
|
||||
html: string,
|
||||
entry: OgEntry,
|
||||
opts?: OgInjectOptions,
|
||||
): string;
|
||||
export function prerenderOg(args: {
|
||||
distDir: string;
|
||||
manifest: OgManifest;
|
||||
ogBase?: string;
|
||||
baseHref?: string;
|
||||
}): Promise<number>;
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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.
|
||||
// Used by the `prerender-og` Vite plugin (see vite.config.ts).
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const escapeHtml = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
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
|
||||
*/
|
||||
export function buildOgTags(entry, { ogBase = "", pageUrlPath = null } = {}) {
|
||||
const title = escapeHtml(entry.title);
|
||||
const description = escapeHtml(entry.description);
|
||||
const imageUrl = absolute(entry.image, ogBase);
|
||||
const image = escapeHtml(imageUrl);
|
||||
const pageUrl = pageUrlPath
|
||||
? escapeHtml(absolute(pageUrlPath, ogBase))
|
||||
: null;
|
||||
const lines = [
|
||||
"<!-- og:start -->",
|
||||
'<meta property="og:type" content="website" />',
|
||||
'<meta property="og:site_name" content="Stirling PDF" />',
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${description}" />`,
|
||||
pageUrl ? `<meta property="og:url" content="${pageUrl}" />` : null,
|
||||
`<meta property="og:image" content="${image}" />`,
|
||||
imageUrl.startsWith("https")
|
||||
? `<meta property="og:image:secure_url" content="${image}" />`
|
||||
: null,
|
||||
'<meta property="og:image:type" content="image/png" />',
|
||||
'<meta property="og:image:width" content="1200" />',
|
||||
'<meta property="og:image:height" content="630" />',
|
||||
'<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 ";
|
||||
}
|
||||
|
||||
/** Inject route-specific <title>, description and OG/Twitter tags into an HTML shell. */
|
||||
export function injectOg(html, entry, opts = {}) {
|
||||
return html
|
||||
.replace(
|
||||
/<title>[\s\S]*?<\/title>/i,
|
||||
() => `<title>${escapeHtml(entry.title)}</title>`,
|
||||
)
|
||||
.replace(
|
||||
/<meta\s+name=["']description["'][\s\S]*?>/i,
|
||||
() =>
|
||||
`<meta name="description" content="${escapeHtml(entry.description)}" />`,
|
||||
)
|
||||
.replace("</head>", ` ${buildOgTags(entry, opts)}</head>`);
|
||||
}
|
||||
|
||||
const BASE_HREF_RE = /<base\s+href="[^"]*"\s*\/?>/i;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* for multi-segment ones (e.g. dist/settings/people.html). Returns the count of
|
||||
* route pages written.
|
||||
*
|
||||
* `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.
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function prerenderOg({
|
||||
distDir,
|
||||
manifest,
|
||||
ogBase = "",
|
||||
baseHref = "/",
|
||||
}) {
|
||||
const template = await fs.readFile(path.join(distDir, "index.html"), "utf8");
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(distDir, "index.html"),
|
||||
injectOg(template, manifest.default, {
|
||||
ogBase,
|
||||
pageUrlPath: ogBase ? "/" : null,
|
||||
}),
|
||||
);
|
||||
|
||||
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 entry = manifest.byTool[id] ?? manifest.default;
|
||||
let html = injectOg(template, entry, {
|
||||
ogBase,
|
||||
pageUrlPath: ogBase ? routePath : null,
|
||||
});
|
||||
const nested = segments.length > 1;
|
||||
if (nested)
|
||||
html = html.replace(BASE_HREF_RE, `<base href="${baseHref}" />`);
|
||||
const outFile = path.join(distDir, ...segments) + ".html";
|
||||
if (nested) await fs.mkdir(path.dirname(outFile), { recursive: true });
|
||||
await fs.writeFile(outFile, html);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ogImageMap from "@app/data/ogImageMap.json";
|
||||
|
||||
// Generated by scripts/generate-og-metadata.mjs from the tool registry +
|
||||
// public/og_images. Maps a tool id to its OG image basename; tools with no
|
||||
// bespoke art are absent and fall back to the default below.
|
||||
const TOOL_OG_IMAGES = ogImageMap as Record<string, string>;
|
||||
const DEFAULT_OG_IMAGE = "home";
|
||||
|
||||
/**
|
||||
* Resolve the Open Graph image URL for a tool. Image filenames historically
|
||||
* follow URL slugs / legacy v1 names rather than the camelCase tool id, so the
|
||||
* basename must come from the generated map, not from the tool id directly.
|
||||
*/
|
||||
export const getToolOgImage = (
|
||||
baseUrl: string,
|
||||
toolId: string | null,
|
||||
): string => {
|
||||
const basename = (toolId && TOOL_OG_IMAGES[toolId]) || DEFAULT_OG_IMAGE;
|
||||
return `${baseUrl}/og_images/${basename}.png`;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"certSign": "certSign",
|
||||
"sign": "sign",
|
||||
"addText": "add-text",
|
||||
"addPassword": "add-password",
|
||||
"removePassword": "remove-password",
|
||||
"removePages": "remove",
|
||||
"removeBlanks": "remove-blank-pages",
|
||||
"removeAnnotations": "remove-annotations",
|
||||
"removeImage": "remove-image",
|
||||
"changePermissions": "change-permissions",
|
||||
"watermark": "add-watermark",
|
||||
"sanitize": "sanitize",
|
||||
"split": "split",
|
||||
"merge": "mergePdfs",
|
||||
"convert": "convert",
|
||||
"ocr": "ocr",
|
||||
"addImage": "add-image",
|
||||
"rotate": "rotate",
|
||||
"annotate": "annotate",
|
||||
"scannerImageSplit": "detect-split-scanned-photos",
|
||||
"editTableOfContents": "edit-table-of-contents",
|
||||
"scannerEffect": "scanner-effect",
|
||||
"autoRename": "auto-rename-pdf-file",
|
||||
"pageLayout": "multi-page-layout",
|
||||
"scalePages": "adjust-page-size-scale",
|
||||
"adjustContrast": "adjust-colors-contrast",
|
||||
"crop": "cropPdf",
|
||||
"pdfToSinglePage": "single-large-page",
|
||||
"repair": "repair",
|
||||
"compare": "compare",
|
||||
"addPageNumbers": "add-page-numbers",
|
||||
"redact": "redact",
|
||||
"flatten": "flatten",
|
||||
"removeCertSign": "remove-certificate-sign",
|
||||
"unlockPDFForms": "unlock-pdf-forms",
|
||||
"compress": "compress",
|
||||
"extractPages": "extract-pages",
|
||||
"reorganizePages": "reorganize-pages",
|
||||
"extractImages": "extract-images",
|
||||
"addStamp": "add-stamp",
|
||||
"addAttachments": "add-attachments",
|
||||
"changeMetadata": "change-metadata",
|
||||
"overlayPdfs": "overlay-pdfs",
|
||||
"getPdfInfo": "get-all-info-on-pdf",
|
||||
"validateSignature": "validate-pdf-signature",
|
||||
"timestampPdf": "timestamp-pdf",
|
||||
"replaceColor": "replace-and-invert-color",
|
||||
"showJS": "show-javascript",
|
||||
"bookletImposition": "booklet-imposition",
|
||||
"pdfTextEditor": "pdf-text-editor",
|
||||
"formFill": "form-fill",
|
||||
"multiTool": "multi-tool",
|
||||
"read": "read",
|
||||
"automate": "automate",
|
||||
"devApi": "dev-api",
|
||||
"devFolderScanning": "dev-folder-scanning",
|
||||
"devSsoGuide": "dev-sso-guide",
|
||||
"devAirgapped": "dev-airgapped"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 {
|
||||
buildOgTags,
|
||||
injectOg,
|
||||
prerenderOg,
|
||||
} from "../../../scripts/og-prerender.mjs";
|
||||
|
||||
const TEMPLATE = `<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<base href="./" />
|
||||
<title>Stirling PDF</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<script type="module" src="/assets/index-abc.js"></script>
|
||||
</head>
|
||||
<body><div id="root"></div></body>
|
||||
</html>`;
|
||||
|
||||
describe("getToolOgImage (client resolver)", () => {
|
||||
it("maps a tool id to its image (camelCase id, kebab filename)", () => {
|
||||
expect(getToolOgImage("", "compress")).toBe("/og_images/compress.png");
|
||||
expect(getToolOgImage("", "addPassword")).toBe(
|
||||
"/og_images/add-password.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps tools whose art uses a legacy v1 filename", () => {
|
||||
expect(getToolOgImage("", "merge")).toBe("/og_images/mergePdfs.png");
|
||||
expect(getToolOgImage("", "getPdfInfo")).toBe(
|
||||
"/og_images/get-all-info-on-pdf.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default image for an unknown tool id or null", () => {
|
||||
expect(getToolOgImage("", "noSuchToolId")).toBe("/og_images/home.png");
|
||||
expect(getToolOgImage("", null)).toBe("/og_images/home.png");
|
||||
});
|
||||
|
||||
it("prefixes the base url", () => {
|
||||
expect(getToolOgImage("https://x.test", "compress")).toBe(
|
||||
"https://x.test/og_images/compress.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectOg (build-time prerender)", () => {
|
||||
const entry = {
|
||||
image: "/og_images/compress.png",
|
||||
title: "Compress - Stirling PDF",
|
||||
description: "Compress PDFs to reduce their file size.",
|
||||
};
|
||||
|
||||
it("replaces title + description and injects exactly one of each", () => {
|
||||
const out = injectOg(TEMPLATE, entry, {});
|
||||
expect(out).toContain("<title>Compress - Stirling PDF</title>");
|
||||
expect(out).toContain(
|
||||
'<meta name="description" content="Compress PDFs to reduce their file size." />',
|
||||
);
|
||||
expect(out.match(/<title>/g)?.length).toBe(1);
|
||||
expect(out.match(/name="description"/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("uses root-relative URLs and omits og:url when no base is given", () => {
|
||||
const out = injectOg(TEMPLATE, entry, { ogBase: "", pageUrlPath: null });
|
||||
expect(out).toContain(
|
||||
'<meta property="og:image" content="/og_images/compress.png" />',
|
||||
);
|
||||
expect(out).not.toContain("og:url");
|
||||
expect(out).not.toContain("og:image:secure_url");
|
||||
expect(out).toContain('name="twitter:card" content="summary_large_image"');
|
||||
});
|
||||
|
||||
it("uses absolute URLs + og:url + secure_url when a canonical base is given", () => {
|
||||
const out = injectOg(TEMPLATE, entry, {
|
||||
ogBase: "https://stirlingpdf.com",
|
||||
pageUrlPath: "/compress",
|
||||
});
|
||||
expect(out).toContain(
|
||||
'<meta property="og:image" content="https://stirlingpdf.com/og_images/compress.png" />',
|
||||
);
|
||||
expect(out).toContain(
|
||||
'<meta property="og:url" content="https://stirlingpdf.com/compress" />',
|
||||
);
|
||||
expect(out).toContain("og:image:secure_url");
|
||||
// asset path stays absolute-from-root so it resolves at the clean URL
|
||||
expect(out).toContain('src="/assets/index-abc.js"');
|
||||
});
|
||||
|
||||
it("escapes HTML in metadata", () => {
|
||||
const tags = buildOgTags(
|
||||
{ image: "/x.png", title: 'A "B" & <C>', description: "d" },
|
||||
{},
|
||||
);
|
||||
expect(tags).toContain("A "B" & <C>");
|
||||
});
|
||||
});
|
||||
|
||||
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-"));
|
||||
await fs.writeFile(path.join(dir, "index.html"), TEMPLATE);
|
||||
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",
|
||||
},
|
||||
},
|
||||
byPath: {
|
||||
"/compress": "compress",
|
||||
"/settings/people": "/settings/people",
|
||||
},
|
||||
};
|
||||
|
||||
const count = await prerenderOg({
|
||||
distDir: dir,
|
||||
manifest,
|
||||
ogBase: "",
|
||||
baseHref: "/",
|
||||
});
|
||||
expect(count).toBe(2);
|
||||
|
||||
const flat = await fs.readFile(path.join(dir, "compress.html"), "utf8");
|
||||
expect(flat).toContain(
|
||||
'<meta property="og:image" content="/og_images/compress.png" />',
|
||||
);
|
||||
expect(flat).toContain('<base href="./"'); // flat keeps the build's relative base
|
||||
|
||||
const nested = await fs.readFile(
|
||||
path.join(dir, "settings", "people.html"),
|
||||
"utf8",
|
||||
);
|
||||
expect(nested).toContain("<title>People Settings - Stirling PDF</title>");
|
||||
expect(nested).toContain('<base href="/"'); // nested base rewritten to absolute
|
||||
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { Group } from "@mantine/core";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { getToolOgImage } from "@app/data/ogImage";
|
||||
import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
||||
import { useIsMobile, useIsTouch } from "@app/hooks/useIsMobile";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -294,9 +295,7 @@ export default function HomePage() {
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: selectedToolKey
|
||||
? `${baseUrl}/og_images/${selectedToolKey}.png`
|
||||
: `${baseUrl}/og_images/home.png`,
|
||||
ogImage: getToolOgImage(baseUrl, selectedToolKey),
|
||||
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl,
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,61 @@ function compressStaticCopyPlugin(): PluginOption {
|
||||
};
|
||||
}
|
||||
|
||||
// Bake per-route Open Graph / Twitter Card tags into static HTML at build time.
|
||||
//
|
||||
// The SPA sets these client-side for real browsers, but link-unfurling crawlers
|
||||
// (Slack, Facebook, X, LinkedIn, iMessage, ...) do not run JavaScript. Prerendering
|
||||
// flat per-route files (e.g. dist/compress.html) means every static host - Cloudflare
|
||||
// Pages, Docker's bundled static dir, desktop - serves correct previews with NO
|
||||
// server-side rendering. Cloudflare Pages serves `compress.html` at `/compress`
|
||||
// automatically (clean URLs), and the Spring backend serves the same file.
|
||||
//
|
||||
// Absolute URLs (best for Facebook/X) are used when a canonical base is known:
|
||||
// VITE_OG_BASE_URL (custom domain) or CF_PAGES_URL (set automatically by Cloudflare
|
||||
// Pages). Otherwise URLs stay root-relative, which still resolves against whatever
|
||||
// origin serves the page (correct for self-hosted Docker). Logic lives in
|
||||
// scripts/og-prerender.mjs so it can be unit-tested without a full build.
|
||||
function prerenderOgPlugin(): PluginOption {
|
||||
return {
|
||||
name: "prerender-og",
|
||||
apply: "build" as const,
|
||||
async closeBundle() {
|
||||
const { prerenderOg } = await import("./scripts/og-prerender.mjs");
|
||||
const ogBase = (
|
||||
process.env.VITE_OG_BASE_URL ||
|
||||
process.env.CF_PAGES_URL ||
|
||||
""
|
||||
).replace(/\/+$/, "");
|
||||
// Absolute deploy base for nested routes' <base href> (matches vite `base`).
|
||||
const subpath = (process.env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, "");
|
||||
const baseHref = subpath ? `/${subpath}/` : "/";
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.resolve(__dirname, "public/og-metadata.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
console.warn(
|
||||
"[prerender-og] public/og-metadata.json missing; skipping OG prerender. " +
|
||||
"Run `node scripts/generate-og-metadata.mjs`.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const distDir = path.resolve(__dirname, "dist");
|
||||
const count = await prerenderOg({ distDir, manifest, ogBase, baseHref });
|
||||
console.log(
|
||||
`[prerender-og] wrote ${count} prerendered route pages` +
|
||||
(ogBase
|
||||
? ` (absolute URLs, base=${ogBase})`
|
||||
: " (root-relative URLs)"),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: cloud/ is a SHARED layer, not a runnable build flavor — it's compiled
|
||||
// into the saas and desktop builds. It has no entry here and no vite tsconfig;
|
||||
// it is only typechecked standalone via editor/src/cloud/tsconfig.json
|
||||
@@ -219,6 +274,7 @@ export default defineConfig(async ({ mode }) => {
|
||||
],
|
||||
}),
|
||||
compressStaticCopyPlugin(),
|
||||
prerenderOgPlugin(),
|
||||
],
|
||||
resolve: {
|
||||
// Global alias so @shared resolves for ALL importers — including the
|
||||
|
||||