Skip to content

Commit 969d003

Browse files
Address various additional site nits discovered in testing (#1576)
1 parent 52bacc4 commit 969d003

18 files changed

Lines changed: 1327 additions & 264 deletions

.github/workflows/deploy-website.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ jobs:
3737
with:
3838
ref: ${{ inputs.ref || github.ref }}
3939

40+
- uses: pnpm/action-setup@v4
41+
42+
- uses: actions/setup-node@v4
43+
with:
44+
node-version: 22
45+
cache: pnpm
46+
47+
- name: Install dependencies
48+
run: pnpm install --frozen-lockfile
49+
50+
- name: Generate llms.txt and AGENTS.md
51+
run: pnpm --filter iii-website build
52+
4053
- name: Configure AWS credentials (GitHub OIDC)
4154
uses: aws-actions/configure-aws-credentials@v4
4255
with:
@@ -49,6 +62,8 @@ jobs:
4962
--delete \
5063
--cache-control "public,max-age=31536000,immutable" \
5164
--exclude "*.html" \
65+
--exclude "llms.txt" \
66+
--exclude "AGENTS.md" \
5267
--exclude "node_modules/*" \
5368
--exclude "package.json" \
5469
--exclude "package-lock.json" \
@@ -57,13 +72,15 @@ jobs:
5772
--exclude "README.md" \
5873
--exclude "vercel.json"
5974
60-
- name: Sync HTML (no cache, must revalidate)
75+
- name: Sync HTML and AI snapshot (no cache, must revalidate)
6176
run: |
6277
aws s3 sync website/ "s3://${{ vars.S3_BUCKET }}/" \
6378
--delete \
6479
--cache-control "public,max-age=0,must-revalidate" \
6580
--exclude "*" \
66-
--include "*.html"
81+
--include "*.html" \
82+
--include "llms.txt" \
83+
--include "AGENTS.md"
6784
6885
- name: Create CloudFront invalidation
6986
id: invalidation

infra/terraform/website/cloudfront_functions/redirects.js

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,73 @@ function redirect(location) {
88
location: { value: location },
99
'cache-control': { value: 'public, max-age=3600' },
1010
},
11+
};
12+
}
13+
14+
// CloudFront Functions deliver request.querystring as
15+
// { key: { value: string, multiValue?: [{ value: string }, ...] } }
16+
// where repeated params spill into multiValue. We re-encode and rejoin so the
17+
// host-redirect path below preserves the original query (otherwise `?a=1&a=2`
18+
// would silently drop on the 301).
19+
function serializeQuerystring(qs) {
20+
if (!qs) return '';
21+
var parts = [];
22+
for (var key in qs) {
23+
if (!Object.prototype.hasOwnProperty.call(qs, key)) continue;
24+
var entry = qs[key];
25+
if (!entry) continue;
26+
var encodedKey = encodeURIComponent(key);
27+
var primary = entry.value == null ? '' : entry.value;
28+
parts.push(encodedKey + '=' + encodeURIComponent(primary));
29+
if (entry.multiValue && entry.multiValue.length) {
30+
for (var i = 0; i < entry.multiValue.length; i++) {
31+
var extra = entry.multiValue[i];
32+
var extraValue = extra && extra.value != null ? extra.value : '';
33+
parts.push(encodedKey + '=' + encodeURIComponent(extraValue));
34+
}
35+
}
1136
}
37+
return parts.length ? '?' + parts.join('&') : '';
1238
}
1339

1440
// biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
1541
// biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
1642
function handler(event) {
17-
var request = event.request
18-
var uri = request.uri
19-
var host = request.headers && request.headers.host ? request.headers.host.value : undefined
43+
var request = event.request;
44+
var uri = request.uri;
45+
var host =
46+
request.headers && request.headers.host
47+
? request.headers.host.value
48+
: undefined;
2049

21-
if (host === 'www.iii.dev') return redirect(`https://iii.dev${uri}`)
50+
if (host === 'www.iii.dev') {
51+
return redirect(
52+
`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
53+
);
54+
}
55+
56+
if (uri.indexOf('/.well-known/') === 0) return request;
2257

23-
if (uri.indexOf('/.well-known/') === 0) return request
58+
// Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
59+
// ship a new top-level page as `pagename.html`.
60+
var htmlPretty = {
61+
'/manifesto': '/manifesto.html',
62+
};
63+
var htmlTarget = htmlPretty[uri];
64+
if (htmlTarget !== undefined) {
65+
request.uri = htmlTarget;
66+
return request;
67+
}
2468

2569
// SPA fallback: extensionless path not ending in /
2670
if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
27-
const lastSlash = uri.lastIndexOf('/')
28-
const lastSegment = uri.substring(lastSlash + 1)
71+
const lastSlash = uri.lastIndexOf('/');
72+
const lastSegment = uri.substring(lastSlash + 1);
2973
if (lastSegment.indexOf('.') === -1) {
30-
request.uri = '/index.html'
31-
return request
74+
request.uri = '/index.html';
75+
return request;
3276
}
3377
}
3478

35-
return request
79+
return request;
3680
}

infra/terraform/website/cloudfront_functions/redirects.test.js

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ const path = require('node:path')
99
const source = fs.readFileSync(path.join(__dirname, 'redirects.js'), 'utf8')
1010
const handler = new Function(source + '\nreturn handler;')()
1111

12-
function buildEvent(uri, host) {
12+
function buildEvent(uri, host, querystring) {
1313
return {
1414
version: '1.0',
1515
context: {},
1616
viewer: {},
1717
request: {
1818
method: 'GET',
1919
uri: uri,
20-
querystring: {},
20+
querystring: querystring || {},
2121
headers: host ? { host: { value: host } } : {},
2222
cookies: {},
2323
},
@@ -57,7 +57,7 @@ test('/docsfoo → NOT redirected (not under /docs/)', () => {
5757
assert.equal(result.uri, '/index.html')
5858
})
5959

60-
test('/llms.txt → pass through unchanged (matches current 404 behavior)', () => {
60+
test('/llms.txt → pass through unchanged (static file)', () => {
6161
const result = handler(buildEvent('/llms.txt', 'iii.dev'))
6262
assert.ok(!isRedirect(result))
6363
assert.equal(result.uri, '/llms.txt')
@@ -81,6 +81,58 @@ test('www.iii.dev/docs/foo → 301 https://iii.dev/docs/foo', () => {
8181
assert.equal(locationOf(result), 'https://iii.dev/docs/foo')
8282
})
8383

84+
test('www.iii.dev preserves querystring with multiValue and empty params', () => {
85+
// Mirrors the CloudFront Functions querystring shape: repeated keys spill into
86+
// multiValue, value-less keys arrive as empty strings, and special chars must
87+
// be re-encoded.
88+
const result = handler(
89+
buildEvent('/some/page', 'www.iii.dev', {
90+
a: { value: '1', multiValue: [{ value: '2' }] },
91+
empty: { value: '' },
92+
ref: { value: 'hello world' },
93+
}),
94+
)
95+
assert.ok(isRedirect(result))
96+
assert.equal(
97+
locationOf(result),
98+
'https://iii.dev/some/page?a=1&a=2&empty=&ref=hello%20world',
99+
)
100+
})
101+
102+
test('www.iii.dev with no querystring → no trailing ?', () => {
103+
const result = handler(buildEvent('/some/page', 'www.iii.dev', {}))
104+
assert.ok(isRedirect(result))
105+
assert.equal(locationOf(result), 'https://iii.dev/some/page')
106+
})
107+
108+
test('www.iii.dev percent-encodes reserved chars in keys and values', () => {
109+
// Values containing &, =, #, + would otherwise corrupt the redirect target
110+
// (& splits params, # ends the URL into a fragment, + flips to space on parse,
111+
// = confuses some clients). Keys with spaces must also be encoded.
112+
const result = handler(
113+
buildEvent('/p', 'www.iii.dev', {
114+
'weird key': { value: 'a&b=c+d#e' },
115+
}),
116+
)
117+
assert.ok(isRedirect(result))
118+
assert.equal(
119+
locationOf(result),
120+
'https://iii.dev/p?weird%20key=a%26b%3Dc%2Bd%23e',
121+
)
122+
})
123+
124+
test('SPA fallback preserves querystring on the request object (no rewrite)', () => {
125+
// The handler mutates request.uri but returns the same request object, so
126+
// CloudFront forwards the original querystring untouched. Pin the no-op so
127+
// a future refactor doesn't accidentally clear it.
128+
const qs = { utm_source: { value: 'twitter' }, ref: { value: 'launch' } }
129+
const event = buildEvent('/some/route', 'iii.dev', qs)
130+
const result = handler(event)
131+
assert.ok(!isRedirect(result))
132+
assert.equal(result.uri, '/index.html')
133+
assert.equal(result.querystring, qs)
134+
})
135+
84136
test('/ (root) → pass through unchanged', () => {
85137
const result = handler(buildEvent('/', 'iii.dev'))
86138
assert.ok(!isRedirect(result))
@@ -93,10 +145,16 @@ test('/some/client/route → rewrite uri to /index.html', () => {
93145
assert.equal(result.uri, '/index.html')
94146
})
95147

96-
test('/manifesto → rewrite uri to /index.html', () => {
148+
test('/manifesto → rewrite uri to /manifesto.html (flat HTML, Option A)', () => {
97149
const result = handler(buildEvent('/manifesto', 'iii.dev'))
98150
assert.ok(!isRedirect(result))
99-
assert.equal(result.uri, '/index.html')
151+
assert.equal(result.uri, '/manifesto.html')
152+
})
153+
154+
test('/AGENTS.md → pass through unchanged', () => {
155+
const result = handler(buildEvent('/AGENTS.md', 'iii.dev'))
156+
assert.ok(!isRedirect(result))
157+
assert.equal(result.uri, '/AGENTS.md')
100158
})
101159

102160
test('/foo/ trailing slash → pass through unchanged (no SPA rewrite)', () => {
@@ -111,10 +169,10 @@ test('/missing.jpg → pass through unchanged (S3 returns 404)', () => {
111169
assert.equal(result.uri, '/missing.jpg')
112170
})
113171

114-
test('/ai/index.htmlpass through unchanged', () => {
115-
const result = handler(buildEvent('/ai/index.html', 'iii.dev'))
172+
test('/ai → SPA fallback to /index.html', () => {
173+
const result = handler(buildEvent('/ai', 'iii.dev'))
116174
assert.ok(!isRedirect(result))
117-
assert.equal(result.uri, '/ai/index.html')
175+
assert.equal(result.uri, '/index.html')
118176
})
119177

120178
test('/assets/main.abc123.js → pass through unchanged', () => {

0 commit comments

Comments
 (0)