Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/cloudflare-pages-build-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<account>.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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "double-duck-lab",
"type": "module",
"version": "1.3.4",
"version": "1.3.5",
"private": true,
"scripts": {
"dev": "astro dev",
Expand Down
18 changes: 16 additions & 2 deletions scripts/site-url.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
19 changes: 16 additions & 3 deletions scripts/smoke-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Comment on lines +19 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate CMS_OAUTH_BASE_URL on normalized validity

normalizeUrl now returns an empty string for malformed OAuth URLs, but getExpectedMissingCmsVars() still treats CMS_OAUTH_BASE_URL as present when the raw env var is non-empty. In builds where CMS_OAUTH_BASE_URL is set to an invalid URL (for example http:// or a malformed quoted value), src/utils/cms-config.ts disables CMS while this smoke script still marks CMS as configured, so the smoke assertions check the wrong admin output and fail for a misconfiguration this patch intended to normalize consistently.

Useful? React with 👍 / 👎.

}

url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
} catch {
return '';
}
}

function getUrlHostname(value) {
Expand All @@ -21,7 +34,7 @@ function getUrlHostname(value) {
}

try {
return new URL(`${value}/`).hostname;
return new URL(value).hostname;
} catch {
return '';
}
Expand Down
20 changes: 16 additions & 4 deletions src/utils/cms-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}
Expand Down
24 changes: 23 additions & 1 deletion src/utils/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' ')
Expand Down
Loading