Skip to content

Commit 5940846

Browse files
gmoonclaude
andcommitted
Add SEO fixes: pre-render all routes, JSON-LD, CloudFront infra
- Extend pre-rendering to / and /getting-started with noscript fallbacks - Add JSON-LD structured data (Organization, WebSite, SoftwareApplication, BlogPosting, WebPage, BreadcrumbList) for all routes - Fix og:type: 'article' only for /blog/{slug}, 'website' for all others - Add CloudFront Function for clean URL → /index.html rewriting - Add CloudFront setup script with security response headers policy - Add 19 new tests covering homepage, getting-started, and JSON-LD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 825b02f commit 5940846

4 files changed

Lines changed: 543 additions & 23 deletions

File tree

infra/cloudfront-url-rewrite.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* CloudFront Function (viewer-request): rewrite clean URLs to /index.html.
3+
*
4+
* Converts paths like /blog/my-post → /blog/my-post/index.html so that
5+
* S3 REST API origin resolves pre-rendered HTML files.
6+
*
7+
* - Skips URIs that already have a file extension (.js, .css, .xml, etc.)
8+
* - Skips root "/" (handled by CloudFront Default Root Object)
9+
* - Non-pre-rendered routes still work: S3 returns 404 → CloudFront custom
10+
* error response serves the SPA shell (/index.html with 200).
11+
*
12+
* Runtime: cloudfront-js-2.0
13+
*/
14+
function handler(event) {
15+
var request = event.request;
16+
var uri = request.uri;
17+
18+
// Skip root — CloudFront Default Root Object handles it
19+
if (uri === '/') {
20+
return request;
21+
}
22+
23+
// Skip URIs that already have a file extension
24+
if (/\.\w+$/.test(uri)) {
25+
return request;
26+
}
27+
28+
// Strip trailing slash, then append /index.html
29+
if (uri.endsWith('/')) {
30+
uri = uri.slice(0, -1);
31+
}
32+
request.uri = uri + '/index.html';
33+
34+
return request;
35+
}

infra/setup-cloudfront.sh

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# ===========================================================================
5+
# One-time CloudFront setup script for forkzero.ai
6+
# Creates/updates: URL rewrite function + security response headers policy
7+
#
8+
# Prerequisites:
9+
# - AWS CLI v2 configured with appropriate credentials
10+
# - jq installed
11+
#
12+
# Usage:
13+
# chmod +x infra/setup-cloudfront.sh
14+
# ./infra/setup-cloudfront.sh
15+
#
16+
# After running, manually associate the function and headers policy with
17+
# your CloudFront distribution's default cache behavior.
18+
# ===========================================================================
19+
20+
FUNCTION_NAME="forkzero-url-rewrite"
21+
FUNCTION_FILE="$(dirname "$0")/cloudfront-url-rewrite.js"
22+
HEADERS_POLICY_NAME="forkzero-security-headers"
23+
24+
# ---- CloudFront Function: URL rewrite ----
25+
echo "==> Creating/updating CloudFront Function: ${FUNCTION_NAME}"
26+
27+
# Check if the function already exists
28+
EXISTING=$(aws cloudfront list-functions --query "FunctionList.Items[?Name=='${FUNCTION_NAME}'].Name" --output text 2>/dev/null || true)
29+
30+
if [ -z "${EXISTING}" ]; then
31+
echo " Creating new function..."
32+
aws cloudfront create-function \
33+
--name "${FUNCTION_NAME}" \
34+
--function-config '{"Comment":"Rewrite clean URLs to /index.html for S3 origin","Runtime":"cloudfront-js-2.0"}' \
35+
--function-code "fileb://${FUNCTION_FILE}"
36+
else
37+
echo " Updating existing function..."
38+
ETAG=$(aws cloudfront describe-function --name "${FUNCTION_NAME}" --query 'ETag' --output text)
39+
aws cloudfront update-function \
40+
--name "${FUNCTION_NAME}" \
41+
--if-match "${ETAG}" \
42+
--function-config '{"Comment":"Rewrite clean URLs to /index.html for S3 origin","Runtime":"cloudfront-js-2.0"}' \
43+
--function-code "fileb://${FUNCTION_FILE}"
44+
fi
45+
46+
echo " Publishing function..."
47+
ETAG=$(aws cloudfront describe-function --name "${FUNCTION_NAME}" --query 'ETag' --output text)
48+
aws cloudfront publish-function \
49+
--name "${FUNCTION_NAME}" \
50+
--if-match "${ETAG}"
51+
52+
FUNCTION_ARN=$(aws cloudfront describe-function --name "${FUNCTION_NAME}" --query 'FunctionSummary.FunctionMetadata.FunctionARN' --output text)
53+
echo " Published: ${FUNCTION_ARN}"
54+
55+
# ---- Response Headers Policy: security headers ----
56+
echo ""
57+
echo "==> Creating/updating Response Headers Policy: ${HEADERS_POLICY_NAME}"
58+
59+
POLICY_CONFIG=$(cat <<'POLICY_JSON'
60+
{
61+
"Name": "forkzero-security-headers",
62+
"Comment": "Security headers for forkzero.ai",
63+
"SecurityHeadersConfig": {
64+
"StrictTransportSecurity": {
65+
"Override": true,
66+
"AccessControlMaxAgeSec": 31536000,
67+
"IncludeSubdomains": true,
68+
"Preload": true
69+
},
70+
"FrameOptions": {
71+
"Override": true,
72+
"FrameOption": "DENY"
73+
},
74+
"ContentTypeOptions": {
75+
"Override": true
76+
},
77+
"ReferrerPolicy": {
78+
"Override": true,
79+
"ReferrerPolicy": "strict-origin-when-cross-origin"
80+
},
81+
"XSSProtection": {
82+
"Override": true,
83+
"Protection": true,
84+
"ModeBlock": true
85+
}
86+
},
87+
"CustomHeadersConfig": {
88+
"Quantity": 1,
89+
"Items": [
90+
{
91+
"Header": "Permissions-Policy",
92+
"Value": "geolocation=(), microphone=(), camera=()",
93+
"Override": true
94+
}
95+
]
96+
}
97+
}
98+
POLICY_JSON
99+
)
100+
101+
# Check if the policy already exists
102+
EXISTING_POLICY_ID=$(aws cloudfront list-response-headers-policies \
103+
--query "ResponseHeadersPolicyList.Items[?ResponseHeadersPolicy.ResponseHeadersPolicyConfig.Name=='${HEADERS_POLICY_NAME}'].ResponseHeadersPolicy.Id" \
104+
--output text 2>/dev/null || true)
105+
106+
if [ -z "${EXISTING_POLICY_ID}" ]; then
107+
echo " Creating new policy..."
108+
RESULT=$(aws cloudfront create-response-headers-policy \
109+
--response-headers-policy-config "${POLICY_CONFIG}")
110+
POLICY_ID=$(echo "${RESULT}" | jq -r '.ResponseHeadersPolicy.Id')
111+
else
112+
echo " Updating existing policy..."
113+
POLICY_ID="${EXISTING_POLICY_ID}"
114+
ETAG=$(aws cloudfront get-response-headers-policy \
115+
--id "${POLICY_ID}" --query 'ETag' --output text)
116+
aws cloudfront update-response-headers-policy \
117+
--id "${POLICY_ID}" \
118+
--if-match "${ETAG}" \
119+
--response-headers-policy-config "${POLICY_CONFIG}"
120+
fi
121+
122+
echo " Policy ID: ${POLICY_ID}"
123+
124+
# ---- Instructions ----
125+
echo ""
126+
echo "==========================================================================="
127+
echo "NEXT STEPS — Associate with your CloudFront distribution:"
128+
echo ""
129+
echo "1. Open the CloudFront console → Distributions → your distribution"
130+
echo "2. Edit the Default Cache Behavior:"
131+
echo " a. Function associations → Viewer request → CloudFront Functions"
132+
echo " → Select '${FUNCTION_NAME}'"
133+
echo " b. Response headers policy → Select '${HEADERS_POLICY_NAME}'"
134+
echo "3. Save and wait for deployment to complete"
135+
echo ""
136+
echo "Or via CLI (replace DIST_ID and BEHAVIOR_CONFIG):"
137+
echo " aws cloudfront get-distribution-config --id DIST_ID > dist-config.json"
138+
echo " # Edit DefaultCacheBehavior to add:"
139+
echo " # FunctionAssociations with ${FUNCTION_ARN}"
140+
echo " # ResponseHeadersPolicyId: ${POLICY_ID}"
141+
echo " aws cloudfront update-distribution --id DIST_ID --distribution-config file://dist-config.json --if-match ETAG"
142+
echo "==========================================================================="

scripts/prerender.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,98 @@ beforeAll(() => {
1313
}
1414
}, 120_000)
1515

16+
describe('pre-rendered homepage', () => {
17+
const file = join(distDir, 'index.html')
18+
19+
it('has correct title', () => {
20+
const html = readFileSync(file, 'utf-8')
21+
expect(html).toContain('<title>Forkzero — Knowledge Coordination for AI-Native Teams</title>')
22+
})
23+
24+
it('has meta description', () => {
25+
const html = readFileSync(file, 'utf-8')
26+
expect(html).toContain('<meta name="description"')
27+
expect(html).toContain('knowledge graph')
28+
})
29+
30+
it('has og:type website', () => {
31+
const html = readFileSync(file, 'utf-8')
32+
expect(html).toContain('og:type" content="website"')
33+
})
34+
35+
it('has canonical URL', () => {
36+
const html = readFileSync(file, 'utf-8')
37+
expect(html).toContain('<link rel="canonical" href="https://forkzero.ai/"')
38+
})
39+
40+
it('has noscript fallback with projects', () => {
41+
const html = readFileSync(file, 'utf-8')
42+
expect(html).toContain('<noscript>')
43+
expect(html).toContain('Lattice')
44+
expect(html).toContain('knowledge graph')
45+
})
46+
47+
it('has Organization JSON-LD', () => {
48+
const html = readFileSync(file, 'utf-8')
49+
expect(html).toContain('application/ld+json')
50+
expect(html).toContain('"@type":"Organization"')
51+
expect(html).toContain('"name":"Forkzero"')
52+
})
53+
54+
it('has WebSite JSON-LD', () => {
55+
const html = readFileSync(file, 'utf-8')
56+
expect(html).toContain('"@type":"WebSite"')
57+
})
58+
59+
it('has SoftwareApplication JSON-LD', () => {
60+
const html = readFileSync(file, 'utf-8')
61+
expect(html).toContain('"@type":"SoftwareApplication"')
62+
expect(html).toContain('"name":"Lattice"')
63+
})
64+
})
65+
66+
describe('pre-rendered getting-started', () => {
67+
const file = join(distDir, 'getting-started', 'index.html')
68+
69+
it('exists', () => {
70+
expect(existsSync(file)).toBe(true)
71+
})
72+
73+
it('has correct title', () => {
74+
const html = readFileSync(file, 'utf-8')
75+
expect(html).toContain('<title>Getting Started — Forkzero</title>')
76+
})
77+
78+
it('has meta description', () => {
79+
const html = readFileSync(file, 'utf-8')
80+
expect(html).toContain('<meta name="description"')
81+
expect(html).toContain('Install Lattice')
82+
})
83+
84+
it('has og:type website', () => {
85+
const html = readFileSync(file, 'utf-8')
86+
expect(html).toContain('og:type" content="website"')
87+
})
88+
89+
it('has noscript fallback with install command', () => {
90+
const html = readFileSync(file, 'utf-8')
91+
expect(html).toContain('<noscript>')
92+
expect(html).toContain('lattice init --skill')
93+
})
94+
95+
it('has WebPage JSON-LD', () => {
96+
const html = readFileSync(file, 'utf-8')
97+
expect(html).toContain('application/ld+json')
98+
expect(html).toContain('"@type":"WebPage"')
99+
})
100+
101+
it('has BreadcrumbList JSON-LD', () => {
102+
const html = readFileSync(file, 'utf-8')
103+
expect(html).toContain('"@type":"BreadcrumbList"')
104+
expect(html).toContain('Getting Started')
105+
})
106+
})
107+
16108
describe('pre-rendered blog listing', () => {
17109
const file = join(distDir, 'blog', 'index.html')
18110

@@ -39,6 +131,11 @@ describe('pre-rendered blog listing', () => {
39131
expect(html).toContain('og:url')
40132
})
41133

134+
it('has og:type website', () => {
135+
const html = readFileSync(file, 'utf-8')
136+
expect(html).toContain('og:type" content="website"')
137+
})
138+
42139
it('has canonical URL', () => {
43140
const html = readFileSync(file, 'utf-8')
44141
expect(html).toContain('<link rel="canonical" href="https://forkzero.ai/blog"')
@@ -51,6 +148,16 @@ describe('pre-rendered blog listing', () => {
51148
expect(html).toContain(`/blog/${post.slug}`)
52149
}
53150
})
151+
152+
it('has WebPage JSON-LD', () => {
153+
const html = readFileSync(file, 'utf-8')
154+
expect(html).toContain('"@type":"WebPage"')
155+
})
156+
157+
it('has BreadcrumbList JSON-LD', () => {
158+
const html = readFileSync(file, 'utf-8')
159+
expect(html).toContain('"@type":"BreadcrumbList"')
160+
})
54161
})
55162

56163
describe('pre-rendered blog post', () => {
@@ -96,4 +203,16 @@ describe('pre-rendered blog post', () => {
96203
expect(html).toContain('The missing layer')
97204
expect(html).toContain('Knowledge as a graph problem')
98205
})
206+
207+
it('has BlogPosting JSON-LD', () => {
208+
const html = readFileSync(file, 'utf-8')
209+
expect(html).toContain('"@type":"BlogPosting"')
210+
expect(html).toContain(`"headline":"${post.title}"`)
211+
})
212+
213+
it('has BreadcrumbList JSON-LD with 3 levels', () => {
214+
const html = readFileSync(file, 'utf-8')
215+
expect(html).toContain('"@type":"BreadcrumbList"')
216+
expect(html).toContain('"position":3')
217+
})
99218
})

0 commit comments

Comments
 (0)