Skip to content

Commit bb9e444

Browse files
Add a static blog site (#1605)
1 parent 26ee6b0 commit bb9e444

31 files changed

Lines changed: 4706 additions & 63 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- 'docs/**'
88
- 'skills/**'
99
- 'website/**'
10+
- 'blog/**'
1011
- '**/*.md'
1112
- '**/*.mdx'
1213
- '.cursor/**'
@@ -16,6 +17,7 @@ on:
1617
- 'docs/**'
1718
- 'skills/**'
1819
- 'website/**'
20+
- 'blog/**'
1921
- '**/*.md'
2022
- '**/*.mdx'
2123
- '.cursor/**'

.github/workflows/deploy-website.yml

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [main]
66
paths:
77
- 'website/**'
8+
- 'blog/**'
89
- 'infra/terraform/website/**'
910
- '.github/workflows/deploy-website.yml'
1011
workflow_dispatch:
@@ -50,13 +51,20 @@ jobs:
5051
- name: Generate llms.txt and AGENTS.md
5152
run: pnpm --filter iii-website build
5253

54+
- name: Build blog (Astro → blog/dist/)
55+
run: pnpm --filter iii-blog build
56+
5357
- name: Configure AWS credentials (GitHub OIDC)
5458
uses: aws-actions/configure-aws-credentials@v4
5559
with:
5660
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
5761
aws-region: ${{ env.AWS_REGION }}
5862

59-
- name: Sync static assets (long cache, immutable)
63+
# Blog and website share the bucket; the blog lives under the /blog/
64+
# prefix and is wiped + repopulated by its own sync below. The website
65+
# sync therefore must exclude blog/* so its --delete doesn't wipe blog
66+
# output between the two steps.
67+
- name: Sync website static assets (long cache, immutable)
6068
run: |
6169
aws s3 sync website/ "s3://${{ vars.S3_BUCKET }}/" \
6270
--delete \
@@ -70,17 +78,39 @@ jobs:
7078
--exclude "pnpm-lock.yaml" \
7179
--exclude ".gitignore" \
7280
--exclude "README.md" \
73-
--exclude "vercel.json"
81+
--exclude "vercel.json" \
82+
--exclude "blog/*"
7483
75-
- name: Sync HTML and AI snapshot (no cache, must revalidate)
84+
- name: Sync website HTML and AI snapshot (no cache, must revalidate)
7685
run: |
7786
aws s3 sync website/ "s3://${{ vars.S3_BUCKET }}/" \
7887
--delete \
7988
--cache-control "public,max-age=0,must-revalidate" \
8089
--exclude "*" \
8190
--include "*.html" \
8291
--include "llms.txt" \
83-
--include "AGENTS.md"
92+
--include "AGENTS.md" \
93+
--exclude "blog/*"
94+
95+
# Blog assets under _astro/ are content-hashed by Astro, so they're
96+
# safe to mark immutable. HTML and rss.xml must revalidate so new posts
97+
# surface immediately after the CloudFront invalidation.
98+
- name: Sync blog static assets (long cache, immutable)
99+
run: |
100+
aws s3 sync blog/dist/ "s3://${{ vars.S3_BUCKET }}/blog/" \
101+
--delete \
102+
--cache-control "public,max-age=31536000,immutable" \
103+
--exclude "*.html" \
104+
--exclude "*.xml"
105+
106+
- name: Sync blog HTML and RSS (no cache, must revalidate)
107+
run: |
108+
aws s3 sync blog/dist/ "s3://${{ vars.S3_BUCKET }}/blog/" \
109+
--delete \
110+
--cache-control "public,max-age=0,must-revalidate" \
111+
--exclude "*" \
112+
--include "*.html" \
113+
--include "*.xml"
84114
85115
- name: Create CloudFront invalidation
86116
id: invalidation

.github/workflows/license-check.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- 'docs/**'
88
- 'skills/**'
99
- 'website/**'
10+
- 'blog/**'
1011
- '**/*.md'
1112
- '**/*.mdx'
1213
- '.cursor/**'
@@ -16,6 +17,7 @@ on:
1617
- 'docs/**'
1718
- 'skills/**'
1819
- 'website/**'
20+
- 'blog/**'
1921
- '**/*.md'
2022
- '**/*.mdx'
2123
- '.cursor/**'

blog/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
dist/
2+
.astro/
3+
node_modules/
4+
.DS_Store
5+
.env
6+
.env.local

blog/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# iii blog
2+
3+
The iii.dev/blog — static blog built with [Astro](https://astro.build).
4+
5+
## Local development
6+
7+
From the monorepo root:
8+
9+
```bash
10+
pnpm install
11+
pnpm --filter iii-blog dev
12+
```
13+
14+
The dev server runs at <http://localhost:4321/blog/>. Posts live in
15+
`src/content/blog/` as Markdown or MDX with the frontmatter schema defined in
16+
`src/content.config.ts`.
17+
18+
## Build
19+
20+
```bash
21+
pnpm --filter iii-blog build
22+
```
23+
24+
Output is emitted to `blog/dist/` with all routes scoped under `/blog/` thanks
25+
to `base: '/blog'` in `astro.config.mjs`.
26+
27+
## Tests
28+
29+
```bash
30+
pnpm --filter iii-blog test
31+
```
32+
33+
Tests live in `tests/` and run via `node:test` after `astro build`. They
34+
verify the build output (URL scoping, RSS feed, post emission) so regressions
35+
in the base path or content collection setup fail loudly.
36+
37+
## Deployment
38+
39+
The blog ships with the rest of `iii.dev` via
40+
[`.github/workflows/deploy-website.yml`](../.github/workflows/deploy-website.yml).
41+
On every push to `main` that touches `blog/**`, `website/**`, or
42+
`infra/terraform/website/**`, CI:
43+
44+
1. Builds with `pnpm --filter iii-blog build``blog/dist/`.
45+
2. Syncs `blog/dist/` to `s3://<site-bucket>/blog/`. Hashed assets get a
46+
long `immutable` cache; `*.html` and `*.xml` get `must-revalidate`.
47+
3. Invalidates the CloudFront distribution at `/*`.
48+
49+
Routing under `/blog/*` is handled in
50+
[`infra/terraform/website/cloudfront_functions/redirects.js`](../infra/terraform/website/cloudfront_functions/redirects.js)
51+
— see the unit tests there for the exact behavior. In short:
52+
53+
- `/blog` → 301 `/blog/`
54+
- `/blog/<slug>/` → S3 key `blog/<slug>/index.html`
55+
- `/blog/<slug>` → 301 `/blog/<slug>/`
56+
- `/blog/<file.ext>` → pass through
57+
58+
## Adding a post
59+
60+
Create a new file in `src/content/blog/<slug>.md` (or `.mdx`):
61+
62+
```markdown
63+
---
64+
title: 'My new post'
65+
description: 'A short summary used for the index page and RSS feed.'
66+
pubDate: 2026-05-10
67+
tags: ['engine']
68+
---
69+
70+
Post body in Markdown.
71+
```
72+
73+
The slug is the filename minus the extension. The post is published
74+
automatically unless `draft: true` is set in frontmatter.

blog/astro.config.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import mdx from '@astrojs/mdx'
2+
import { defineConfig } from 'astro/config'
3+
4+
// Served from CloudFront under /blog. Built artifacts are synced to
5+
// s3://<site-bucket>/blog/ — see infra/terraform/website/cloudfront.tf.
6+
export default defineConfig({
7+
site: 'https://iii.dev',
8+
base: '/blog',
9+
trailingSlash: 'always',
10+
build: {
11+
format: 'directory',
12+
},
13+
integrations: [mdx()],
14+
})

blog/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "iii-blog",
3+
"private": true,
4+
"license": "Apache-2.0",
5+
"version": "0.1.0",
6+
"type": "module",
7+
"description": "iii.dev/blog — static blog built with Astro",
8+
"scripts": {
9+
"dev": "astro dev",
10+
"build": "astro build",
11+
"preview": "astro preview",
12+
"type-check": "astro check",
13+
"test": "astro build && tsx --test tests/*.test.ts"
14+
},
15+
"dependencies": {
16+
"@astrojs/mdx": "^5.0.4",
17+
"@astrojs/rss": "^4.0.18",
18+
"astro": "^6.2.2"
19+
},
20+
"devDependencies": {
21+
"@astrojs/check": "^0.9.4",
22+
"tsx": "^4.21.0",
23+
"typescript": "^5.9.2"
24+
}
25+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
// Mirrors website/index.html lines 7-56. Kept verbatim so the localStorage
3+
// key 'iii_cookie_consent' and the iiiLoadCommonRoomSignals /
4+
// iiiNotifyCommonRoomEmail globals stay identical across iii.dev and /blog —
5+
// consent travels with the user, not the page. See blog/tests/build.test.ts
6+
// for the contract this mirrors.
7+
---
8+
9+
<!-- Google Tag Manager -->
10+
<script is:inline>
11+
(function (w, d, s, l, i) {
12+
w[l] = w[l] || [];
13+
w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
14+
var f = d.getElementsByTagName(s)[0],
15+
j = d.createElement(s),
16+
dl = l != 'dataLayer' ? '&l=' + l : '';
17+
j.async = true;
18+
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
19+
f.parentNode.insertBefore(j, f);
20+
})(window, document, 'script', 'dataLayer', 'GTM-N8DCTFB8');
21+
</script>
22+
<!-- End Google Tag Manager -->
23+
24+
<!-- Common Room: loads only after analytics consent (see CookieBanner) -->
25+
<script is:inline>
26+
(function () {
27+
var STORAGE_KEY = 'iii_cookie_consent';
28+
window.iiiLoadCommonRoomSignals = function () {
29+
if (typeof window.signals !== 'undefined') return;
30+
var script = document.createElement('script');
31+
script.src = 'https://cdn.cr-relay.com/v1/site/da18833a-8f00-4ad0-9833-6608b59a713a/signals.js';
32+
script.async = true;
33+
window.signals = Object.assign(
34+
[],
35+
{ _opts: { apiHost: 'https://api.cr-relay.com' } },
36+
['page', 'identify', 'form'].reduce(function (acc, method) {
37+
acc[method] = function () {
38+
signals.push([method, arguments]);
39+
return signals;
40+
};
41+
return acc;
42+
}, {})
43+
);
44+
document.head.appendChild(script);
45+
};
46+
window.iiiNotifyCommonRoomEmail = function (email) {
47+
try {
48+
if (!email || localStorage.getItem(STORAGE_KEY) !== 'accepted') return;
49+
if (typeof window.signals === 'undefined' || !window.signals.form) return;
50+
window.signals.form({ email: email });
51+
} catch (_) {}
52+
};
53+
try {
54+
if (localStorage.getItem(STORAGE_KEY) === 'accepted') window.iiiLoadCommonRoomSignals();
55+
} catch (_) {}
56+
})();
57+
</script>

0 commit comments

Comments
 (0)