Fix OG metadata drift, dev-tool indexing and the /processor price-0 Offer

This commit is contained in:
Anthony Stirling
2026-08-30 10:43:00 +01:00
parent 4834bf4dbd
commit 17d38f0317
7 changed files with 100 additions and 15 deletions
+13 -5
View File
@@ -272,7 +272,7 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Form Editor - Stirling PDF",
"title": "Fill PDF Forms - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
@@ -293,22 +293,26 @@
"devApi": {
"image": "/og_images/dev-api.png",
"title": "API - Stirling PDF",
"description": "Link to API documentation"
"description": "Link to API documentation",
"noindex": true
},
"devFolderScanning": {
"image": "/og_images/dev-folder-scanning.png",
"title": "Automated Folder Scanning - Stirling PDF",
"description": "Link to automated folder scanning guide"
"description": "Link to automated folder scanning guide",
"noindex": true
},
"devSsoGuide": {
"image": "/og_images/dev-sso-guide.png",
"title": "SSO Guide - Stirling PDF",
"description": "Link to SSO guide"
"description": "Link to SSO guide",
"noindex": true
},
"devAirgapped": {
"image": "/og_images/dev-airgapped.png",
"title": "Air-gapped Setup - Stirling PDF",
"description": "Link to air-gapped setup guide"
"description": "Link to air-gapped setup guide",
"noindex": true
},
"/editor": {
"image": "/og_images/home.png",
@@ -933,6 +937,10 @@
"path": "/change-permissions",
"label": "Change PDF Permissions"
},
{
"path": "/classify",
"label": "Classify"
},
{
"path": "/single-large-page",
"label": "Combine PDF into a Single Page"
+15 -6
View File
@@ -273,7 +273,7 @@
},
"formFill": {
"image": "/og_images/form-fill.png",
"title": "Form Editor - Stirling PDF",
"title": "Fill PDF Forms - Stirling PDF",
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"multiTool": {
@@ -294,22 +294,26 @@
"devApi": {
"image": "/og_images/dev-api.png",
"title": "API - Stirling PDF",
"description": "Link to API documentation"
"description": "Link to API documentation",
"noindex": true
},
"devFolderScanning": {
"image": "/og_images/dev-folder-scanning.png",
"title": "Automated Folder Scanning - Stirling PDF",
"description": "Link to automated folder scanning guide"
"description": "Link to automated folder scanning guide",
"noindex": true
},
"devSsoGuide": {
"image": "/og_images/dev-sso-guide.png",
"title": "SSO Guide - Stirling PDF",
"description": "Link to SSO guide"
"description": "Link to SSO guide",
"noindex": true
},
"devAirgapped": {
"image": "/og_images/dev-airgapped.png",
"title": "Air-gapped Setup - Stirling PDF",
"description": "Link to air-gapped setup guide"
"description": "Link to air-gapped setup guide",
"noindex": true
},
"/editor": {
"image": "/og_images/saas/app-editor.png",
@@ -692,7 +696,8 @@
"image": "/og_images/saas/app-processor.png",
"title": "Stirling Processor - Govern every PDF your organization touches",
"ogTitle": "Govern every PDF your organization touches",
"description": "Redaction, retention, and encryption policies enforced everywhere PDFs enter your org. Distribute the free Editor anywhere. 1¢ per PDF."
"description": "Redaction, retention, and encryption policies enforced everywhere PDFs enter your org. Distribute the free Editor anywhere. 1¢ per PDF.",
"noOffer": true
}
},
"byPath": {
@@ -948,6 +953,10 @@
"path": "/change-permissions",
"label": "Change PDF Permissions"
},
{
"path": "/classify",
"label": "Classify"
},
{
"path": "/single-large-page",
"label": "Combine PDF into a Single Page"
@@ -216,6 +216,8 @@ const SAAS_ROUTE_OVERRIDES = {
ogTitle: "Govern every PDF your organization touches",
description:
"Redaction, retention, and encryption policies enforced everywhere PDFs enter your org. Distribute the free Editor anywhere. 1¢ per PDF.",
// Metered product - a price-0 Offer would contradict the page copy.
noOffer: true,
},
"/editor": {
image: "/og_images/saas/app-editor.png",
@@ -322,6 +324,8 @@ for (const id of allIds) {
image: `/og_images/${img || DEFAULT_IMAGE_BASENAME}.png`,
title: titleFor(id),
description: descFor(id),
// Link tools are external redirects with no component, not landing pages.
...(linkIds.includes(id) ? { noindex: true } : {}),
};
}
@@ -5,6 +5,8 @@ export interface OgEntry {
title: string;
description: string;
noindex?: boolean;
/** Omit the price-0 Offer from JSON-LD (metered/paid surfaces). */
noOffer?: boolean;
/** Punchier social-card title; falls back to `title` for the <title> tag. */
ogTitle?: string;
}
+8 -3
View File
@@ -79,9 +79,9 @@ export function buildCanonicalTag(canonicalUrl) {
/**
* Build a JSON-LD structured-data block. Home gets WebSite + Organization;
* tool pages get a free WebApplication plus a Home > Tool breadcrumb. Needs an
* tool pages get a WebApplication plus a Home > Tool breadcrumb. Needs an
* absolute origin, so callers only invoke it when a canonical base is known.
* @param {{title:string,description:string}} entry
* @param {{title:string,description:string,noOffer?:boolean}} entry
* @param {{siteRoot:string, pageUrl:string, isHome:boolean}} opts
*/
export function buildJsonLd(entry, { siteRoot, pageUrl, isHome }) {
@@ -115,7 +115,12 @@ export function buildJsonLd(entry, { siteRoot, pageUrl, isHome }) {
applicationCategory: "BusinessApplication",
operatingSystem: "All",
browserRequirements: "Requires JavaScript. Requires HTML5.",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
// Manifest entries for metered products opt out (see noOffer).
...(entry.noOffer
? {}
: {
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
}),
isPartOf: { "@type": "WebSite", name: SITE_NAME, url: siteRoot },
publisher: organization,
},
@@ -1,12 +1,15 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getToolOgImage } from "@app/data/ogImage";
import { CORE_LINK_TOOL_IDS } from "@app/types/toolId";
// Build tooling (plain ESM, node:fs only) - import the helpers for coverage.
// oxlint-disable-next-line no-restricted-imports -- build script lives outside the @app alias root
import {
buildBodyContent,
buildJsonLd,
buildOgTags,
buildSitemap,
injectBody,
@@ -181,6 +184,18 @@ describe("injectOg SEO extras (robots, canonical, JSON-LD)", () => {
expect(out).toContain('"@type":"BreadcrumbList"');
});
it("omits the price-0 Offer when the entry opts out (metered surfaces)", () => {
const opts = {
siteRoot: "https://stirling.com/",
pageUrl: "https://stirling.com/processor",
isHome: false,
};
expect(buildJsonLd(entry, opts)).toContain('"price":"0"');
expect(buildJsonLd({ ...entry, noOffer: true }, opts)).not.toContain(
'"offers"',
);
});
it("escapes '<' inside JSON-LD so a value cannot close the script early", () => {
const out = injectOg(
TEMPLATE,
@@ -564,3 +579,45 @@ describe("prerenderOg (flat + nested route files)", () => {
await fs.rm(dir, { recursive: true, force: true });
});
});
// Contract over the committed generator output, which the fixture-based suites
// above cannot see. Regenerate with `node scripts/generate-og-metadata.mjs`.
describe("shipped OG manifests", () => {
// Read import.meta.url via a variable: inlined, vite rewrites the
// `new URL(..., import.meta.url)` asset pattern and the path resolves wrong.
const here = import.meta.url;
const load = async (name: string) =>
JSON.parse(
await fs.readFile(
fileURLToPath(new URL(`../../../public/${name}`, here)),
"utf8",
),
);
it.each(["og-metadata.json", "og-metadata.saas.json"])(
"%s marks every link tool noindex and keeps it out of the sitemap",
async (name) => {
const manifest = await load(name);
const linkIds: readonly string[] = CORE_LINK_TOOL_IDS;
for (const id of linkIds)
expect(manifest.byTool[id]?.noindex, id).toBe(true);
const linkPaths = Object.entries(manifest.byPath)
.filter(([, id]) => linkIds.includes(id as string))
.map(([routePath]) => routePath);
expect(linkPaths.length).toBeGreaterThanOrEqual(linkIds.length);
const xml = buildSitemap(manifest, { ogBase: "https://stirling.com" });
for (const routePath of linkPaths)
expect(xml).not.toContain(
`<loc>https://stirling.com${routePath}</loc>`,
);
},
);
it("opts the metered /processor out of the price-0 Offer, not /editor", async () => {
const manifest = await load("og-metadata.saas.json");
expect(manifest.byTool["/processor"].noOffer).toBe(true);
expect(manifest.byTool["/editor"].noOffer).toBeUndefined();
});
});
@@ -337,7 +337,7 @@
},
"/form-fill": {
"title": "Fill PDF Forms",
"description": "Fill PDF form fields interactively with a visual editor"
"description": "Fill, create, edit, and delete PDF form fields with a visual editor"
},
"/multi-tool": {
"title": "PDF Page Editor",