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
7 changes: 7 additions & 0 deletions .github/workflows/hugo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ jobs:
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-node@v6
with:
node-version: '22.6'

- name: Install dependencies
run: npm ci

- name: Setup Hugo
uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # 3.0.0
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: '22'
node-version: '22.6'

- name: Get cached dependencies
uses: actions/cache@v4
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ resources/

# Precious install
local/

# Cloudflare Pages headers
static/_headers
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v22
v22.6.0
File renamed without changes.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ hugo server
npm run lint
```

#### Cloudflare Pages HTTP Headers Configuration

The `static/_headers` file is automatically generated from
`bin/_headers.config.ts`. **Do not edit `static/_headers` directly**.

##### Making Changes to Headers

1. Edit `bin/_headers.config.ts` (the source of truth with readable format and
TypeScript type safety)
2. Test your changes locally:
```sh
npm run build:headers
```
3. Commit only `bin/_headers.config.ts` - the headers file is regenerated during
deployment

##### Build-Time Generation

The `static/_headers` file is generated during the build process via `build.sh`
and is not committed to git. For local testing, you can manually generate it
with `npm run build:headers`.

## Writing Blog Posts

Blog posts are written using
Expand Down
203 changes: 203 additions & 0 deletions bin/_headers.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* HTTP Headers Configuration for Cloudflare Pages
* This file is the source of truth for static/_headers generation
* Run: npm run build:headers
*/

interface HeadersConfig {
paths: Array<{
pattern: string;
headers: Record<string, string[] | Record<string, string[]>>;
}>;
}

const config: HeadersConfig = {
paths: [
{
pattern: '/*',
headers: {
'Content-Security-Policy': {
'connect-src': [
"'self'",
'https://status.maxmind.com',
'https://www.maxmind.com',

// eslint-disable-next-line max-len
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy

// HubSpot API
'https://api.hubspot.com',

// HubSpot static assets (conversations embed)
'https://forms.hsforms.com',

'https://*.googleapis.com',

// eslint-disable-next-line max-len
// https://developers.google.com/tag-platform/security/guides/csp#google_analytics_4_google_analytics
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://*.googletagmanager.com',

// https://developers.google.com/tag-platform/security/guides/csp#google_ads
'https://*.g.doubleclick.net',

// Google domains (various TLDs for international support)
'https://*.google.com',
],
'default-src': [
"'self'",
],
'font-src': [
"'self'",

// Loaded indirectly by Google Vertex search
'https://fonts.gstatic.com',
],
'form-action': [
"'self'",
],
'frame-ancestors': [
"'self'",
],
'frame-src': [
"'self'",

// eslint-disable-next-line max-len
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy

// HubSpot calls-to-action (pop-ups) and chatflows
'https://app.hubspot.com',

// https://developers.google.com/tag-platform/security/guides/csp#google_ads
'https://www.googletagmanager.com',

// Google Vertex search
'https://www.google.com',
],
'img-src': [
"'self'",
'data:',
'https:',
],
'object-src': [
"'none'",
],
'script-src': [
"'self'",
"'report-sample'",
"'unsafe-inline'",

// eslint-disable-next-line max-len
// https://knowledge.hubspot.com/domains-and-urls/ssl-and-domain-security-in-hubspot#content-security-policy

// HubSpot tracking code
'https://js.hs-scripts.com',

// HubSpot Analytics
'https://js.hs-analytics.net',

// HubSpot cookie banner
'https://js.hs-banner.com',

// HubSpot Conversations and Chatflows
'https://js.usemessages.com',

// HubSpot forms
'https://js.hsforms.net',

// MaxMind marketing site
'https://www.maxmind.com',

// Google Vertex search
'https://cloud.google.com',
'https://www.gstatic.com',

// https://developers.google.com/tag-platform/security/guides/csp#google_ads_conversions
'https://www.googleadservices.com',
'https://www.google.com',

// Google Tag Manager
'https://*.googletagmanager.com',
],
'style-src': [
"'self'",
"'unsafe-inline'",

// Google Fonts API and Vertex search default styles
'https://fonts.googleapis.com',

// Google static assets
'https://www.gstatic.com',
],
},
'Feature-Policy': [
"accelerometer 'none'",
"autoplay 'none'",
"camera 'none'",
"encrypted-media 'none'",
"fullscreen 'none'",
"geolocation 'none'",
"gyroscope 'none'",
"magnetometer 'none'",
"microphone 'none'",
"midi 'none'",
"payment 'none'",
"picture-in-picture 'none'",
"usb 'none'",
"sync-xhr 'none'",
],
'Permissions-Policy': [
'accelerometer=()',
'ambient-light-sensor=()',
'autoplay=()',
'battery=()',
'camera=()',
'display-capture=()',
'document-domain=()',
'encrypted-media=()',
'execution-while-not-rendered=()',
'execution-while-out-of-viewport=()',
'fullscreen=()',
'gamepad=()',
'geolocation=()',
'gyroscope=()',
'hid=()',
'idle-detection=()',
'magnetometer=()',
'microphone=()',
'midi=()',
'payment=()',
'picture-in-picture=()',
'publickey-credentials-get=()',
'screen-wake-lock=()',
'serial=()',
'speaker-selection=()',
'usb=()',
'web-share=()',
'xr-spatial-tracking=()',
],
'Referrer-Policy': [
'strict-origin-when-cross-origin',
],
'Strict-Transport-Security': [
'max-age=63072000',
'includeSubDomains',
'preload',
],
'X-Content-Type-Options': [
'nosniff',
],
'X-Frame-Options': [
'DENY',
],
'X-XSS-Protection': [
'1',
'mode=block',
],
},
},
],
};

export default config;
84 changes: 84 additions & 0 deletions bin/generate-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Generate static/_headers file from bin/_headers.config.ts
*
* This script converts a structured TypeScript configuration into the
* Cloudflare Pages _headers format.
*/

import * as fs from 'fs';
import * as path from 'path';

import config from './_headers.config.ts';

interface PathConfig {
pattern: string;
headers: Record<string, string[] | Record<string, string[]>>;
}

/**
* Generate _headers file content from config
*/
function generateHeaders(config: { paths: PathConfig[] }): string {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not generating the expected output. It's not outputting the proper delimiters (ie ; or ,) for non-CSP values.

let output = '';

// Add warning comment at the top
output += '# ⚠️ DO NOT EDIT THIS FILE DIRECTLY!\n';
output += '# This file is automatically generated from bin/_headers.config.ts\n';
output += '# To make changes, edit bin/_headers.config.ts and run: npm run build:headers\n';
output += '#\n';
output += '# See README.md for more information\n';
output += '\n';

for (const pathConfig of config.paths) {
// Write path pattern
output += pathConfig.pattern + '\n';

// Process all headers
for (const [
header,
value,
] of Object.entries(pathConfig.headers)) {
// Handle Content-Security-Policy (nested object)
if (header === 'Content-Security-Policy') {
const csp = value as Record<string, string[]>;
const directives: string[] = [];

for (const [
directive,
sources,
] of Object.entries(csp)) {
directives.push(`${directive} ${sources.join(' ')}`);
}

output += ` Content-Security-Policy: ${directives.join('; ')}\n`;
continue;
}

// Handle all other headers (arrays)
const values = value as string[];
const separator = header === 'Permissions-Policy' ? ', ' : '; ';
output += ` ${header}: ${values.join(separator)}\n`;
}
}

return output;
}

// Main execution
try {
const outputPath = path.join(process.cwd(), 'static', '_headers');

// Generate headers file
const headersContent = generateHeaders(config);

// Write output file
fs.writeFileSync(outputPath, headersContent);

console.log('✅ Generated static/_headers from bin/_headers.config.ts');
} catch (error) {
console.error(
'Error generating headers file:',
error instanceof Error ? error.message : String(error)
);
process.exit(1);
}
3 changes: 3 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ if [[ "${BUILD_ENV:-}" == "preview" ]]; then
extra_args+=("--buildFuture")
fi

# Generate _headers file from TypeScript configuration
npm run build:headers

hugo --gc --minify -b "$CF_PAGES_URL" "${extra_args[@]}"
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export default tseslint.config(
quotes: [
'warn',
'single',
{
avoidEscape: true,
},
],
semi: [
1,
Expand Down
Loading