From 3c605eef56384957d5b3dd21da3a090a8a7bb586 Mon Sep 17 00:00:00 2001 From: David Cumps Date: Wed, 21 Oct 2026 19:02:45 +0000 Subject: [PATCH 1/4] fix(seo): honour SITE_URL for blog canonicals (#933) A build with a custom SITE_URL failed seo-audit on the blog pages: generate-blog.mjs hard-coded https://www.bentopdf.com, and the hand-written blog/author-alam.html carries that origin literally while generate-i18n-pages.mjs only rewrites top-level pages. generate-blog.mjs now reads SITE_URL like its sibling scripts, and enhance-seo-pages.mjs rebases the default origin in dist/blog/*.html to SITE_URL (dist only, no-op for the default URL) through a small side-effect-free helper, scripts/site-origin.mjs, covered by src/tests/site-origin.test.ts. --- scripts/enhance-seo-pages.mjs | 30 ++++++++++++++++++++----- scripts/generate-blog.mjs | 3 ++- scripts/site-origin.mjs | 19 ++++++++++++++++ src/tests/site-origin.test.ts | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 scripts/site-origin.mjs create mode 100644 src/tests/site-origin.test.ts diff --git a/scripts/enhance-seo-pages.mjs b/scripts/enhance-seo-pages.mjs index 2d3ca4f1..0dc4e1db 100644 --- a/scripts/enhance-seo-pages.mjs +++ b/scripts/enhance-seo-pages.mjs @@ -3,15 +3,14 @@ import path from 'path'; import { JSDOM } from 'jsdom'; import { fileURLToPath } from 'url'; import { serializeJsonLd } from './json-ld.mjs'; +import { DEFAULT_SITE_URL, rebaseDefaultOrigin } from './site-origin.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DIST_DIR = path.resolve(__dirname, '../dist'); -const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace( - /\/+$/, - '' -); +const SITE_URL = (process.env.SITE_URL || DEFAULT_SITE_URL).replace(/\/+$/, ''); +const BLOG_DIST_DIR = path.join(DIST_DIR, 'blog'); const FALLBACK_SOCIAL_IMAGE = '/images/og-tools.png'; @@ -22,6 +21,26 @@ function listTopLevelHtml() { .map((f) => path.join(DIST_DIR, f)); } +// generate-blog.mjs writes the blog index and posts with SITE_URL, but +// hand-written blog pages (blog/author-alam.html) carry the official origin +// literally, and generate-i18n-pages.mjs only rewrites top-level pages. Rebase +// them here so a custom-SITE_URL build passes seo-audit's canonical check. +function rebaseBlogPages() { + if (!fs.existsSync(BLOG_DIST_DIR)) return 0; + let rebased = 0; + for (const f of fs.readdirSync(BLOG_DIST_DIR)) { + if (!f.endsWith('.html')) continue; + const file = path.join(BLOG_DIST_DIR, f); + const html = fs.readFileSync(file, 'utf-8'); + const out = rebaseDefaultOrigin(html, SITE_URL); + if (out !== html) { + fs.writeFileSync(file, out); + rebased++; + } + } + return rebased; +} + function imageExists(url) { if (!url) return true; let pathname; @@ -159,8 +178,9 @@ function enhance() { fs.writeFileSync(file, dom.serialize()); dom.window.close(); } + const rebasedBlogPages = rebaseBlogPages(); console.log( - `SEO enhance: ${files.length} pages processed, ${faqSchemaCount} FAQPage schemas added.` + `SEO enhance: ${files.length} pages processed, ${faqSchemaCount} FAQPage schemas added, ${rebasedBlogPages} blog pages rebased to ${SITE_URL}.` ); } diff --git a/scripts/generate-blog.mjs b/scripts/generate-blog.mjs index 2474787e..0655135f 100644 --- a/scripts/generate-blog.mjs +++ b/scripts/generate-blog.mjs @@ -3,13 +3,14 @@ import path from 'path'; import { marked } from 'marked'; import { fileURLToPath } from 'url'; import { serializeJsonLd } from './json-ld.mjs'; +import { DEFAULT_SITE_URL } from './site-origin.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const POSTS_DIR = path.resolve(__dirname, '../blog/posts'); const BLOG_DIR = path.resolve(__dirname, '../blog'); -const SITE_URL = 'https://www.bentopdf.com'; +const SITE_URL = (process.env.SITE_URL || DEFAULT_SITE_URL).replace(/\/+$/, ''); const AUTHOR = { name: 'Alam', url: `${SITE_URL}/blog/author-alam`, diff --git a/scripts/site-origin.mjs b/scripts/site-origin.mjs new file mode 100644 index 00000000..920bdef8 --- /dev/null +++ b/scripts/site-origin.mjs @@ -0,0 +1,19 @@ +/** + * The public origin the build writes into canonical / og:url / JSON-LD URLs. + * Self-hosters set SITE_URL at build time; the default is the official site. + */ +export const DEFAULT_SITE_URL = 'https://www.bentopdf.com'; + +const DEFAULT_ORIGIN_RE = /https:\/\/www\.bentopdf\.com(?![\w.-])/g; + +/** + * Rewrites absolute URLs on the default origin to `siteUrl` (no trailing + * slash). Used for hand-written pages that carry the official origin + * literally, so a build with a custom SITE_URL emits canonicals on its own + * host. Other hosts, including look-alikes such as + * `https://www.bentopdf.com.example`, are left untouched. + */ +export function rebaseDefaultOrigin(html, siteUrl) { + if (siteUrl === DEFAULT_SITE_URL) return html; + return html.replace(DEFAULT_ORIGIN_RE, siteUrl); +} diff --git a/src/tests/site-origin.test.ts b/src/tests/site-origin.test.ts new file mode 100644 index 00000000..3ff994f4 --- /dev/null +++ b/src/tests/site-origin.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_SITE_URL, + rebaseDefaultOrigin, +} from '../../scripts/site-origin.mjs'; + +const PAGE = ` + +`; + +describe('rebaseDefaultOrigin', () => { + it('rewrites every default-origin URL to the custom site URL', () => { + const out = rebaseDefaultOrigin(PAGE, 'https://pdf.example.org'); + expect(out).not.toContain('www.bentopdf.com'); + expect(out).toContain('href="https://pdf.example.org/blog/author-alam"'); + expect(out).toContain( + 'content="https://pdf.example.org/images/author-alam.jpg"' + ); + expect(out).toContain('"url":"https://pdf.example.org"'); + expect(out).toContain('https://github.com/alam00000'); + }); + + it('keeps a subpath site URL intact', () => { + expect( + rebaseDefaultOrigin( + 'href="https://www.bentopdf.com/blog/"', + 'https://example.org/pdf' + ) + ).toBe('href="https://example.org/pdf/blog/"'); + }); + + it('leaves look-alike hosts alone', () => { + const html = + 'href="https://www.bentopdf.com.example/x" href="https://www.bentopdf.community/"'; + expect(rebaseDefaultOrigin(html, 'https://pdf.example.org')).toBe(html); + }); + + it('is a no-op for the default site URL', () => { + expect(rebaseDefaultOrigin(PAGE, DEFAULT_SITE_URL)).toBe(PAGE); + }); +}); From 2b56c451778a5af04c480b6e7b02fbea98376308 Mon Sep 17 00:00:00 2001 From: David Cumps Date: Wed, 21 Oct 2026 22:09:07 +0000 Subject: [PATCH 2/4] fix(seo): rebase only the exact default authority, insert SITE_URL literally Review follow-up: the origin regex also matched a port (https://www.bentopdf.com:8443) and userinfo (https://www.bentopdf.com@other.example), which are different origins; the lookahead now excludes ':' and '@'. The replacement is a callback, so `$&`/`$1` in SITE_URL stay literal. Tests cover both. --- scripts/site-origin.mjs | 12 ++++++++---- src/tests/site-origin.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/site-origin.mjs b/scripts/site-origin.mjs index 920bdef8..ae5e9a5b 100644 --- a/scripts/site-origin.mjs +++ b/scripts/site-origin.mjs @@ -4,16 +4,20 @@ */ export const DEFAULT_SITE_URL = 'https://www.bentopdf.com'; -const DEFAULT_ORIGIN_RE = /https:\/\/www\.bentopdf\.com(?![\w.-])/g; +// The exact authority only: not a longer host (`.com.example`), a port +// (`:8443`, a different origin) or userinfo (`@other.example`, another host). +const DEFAULT_ORIGIN_RE = /https:\/\/www\.bentopdf\.com(?![\w.:@-])/g; /** * Rewrites absolute URLs on the default origin to `siteUrl` (no trailing * slash). Used for hand-written pages that carry the official origin * literally, so a build with a custom SITE_URL emits canonicals on its own - * host. Other hosts, including look-alikes such as - * `https://www.bentopdf.com.example`, are left untouched. + * host. Other origins are left untouched: look-alike hosts such as + * `https://www.bentopdf.com.example`, other ports and userinfo URLs. */ export function rebaseDefaultOrigin(html, siteUrl) { if (siteUrl === DEFAULT_SITE_URL) return html; - return html.replace(DEFAULT_ORIGIN_RE, siteUrl); + // A callback keeps `siteUrl` literal: a string replacement would expand + // `$&`, `$1`, … inside it. + return html.replace(DEFAULT_ORIGIN_RE, () => siteUrl); } diff --git a/src/tests/site-origin.test.ts b/src/tests/site-origin.test.ts index 3ff994f4..16a7e4e7 100644 --- a/src/tests/site-origin.test.ts +++ b/src/tests/site-origin.test.ts @@ -35,6 +35,21 @@ describe('rebaseDefaultOrigin', () => { expect(rebaseDefaultOrigin(html, 'https://pdf.example.org')).toBe(html); }); + it('leaves other ports and userinfo URLs alone', () => { + const html = + 'href="https://www.bentopdf.com:8443/blog/" href="https://www.bentopdf.com@other.example/"'; + expect(rebaseDefaultOrigin(html, 'https://pdf.example.org')).toBe(html); + }); + + it('inserts the site URL literally', () => { + expect( + rebaseDefaultOrigin( + 'href="https://www.bentopdf.com/blog/"', + 'https://example.org/$&' + ) + ).toBe('href="https://example.org/$&/blog/"'); + }); + it('is a no-op for the default site URL', () => { expect(rebaseDefaultOrigin(PAGE, DEFAULT_SITE_URL)).toBe(PAGE); }); From cd59d087d3c3ea9b7e24093b236272a1472ddd54 Mon Sep 17 00:00:00 2001 From: David Cumps Date: Fri, 23 Oct 2026 13:54:39 +0000 Subject: [PATCH 3/4] revert(seo): drop the dist/blog origin rebase Per maintainer review: the blog is BentoPDF's own content, so self-hosted builds keep its canonicals on www.bentopdf.com. --- scripts/enhance-seo-pages.mjs | 30 ++++--------------- scripts/generate-blog.mjs | 3 +- scripts/site-origin.mjs | 23 -------------- src/tests/site-origin.test.ts | 56 ----------------------------------- 4 files changed, 6 insertions(+), 106 deletions(-) delete mode 100644 scripts/site-origin.mjs delete mode 100644 src/tests/site-origin.test.ts diff --git a/scripts/enhance-seo-pages.mjs b/scripts/enhance-seo-pages.mjs index 0dc4e1db..2d3ca4f1 100644 --- a/scripts/enhance-seo-pages.mjs +++ b/scripts/enhance-seo-pages.mjs @@ -3,14 +3,15 @@ import path from 'path'; import { JSDOM } from 'jsdom'; import { fileURLToPath } from 'url'; import { serializeJsonLd } from './json-ld.mjs'; -import { DEFAULT_SITE_URL, rebaseDefaultOrigin } from './site-origin.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DIST_DIR = path.resolve(__dirname, '../dist'); -const SITE_URL = (process.env.SITE_URL || DEFAULT_SITE_URL).replace(/\/+$/, ''); -const BLOG_DIST_DIR = path.join(DIST_DIR, 'blog'); +const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace( + /\/+$/, + '' +); const FALLBACK_SOCIAL_IMAGE = '/images/og-tools.png'; @@ -21,26 +22,6 @@ function listTopLevelHtml() { .map((f) => path.join(DIST_DIR, f)); } -// generate-blog.mjs writes the blog index and posts with SITE_URL, but -// hand-written blog pages (blog/author-alam.html) carry the official origin -// literally, and generate-i18n-pages.mjs only rewrites top-level pages. Rebase -// them here so a custom-SITE_URL build passes seo-audit's canonical check. -function rebaseBlogPages() { - if (!fs.existsSync(BLOG_DIST_DIR)) return 0; - let rebased = 0; - for (const f of fs.readdirSync(BLOG_DIST_DIR)) { - if (!f.endsWith('.html')) continue; - const file = path.join(BLOG_DIST_DIR, f); - const html = fs.readFileSync(file, 'utf-8'); - const out = rebaseDefaultOrigin(html, SITE_URL); - if (out !== html) { - fs.writeFileSync(file, out); - rebased++; - } - } - return rebased; -} - function imageExists(url) { if (!url) return true; let pathname; @@ -178,9 +159,8 @@ function enhance() { fs.writeFileSync(file, dom.serialize()); dom.window.close(); } - const rebasedBlogPages = rebaseBlogPages(); console.log( - `SEO enhance: ${files.length} pages processed, ${faqSchemaCount} FAQPage schemas added, ${rebasedBlogPages} blog pages rebased to ${SITE_URL}.` + `SEO enhance: ${files.length} pages processed, ${faqSchemaCount} FAQPage schemas added.` ); } diff --git a/scripts/generate-blog.mjs b/scripts/generate-blog.mjs index 0655135f..2474787e 100644 --- a/scripts/generate-blog.mjs +++ b/scripts/generate-blog.mjs @@ -3,14 +3,13 @@ import path from 'path'; import { marked } from 'marked'; import { fileURLToPath } from 'url'; import { serializeJsonLd } from './json-ld.mjs'; -import { DEFAULT_SITE_URL } from './site-origin.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const POSTS_DIR = path.resolve(__dirname, '../blog/posts'); const BLOG_DIR = path.resolve(__dirname, '../blog'); -const SITE_URL = (process.env.SITE_URL || DEFAULT_SITE_URL).replace(/\/+$/, ''); +const SITE_URL = 'https://www.bentopdf.com'; const AUTHOR = { name: 'Alam', url: `${SITE_URL}/blog/author-alam`, diff --git a/scripts/site-origin.mjs b/scripts/site-origin.mjs deleted file mode 100644 index ae5e9a5b..00000000 --- a/scripts/site-origin.mjs +++ /dev/null @@ -1,23 +0,0 @@ -/** - * The public origin the build writes into canonical / og:url / JSON-LD URLs. - * Self-hosters set SITE_URL at build time; the default is the official site. - */ -export const DEFAULT_SITE_URL = 'https://www.bentopdf.com'; - -// The exact authority only: not a longer host (`.com.example`), a port -// (`:8443`, a different origin) or userinfo (`@other.example`, another host). -const DEFAULT_ORIGIN_RE = /https:\/\/www\.bentopdf\.com(?![\w.:@-])/g; - -/** - * Rewrites absolute URLs on the default origin to `siteUrl` (no trailing - * slash). Used for hand-written pages that carry the official origin - * literally, so a build with a custom SITE_URL emits canonicals on its own - * host. Other origins are left untouched: look-alike hosts such as - * `https://www.bentopdf.com.example`, other ports and userinfo URLs. - */ -export function rebaseDefaultOrigin(html, siteUrl) { - if (siteUrl === DEFAULT_SITE_URL) return html; - // A callback keeps `siteUrl` literal: a string replacement would expand - // `$&`, `$1`, … inside it. - return html.replace(DEFAULT_ORIGIN_RE, () => siteUrl); -} diff --git a/src/tests/site-origin.test.ts b/src/tests/site-origin.test.ts deleted file mode 100644 index 16a7e4e7..00000000 --- a/src/tests/site-origin.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - DEFAULT_SITE_URL, - rebaseDefaultOrigin, -} from '../../scripts/site-origin.mjs'; - -const PAGE = ` - -`; - -describe('rebaseDefaultOrigin', () => { - it('rewrites every default-origin URL to the custom site URL', () => { - const out = rebaseDefaultOrigin(PAGE, 'https://pdf.example.org'); - expect(out).not.toContain('www.bentopdf.com'); - expect(out).toContain('href="https://pdf.example.org/blog/author-alam"'); - expect(out).toContain( - 'content="https://pdf.example.org/images/author-alam.jpg"' - ); - expect(out).toContain('"url":"https://pdf.example.org"'); - expect(out).toContain('https://github.com/alam00000'); - }); - - it('keeps a subpath site URL intact', () => { - expect( - rebaseDefaultOrigin( - 'href="https://www.bentopdf.com/blog/"', - 'https://example.org/pdf' - ) - ).toBe('href="https://example.org/pdf/blog/"'); - }); - - it('leaves look-alike hosts alone', () => { - const html = - 'href="https://www.bentopdf.com.example/x" href="https://www.bentopdf.community/"'; - expect(rebaseDefaultOrigin(html, 'https://pdf.example.org')).toBe(html); - }); - - it('leaves other ports and userinfo URLs alone', () => { - const html = - 'href="https://www.bentopdf.com:8443/blog/" href="https://www.bentopdf.com@other.example/"'; - expect(rebaseDefaultOrigin(html, 'https://pdf.example.org')).toBe(html); - }); - - it('inserts the site URL literally', () => { - expect( - rebaseDefaultOrigin( - 'href="https://www.bentopdf.com/blog/"', - 'https://example.org/$&' - ) - ).toBe('href="https://example.org/$&/blog/"'); - }); - - it('is a no-op for the default site URL', () => { - expect(rebaseDefaultOrigin(PAGE, DEFAULT_SITE_URL)).toBe(PAGE); - }); -}); From 4d3468c06f843437be15ea85b6fc1f75ee2d90cf Mon Sep 17 00:00:00 2001 From: David Cumps Date: Fri, 23 Oct 2026 13:58:14 +0000 Subject: [PATCH 4/4] fix(seo): accept official blog canonicals under a custom SITE_URL (#933) The blog is BentoPDF's own content, so generate-blog.mjs and the hand-written blog pages keep their canonical on www.bentopdf.com in self-hosted builds too. seo-audit.mjs now expects that official canonical (and host) for blog/ pages instead of failing every custom-SITE_URL build. --- scripts/seo-audit.mjs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/seo-audit.mjs b/scripts/seo-audit.mjs index 68ab7212..ebcab8fa 100644 --- a/scripts/seo-audit.mjs +++ b/scripts/seo-audit.mjs @@ -7,12 +7,17 @@ const __dirname = path.dirname(__filename); const DIST_DIR = path.resolve(__dirname, '../dist'); const LOCALES_DIR = path.resolve(__dirname, '../public/locales'); -const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace( +// The blog is BentoPDF's own content, so generate-blog.mjs and the hand-written +// blog pages keep their canonicals on the official site, also in self-hosted +// builds with a custom SITE_URL or BASE_URL. +const OFFICIAL_SITE_URL = 'https://www.bentopdf.com'; +const SITE_URL = (process.env.SITE_URL || OFFICIAL_SITE_URL).replace( /\/+$/, '' ); const BASE_PATH = (process.env.BASE_URL || '/').replace(/\/$/, ''); const HOST = new URL(SITE_URL).hostname; +const OFFICIAL_HOST = new URL(OFFICIAL_SITE_URL).hostname; const NOINDEX_ALLOWLIST = new Set(['404.html', 'wasm-settings.html']); const SKIP_DIRS = new Set([ @@ -90,18 +95,23 @@ function hasTrailingSlash(href) { return pathname !== '/' && pathname.endsWith('/'); } +function isBlogPage(rel) { + return rel.startsWith('blog/'); +} + function expectedCanonicalForFile(rel) { const parts = rel.split('/'); const fileName = parts.pop(); const baseName = fileName.replace(/\.html$/, ''); const slug = baseName === 'index' ? '' : baseName; + if (isBlogPage(rel)) { + return `${OFFICIAL_SITE_URL}/blog${slug ? `/${slug}` : ''}`; + } const translationKey = KEY_MAPPING[baseName] || toCamelCase(baseName); const segments = [SITE_URL]; if (BASE_PATH) segments.push(BASE_PATH.replace(/^\//, '')); for (const dir of parts) { - if (dir === 'blog') { - segments.push(dir); - } else if (LOCALE_DIRS.has(dir)) { + if (LOCALE_DIRS.has(dir)) { const translated = baseName === 'index' || Boolean(toolsByLang[dir] && toolsByLang[dir][translationKey]); @@ -177,10 +187,11 @@ function auditHtml(file, knownSlugs) { `${file.rel}: canonical "${actual}" ends with "/" (the server redirects it to the slashless form)` ); } - if (url.hostname !== HOST) { + const expectedHost = isBlogPage(file.rel) ? OFFICIAL_HOST : HOST; + if (url.hostname !== expectedHost) { warn( 'canonical', - `${file.rel}: canonical host "${url.hostname}" != expected "${HOST}"` + `${file.rel}: canonical host "${url.hostname}" != expected "${expectedHost}"` ); } } catch {