diff --git a/.claude/context/deployment.md b/.claude/context/deployment.md new file mode 100644 index 0000000..2925406 --- /dev/null +++ b/.claude/context/deployment.md @@ -0,0 +1,32 @@ +# Deployment + +## Pipeline + +GitHub Actions workflow: `.github/workflows/cicd.yml` + +**Trigger**: Push to `src/**` or `.github/workflows/**` + +**Steps**: +1. Checkout repo (with Git LFS) +2. Detect branch name +3. Configure AWS credentials +4. Create/select S3 bucket (main → production bucket, branches → `{branch}.{bucket}`) +5. Sync `src/` directory to S3 with public-read ACL +6. Configure S3 static website hosting (index: `index.html`, error: `404.html`) +7. Apply bucket policy from `.github/workflows/policy.json` +8. For non-main branches: create Cloudflare CNAME record for subdomain +9. Purge Cloudflare cache + +## Branch Strategy + +| Branch | URL | Auto-created | +|--------|-----|-------------| +| `main` | https://lef.fyi | No (production) | +| `feature-x` | https://feature-x.lef.fyi | Yes (DNS + S3 bucket) | + +## Secrets (referenced in CI, never stored in repo) + +- `AWS_ACCESS_KEY`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` — S3 access +- `AWS_S3_BUCKET` — production bucket name +- `CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_DNS_SECRET_API_TOKEN` — DNS + cache management +- `EMAIL` — Cloudflare auth email diff --git a/.claude/context/project-structure.md b/.claude/context/project-structure.md new file mode 100644 index 0000000..92be059 --- /dev/null +++ b/.claude/context/project-structure.md @@ -0,0 +1,55 @@ +# Project Structure + +``` +lef-web/ +├── src/ # Deployable website root +│ ├── index.html # Homepage +│ ├── 404.html # Error page +│ ├── pages/ +│ │ └── posts/ # Blog posts +│ │ ├── 20220305-my-first-post.html +│ │ └── 20220319-a-very-poetic-python.html +│ ├── media/ # All media assets +│ │ ├── 00-common/ # Shared assets (banner image) +│ │ └── YYYYMMDD-slug/ # Post-specific media directories +│ ├── tufte.css # Source stylesheet +│ ├── tufte.min.css # Minified stylesheet (generated, commit both) +│ ├── et-book/ # ET Book font files (Roman, Bold, Italic, Display variants) +│ ├── favicon.ico +│ ├── robots.txt +│ └── sitemap.txt # Generated by tools/src/sitemap_generator.py +├── tools/ # Python utilities +│ ├── pyproject.toml # uv config (Python 3.9+, ruff linter) +│ ├── uv.lock +│ └── src/ +│ └── sitemap_generator.py # Generates sitemap.txt from src/ directory +├── .github/ +│ └── workflows/ +│ ├── cicd.yml # Deploy pipeline (S3 + Cloudflare) +│ └── policy.json # S3 bucket policy template +├── .claude/ # Claude AI collaboration config +│ ├── settings.json # Permissions +│ ├── context/ # Domain knowledge files +│ └── skills/ # Step-by-step procedures +├── .vscode/ # Editor config +│ ├── settings.json # Spell checker words +│ ├── launch.json # Debug configs +│ └── html.code-snippets # HTML authoring snippets +├── .gitattributes # Git LFS: *.png, *.jpg, *.jpeg +├── .gitignore # .DS_Store, node_modules +├── package.json # npm: clean-css-cli, http-server +└── CLAUDE.md # Primary Claude developer guide +``` + +## Naming Conventions + +- Post files: `YYYYMMDD-slug.html` (date prefix for chronological sorting) +- Media directories: match the post name (`YYYYMMDD-slug/`) +- Common/shared media: `00-common/` prefix ensures it sorts first +- CSS: edit `tufte.css`, always regenerate `tufte.min.css` + +## Git LFS + +Tracked via `.gitattributes`: +- `*.png` +- `*.jpg` / `*.jpeg` diff --git a/.claude/context/tufte-patterns.md b/.claude/context/tufte-patterns.md new file mode 100644 index 0000000..b697d81 --- /dev/null +++ b/.claude/context/tufte-patterns.md @@ -0,0 +1,110 @@ +# Tufte CSS HTML Patterns + +Reference for all HTML patterns used in this project. Copy and adapt as needed. + +## Page Template + +```html + + + + + +PAGE TITLE | Lef adores you ❤️ + + + + + + + + +
+
+

PAGE TITLE

+

First paragraph.

+
+
+ + + +``` + +Note: The invisible duplicate banner image is a spacing hack — do not remove it. + +## Sidenote (Numbered Marginal Note) + +Each sidenote needs a **unique id** within the page. Place inline within a `

`: + +```html + + + + + Note content here. + +``` + +- Desktop: appears in the right margin with a superscript number +- Mobile: collapses behind a ⊕ toggle button (CSS-only, no JS) + +## Sidenote with Image + +```html + + + + + + +``` + +## Full-Width Image + +```html + +``` + +## Blockquote + +```html +

+

Title

+ Content here. +
+``` + +## Inline Code + +```html +some_command +``` + +## Links + +Same tab: +```html +Link text +``` + +New tab (external links): +```html +Link text +``` + +## Section Structure + +Posts use `
` with multiple `
` children. Each section typically has a heading: + +```html +
+
+

Post Title

+

Intro content...

+
+
+

Subsection

+

More content...

+
+
+``` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c30a15f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run start)", + "Bash(npm run cssmin)", + "Bash(npx http-server*)", + "Bash(cd tools && uv run python*)" + ] + } +} diff --git a/.claude/skills/creating-post/SKILL.md b/.claude/skills/creating-post/SKILL.md new file mode 100644 index 0000000..4ac54d2 --- /dev/null +++ b/.claude/skills/creating-post/SKILL.md @@ -0,0 +1,46 @@ +--- +name: creating-post +description: Create a new blog post with the standard Tufte CSS template and update all references. +--- + +# Creating a Blog Post + +## When to Use +When creating a new page or blog post for lef.fyi. + +## Steps + +1. **Create the HTML file** + - Path: `src/pages/posts/YYYYMMDD-slug.html` + - Use today's date (YYYYMMDD format) and a URL-friendly slug + - Start from the page template in `.claude/context/tufte-patterns.md` + +2. **Create media directory** (if the post has images) + - Path: `src/media/YYYYMMDD-slug/` + - Name must match the post filename (without `.html`) + +3. **Write the content** + - Use `
` > `
` structure + - Add sidenotes for supplementary information (see tufte-patterns.md) + - Each sidenote `id` must be unique within the page + - Use external link pattern (`target="_blank" rel="noopener noreferrer"`) for off-site links + +4. **Add to homepage** + - Edit `src/index.html` + - Add a link to the new post in the appropriate section + +5. **Update sitemap** + - Run: `cd tools && uv run python src/sitemap_generator.py https://lef.fyi ../src --except "et-book"` + - Or manually add the URL to `src/sitemap.txt` + +6. **Preview locally** + - Run: `npm run start` + - Check at http://127.0.0.1:8080/pages/posts/YYYYMMDD-slug.html + - Verify sidenotes toggle correctly on narrow viewport + +## Verification +- Page loads without errors +- Banner image displays and links to homepage +- Sidenotes display in margin on desktop, toggle on mobile +- Footer "Return home" link works +- Post appears on homepage diff --git a/.claude/skills/updating-styles/SKILL.md b/.claude/skills/updating-styles/SKILL.md new file mode 100644 index 0000000..03915f9 --- /dev/null +++ b/.claude/skills/updating-styles/SKILL.md @@ -0,0 +1,35 @@ +--- +name: updating-styles +description: Modify the Tufte CSS stylesheet and regenerate the minified version. +--- + +# Updating Styles + +## When to Use +When making any CSS changes to the website's appearance. + +## Steps + +1. **Edit the source stylesheet** + - Path: `src/tufte.css` + - Never edit `tufte.min.css` directly — it's generated + +2. **Regenerate minified CSS** + - Run: `npm run cssmin` + - This overwrites `src/tufte.min.css` from `src/tufte.css` + +3. **Preview changes** + - Run: `npm run start` + - Check multiple pages (homepage + a post) to verify nothing broke + - Test responsive behavior at narrow widths (sidenote toggles, full-width images) + +4. **Commit both files** + - Always commit `tufte.css` and `tufte.min.css` together + +## Key CSS Architecture +- Base width: 55% for text, right margin reserved for sidenotes +- Responsive: orientation-based media queries (portrait mode triggers mobile layout) +- Sidenotes use CSS counters for automatic numbering +- Full-width images use `max-width: none` to escape the text column +- Colors: background `#f9fefc`, text `#443b36`, accent `#dc5945` +- Font: ET Book (custom, loaded from `et-book/` directory) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7bf040d..6ef2ea7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -10,58 +10,77 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 with: lfs: true + - name: Get branch name id: branch-name - uses: tj-actions/branch-names@v5.1 + run: echo "BRANCH=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }} - - name: Create S3 Bucket - id: create-s3-bucket + + - name: Determine S3 bucket name + id: bucket run: | - if [ "${{ steps.branch-name.outputs.current_branch }}" = "main" ]; then + if [ "${{ steps.branch-name.outputs.BRANCH }}" = "main" ]; then BUCKET=${{ secrets.AWS_S3_BUCKET }} else - BUCKET=${{ steps.branch-name.outputs.current_branch }}.${{ secrets.AWS_S3_BUCKET }} + BUCKET=${{ steps.branch-name.outputs.BRANCH }}.${{ secrets.AWS_S3_BUCKET }} fi - if ! aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then - aws s3 mb s3://${BUCKET} + echo "NAME=${BUCKET}" >> $GITHUB_OUTPUT + + - name: Create S3 bucket if needed + run: | + if ! aws s3api head-bucket --bucket "${{ steps.bucket.outputs.NAME }}" 2>/dev/null; then + aws s3 mb s3://${{ steps.bucket.outputs.NAME }} fi - echo "::set-output name=BUCKET::${BUCKET}" - - name: Sync repo to S3 - uses: jakejarvis/s3-sync-action@v0.5.1 - with: - args: --acl public-read --follow-symlinks --delete - env: - AWS_S3_BUCKET: ${{ steps.create-s3-bucket.outputs.BUCKET }} - SOURCE_DIR: "src" - - name: Rename example.com to our domain in policy.json + + - name: Configure bucket for static website hosting run: | - cat ${{ github.workspace }}/.github/workflows/policy.json |sed s/www.example.com/${{ steps.create-s3-bucket.outputs.BUCKET }}/g > policy.json - - name: Make S3 bucket into a statically hosted website and apply policies + aws s3api delete-public-access-block \ + --bucket ${{ steps.bucket.outputs.NAME }} + sed "s/www.example.com/${{ steps.bucket.outputs.NAME }}/g" \ + ${{ github.workspace }}/.github/workflows/policy.json > /tmp/policy.json + aws s3api put-bucket-policy \ + --bucket ${{ steps.bucket.outputs.NAME }} \ + --policy file:///tmp/policy.json + aws s3 website s3://${{ steps.bucket.outputs.NAME }}/ \ + --index-document index.html \ + --error-document 404.html + + - name: Sync src/ to S3 run: | - aws s3 website s3://${{ steps.create-s3-bucket.outputs.BUCKET }}/ --index-document index.html --error-document 404.html - aws s3api put-bucket-policy --bucket ${{ steps.create-s3-bucket.outputs.BUCKET }} --policy file://policy.json - aws s3api delete-public-access-block --bucket ${{ steps.create-s3-bucket.outputs.BUCKET }} - - name: Update DNS Records + aws s3 sync src/ s3://${{ steps.bucket.outputs.NAME }}/ \ + --follow-symlinks \ + --delete + + - name: Create DNS record for branch subdomain + if: steps.branch-name.outputs.BRANCH != 'main' run: | - if ! [ "${{ steps.branch-name.outputs.current_branch }}" = "main" ]; then - curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ - -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ - -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ - -H "Content-Type: application/json" \ - --data '{"type":"CNAME","name":"${{ steps.branch-name.outputs.current_branch }}","content":"${{ steps.branch-name.outputs.current_branch }}.${{ secrets.AWS_S3_BUCKET }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com","ttl":1,"priority":10,"proxied":true}' - fi + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \ + -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ + -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ + -H "Content-Type: application/json" \ + --data '{ + "type": "CNAME", + "name": "${{ steps.branch-name.outputs.BRANCH }}", + "content": "${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com", + "ttl": 1, + "proxied": true + }' + - name: Purge Cloudflare cache run: | - curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ + curl -sS -X POST \ + "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \ -H "Content-Type: application/json" \ - --data '{"purge_everything":true}' + --data '{"purge_everything": true}' diff --git a/.gitignore b/.gitignore index 9daa824..b58f48d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store node_modules +.venv diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fda88cc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# lef-web — lef.fyi + +Static personal website built with hand-coded HTML and Tufte CSS. Hosted on AWS S3 with Cloudflare CDN. + +**Repo**: `git@github.com:Lef-F/lef-web.git` +**Live**: https://lef.fyi + +## Architecture + +``` +src/ → Deployable website (HTML, CSS, fonts, media) +tools/ → Python utilities (sitemap generator) +.github/ → CI/CD (GitHub Actions → S3 + Cloudflare) +.claude/ → Claude context, skills, settings +``` + +All pages are hand-written HTML — no static site generator, no templating engine, no JavaScript. + +## Development Commands + +```shell +npm run start # Local server at http://127.0.0.1:8080 +npm run cssmin # Minify tufte.css → tufte.min.css (MUST run after CSS changes) +``` + +Sitemap generation (requires uv in `tools/`): +```shell +cd tools && uv run python src/sitemap_generator.py https://lef.fyi ../src --except "et-book" +``` + +## Conventions + +### File Naming +- Blog posts: `src/pages/posts/YYYYMMDD-slug.html` +- Post media: `src/media/YYYYMMDD-slug/` (matching the post date+slug) +- Shared media: `src/media/00-common/` + +### HTML Patterns +Every page follows this structure: +- `` with viewport meta, charset, title (`Page Title | Lef adores you ❤️`), favicon, tufte.min.css +- Banner: clickable full-width image linking to `/` + invisible duplicate for spacing +- Blog posts: content in `
` > `
` elements +- Homepage: uses `
` elements directly (no `
` wrapper) +- `