Skip to content

Commit c3c7976

Browse files
committed
Clean up automated header generation
1 parent c0807ce commit c3c7976

File tree

17 files changed

+291
-807
lines changed

17 files changed

+291
-807
lines changed

.github/workflows/hugo.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ jobs:
1919
fetch-depth: 0
2020
persist-credentials: false
2121

22+
- uses: actions/setup-node@v6
23+
with:
24+
node-version: '22.6'
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
2229
- name: Setup Hugo
2330
uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # 3.0.0
2431
with:

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141

4242
- uses: actions/setup-node@v6
4343
with:
44-
node-version: '22'
44+
node-version: '22.6'
4545

4646
- name: Get cached dependencies
4747
uses: actions/cache@v4

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ resources/
77

88
# Precious install
99
local/
10+
11+
# Cloudflare Pages headers
12+
static/_headers

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v22
1+
v22.6.0
File renamed without changes.

README.md

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -101,37 +101,27 @@ hugo server
101101
npm run lint
102102
```
103103

104-
#### HTTP Headers Configuration
104+
#### Cloudflare Pages HTTP Headers Configuration
105105

106106
The `static/_headers` file is automatically generated from
107-
`scripts/_headers.config.ts`. **Do not edit `static/_headers` directly** - your
108-
changes will be overwritten.
107+
`bin/_headers.config.ts`. **Do not edit `static/_headers` directly**.
109108

110109
##### Making Changes to Headers
111110

112-
1. Edit `scripts/_headers.config.ts` (the source of truth with readable format
111+
1. Edit `bin/_headers.config.ts` (the source of truth with readable format
113112
and TypeScript type safety)
114-
2. Generate the headers file:
113+
2. Test your changes locally:
115114
```sh
116115
npm run build:headers
117116
```
118-
3. Commit both files - a git pre-commit hook will automatically regenerate
119-
`static/_headers` when you modify `scripts/_headers.config.ts`
117+
3. Commit only `bin/_headers.config.ts` - the headers file is regenerated during
118+
deployment
120119

121-
##### Automatic Synchronization
120+
##### Build-Time Generation
122121

123-
The files stay in sync automatically through:
124-
125-
- **Pre-commit hook** - Regenerates `_headers` when `_headers.config.ts` changes
126-
- **Build process** - `build.sh` regenerates before deployment
127-
- **Manual generation** - Run `npm run build:headers` anytime
128-
129-
The TypeScript config format provides:
130-
131-
- Readable multi-line arrays for CSP directives
132-
- Native TypeScript type safety and IDE support
133-
- Easy-to-review diffs in pull requests
134-
- Zero dependencies for parsing
122+
The `static/_headers` file is generated during the build process via `build.sh`
123+
and is not committed to git. For local testing, you can manually generate it
124+
with `npm run build:headers`.
135125

136126
## Writing Blog Posts
137127

bin/_headers.config.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* HTTP Headers Configuration for Cloudflare Pages
3+
* This file is the source of truth for static/_headers generation
4+
* Run: npm run build:headers
5+
*/
6+
7+
interface HeadersConfig {
8+
paths: Array<{
9+
pattern: string;
10+
headers: Record<string, string[] | Record<string, string[]>>;
11+
}>;
12+
}
13+
14+
const config: HeadersConfig = {
15+
paths: [
16+
{
17+
pattern: '/*',
18+
headers: {
19+
'Content-Security-Policy': {
20+
// Allow AJAX/fetch requests to status page, marketing site, HubSpot, and Google services
21+
'connect-src': [
22+
'\'self\'',
23+
'https://status.maxmind.com',
24+
'https://www.maxmind.com',
25+
// HubSpot API endpoint
26+
// https://legacydocs.hubspot.com/docs/faq/how-do-i-create-a-custom-domain-for-my-forms
27+
'https://api.hubspot.com',
28+
// HubSpot static assets used by conversations embed
29+
// eslint-disable-next-line max-len
30+
// https://developers.hubspot.com/beta-docs/guides/apps/authentication/working-with-oauth#frequently-asked-questions
31+
'https://forms.hsforms.com',
32+
'https://*.googleapis.com',
33+
'https://*.google-analytics.com',
34+
'https://*.analytics.google.com',
35+
'https://*.googletagmanager.com',
36+
'https://*.g.doubleclick.net',
37+
// Google
38+
// eslint-disable-next-line max-len
39+
// https://developers.google.com/tag-platform/tag-manager/csp#google_analytics_4_google_analytics
40+
'https://*.google.com',
41+
],
42+
'default-src': [
43+
'\'self\'',
44+
],
45+
// Google Fonts and Vertex search (indirectly loaded when setting up the searchbox)
46+
'font-src': [
47+
'\'self\'',
48+
'https://fonts.gstatic.com',
49+
],
50+
'form-action': [
51+
'\'self\'',
52+
],
53+
'frame-ancestors': [
54+
'\'self\'',
55+
],
56+
// HubSpot calls-to-action (pop-ups) and chatflows
57+
// eslint-disable-next-line max-len
58+
// https://knowledge.hubspot.com/website-pages/use-hubspot-content-on-external-sites#calls-to-action
59+
// Google Vertex search
60+
'frame-src': [
61+
'\'self\'',
62+
'https://app.hubspot.com',
63+
'https://www.google.com',
64+
'https://www.googletagmanager.com',
65+
],
66+
'img-src': [
67+
'\'self\'',
68+
'data:',
69+
'https:',
70+
],
71+
'object-src': [
72+
'\'none\'',
73+
],
74+
'script-src': [
75+
'\'self\'',
76+
'\'report-sample\'',
77+
'\'unsafe-inline\'',
78+
// HubSpot tracking code
79+
// eslint-disable-next-line max-len
80+
// https://developers.hubspot.com/beta-docs/guides/api/tracking-code-api/tracking-code-quickstart-guide#frequently-asked-questions
81+
'https://js.hs-scripts.com',
82+
// HubSpot analytics
83+
// https://knowledge.hubspot.com/reports/install-the-hubspot-tracking-code
84+
'https://js.hs-analytics.net',
85+
// HubSpot cookie banner
86+
// https://knowledge.hubspot.com/privacy-and-consent/add-a-cookie-banner-to-your-website
87+
'https://js.hs-banner.com',
88+
// HubSpot conversations (live chat widget, chat flow)
89+
// https://knowledge.hubspot.com/chatflows/install-the-hubspot-tracking-code-for-chat
90+
'https://js.usemessages.com',
91+
// HubSpot form widgets
92+
// https://legacydocs.hubspot.com/docs/methods/forms/advanced_form_options
93+
'https://js.hsforms.net',
94+
'https://www.maxmind.com',
95+
// Google
96+
// eslint-disable-next-line max-len
97+
// https://developers.google.com/tag-platform/tag-manager/csp#google_analytics_4_google_analytics
98+
'https://cloud.google.com',
99+
'https://www.gstatic.com',
100+
'https://www.googleadservices.com',
101+
'https://www.google.com',
102+
'https://*.googletagmanager.com',
103+
],
104+
// Google Fonts API and Vertex search
105+
// Google static assets
106+
'style-src': [
107+
'\'self\'',
108+
'\'unsafe-inline\'',
109+
'https://fonts.googleapis.com',
110+
'https://www.gstatic.com',
111+
],
112+
},
113+
'Feature-Policy': [
114+
'accelerometer \'none\'',
115+
'autoplay \'none\'',
116+
'camera \'none\'',
117+
'encrypted-media \'none\'',
118+
'fullscreen \'none\'',
119+
'geolocation \'none\'',
120+
'gyroscope \'none\'',
121+
'magnetometer \'none\'',
122+
'microphone \'none\'',
123+
'midi \'none\'',
124+
'payment \'none\'',
125+
'picture-in-picture \'none\'',
126+
'usb \'none\'',
127+
'sync-xhr \'none\'',
128+
],
129+
'Permissions-Policy': [
130+
'accelerometer=()',
131+
'ambient-light-sensor=()',
132+
'autoplay=()',
133+
'battery=()',
134+
'camera=()',
135+
'display-capture=()',
136+
'document-domain=()',
137+
'encrypted-media=()',
138+
'execution-while-not-rendered=()',
139+
'execution-while-out-of-viewport=()',
140+
'fullscreen=()',
141+
'gamepad=()',
142+
'geolocation=()',
143+
'gyroscope=()',
144+
'hid=()',
145+
'idle-detection=()',
146+
'magnetometer=()',
147+
'microphone=()',
148+
'midi=()',
149+
'payment=()',
150+
'picture-in-picture=()',
151+
'publickey-credentials-get=()',
152+
'screen-wake-lock=()',
153+
'serial=()',
154+
'speaker-selection=()',
155+
'usb=()',
156+
'web-share=()',
157+
'xr-spatial-tracking=()',
158+
],
159+
'Referrer-Policy': [
160+
'strict-origin-when-cross-origin',
161+
],
162+
'Strict-Transport-Security': [
163+
'max-age=63072000',
164+
'includeSubDomains',
165+
'preload',
166+
],
167+
'X-Content-Type-Options': [
168+
'nosniff',
169+
],
170+
'X-Frame-Options': [
171+
'DENY',
172+
],
173+
'X-XSS-Protection': [
174+
'1',
175+
'mode=block',
176+
],
177+
},
178+
},
179+
],
180+
};
181+
182+
export default config;

bin/generate-headers.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Generate static/_headers file from bin/_headers.config.ts
3+
*
4+
* This script converts a structured TypeScript configuration into the
5+
* Cloudflare Pages _headers format.
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
11+
import config from './_headers.config.ts';
12+
13+
interface PathConfig {
14+
pattern: string;
15+
headers: Record<string, string[] | Record<string, string[]>>;
16+
}
17+
18+
/**
19+
* Generate _headers file content from config
20+
*/
21+
function generateHeaders(config: { paths: PathConfig[] }): string {
22+
let output = '';
23+
24+
// Add warning comment at the top
25+
output += '# ⚠️ DO NOT EDIT THIS FILE DIRECTLY!\n';
26+
output += '# This file is automatically generated from bin/_headers.config.ts\n';
27+
output += '# To make changes, edit bin/_headers.config.ts and run: npm run build:headers\n';
28+
output += '#\n';
29+
output += '# See README.md for more information\n';
30+
output += '\n';
31+
32+
for (const pathConfig of config.paths) {
33+
// Write path pattern
34+
output += pathConfig.pattern + '\n';
35+
36+
// Process all headers
37+
for (const [
38+
header,
39+
value,
40+
] of Object.entries(pathConfig.headers)) {
41+
if (typeof value === 'object' && !Array.isArray(value)) {
42+
// CSP-style header with directives
43+
const directives: string[] = [];
44+
for (const [
45+
directive,
46+
sources,
47+
] of Object.entries(value)) {
48+
directives.push(`${directive} ${sources.join(' ')}`);
49+
}
50+
output += ` ${header}: ${directives.join('; ')}\n`;
51+
} else {
52+
// Simple array-based header
53+
output += ` ${header}: ${value.join(' ')}\n`;
54+
}
55+
}
56+
}
57+
58+
return output;
59+
}
60+
61+
// Main execution
62+
try {
63+
const outputPath = path.join(process.cwd(), 'static', '_headers');
64+
65+
// Generate headers file
66+
const headersContent = generateHeaders(config);
67+
68+
// Write output file
69+
fs.writeFileSync(outputPath, headersContent);
70+
71+
console.log('✅ Generated static/_headers from bin/_headers.config.ts');
72+
} catch (error) {
73+
console.error(
74+
'Error generating headers file:',
75+
error instanceof Error ? error.message : String(error)
76+
);
77+
process.exit(1);
78+
}

build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ if [[ "${BUILD_ENV:-}" == "preview" ]]; then
99
fi
1010

1111
# Generate _headers file from TypeScript configuration
12-
npx tsx scripts/generate-headers.ts
12+
npm run build:headers
1313

1414
hugo --gc --minify -b "$CF_PAGES_URL" "${extra_args[@]}"

eslint.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ export default tseslint.config(
144144
quotes: [
145145
'warn',
146146
'single',
147+
{
148+
avoidEscape: true,
149+
},
147150
],
148151
semi: [
149152
1,

0 commit comments

Comments
 (0)