From 7293be92d0b894ccad67cf28d67711bb26108bfd Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sun, 3 May 2026 16:25:30 +0800 Subject: [PATCH] fix: normalize production site urls --- CHANGELOG.md | 10 ++++++++++ README_EN.md | 1 + docs/cloudflare-pages-build-settings.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/site-url.mjs | 18 ++++++++++++++++-- scripts/smoke-build.mjs | 19 ++++++++++++++++--- src/utils/cms-config.ts | 20 ++++++++++++++++---- src/utils/seo.ts | 24 +++++++++++++++++++++++- 9 files changed, 86 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 393b507..774a4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.3.5] - 2026-05-03 + +### Changed +- Bumped the package version from `1.3.4` to `1.3.5` for production Git build URL hardening. +- Normalized site URL inputs so missing `https://` is treated as HTTPS instead of breaking static route generation. + +### Fixed +- Prevented invalid `PUBLIC_SITE_URL` values from crashing Open Graph and canonical URL generation during Cloudflare Pages production builds. +- Kept CMS config and smoke checks aligned with the same URL normalization rules. + ## [1.3.4] - 2026-05-03 ### Added diff --git a/README_EN.md b/README_EN.md index f77fbcc..ec28b0a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -228,6 +228,7 @@ venue: "Conference Name" - `1.3.2`: CMS stability patch that adds stable legacy news slugs, removes CMS test residue, and tightens news slug validation - `1.3.3`: build-log cleanup that clears Astro content cache before builds to remove duplicate news id warnings - `1.3.4`: Cloudflare Pages Git build stability patch that pins Node.js and records the Pages output directory in repo config +- `1.3.5`: production Git build hardening that normalizes malformed site URL variables before static rendering ## License diff --git a/docs/cloudflare-pages-build-settings.md b/docs/cloudflare-pages-build-settings.md index ef0598c..7a60823 100644 --- a/docs/cloudflare-pages-build-settings.md +++ b/docs/cloudflare-pages-build-settings.md @@ -18,7 +18,7 @@ The repository pins the Node.js version in `.node-version`, and the Pages projec - `CMS_OAUTH_BASE_URL=https://doubleducklab-cms-oauth..workers.dev` - `PUBLIC_SITE_URL=https://doubleducklab.pages.dev` -`NODE_VERSION` is a build-environment pin, not a CMS secret. It is listed with Pages secrets only because Wrangler manages Pages variables through `wrangler pages secret`. +`PUBLIC_SITE_URL` must be an absolute site URL. The build now tolerates a missing `https://` prefix, but the stored Pages value should still include the full `https://` URL. `NODE_VERSION` is a build-environment pin, not a CMS secret. It is listed with Pages secrets only because Wrangler manages Pages variables through `wrangler pages secret`. ## Verification diff --git a/package-lock.json b/package-lock.json index 72afbcb..9fe319b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "double-duck-lab", - "version": "1.3.4", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "double-duck-lab", - "version": "1.3.4", + "version": "1.3.5", "devDependencies": { "astro": "^5.13.0", "vite": "^6.4.1" diff --git a/package.json b/package.json index b02cc5d..458bb55 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "double-duck-lab", "type": "module", - "version": "1.3.4", + "version": "1.3.5", "private": true, "scripts": { "dev": "astro dev", diff --git a/scripts/site-url.mjs b/scripts/site-url.mjs index a468568..d97b3da 100644 --- a/scripts/site-url.mjs +++ b/scripts/site-url.mjs @@ -3,8 +3,22 @@ import { loadEnv } from 'vite'; export const DEFAULT_SITE_URL = 'https://doubleducklab.com/'; export function normalizeSiteUrl(value) { - const site = (value || DEFAULT_SITE_URL).trim(); - return site.endsWith('/') ? site : `${site}/`; + const raw = `${value || DEFAULT_SITE_URL}`.trim().replace(/^['"]|['"]$/g, ''); + const candidate = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw) ? raw : `https://${raw}`; + + try { + const url = new URL(candidate); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported site URL protocol: ${url.protocol}`); + } + + url.search = ''; + url.hash = ''; + const site = url.toString(); + return site.endsWith('/') ? site : `${site}/`; + } catch { + return DEFAULT_SITE_URL; + } } export function resolveConfiguredSiteUrl(mode = 'production') { diff --git a/scripts/smoke-build.mjs b/scripts/smoke-build.mjs index 48c7513..d5ab128 100644 --- a/scripts/smoke-build.mjs +++ b/scripts/smoke-build.mjs @@ -8,11 +8,24 @@ const publicSiteUrl = (process.env.PUBLIC_SITE_URL || '').trim(); const cmsBranch = (process.env.CMS_BRANCH || 'main').trim() || 'main'; function normalizeUrl(value) { - if (!value) { + const raw = (value || '').trim().replace(/^['"]|['"]$/g, ''); + if (!raw) { return ''; } - return value.replace(/\/$/, ''); + const candidate = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw) ? raw : `https://${raw}`; + try { + const url = new URL(candidate); + if (!['http:', 'https:'].includes(url.protocol)) { + return ''; + } + + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return ''; + } } function getUrlHostname(value) { @@ -21,7 +34,7 @@ function getUrlHostname(value) { } try { - return new URL(`${value}/`).hostname; + return new URL(value).hostname; } catch { return ''; } diff --git a/src/utils/cms-config.ts b/src/utils/cms-config.ts index 0cdde0e..724fa40 100644 --- a/src/utils/cms-config.ts +++ b/src/utils/cms-config.ts @@ -16,17 +16,29 @@ export type CmsRuntimeConfig = { }; function normalizeUrl(value: string | undefined, fallback = '') { - const normalized = (value || fallback).trim(); - if (!normalized) { + const raw = (value || fallback).trim().replace(/^['"]|['"]$/g, ''); + if (!raw) { return ''; } - return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; + const candidate = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw) ? raw : `https://${raw}`; + try { + const url = new URL(candidate); + if (!['http:', 'https:'].includes(url.protocol)) { + return ''; + } + + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + return ''; + } } function getUrlHostname(value: string) { try { - return new URL(`${value}/`).hostname; + return new URL(value).hostname; } catch { return ''; } diff --git a/src/utils/seo.ts b/src/utils/seo.ts index f46fd33..9106242 100644 --- a/src/utils/seo.ts +++ b/src/utils/seo.ts @@ -53,10 +53,32 @@ export function buildDefaultLocaleAlternates(pathname: string): LocaleAlternates } export function toAbsoluteUrl(pathname: string, site: URL | string | undefined) { - const siteUrl = site instanceof URL ? site : new URL(site || DEFAULT_SITE_URL); + const siteUrl = normalizeSiteUrl(site); return new URL(pathname, siteUrl).toString(); } +export function normalizeSiteUrl(site: URL | string | undefined) { + if (site instanceof URL) { + return site; + } + + const raw = `${site || DEFAULT_SITE_URL}`.trim().replace(/^['"]|['"]$/g, ''); + const candidate = /^[a-z][a-z\d+\-.]*:\/\//i.test(raw) ? raw : `https://${raw}`; + + try { + const url = new URL(candidate); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error(`Unsupported site URL protocol: ${url.protocol}`); + } + + url.search = ''; + url.hash = ''; + return url; + } catch { + return new URL(DEFAULT_SITE_URL); + } +} + export function summarizeText(value: string | undefined, maxLength = 160) { const plain = (value || '') .replace(/```[\s\S]*?```/g, ' ')