this repo has no description madoka.systmes

fix malformed html

+720 -257
+1
.gitignore
··· 2 2 result 3 3 public/ 4 4 .DS_Store 5 + static/processed_images/
+117 -51
CLAUDE.md
··· 1 1 # CLAUDE.md 2 2 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 - 5 - ## Development Commands 3 + Guidance for AI agents working on madoka.systems - a personal blog built with Zola. 6 4 7 - **Primary workflow (via Nix):** 5 + ## Quick Start 8 6 9 7 ```bash 10 - nix develop # Enter development shell 11 - zola serve # Start dev server with hot reload on http://127.0.0.1:1111 8 + nix develop # Enter dev shell 9 + zola serve # Dev server at http://127.0.0.1:1111 12 10 zola build --output-dir public # Production build 13 11 ``` 14 12 15 - **Font optimization workflow:** 13 + ## Architecture 16 14 17 - ```bash 18 - pyftsubset static/fonts/HostGrotesk-Italic-VariableFont.ttf \ 19 - --unicodes="U+0000-00FF,U+0100-017F,U+0180-024F,U+1E00-1EFF,U+2000-206F,U+2070-209F,U+20A0-20CF,U+2100-214F,U+2190-21FF" \ 20 - --output-file=static/fonts/HostGrotesk-Italic-VariableFont.woff2 \ 21 - --flavor=woff2 15 + **Stack:** Zola (Rust) + Tera templates + SCSS + Nix 16 + 17 + **Key directories:** 18 + - `content/` - Markdown with frontmatter 19 + - `templates/` - Tera HTML templates 20 + - `sass/` - SCSS stylesheets 21 + - `static/` - Unprocessed assets 22 + - `syntax_themes/` - Custom Oxocarbon highlighting themes 23 + 24 + ## Content Patterns 25 + 26 + ### Creating Blog Posts 27 + 28 + Add to `content/blog/` with frontmatter: 29 + 30 + ```markdown 31 + --- 32 + title: "Post Title" 33 + description: "Brief description for SEO/previews" 34 + date: 2024-01-15 35 + updated: 2024-01-16 # Optional 36 + [extra] 37 + bsky_uri: "at://did:plc:.../app.bsky.feed.post/..." # For comments 38 + --- 22 39 ``` 23 40 24 - ## Architecture Overview 41 + ### Images 25 42 26 - **Static Site Generator:** Zola (Rust-based) with Tera templating (Jinja2-like syntax) 43 + Use the `image` shortcode for responsive images: 27 44 28 - **Development Environment:** NixOS flake with direnv for reproducible builds 45 + ```markdown 46 + {{ image(src="photo.jpg", alt="Description", caption="Optional caption", eager=false) }} 47 + ``` 29 48 30 - **Content Structure:** 49 + - Place images in same directory as post (colocated assets) 50 + - Shortcode auto-generates AVIF, WebP, and original formats 51 + - Use `eager=true` for above-fold images only 31 52 32 - - `/content/` - Markdown files with frontmatter 33 - - `/content/blog/` - Blog posts with pagination (6 posts per page) 34 - - Section-based organization with `_index.md` files defining templates 53 + ### Pages 35 54 36 - **Template Hierarchy:** 55 + - `content/_index.md` - Homepage 56 + - `content/uses.md` - Static page example 57 + - Add nav links in `config.toml` under `[extra]` 37 58 38 - - `base.html` - Root layout importing macros and includes 39 - - `lander.html` - Base template for homepage and blog listing 40 - - Specialized templates: `blog.html`, `post.html`, `index.html`, `page.html`, `header.html`, `404.html` 41 - - Reusable macros in `/templates/macros/post_macros.html` 42 - - SEO meta tags in `meta.html` 59 + ## Template System 43 60 44 - **Styling Architecture:** 61 + **Hierarchy:** 62 + - `base.html` - Root layout, includes header/footer 63 + - `lander.html` - For homepage and blog listing 64 + - `post.html` - Individual blog posts (extends `page.html`) 65 + - `page.html` - Generic content pages 45 66 46 - - Modular SCSS: `_variables.scss`, `_base.scss`, `_layout.scss` 47 - - Variable font implementation with system font fallbacks 48 - - Automatic dark/light theme switching via CSS media queries 49 - - Giallo syntax highlighting (Zola's built-in theme system, generates CSS automatically) 67 + **Key macros:** `templates/macros/post_macros.html` 68 + - `post_list(posts)` - Renders list of posts 69 + - `post_preview(post)` - Single post preview card 70 + - `post_meta(post)` - Date and reading time 50 71 51 - **Build Process:** 72 + **Shortcodes:** 73 + - `image` - Responsive images with AVIF/WebP 52 74 53 - 1. Git commit hash substitution (`__GIT_COMMIT__` placeholders in config.toml) 54 - 2. SASS compilation to CSS 55 - 3. Markdown processing with syntax highlighting 56 - 4. RSS feed and sitemap generation 57 - 5. Static output to `/public/` directory 75 + ## Styling 58 76 59 - ## Key Configuration Files 77 + **SCSS files:** 78 + - `_variables.scss` - CSS variables, colors, spacing 79 + - `_base.scss` - Typography and base elements 80 + - `_layout.scss` - Layout structures 81 + - `_bsky-comments.scss` - Bluesky comments widget 82 + - `main.scss` - Entry point 60 83 61 - - `/config.toml` - Main Zola configuration with git commit templating 62 - - `/flake.nix` - Nix development environment and build pipeline 63 - - `/sass/_variables.scss` - Design tokens and theme variables 84 + **Patterns:** 85 + - CSS custom properties for theming (`--bg-primary`, `--text-primary`, etc.) 86 + - Automatic dark/light mode via `prefers-color-scheme` 87 + - Host Grotesk variable font with system fallbacks 64 88 65 - ## Content Management Patterns 89 + ## Bluesky Comments 66 90 67 - **Frontmatter conventions:** 91 + Posts can include Bluesky-powered comments by adding `bsky_uri` in extra frontmatter. The widget: 92 + - Fetches thread from `public.api.bsky.app` 93 + - Renders nested replies sorted by likes 94 + - Shows stats (likes, reposts, replies) linking to original post 68 95 69 - - `title`, `description` for SEO 70 - - `template` for template override 71 - - `sort_by: "date"` for chronological ordering 72 - - `paginate_by` for pagination control 96 + ## Build System 73 97 74 - **Template patterns:** 98 + **Nix flake handles:** 99 + - Git commit substitution (`__GIT_COMMIT__` → actual hash) 100 + - Zola build 101 + - PurgeCSS for unused styles 75 102 76 - - Block inheritance: `{% block title %}`, `{% block main %}` 77 - - Includes: `{% include "header.html" %}` 78 - - Macros: `{{ post_macros::post_preview(post=post) }}` 103 + **Formatting:** 104 + - `nix fmt` runs treefmt (djlint for HTML, nixfmt for Nix) 79 105 80 - This is a content-focused static site without complex logic requiring testing frameworks. 106 + ## Common Tasks 107 + 108 + ### Add new blog post 109 + 1. Create `content/blog/YYYY-MM-DD-slug.md` 110 + 2. Add frontmatter with title, description, date 111 + 3. Optional: Add `bsky_uri` for comments 112 + 4. Use `{{ image(...) }}` for images 113 + 114 + ### Modify templates 115 + - Follow Tera syntax: `{% extends %}`, `{% block %}`, `{{ variable }}` 116 + - Import macros: `{% import "macros/post_macros.html" as post_macros %}` 117 + 118 + ### Update styles 119 + - Edit SCSS files in `sass/` 120 + - Zola compiles automatically in dev mode 121 + - Uses CSS nesting (modern browsers) 122 + 123 + ### Font optimization 124 + 125 + ```bash 126 + pyftsubset static/fonts/HostGrotesk-VariableFont.ttf \ 127 + --unicodes="U+0000-00FF,U+0100-017F" \ 128 + --output-file=static/fonts/HostGrotesk-Regular-subset.woff2 \ 129 + --flavor=woff2 130 + ``` 131 + 132 + ## Configuration Notes 133 + 134 + **config.toml:** 135 + - `base_url = "https://madoka.systems"` 136 + - Custom syntax themes: Oxocarbon Light/Dark 137 + - Git commit placeholders replaced at build time 138 + - Nav links defined in `[extra]` 139 + 140 + ## Tips 141 + 142 + - Blog pagination: 6 posts per page (set in `content/blog/_index.md`) 143 + - Reading time auto-calculated by Zola 144 + - Anchor links auto-generated on headings 145 + - RSS feed at `/rss.xml` 146 + - No testing framework - this is a static content site
+12 -12
flake.lock
··· 5 5 "nixpkgs-lib": "nixpkgs-lib" 6 6 }, 7 7 "locked": { 8 - "lastModified": 1768135262, 9 - "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", 8 + "lastModified": 1769996383, 9 + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", 10 10 "owner": "hercules-ci", 11 11 "repo": "flake-parts", 12 - "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", 12 + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", 13 13 "type": "github" 14 14 }, 15 15 "original": { ··· 20 20 }, 21 21 "nixpkgs": { 22 22 "locked": { 23 - "lastModified": 1768564909, 24 - "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=", 23 + "lastModified": 1770019141, 24 + "narHash": "sha256-VKS4ZLNx4PNrABoB0L8KUpc1fE7CLpQXQs985tGfaCU=", 25 25 "owner": "NixOS", 26 26 "repo": "nixpkgs", 27 - "rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f", 27 + "rev": "cb369ef2efd432b3cdf8622b0ffc0a97a02f3137", 28 28 "type": "github" 29 29 }, 30 30 "original": { ··· 36 36 }, 37 37 "nixpkgs-lib": { 38 38 "locked": { 39 - "lastModified": 1765674936, 40 - "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", 39 + "lastModified": 1769909678, 40 + "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", 41 41 "owner": "nix-community", 42 42 "repo": "nixpkgs.lib", 43 - "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", 43 + "rev": "72716169fe93074c333e8d0173151350670b824c", 44 44 "type": "github" 45 45 }, 46 46 "original": { ··· 77 77 "nixpkgs": "nixpkgs_2" 78 78 }, 79 79 "locked": { 80 - "lastModified": 1768158989, 81 - "narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=", 80 + "lastModified": 1769691507, 81 + "narHash": "sha256-8aAYwyVzSSwIhP2glDhw/G0i5+wOrren3v6WmxkVonM=", 82 82 "owner": "numtide", 83 83 "repo": "treefmt-nix", 84 - "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", 84 + "rev": "28b19c5844cc6e2257801d43f2772a4b4c050a1b", 85 85 "type": "github" 86 86 }, 87 87 "original": {
+6 -40
sass/_base.scss
··· 23 23 } 24 24 25 25 body { 26 - background: $zinc-100; 27 - color: $black; 26 + background: var(--bg-secondary); 27 + color: var(--text-primary); 28 28 display: flex; 29 29 flex-direction: column; 30 30 align-items: center; 31 31 min-height: 100vh; 32 32 font-weight: 400; 33 - 34 - @media (prefers-color-scheme: dark) { 35 - background: $zinc-900; 36 - color: $white; 37 - } 38 33 } 39 34 40 35 // Typography weight defaults ··· 60 55 } 61 56 62 57 &:focus { 63 - outline: 2px solid $black; 58 + outline: 2px solid var(--border-primary); 64 59 outline-offset: 1px; 65 - 66 - @media (prefers-color-scheme: dark) { 67 - outline-color: $white; 68 - } 69 60 } 70 61 } 71 62 ··· 74 65 position: absolute; 75 66 top: -40px; 76 67 left: 6px; 77 - background: $black; 78 - color: $white; 68 + background: var(--text-primary); 69 + color: var(--bg-primary); 79 70 padding: $space-sm $space-md; 80 71 text-decoration: none; 81 72 @include bold-text; ··· 84 75 85 76 &:focus { 86 77 top: 6px; 87 - outline: 2px solid $white; 78 + outline: 2px solid var(--bg-primary); 88 79 text-decoration: underline; 89 80 } 90 - 91 - @media (prefers-color-scheme: dark) { 92 - background: $white; 93 - color: $black; 94 - 95 - &:focus { 96 - outline-color: $black; 97 - } 98 - } 99 81 } 100 82 101 83 img, ··· 108 90 pre { 109 91 border-radius: 0; 110 92 font-family: $font-mono; 111 - } 112 - 113 - .giallo-l { 114 - display: inline-block; 115 - min-height: 1lh; 116 - width: 100%; 117 - } 118 - 119 - .giallo-ln { 120 - display: inline-block; 121 - user-select: none; 122 - margin-right: 0.4em; 123 - padding: 0.4em; 124 - min-width: 3ch; 125 - text-align: right; 126 - opacity: 0.8; 127 93 } 128 94 129 95 code {
+153
sass/_bsky-comments.scss
··· 1 + // Bluesky comments 2 + .bsky-comments { 3 + display: none; 4 + margin-top: $space-xl; 5 + padding-top: $space-lg; 6 + 7 + h2 { 8 + margin-bottom: $space-md; 9 + } 10 + } 11 + 12 + .bsky-comment-wrapper { 13 + margin-bottom: $space-sm; 14 + position: relative; 15 + } 16 + 17 + .bsky-comment { 18 + display: flex; 19 + gap: $space-sm; 20 + padding: $space-sm; 21 + border: 2px solid var(--border-primary); 22 + } 23 + 24 + .bsky-avatar { 25 + width: 32px; 26 + height: 32px; 27 + margin-top: 0.25rem; 28 + border-radius: 50%; 29 + flex-shrink: 0; 30 + 31 + &.bsky-avatar--fallback { 32 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 33 + } 34 + } 35 + 36 + .bsky-content { 37 + flex: 1; 38 + min-width: 0; 39 + } 40 + 41 + .bsky-author { 42 + font-size: $text-xs; 43 + margin-bottom: 0.25rem; 44 + a { 45 + text-decoration: none; 46 + } 47 + } 48 + 49 + .bsky-name { 50 + @include bold-text; 51 + } 52 + 53 + .bsky-text { 54 + font-size: $text-sm; 55 + line-height: 1.5; 56 + white-space: pre-wrap; 57 + word-break: break-word; 58 + } 59 + 60 + .bsky-actions { 61 + display: flex; 62 + gap: $space-md; 63 + margin-top: $space-xs; 64 + font-size: $text-xs; 65 + color: var(--text-muted); 66 + } 67 + 68 + .bsky-action { 69 + display: flex; 70 + align-items: center; 71 + gap: 0.25rem; 72 + 73 + svg { 74 + width: 14px; 75 + height: 14px; 76 + } 77 + } 78 + 79 + .bsky-handle { 80 + color: var(--text-muted); 81 + font-weight: normal; 82 + margin-left: 0.25rem; 83 + } 84 + 85 + // Thread nesting - simple border approach 86 + .bsky-thread { 87 + margin-top: $space-sm; 88 + padding-left: 1rem; 89 + border-left: 2px solid var(--border-tertiary); 90 + } 91 + 92 + .bsky-comment-wrapper:last-child > .bsky-thread { 93 + position: relative; 94 + 95 + &::before { 96 + content: ""; 97 + position: absolute; 98 + left: -2px; 99 + top: 0; 100 + height: 1.75rem; 101 + width: 2px; 102 + background: var(--border-tertiary); 103 + } 104 + } 105 + 106 + .bsky-stats { 107 + margin: $space-md 0; 108 + } 109 + 110 + .bsky-stats-link { 111 + display: flex; 112 + gap: $space-lg; 113 + text-decoration: none; 114 + color: inherit; 115 + 116 + &:hover { 117 + .bsky-stat { 118 + color: var(--text-primary); 119 + } 120 + } 121 + } 122 + 123 + .bsky-stat { 124 + display: flex; 125 + align-items: center; 126 + gap: 0.375rem; 127 + font-size: $text-sm; 128 + color: var(--text-secondary); 129 + transition: color 0.2s; 130 + 131 + svg { 132 + width: 18px; 133 + height: 18px; 134 + } 135 + } 136 + 137 + .bsky-reply-cta { 138 + font-size: $text-sm; 139 + color: var(--text-secondary); 140 + margin: $space-sm 0 $space-md; 141 + 142 + a { 143 + text-decoration: underline; 144 + } 145 + } 146 + 147 + .bsky-empty, 148 + .bsky-error { 149 + font-size: $text-sm; 150 + color: var(--text-muted); 151 + font-style: italic; 152 + padding: $space-md 0; 153 + }
+65 -105
sass/_layout.scss
··· 1 1 // Main container 2 2 .container { 3 - background: $white; 3 + background: var(--bg-primary); 4 4 margin: 0 $space-md; 5 5 display: flex; 6 6 flex-direction: column-reverse; ··· 9 9 10 10 @media (min-width: $breakpoint-sm) { 11 11 margin: $space-md 0; 12 - border: 2px solid $black; 12 + border: 2px solid var(--border-primary); 13 13 min-width: 60ch; 14 14 width: 60ch; 15 15 flex-direction: column; ··· 18 18 @media (min-width: $breakpoint-lg) { 19 19 min-width: 78ch; 20 20 } 21 - 22 - @include dark-bg-white; 23 21 } 24 22 25 23 // Header navigation ··· 30 28 flex-wrap: wrap; 31 29 position: sticky; 32 30 bottom: 0; 31 + z-index: 10; 33 32 backdrop-filter: blur(8px); 34 - background: $white-75; 33 + background: var(--bg-header); 35 34 @include mono-font($text-xs); 36 35 37 36 &:not(:first-child) { ··· 42 41 @include dashed-border-top; 43 42 } 44 43 44 + > span:first-child { 45 + align-content: center; 46 + } 47 + 45 48 > * + * { 46 49 margin-left: $space-md; 47 50 } 48 51 49 52 @media (min-width: $breakpoint-sm) { 50 53 text-wrap: wrap; 51 - } 52 - 53 - @media (prefers-color-scheme: dark) { 54 - background: $black-50; 55 54 } 56 55 57 56 nav { ··· 64 63 65 64 a { 66 65 color: inherit; 67 - /* border-bottom: 2px solid transparent; */ 66 + padding-inline: 0.4rem; 67 + text-decoration: none; 68 + border: 2px solid transparent; 68 69 69 70 &.current { 70 - background-color: $black; 71 - padding-inline: 0.4rem; 72 - color: $white; 73 - 74 - @media (prefers-color-scheme: dark) { 75 - background-color: $white; 76 - color: $black; 77 - } 78 - } 79 - 80 - &:not(.current) { 81 - padding-inline: 0.4rem; 71 + background-color: var(--text-primary); 72 + color: var(--bg-primary); 82 73 } 83 74 84 75 &:hover { 85 - font-style: bold; 86 - 87 - @media (prefers-color-scheme: dark) { 88 - border-color: $white-50; 89 - } 76 + border-color: var(--border-primary); 90 77 } 91 78 } 92 79 } ··· 99 86 display: inline-block; 100 87 width: 10px; 101 88 height: 10px; 102 - border: 1px solid #000; 89 + border: 1px solid var(--border-primary); 103 90 border-radius: 6px; 104 91 transition: background 0.3s ease; 105 - 106 - // default/unknown state - gray 107 - background: $zinc-400; 92 + background: var(--border-tertiary); 108 93 109 - // up state - green 110 94 &.up { 111 - background: $green; 95 + background: var(--accent-green); 112 96 } 113 97 114 - // down state - red/accent 115 98 &.down { 116 - background: $accent; 99 + background: var(--accent); 117 100 } 118 101 119 - // pending state - yellow/warning 120 102 &.pending { 121 - background: oklch(80% 0.15 85); // yellowish 103 + background: oklch(80% 0.15 85); 122 104 } 123 105 124 - // maintenance state - blue 125 106 &.maintenance { 126 - background: oklch(60% 0.15 250); // blueish 127 - } 128 - 129 - // unknown/error state 130 - &.unknown { 131 - background: $zinc-400; 107 + background: oklch(60% 0.15 250); 132 108 } 133 109 } 134 110 } ··· 150 126 151 127 p { 152 128 @include mono-font; 153 - color: $zinc-600; 154 - 155 - @media (prefers-color-scheme: dark) { 156 - color: $zinc-400; 157 - } 129 + color: var(--text-secondary); 158 130 } 159 131 } 160 132 161 133 // Button component 162 134 .button { 163 - background: $black; 164 - color: $white; 135 + @include color-swap; 165 136 width: 100%; 166 137 padding: 0 ($space-xs * 3); 167 138 text-align: center; 168 139 font-family: $font-mono; 169 140 text-decoration: none; 170 141 display: inline-block; 171 - border: 2px solid $white; 172 - 173 - &:hover { 174 - background: $white; 175 - color: $black; 176 - border-color: $black; 177 - } 178 - 179 - @media (prefers-color-scheme: dark) { 180 - background: $white; 181 - color: $black; 182 - 183 - &:hover { 184 - background: $black; 185 - color: $white; 186 - border-color: $white; 187 - } 188 - } 189 142 } 190 143 191 144 // Post preview ··· 209 162 gap: $space-xs; 210 163 margin-bottom: $space-sm; 211 164 font-size: $text-sm; 212 - color: $zinc-600; 213 - 214 - @media (prefers-color-scheme: dark) { 215 - color: $zinc-400; 216 - } 165 + color: var(--text-secondary); 217 166 } 218 167 219 168 // Post containers 220 - .post-list, 221 - .featured-post { 169 + .post-list { 222 170 display: flex; 223 171 flex-direction: column; 224 172 @include solid-border; 225 - } 226 - 227 - .post-list { 228 173 margin-top: $space-md; 229 174 230 175 .post-preview:not(:last-child) { 231 - border-bottom: 1px dotted $zinc-200; 232 - @include dark-border; 176 + border-bottom: 1px dotted var(--border-secondary); 233 177 } 234 178 } 235 179 ··· 306 250 307 251 // Links in main content 308 252 a:not(.button):not(.zola-anchor) { 309 - color: inherit; 310 253 @include bold-text; 311 254 text-decoration: none; 312 255 313 - &.zola-anchor { 314 - color: $black !important; 315 - text-decoration: none; 316 - } 317 - 318 256 &:hover { 319 257 text-decoration: underline; 320 258 } 259 + 321 260 &[href^="http"]:not([href*="madoka.systems"]):not([href*="127.0.0.1"]):not( 322 261 [href*="localhost"] 323 262 )::after { 324 - // using a non-breaking space here 325 263 content: " ↗"; 326 264 font-size: 0.875em; 327 265 } 328 266 } 329 267 330 - strong { 331 - @include bold-text; 268 + // Zola anchor links 269 + .zola-anchor { 270 + color: var(--text-primary); 271 + text-decoration: none; 332 272 } 333 273 334 274 // Lists ··· 357 297 } 358 298 359 299 pre { 360 - background: $zinc-200; 300 + background: var(--bg-code); 361 301 margin: 1em 0; 362 302 padding: $space-md; 363 303 font-size: $text-sm; 364 304 line-height: 1.25; 365 305 overflow-x: auto; 366 - border: 2px solid $black; 367 - 368 306 @include solid-border; 369 - 370 - @media (prefers-color-scheme: dark) { 371 - background: $zinc-700; 372 - } 373 307 374 308 code { 375 309 background: none; ··· 381 315 382 316 // Blockquotes 383 317 blockquote { 384 - border-left: 0.25rem solid $zinc-200; 318 + border-left: 0.25rem solid var(--border-secondary); 385 319 padding-left: 1em; 386 320 margin: 1.6em 0; 387 321 font-style: italic; 388 322 font-weight: 400; 389 - @include dark-border; 390 323 } 391 324 392 - // Images 325 + // Images - default max-height prevents tall images from dominating screen 393 326 img, 394 327 video { 395 - @include content-spacing; 328 + max-height: 80vh; 329 + object-fit: contain; 330 + margin-inline: auto; 331 + /* @include content-spacing; */ 332 + } 333 + 334 + // Figure with caption 335 + figure { 336 + margin: 1.6em 0; 337 + 338 + img { 339 + width: 100%; 340 + height: auto; 341 + object-fit: contain; 342 + } 343 + 344 + &.figure--limited img { 345 + width: auto; 346 + max-width: 100%; 347 + display: block; 348 + margin: 0 auto; 349 + } 350 + } 351 + 352 + figcaption { 353 + font-size: $text-sm; 354 + color: var(--text-secondary); 355 + text-align: center; 356 + margin-top: $space-sm; 357 + font-style: italic; 396 358 } 397 359 398 360 // Tables ··· 408 370 td { 409 371 text-align: left; 410 372 padding: 0.5em; 411 - border-bottom: 1px solid $zinc-200; 412 - @include dark-border; 373 + border-bottom: 1px solid var(--border-secondary); 413 374 } 414 375 415 376 th { ··· 419 380 // Horizontal rules 420 381 hr { 421 382 border: none; 422 - border-top: 1px solid $zinc-200; 383 + border-top: 1px solid var(--border-secondary); 423 384 margin: 3em 0; 424 - @include dark-border; 425 385 } 426 386 } 427 387
+42 -14
sass/_variables.scss
··· 46 46 $text-base: 1rem; 47 47 $text-lg: 1.125rem; 48 48 49 + // CSS Custom Properties for theming with light-dark() 50 + :root { 51 + color-scheme: light dark; 52 + 53 + // Background colors 54 + --bg-primary: #{light-dark($white, $black)}; 55 + --bg-secondary: #{light-dark($zinc-100, $zinc-900)}; 56 + --bg-code: #{light-dark($zinc-200, $zinc-700)}; 57 + --bg-header: #{light-dark($white-75, $black-50)}; 58 + 59 + // Text colors 60 + --text-primary: #{light-dark($black, $white)}; 61 + --text-secondary: #{light-dark($zinc-600, $zinc-400)}; 62 + --text-muted: #{light-dark($zinc-500, $zinc-400)}; 63 + 64 + // Border colors 65 + --border-primary: #{light-dark($black, $white)}; 66 + --border-secondary: #{light-dark($zinc-200, $zinc-700)}; 67 + --border-tertiary: #{light-dark($zinc-400, $zinc-500)}; 68 + 69 + // Accent colors (same in both themes) 70 + --accent: #{$accent}; 71 + --accent-green: #{$green}; 72 + } 73 + 49 74 // Mixins for repeated patterns 50 75 @mixin dark-border { 51 - @media (prefers-color-scheme: dark) { 52 - border-color: $zinc-700; 53 - } 76 + border-color: var(--border-secondary); 54 77 } 55 78 56 79 @mixin bold-text { ··· 58 81 } 59 82 60 83 @mixin dashed-border-top { 61 - border-top: 2px dashed $zinc-200; 62 - @include dark-border; 84 + border-top: 2px dashed var(--border-secondary); 63 85 } 64 86 65 87 @mixin solid-border { 66 - border: 2px solid $black; 67 - 68 - @media (prefers-color-scheme: dark) { 69 - border-color: $white; 70 - } 88 + border: 2px solid var(--border-primary); 71 89 } 72 90 73 91 @mixin dark-bg-white { 74 - @media (prefers-color-scheme: dark) { 75 - background: $black; 76 - border-color: $white; 77 - } 92 + background: var(--bg-primary); 93 + border-color: var(--border-primary); 78 94 } 79 95 80 96 @mixin content-spacing { ··· 85 101 font-family: $font-mono; 86 102 font-size: $size; 87 103 } 104 + 105 + // Color swap mixin for buttons and interactive elements 106 + @mixin color-swap { 107 + background: var(--text-primary); 108 + color: var(--bg-primary); 109 + border: 2px solid var(--border-primary); 110 + 111 + &:hover { 112 + background: var(--bg-primary); 113 + color: var(--text-primary); 114 + } 115 + }
+1
sass/main.scss
··· 38 38 @import "variables"; 39 39 @import "base"; 40 40 @import "layout"; 41 + @import "bsky-comments";
+203
static/js/bsky-comments.js
··· 1 + (function () { 2 + const API = 'https://public.api.bsky.app/xrpc' 3 + const REQUEST_TIMEOUT = 10000 4 + 5 + async function getPostThread(uri) { 6 + const controller = new AbortController() 7 + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) 8 + 9 + try { 10 + const res = await fetch( 11 + `${API}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=10`, 12 + { signal: controller.signal } 13 + ) 14 + clearTimeout(timeout) 15 + if (!res.ok) throw new Error('failed to fetch thread') 16 + return res.json() 17 + } catch (e) { 18 + clearTimeout(timeout) 19 + throw e 20 + } 21 + } 22 + 23 + function escapeHtml(text) { 24 + const div = document.createElement('div') 25 + div.textContent = text 26 + return div.innerHTML 27 + } 28 + 29 + function isValidAvatarUrl(url) { 30 + try { 31 + const parsed = new URL(url) 32 + return parsed.protocol === 'https:' && 33 + (parsed.hostname === 'cdn.bsky.app' || parsed.hostname === 'avatar.bsky.app') 34 + } catch { 35 + return false 36 + } 37 + } 38 + 39 + function formatNumber(num) { 40 + if (num === undefined || num === null) return '0' 41 + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M' 42 + if (num >= 1000) return (num / 1000).toFixed(1) + 'k' 43 + return num.toString() 44 + } 45 + 46 + function getPostUrl(uri) { 47 + const match = uri.match(/^at:\/\/did:plc:([^/]+)\/[^/]+\/([^/]+)$/) 48 + if (!match) return null 49 + const [, did, rkey] = match 50 + return `https://bsky.app/profile/did:plc:${did}/post/${rkey}` 51 + } 52 + 53 + function sortByLikes(a, b) { 54 + return (b.post?.likeCount || 0) - (a.post?.likeCount || 0) 55 + } 56 + 57 + function renderComment(reply) { 58 + const post = reply.post 59 + const author = post.author 60 + const text = post.record?.text || '' 61 + 62 + const comment = document.createElement('div') 63 + comment.className = 'bsky-comment' 64 + 65 + const avatarHtml = author.avatar && isValidAvatarUrl(author.avatar) 66 + ? `<img class="bsky-avatar" src="${escapeHtml(author.avatar)}" alt="" loading="lazy">` 67 + : `<div class="bsky-avatar bsky-avatar--fallback"></div>` 68 + 69 + const actionsHtml = ` 70 + <div class="bsky-actions"> 71 + <span class="bsky-action"> 72 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 73 + <path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /> 74 + </svg> 75 + ${formatNumber(post.likeCount)} 76 + </span> 77 + <span class="bsky-action"> 78 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 79 + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c-.017-.22-.032-.441-.046-.662M4.5 12l3 3m-3-3-3 3" /> 80 + </svg> 81 + ${formatNumber(post.repostCount)} 82 + </span> 83 + </div> 84 + ` 85 + 86 + comment.innerHTML = ` 87 + ${avatarHtml} 88 + <div class="bsky-content"> 89 + <div class="bsky-author"> 90 + <a href="https://bsky.app/profile/${escapeHtml(author.handle)}" target="_blank" rel="noopener"> 91 + <span class="bsky-name">${escapeHtml(author.displayName || author.handle)}</span> 92 + <span class="bsky-handle">@${escapeHtml(author.handle)}</span> 93 + </a> 94 + </div> 95 + <div class="bsky-text">${escapeHtml(text)}</div> 96 + ${actionsHtml} 97 + </div> 98 + ` 99 + 100 + return comment 101 + } 102 + 103 + function renderReplies(replies, container) { 104 + const sortedReplies = [...replies].sort(sortByLikes) 105 + 106 + for (const reply of sortedReplies) { 107 + if (reply.$type === 'app.bsky.feed.defs#blockedPost') continue 108 + if (!reply.post) continue 109 + 110 + const wrapper = document.createElement('div') 111 + wrapper.className = 'bsky-comment-wrapper' 112 + wrapper.appendChild(renderComment(reply)) 113 + 114 + if (reply.replies?.length) { 115 + const threadContainer = document.createElement('div') 116 + threadContainer.className = 'bsky-thread' 117 + renderReplies(reply.replies, threadContainer) 118 + wrapper.appendChild(threadContainer) 119 + } 120 + 121 + container.appendChild(wrapper) 122 + } 123 + } 124 + 125 + function renderStats(thread, container) { 126 + const post = thread.post 127 + if (!post) return 128 + 129 + const postUrl = getPostUrl(post.uri) 130 + if (!postUrl) return 131 + 132 + const stats = document.createElement('div') 133 + stats.className = 'bsky-stats' 134 + stats.innerHTML = ` 135 + <a href="${postUrl}" target="_blank" rel="noopener" class="bsky-stats-link"> 136 + <span class="bsky-stat"> 137 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 138 + <path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /> 139 + </svg> 140 + ${formatNumber(post.likeCount)} likes 141 + </span> 142 + <span class="bsky-stat"> 143 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 144 + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c-.017-.22-.032-.441-.046-.662M4.5 12l3 3m-3-3-3 3" /> 145 + </svg> 146 + ${formatNumber(post.repostCount)} reposts 147 + </span> 148 + <span class="bsky-stat"> 149 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 150 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" /> 151 + </svg> 152 + ${formatNumber(post.replyCount)} replies 153 + </span> 154 + </a> 155 + ` 156 + container.appendChild(stats) 157 + 158 + const replyCta = document.createElement('p') 159 + replyCta.className = 'bsky-reply-cta' 160 + replyCta.innerHTML = `reply on <a href="${postUrl}" target="_blank" rel="noopener">bluesky</a> to join the conversation` 161 + container.appendChild(replyCta) 162 + } 163 + 164 + async function init() { 165 + const container = document.getElementById('bsky-comments') 166 + if (!container) return 167 + 168 + const uri = container.dataset.uri 169 + if (!uri) return 170 + 171 + try { 172 + const data = await getPostThread(uri) 173 + if (!data.thread) throw new Error('invalid thread data') 174 + 175 + renderStats(data.thread, container) 176 + 177 + if (data.thread.replies?.length) { 178 + const list = document.createElement('div') 179 + list.className = 'bsky-comments-list' 180 + renderReplies(data.thread.replies, list) 181 + container.appendChild(list) 182 + } else { 183 + const empty = document.createElement('p') 184 + empty.className = 'bsky-empty' 185 + empty.textContent = 'no comments yet. be the first to reply on bluesky!' 186 + container.appendChild(empty) 187 + } 188 + 189 + container.style.display = 'block' 190 + } catch (e) { 191 + console.error('bsky comments:', e) 192 + const error = document.createElement('p') 193 + error.className = 'bsky-error' 194 + error.textContent = 'failed to load comments' 195 + container.appendChild(error) 196 + container.style.display = 'block' 197 + } 198 + } 199 + 200 + document.readyState === 'loading' 201 + ? document.addEventListener('DOMContentLoaded', init) 202 + : init() 203 + })()
+31 -33
templates/macros/post_macros.html
··· 9 9 {% endmacro %} 10 10 11 11 {% macro post_preview(post) %} 12 - <article class="post-preview"> 13 - <h3> 14 - <a href="{{ post.permalink }}">{{ post.title }}</a> 15 - </h3> 16 - {{ self::post_meta(post=post) }} 17 - <div> 18 - {% if post.description -%} 19 - {{ post.description | safe | striptags }} 20 - {% else %} 21 - {{ post.content 22 - | safe | striptags | truncate(length=300) }} {%- endif %} 23 - </div> 24 - <a href="{{ post.permalink }}" 25 - class="button" 26 - aria-label="read full post: {{ post.title }}">Read Now</a> 27 - </article> 28 - {% endmacro %} 29 - {% macro post_meta(post) %} 30 - <aside class="post-meta"> 31 - {% if post.updated and post.updated != post.date %} 32 - <time datetime="{{ post.date }}" 33 - class="updated" 34 - title="Updated {{ post.updated }}"> 35 - {% else %} 36 - <time datetime="{{ post.date }}"> 37 - {% endif %} 38 - {{ post.date }} </time> 39 - 40 - <span class="reading-time">{{ post.reading_time }} min read</span> 41 - <aside /> 42 - {% endmacro %} 43 - </time> 44 - </aside> 12 + <article class="post-preview"> 13 + <h3> 14 + <a href="{{ post.permalink }}">{{ post.title }}</a> 15 + </h3> 16 + {{ self::post_meta(post=post) }} 17 + <div> 18 + {% if post.description -%} 19 + {{ post.description | safe | striptags }} 20 + {% else %} 21 + {{ post.content | safe | striptags | truncate(length=300) }} 22 + {%- endif %} 23 + </div> 24 + <a href="{{ post.permalink }}" 25 + class="button" 26 + aria-label="read full post: {{ post.title }}">Read Now</a> 27 + </article> 28 + {% endmacro %} 29 + 30 + {% macro post_meta(post) %} 31 + <aside class="post-meta"> 32 + {% if post.updated and post.updated != post.date %} 33 + <time datetime="{{ post.date }}" class="updated" title="Updated {{ post.updated }}"> 34 + {% else %} 35 + <time datetime="{{ post.date }}"> 36 + {% endif %} 37 + {{ post.date }} 38 + </time> 39 + 40 + <span class="reading-time">{{ post.reading_time }} min read</span> 41 + </aside> 42 + {% endmacro %}
+4 -1
templates/meta.html
··· 81 81 content="{{ page.date | date(format='%Y-%m-%d') }}" /> 82 82 {%- endif -%} 83 83 <meta name="robots" content="index, follow" /> 84 - <meta name="author" content="{{ config.extra.name }}" /> 84 + <meta name="author" content="{{ config.extra.name }}" /> 85 + <!-- Content Security Policy --> 86 + <meta http-equiv="Content-Security-Policy" 87 + content="default-src 'self'; connect-src 'self' https://public.api.bsky.app https://status.madoka.systems; img-src 'self' https://cdn.bsky.app https://avatar.bsky.app https://blobs.blue; script-src 'self'; style-src 'self';"> 85 88 </head>
+11 -1
templates/post.html
··· 1 - {% extends "page.html" %} {%- block main -%} {{ post_macros::post_meta(post=page) }} {{ super() }} 1 + {% extends "page.html" %} 2 + {%- block main -%} 3 + {{ post_macros::post_meta(post=page) }} 4 + {{ super() }} 5 + 6 + {% if page.extra.bsky_uri %} 7 + <section class="bsky-comments" id="bsky-comments" data-uri="{{ page.extra.bsky_uri }}"> 8 + <h2>comments</h2> 9 + </section> 10 + <script src="{{ get_url(path='js/bsky-comments.js') }}"></script> 11 + {% endif %} 2 12 {%- endblock -%}
+74
templates/shortcodes/image.html
··· 1 + {# Responsive image shortcode - drop-in simple image embedding #} 2 + {# Usage: {{ image(src="photo.jpg", alt="description", caption="optional", eager=false) }} #} 3 + 4 + {# Get image metadata - handle colocated assets automatically #} 5 + {% if page %} 6 + {% set img_path = page.colocated_path ~ src %} 7 + {% else %} 8 + {% set img_path = src %} 9 + {% endif %} 10 + {% set meta = get_image_metadata(path=img_path) %} 11 + 12 + {# Calculate half size for low-res screens #} 13 + {% set half_width = (meta.width / 2) | int %} 14 + {% set half_height = (meta.height / 2) | int %} 15 + 16 + {# Generate different sizes - original and half #} 17 + {% set img_half = resize_image(path=img_path, width=half_width, height=half_height, op="fit") %} 18 + {% set img_full = resize_image(path=img_path, width=meta.width, height=meta.height, op="fit") %} 19 + 20 + {# Generate WebP versions #} 21 + {% set webp_half = resize_image(path=img_path, width=half_width, height=half_height, op="fit", format="webp") %} 22 + {% set webp_full = resize_image(path=img_path, width=meta.width, height=meta.height, op="fit", format="webp") %} 23 + 24 + {# Generate AVIF versions #} 25 + {% set avif_half = resize_image(path=img_path, width=half_width, height=half_height, op="fit", format="avif") %} 26 + {% set avif_full = resize_image(path=img_path, width=meta.width, height=meta.height, op="fit", format="avif") %} 27 + 28 + {# Loading strategy - defaults to lazy, use eager=true for above-fold images #} 29 + {% set loading_attr = "lazy" %} 30 + {% set fetchpriority_attr = "auto" %} 31 + {% set decoding_attr = "async" %} 32 + {% if eager %} 33 + {% set loading_attr = "eager" %} 34 + {% set fetchpriority_attr = "high" %} 35 + {% set decoding_attr = "sync" %} 36 + {% endif %} 37 + 38 + {# Calculate aspect ratio for height-aware sizing #} 39 + {% set aspect_ratio = meta.width / meta.height %} 40 + 41 + {# Height-aware sizes - considers max-height: 80vh constraint #} 42 + {% set sizes_attr = "(max-height: 80vh and max-width: 40rem) calc(80vh * " ~ aspect_ratio ~ "), (max-height: 80vh) calc(80vh * " ~ aspect_ratio ~ "), (max-width: 40rem) 100vw, 65ch" %} 43 + 44 + <figure class="figure"> 45 + <picture> 46 + {# AVIF sources - best compression #} 47 + <source 48 + srcset="{{ avif_half.url }} {{ half_width }}w, {{ avif_full.url }} {{ meta.width }}w" 49 + sizes="{{ sizes_attr }}" 50 + type="image/avif"> 51 + 52 + {# WebP sources - good fallback #} 53 + <source 54 + srcset="{{ webp_half.url }} {{ half_width }}w, {{ webp_full.url }} {{ meta.width }}w" 55 + sizes="{{ sizes_attr }}" 56 + type="image/webp"> 57 + 58 + {# PNG/JPEG fallback - original format #} 59 + <img 60 + src="{{ img_half.url }}" 61 + srcset="{{ img_half.url }} {{ half_width }}w, {{ img_full.url }} {{ meta.width }}w" 62 + sizes="{{ sizes_attr }}" 63 + alt="{{ alt }}" 64 + loading="{{ loading_attr }}" 65 + fetchpriority="{{ fetchpriority_attr }}" 66 + decoding="{{ decoding_attr }}" 67 + width="{{ img_half.width }}" 68 + height="{{ img_half.height }}"> 69 + </picture> 70 + 71 + {% if caption %} 72 + <figcaption>{{ caption }}</figcaption> 73 + {% endif %} 74 + </figure>