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.
This commit is contained in:
David Cumps
2026-10-21 19:02:45 +00:00
parent 4a7dc7675b
commit 3c605eef56
4 changed files with 87 additions and 6 deletions
+25 -5
View File
@@ -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}.`
);
}
+2 -1
View File
@@ -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`,
+19
View File
@@ -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);
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_SITE_URL,
rebaseDefaultOrigin,
} from '../../scripts/site-origin.mjs';
const PAGE = `<link rel="canonical" href="https://www.bentopdf.com/blog/author-alam" />
<meta property="og:image" content="https://www.bentopdf.com/images/author-alam.jpg" />
<script type="application/ld+json">{"url":"https://www.bentopdf.com","sameAs":["https://github.com/alam00000"]}</script>`;
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);
});
});