[
  {
    "path": ".cursor/mcp.json",
    "content": "{\n  \"mcpServers\": {\n    \"Astro docs\": {\n      \"type\": \"http\",\n      \"url\": \"https://mcp.docs.astro.build/mcp\"\n    }\n  }\n}\n"
  },
  {
    "path": ".gitignore",
    "content": "# build output\ndist/\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n\n# environment variables\n.env\n.env.production\n.dev.vars\n\n# macOS-specific files\n.DS_Store\n\n# jetbrains setting folder\n.idea/\n\n.mastra\n.vercel\n\n# temporary exclude for wrangler \nworker/\n.wrangler/"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"worker-og\"]\n\tpath = worker-og\n\turl = https://github.com/iammatthias/og.git\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"astro-build.astro-vscode\"],\n  \"unwantedRecommendations\": []\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"command\": \"./node_modules/.bin/astro dev\",\n      \"name\": \"Development server\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\"\n    }\n  ]\n}\n"
  },
  {
    "path": "README.md",
    "content": "```\n\n`7MMF'\n  MM\n  MM       ,6\"Yb.  `7MMpMMMb.pMMMb.\n  MM      8)   MM    MM    MM    MM\n  MM       ,pm9MM    MM    MM    MM\n  MM      8M   MM    MM    MM    MM\n.JMML.    `Moo9^Yo..JMML  JMML  JMML.\n\n\n\n                                       ,,          ,,\n`7MMM.     ,MMF'         mm     mm   `7MM          db\n  MMMb    dPMM           MM     MM     MM\n  M YM   ,M MM   ,6\"Yb.mmMMmm mmMMmm   MMpMMMb.  `7MM   ,6\"Yb.  ,pP\"Ybd\n  M  Mb  M' MM  8)   MM  MM     MM     MM    MM    MM  8)   MM  8I   `\"\n  M  YM.P'  MM   ,pm9MM  MM     MM     MM    MM    MM   ,pm9MM  `YMMMa.\n  M  `YM'   MM  8M   MM  MM     MM     MM    MM    MM  8M   MM  L.   I8\n.JML. `'  .JMML.`Moo9^Yo.`Mbmo  `Mbmo.JMML  JMML..JMML.`Moo9^Yo.M9mmmP'\n\n```\n\n### hi\n\nThis is the latest version of my site. It is built with [Astro](https://astro.build/), and the content is authored in [Obsidian](https://obsidian.md/) and stored in a private repo.\n\nImages hosted on Pinata IPFS.\n\nThe site is hosted on [Vercel](https://vercel.com/), and I'm using [PostHog](https://posthog.com/) for some basic analytics.\n\n> The code is provided as-is, and I'm not planning to provide support for this setup. Feel free to use it as inspiration for your own projects.\n\n### built with\n\n- [Astro](https://astro.build/)\n- [Obsidian](https://obsidian.md/)\n- [Pinata](https://www.pinata.cloud/)\n- [Cloudflare](https://www.cloudflare.com/)\n"
  },
  {
    "path": "astro.config.mjs",
    "content": "// @ts-check\nimport { defineConfig } from \"astro/config\";\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport react from \"@astrojs/react\";\nimport rehypeUnwrapImages from \"rehype-unwrap-images\";\n\nimport sitemap from \"@astrojs/sitemap\";\n\nimport cloudflare from \"@astrojs/cloudflare\";\n\n/**\n * @param {string[]} extensions\n * @returns {import('vite').Plugin}\n */\nfunction rawFonts(extensions) {\n  return {\n    name: 'vite-plugin-raw-fonts',\n    enforce: /** @type {const} */ ('pre'),\n    resolveId(id, importer) {\n      if (extensions.some(ext => id.includes(ext))) {\n        if (id.startsWith('.')) {\n          const resolvedPath = path.resolve(path.dirname(importer || ''), id);\n          return resolvedPath;\n        }\n        return id;\n      }\n    },\n    load(id) {\n      if (extensions.some(ext => id.includes(ext))) {\n        try {\n          const buffer = fs.readFileSync(id);\n          return `export default new Uint8Array([${Array.from(buffer).join(',')}]);`;\n        } catch (error) {\n          const err = /** @type {Error} */ (error);\n          console.error('Error loading font:', err.message);\n          throw error;\n        }\n      }\n    }\n  };\n}\n\n// https://astro.build/config\nexport default defineConfig({\n  site: \"https://iammatthias.com\",\n  integrations: [react(), sitemap()],\n\n  markdown: {\n    rehypePlugins: [rehypeUnwrapImages],\n  },\n\n  redirects: {\n    \"/art\": {\n      status: 302,\n      destination: \"/content/art\",\n    },\n    \"/open-source\": {\n      status: 302,\n      destination: \"/content/open-source\",\n    },\n    \"/posts\": {\n      status: 302,\n      destination: \"/content/posts\",\n    },\n    \"/recipes\": {\n      status: 302,\n      destination: \"/content/recipes\",\n    },\n    \"/post/1563778800000\": {\n      status: 302,\n      destination: \"/content/notes/1563778800000-flatframe\",\n    },\n    \"/post/1587970800000\": {\n      status: 302,\n      destination: \"/content/notes/1587970800000-sourdough\",\n    },\n    \"/post/1687071600000\": {\n      status: 302,\n      destination: \"/content/notes/1687071600000-feels-like-summer\",\n    },\n    \"/post/1681369200000\": {\n      status: 302,\n      destination: \"/content/posts/1681369200000-ai-legion-on-bluesky\",\n    },\n    \"/post/1670659200001\": {\n      status: 302,\n      destination: \"/content/posts/1670659200001-obsidian-as-a-cms\",\n    },\n    \"/post/1691436904927\": {\n      status: 302,\n      destination: \"/content/posts/1691436904927-deploy-html-on-replit\",\n    },\n    \"/post/1699332127006\": {\n      status: 302,\n      destination: \"/content/posts/1699332127006-revisiting-obsidian-as-a-cms\",\n    },\n  },\n\n  adapter: cloudflare({\n    prerenderEnvironment: 'node',\n  }),\n\n  vite: {\n    plugins: [rawFonts(['.ttf'])],\n    assetsInclude: ['**/*.wasm'],\n    resolve: {\n      alias: {\n        '@src': path.resolve('./src'),\n        '@styles': path.resolve('./src/styles'),\n        '@components': path.resolve('./src/components'),\n        '@layouts': path.resolve('./src/layouts'),\n        '@pages': path.resolve('./src/pages'),\n        '@mastra': path.resolve('./src/mastra'),\n        '@actions': path.resolve('./src/actions'),\n        '@lib': path.resolve('./src/lib'),\n      },\n    },\n    ssr: {\n      external: [\"buffer\", \"path\", \"fs\"].map((i) => `node:${i}`),\n      noExternal: ['@cf-wasm/resvg'],\n    },\n  },\n});"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"com\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"dev\": \"astro dev\",\n    \"build\": \"astro build\",\n    \"preview\": \"astro preview\",\n    \"astro\": \"astro\"\n  },\n  \"dependencies\": {\n    \"@astrojs/cloudflare\": \"^13.1.10\",\n    \"@astrojs/react\": \"^5.0.3\",\n    \"@astrojs/rss\": \"^4.0.18\",\n    \"@astrojs/sitemap\": \"^3.7.2\",\n    \"@cf-wasm/resvg\": \"^0.3.3\",\n    \"@cloudflare/agents\": \"^0.0.16\",\n    \"@modelcontextprotocol/sdk\": \"^1.29.0\",\n    \"@noble/hashes\": \"^2.2.0\",\n    \"@react-three/drei\": \"^10.7.7\",\n    \"@react-three/fiber\": \"^9.6.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"astro\": \"^6.1.8\",\n    \"pinata\": \"^2.5.5\",\n    \"react\": \"^19.2.5\",\n    \"react-dom\": \"^19.2.5\",\n    \"react-plock\": \"^3.6.1\",\n    \"rehype-unwrap-images\": \"^1.0.0\",\n    \"satori\": \"^0.26.0\",\n    \"satori-html\": \"^0.3.2\",\n    \"three\": \"^0.184.0\",\n    \"viem\": \"^2.48.1\",\n    \"zod\": \"^4\"\n  },\n  \"devDependencies\": {\n    \"wrangler\": \"^4.84.0\"\n  }\n}\n"
  },
  {
    "path": "public/.well-known/atproto-did",
    "content": "did:plc:p5xem22ammiafn5kxonaksfa\n"
  },
  {
    "path": "public/rss.xml.xsl",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n  <xsl:output method=\"html\" encoding=\"UTF-8\" indent=\"yes\"/>\n  \n  <xsl:template match=\"/rss/channel\">\n    <html>\n      <head>\n        <title><xsl:value-of select=\"title\"/> - RSS Feed</title>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n\n        <!-- Open Graph / Social Media -->\n        <meta property=\"og:type\" content=\"website\"/>\n        <meta property=\"og:title\" content=\"{title} - RSS Feed\"/>\n        <meta property=\"og:description\" content=\"{description}\"/>\n        <meta property=\"og:image\" content=\"https://og.iammatthias.com/\"/>\n        <meta property=\"og:url\" content=\"{link}/rss.xml\"/>\n\n        <meta name=\"twitter:card\" content=\"summary_large_image\"/>\n        <meta name=\"twitter:title\" content=\"{title} - RSS Feed\"/>\n        <meta name=\"twitter:description\" content=\"{description}\"/>\n        <meta name=\"twitter:image\" content=\"https://og.iammatthias.com/RSS\"/>\n        <meta name=\"theme-color\" content=\"#0F1419\"/>\n\n        <style>\n          * {\n            margin: 0;\n            padding: 0;\n            box-sizing: border-box;\n          }\n\n          body {\n            font-size: 16px;\n            padding: 1rem;\n            font-family: -apple-system-ui-serif, ui-serif, \"Georgia\", serif;\n            background-color: #0f1419;\n            background-color: color(display-p3 0.06 0.08 0.1);\n            color: #f1faee;\n            color: color(display-p3 0.95 0.98 0.94);\n            caret-color: #ffb800;\n            caret-color: color(display-p3 1 0.75 0);\n            line-height: 1.6;\n          }\n\n          ::selection {\n            background-color: #1b4965;\n            background-color: color(display-p3 0.1 0.3 0.42);\n            color: #fefae0;\n            color: color(display-p3 1 0.98 0.88);\n          }\n\n          ::-moz-selection {\n            background-color: #1b4965;\n            background-color: color(display-p3 0.1 0.3 0.42);\n            color: #fefae0;\n            color: color(display-p3 1 0.98 0.88);\n          }\n          \n          .container {\n            max-width: 1200px;\n            margin: 0 auto;\n            padding: 2rem;\n          }\n\n          h1, h2, h3, h4, h5, h6 {\n            line-height: 1.2;\n            font-weight: 700;\n          }\n\n          h1 {\n            font-size: 3.5em;\n            margin-bottom: 0.5rem;\n          }\n\n          h2 {\n            font-size: 1.5rem;\n            margin: 0 0 0.5rem 0;\n          }\n\n          a {\n            color: inherit;\n            text-decoration: underline;\n          }\n\n          a:hover {\n            opacity: 0.7;\n          }\n\n          .header {\n            margin-bottom: 2rem;\n          }\n\n          .header-meta {\n            display: flex;\n            align-items: center;\n            gap: 1rem;\n            margin-bottom: 2rem;\n          }\n\n          .header-meta p {\n            color: #6b8ca3;\n            color: color(display-p3 0.42 0.55 0.64);\n            margin: 0;\n            font-size: 0.9rem;\n          }\n\n          .rss-link {\n            font-size: 0.85rem;\n            text-decoration: none;\n            color: inherit;\n            border: 1px solid #ffb800;\n            border: 1px solid color(display-p3 1 0.75 0);\n            padding: 0.25rem 0.5rem;\n            font-family: ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\", \"Ubuntu Mono\", monospace;\n          }\n\n          .rss-link:hover {\n            background: #1a1d23;\n            background: color(display-p3 0.1 0.11 0.14);\n            opacity: 1;\n          }\n          \n          .items {\n            display: flex;\n            flex-direction: column;\n            gap: 1.5rem;\n          }\n\n          .item {\n            border: 1px solid #ffb800;\n            border: 1px solid color(display-p3 1 0.75 0);\n            padding: 1.5rem;\n          }\n\n          .item h2 a {\n            text-decoration: none;\n          }\n\n          .item h2 a:hover {\n            text-decoration: underline;\n          }\n\n          .item-meta {\n            font-family: ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\", \"Ubuntu Mono\", monospace;\n            font-size: 0.9rem;\n            color: #6b8ca3;\n            color: color(display-p3 0.42 0.55 0.64);\n            margin-bottom: 1rem;\n          }\n\n          .item-description {\n            line-height: 1.6;\n          }\n\n          .footer {\n            margin-top: 3rem;\n            padding-top: 2rem;\n            border-top: 1px solid #ffb800;\n            border-top: 1px solid color(display-p3 1 0.75 0);\n            display: flex;\n            flex-wrap: wrap;\n            gap: 0;\n          }\n\n          .footer > * {\n            border-right: 1px solid #ffb800;\n            border-right: 1px solid color(display-p3 1 0.75 0);\n            padding: 0.25rem 0.5rem;\n            font-size: 0.8rem;\n            font-family: ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\", \"Ubuntu Mono\", monospace;\n          }\n\n          code {\n            font-family: ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\", \"Ubuntu Mono\", monospace;\n            font-size: 0.9em;\n            padding: 0.2rem 0.4rem;\n            background: #1a1d23;\n            background: color(display-p3 0.1 0.11 0.14);\n          }\n\n          @media (max-width: 768px) {\n            .container {\n              padding: 1rem;\n            }\n\n            .item {\n              padding: 1rem;\n            }\n          }\n\n          @media (max-width: 640px) {\n            body {\n              padding: 0.5rem;\n            }\n\n            .header h1 {\n              font-size: 2.5em;\n            }\n          }\n        </style>\n      </head>\n      <body>\n        <div class=\"container\">\n          <div class=\"header\">\n            <h1><xsl:value-of select=\"title\"/></h1>\n            <div class=\"header-meta\">\n              <p><xsl:value-of select=\"description\"/></p>\n              <a href=\"{link}\" class=\"rss-link\">View Site →</a>\n            </div>\n          </div>\n\n          <div class=\"items\">\n            <xsl:apply-templates select=\"item\"/>\n          </div>\n\n          <div class=\"footer\">\n            <p>Last Updated: <xsl:value-of select=\"lastBuildDate\"/></p>\n            <p>Subscribe by copying the URL into your RSS reader</p>\n          </div>\n        </div>\n      </body>\n    </html>\n  </xsl:template>\n  \n  <xsl:template match=\"item\">\n    <article class=\"item\">\n      <h2>\n        <a href=\"{link}\">\n          <xsl:value-of select=\"title\"/>\n        </a>\n      </h2>\n      <div class=\"item-meta\">\n        <xsl:value-of select=\"pubDate\"/>\n      </div>\n      <div class=\"item-description\">\n        <xsl:value-of select=\"description\"/>\n      </div>\n    </article>\n  </xsl:template>\n  \n</xsl:stylesheet>"
  },
  {
    "path": "src/components/BlackHole/index.tsx",
    "content": "import { useRef, useMemo, useEffect } from \"react\";\nimport { Canvas, useFrame, useThree } from \"@react-three/fiber\";\nimport * as THREE from \"three\";\nimport { vertexShader, fragmentShader } from \"./shaders\";\n\n// Seeded random number generator\nclass SeededRNG {\n  seed: number;\n\n  constructor(seed: number) {\n    this.seed = seed % 2147483647;\n    if (this.seed <= 0) this.seed += 2147483646;\n  }\n\n  next() {\n    this.seed = (this.seed * 16807) % 2147483647;\n    return (this.seed - 1) / 2147483646;\n  }\n}\n\n// Generate procedural starfield texture\nfunction generateStarTexture(seed: number, size: number = 1024): THREE.DataTexture {\n  const data = new Uint8Array(size * size * 4);\n  const rng = new SeededRNG(seed);\n\n  // Fill with dark background\n  for (let i = 0; i < size * size * 4; i += 4) {\n    data[i] = 2;\n    data[i + 1] = 2;\n    data[i + 2] = 5;\n    data[i + 3] = 255;\n  }\n\n  // Add stars - scale count based on texture size\n  const starCount = Math.floor((size / 1024) * (size / 1024) * 4000);\n  for (let i = 0; i < starCount; i++) {\n    const x = Math.floor(rng.next() * size);\n    const y = Math.floor(rng.next() * size);\n    const idx = (y * size + x) * 4;\n\n    const bright = 200 + Math.floor(rng.next() * 55);\n    const tint = rng.next();\n\n    if (tint > 0.95) {\n      // Blue stars\n      data[idx] = bright * 0.8;\n      data[idx + 1] = bright * 0.9;\n      data[idx + 2] = bright;\n    } else if (tint > 0.9) {\n      // Orange stars\n      data[idx] = bright;\n      data[idx + 1] = bright * 0.8;\n      data[idx + 2] = bright * 0.6;\n    } else {\n      // White stars\n      data[idx] = bright;\n      data[idx + 1] = bright;\n      data[idx + 2] = bright;\n    }\n    data[idx + 3] = 255;\n  }\n\n  const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);\n  texture.needsUpdate = true;\n  texture.wrapS = THREE.RepeatWrapping;\n  texture.wrapT = THREE.RepeatWrapping;\n\n  return texture;\n}\n\nfunction BlackHoleShader({ seed, iterations }: { seed: number; iterations: number }) {\n  const meshRef = useRef<THREE.Mesh>(null);\n  const { viewport, size } = useThree();\n\n  // Reuse geometry across renders\n  const geometry = useMemo(() => {\n    return new THREE.PlaneGeometry(1, 1);\n  }, []);\n\n  const material = useMemo(() => {\n    // Scale texture size based on iterations for memory optimization\n    const textureSize = Math.min(1024, Math.max(256, Math.floor(iterations * 4)));\n    const texture = generateStarTexture(seed, textureSize);\n\n    // Enable mipmaps for better memory usage\n    texture.generateMipmaps = true;\n    texture.minFilter = THREE.LinearMipmapLinearFilter;\n\n    return new THREE.ShaderMaterial({\n      vertexShader,\n      fragmentShader,\n      uniforms: {\n        uSpaceTexture: { value: texture },\n        uResolution: { value: new THREE.Vector2(size.width, size.height) },\n        uTime: { value: 0 },\n        uMaxIterations: { value: iterations },\n      },\n    });\n  }, [seed, iterations]);\n\n  useEffect(() => {\n    const handleResize = () => {\n      if (material.uniforms.uResolution) {\n        material.uniforms.uResolution.value.set(window.innerWidth, window.innerHeight);\n      }\n    };\n    window.addEventListener(\"resize\", handleResize);\n    return () => window.removeEventListener(\"resize\", handleResize);\n  }, [material]);\n\n  useEffect(() => {\n    return () => {\n      material.uniforms.uSpaceTexture.value?.dispose();\n      material.dispose();\n      geometry.dispose();\n    };\n  }, [material, geometry]);\n\n  useFrame((state) => {\n    if (material.uniforms.uTime) {\n      material.uniforms.uTime.value = state.clock.elapsedTime;\n    }\n  });\n\n  return (\n    <mesh ref={meshRef} position={[0, 0, 0]} scale={[viewport.width, viewport.height, 1]}>\n      <primitive object={geometry} attach='geometry' />\n      <primitive object={material} attach='material' />\n    </mesh>\n  );\n}\n\nexport default function BlackHole({ seed = 404 }: { seed?: number }) {\n  // Derive iterations from seed (50-250 range)\n  const iterations = useMemo(() => {\n    const normalized = (seed % 200) + 50; // Maps to 50-250 range\n    return normalized;\n  }, [seed]);\n\n  // Scale down DPR for high iteration counts to save memory/performance\n  const dpr = useMemo((): [number, number] => {\n    if (iterations > 200) return [0.5, 0.75];\n    if (iterations > 150) return [0.75, 1];\n    return [1, 1.25];\n  }, [iterations]);\n\n  return (\n    <div\n      style={{\n        position: \"fixed\",\n        top: 54,\n        left: 17,\n        right: 17,\n        bottom: 54,\n        zIndex: -1,\n        pointerEvents: \"none\",\n      }}\n    >\n      <Canvas\n        camera={{ position: [0, 0, 1], fov: 60 }}\n        dpr={dpr}\n        style={{ width: \"100%\", height: \"100%\", display: \"block\" }}\n        resize={{ scroll: false }}\n      >\n        <BlackHoleShader seed={seed} iterations={iterations} />\n      </Canvas>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/BlackHole/shaders.ts",
    "content": "export const vertexShader = `\n  varying vec2 vUv;\n\n  void main() {\n    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    vUv = uv;\n  }\n`;\n\nexport const fragmentShader = `\n  varying vec2 vUv;\n  uniform sampler2D uSpaceTexture;\n  uniform vec2 uResolution;\n  uniform float uTime;\n  uniform int uMaxIterations;\n\n  #define STEP_SIZE 0.1\n  #define PI 3.1415926535897932384626433832795\n  #define TAU 6.283185307179586476925286766559\n\n  vec3 camPos = vec3(0, 1, -7);\n  vec3 blackholePos = vec3(0, 0, 0);\n  float innerDiskRadius = 2.0;\n  float outerDiskRadius = 5.0;\n  float diskTwist = 10.0;\n  float flowRate = 0.6;\n\n  // Noise functions for disk texture\n  float hash(float n) {\n    return fract(sin(n) * 753.5453123);\n  }\n\n  float noise(vec3 x) {\n    vec3 p = floor(x);\n    vec3 f = fract(x);\n    f = f * f * (3.0 - 2.0 * f);\n    float n = p.x + p.y * 157.0 + 113.0 * p.z;\n\n    return mix(mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),\n        mix(hash(n + 157.0), hash(n + 158.0), f.x), f.y),\n        mix(mix(hash(n + 113.0), hash(n + 114.0), f.x),\n        mix(hash(n + 270.0), hash(n + 271.0), f.x), f.y), f.z);\n  }\n\n  float fbm(vec3 pos, const int numOctaves, const float iterScale, const float detail, const float weight) {\n    float mul = weight;\n    float add = 1.0 - 0.5 * mul;\n    float t = noise(pos) * mul + add;\n\n    for (int i = 1; i < numOctaves; ++i) {\n      pos *= iterScale;\n      mul = exp2(log2(weight) - float(i) / detail);\n      add = 1.0 - 0.5 * mul;\n      t *= noise(pos) * mul + add;\n    }\n\n    return t;\n  }\n\n  vec4 raytrace(vec3 rayDir, vec3 rayPos) {\n    vec4 color = vec4(0, 0, 0, 1);\n    float h2 = pow(length(cross(rayPos, rayDir)), 2.0);\n\n    for (int i = 0; i < uMaxIterations; i++) {\n      float dist = length(rayPos - blackholePos);\n\n      // Event horizon check\n      if (dist < 1.0) {\n        return vec4(0, 0, 0, 1);\n      }\n\n      // Schwarzschild metric\n      rayDir += -1.5 * h2 * rayPos / pow(pow(dist, 2.0), 2.5) * STEP_SIZE;\n\n      vec3 steppedRayPos = rayPos + rayDir * STEP_SIZE;\n\n      // Varying depth for accretion disk\n      float depth = pow(STEP_SIZE, 3.0) + fbm(rayPos, 5, 10.0, 1.8, 10.5) * 0.05;\n\n      // Accretion disk check\n      if (dist > innerDiskRadius && dist < outerDiskRadius && rayPos.y * steppedRayPos.y < depth) {\n        // Disk UV calculation\n        float deltaDiskRadius = outerDiskRadius - innerDiskRadius;\n        float diskDist = dist - innerDiskRadius;\n\n        vec3 uvw = vec3(\n          (atan(steppedRayPos.z, abs(steppedRayPos.x)) / TAU) - (diskTwist / sqrt(dist)),\n          pow(diskDist / deltaDiskRadius, 2.0) + ((flowRate / TAU) / deltaDiskRadius),\n          steppedRayPos.y * 0.5 + 0.5\n        ) / 2.0;\n\n        // Disk density with texture\n        float diskDensity = 1.0 - length(steppedRayPos / vec3(outerDiskRadius, 1.0, outerDiskRadius));\n        diskDensity *= smoothstep(innerDiskRadius, innerDiskRadius + 1.0, dist);\n\n        float densityVariation = fbm(2.0 * uvw, 5, 2.0, 1.0, 1.0);\n        diskDensity *= inversesqrt(dist) * densityVariation;\n\n        float opticalDepth = STEP_SIZE * 50.0 * diskDensity;\n\n        // Doppler shift\n        vec3 shiftVector = 0.6 * cross(normalize(steppedRayPos), vec3(0.0, 1.0, 0.0));\n        float velocity = dot(rayDir, shiftVector);\n        float dopplerShift = sqrt((1.0 - velocity) / (1.0 + velocity));\n\n        // Gravitational shift\n        float gravitationalShift = sqrt((1.0 - 2.0 / dist) / (1.0 - 2.0 / length(camPos)));\n\n        return vec4(vec3(1) * dopplerShift * gravitationalShift * opticalDepth, 1.0);\n      }\n\n      rayPos = steppedRayPos;\n    }\n\n    // Sample background texture\n    // Convert ray direction to spherical coordinates for texture sampling\n    float theta = atan(rayDir.z, rayDir.x);\n    float phi = asin(rayDir.y);\n    vec2 texCoord = vec2(\n      theta / TAU + 0.5,\n      phi / PI + 0.5\n    );\n\n    color = texture2D(uSpaceTexture, texCoord);\n\n    return color;\n  }\n\n  void main() {\n    vec2 uv = (vUv - 0.5) * 2.0 * vec2(uResolution.x / uResolution.y, 1);\n\n    vec3 rayDir = normalize(vec3(uv, 1));\n    vec3 rayPos = camPos;\n\n    gl_FragColor = raytrace(rayDir, rayPos);\n  }\n`;\n"
  },
  {
    "path": "src/components/Bluesky/Bluesky.css",
    "content": ".bsky-container {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n}\n\n.bsky-feed {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n}\n\n.bsky-post {\n    border: 1px solid var(--color-border);\n    padding: 1rem;\n    display: flex;\n    flex-direction: column;\n    gap: 0.75rem;\n}\n\n.bsky-post-link {\n    text-decoration: none;\n    color: inherit;\n    display: flex;\n    flex-direction: column;\n    gap: 0.75rem;\n}\n\n.bsky-post-header {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.bsky-avatar {\n    width: 40px;\n    height: 40px;\n    border-radius: 50%;\n    object-fit: cover;\n}\n\n.bsky-author {\n    display: flex;\n    flex-direction: column;\n    flex: 1;\n    min-width: 0;\n}\n\n.bsky-display-name {\n    font-weight: 600;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.bsky-handle {\n    color: var(--color-muted);\n    font-size: 0.875rem;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.bsky-time {\n    color: var(--color-muted);\n    font-size: 0.875rem;\n    white-space: nowrap;\n}\n\n.bsky-text {\n    margin: 0;\n    white-space: pre-wrap;\n    word-wrap: break-word;\n    line-height: 1.5;\n}\n\n/* Image grid layouts */\n.bsky-images {\n    display: grid;\n    gap: 0.25rem;\n    border-radius: 0.5rem;\n    overflow: hidden;\n}\n\n.bsky-images-1 {\n    grid-template-columns: 1fr;\n}\n\n.bsky-images-2 {\n    grid-template-columns: 1fr 1fr;\n}\n\n.bsky-images-3 {\n    grid-template-columns: 1fr 1fr;\n    grid-template-rows: 1fr 1fr;\n}\n\n.bsky-images-3 .bsky-image:first-child {\n    grid-row: span 2;\n}\n\n.bsky-images-4 {\n    grid-template-columns: 1fr 1fr;\n    grid-template-rows: 1fr 1fr;\n}\n\n.bsky-image {\n    display: block;\n    overflow: hidden;\n}\n\n.bsky-image img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    transition: opacity 0.2s ease;\n}\n\n.bsky-image:hover img {\n    opacity: 0.9;\n}\n\n/* Video embed */\n.bsky-video {\n    border-radius: 0.5rem;\n    overflow: hidden;\n}\n\n.bsky-video video {\n    width: 100%;\n    display: block;\n}\n\n.bsky-video-alt {\n    display: block;\n    padding: 0.5rem;\n    font-size: 0.875rem;\n    color: var(--color-muted);\n    background: var(--color-background);\n}\n\n/* External link embed */\n.bsky-external {\n    display: flex;\n    flex-direction: column;\n    border: 1px solid var(--color-border);\n    border-radius: 0.5rem;\n    overflow: hidden;\n    text-decoration: none;\n    color: inherit;\n    transition: border-color 0.2s ease;\n}\n\n.bsky-external:hover {\n    border-color: var(--color-muted);\n}\n\n.bsky-external-thumb {\n    width: 100%;\n    height: auto;\n    max-height: 200px;\n    object-fit: cover;\n}\n\n.bsky-external-content {\n    padding: 0.75rem;\n    display: flex;\n    flex-direction: column;\n    gap: 0.25rem;\n}\n\n.bsky-external-title {\n    font-weight: 600;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.bsky-external-description {\n    font-size: 0.875rem;\n    color: var(--color-muted);\n    display: -webkit-box;\n    -webkit-line-clamp: 2;\n    -webkit-box-orient: vertical;\n    overflow: hidden;\n}\n\n.bsky-external-uri {\n    font-size: 0.75rem;\n    color: var(--color-muted);\n}\n\n/* Quote embed */\n.bsky-quote {\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n    padding: 0.75rem;\n    border: 1px solid var(--color-border);\n    border-radius: 0.5rem;\n    text-decoration: none;\n    color: inherit;\n    transition: border-color 0.2s ease;\n}\n\n.bsky-quote:hover {\n    border-color: var(--color-muted);\n}\n\n.bsky-quote-header {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.bsky-quote-avatar {\n    width: 20px;\n    height: 20px;\n    border-radius: 50%;\n    object-fit: cover;\n}\n\n.bsky-quote-author {\n    display: flex;\n    gap: 0.25rem;\n    font-size: 0.875rem;\n    overflow: hidden;\n}\n\n.bsky-quote-handle {\n    color: var(--color-muted);\n}\n\n.bsky-quote-text {\n    margin: 0;\n    font-size: 0.875rem;\n    color: var(--color-muted);\n    display: -webkit-box;\n    -webkit-line-clamp: 3;\n    -webkit-box-orient: vertical;\n    overflow: hidden;\n}\n\n/* Quote label */\n.bsky-quote-label {\n    font-size: 0.875rem;\n    color: var(--color-muted);\n}\n\n/* Loading/error/empty states */\n.bsky-loading,\n.bsky-error,\n.bsky-empty {\n    text-align: center;\n    padding: 2rem;\n    color: var(--color-muted);\n}\n\n.bsky-error {\n    color: var(--color-error, #e74c3c);\n}\n\n/* View all link */\n.bsky-view-all {\n    text-align: center;\n    margin-top: 0.5rem;\n}\n\n.bsky-view-all a {\n    text-decoration: none;\n    color: inherit;\n    font-weight: 500;\n}\n\n.bsky-view-all a:hover {\n    text-decoration: underline;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n    .bsky-post {\n        padding: 0.75rem;\n    }\n\n    .bsky-avatar {\n        width: 32px;\n        height: 32px;\n    }\n\n    .bsky-images-3 .bsky-image:first-child {\n        grid-row: span 1;\n    }\n\n    .bsky-images-3 {\n        grid-template-columns: 1fr;\n        grid-template-rows: auto;\n    }\n}\n"
  },
  {
    "path": "src/components/Bluesky/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport \"./Bluesky.css\";\n\n// Types matching the API response\ninterface NormalizedImage {\n  type: \"image\";\n  thumb: string;\n  fullsize: string;\n  alt: string;\n  aspectRatio?: { width: number; height: number };\n  mimeType: string;\n}\n\ninterface NormalizedVideo {\n  type: \"video\";\n  url: string;\n  alt?: string;\n  aspectRatio?: { width: number; height: number };\n  mimeType: string;\n}\n\ninterface NormalizedExternal {\n  type: \"external\";\n  uri: string;\n  title: string;\n  description: string;\n  thumb?: string;\n}\n\ninterface NormalizedQuote {\n  type: \"quote\";\n  uri: string;\n  cid: string;\n}\n\ntype NormalizedEmbed =\n  | NormalizedImage\n  | NormalizedVideo\n  | NormalizedExternal\n  | NormalizedQuote;\n\ninterface BlueskyPost {\n  uri: string;\n  cid: string;\n  text: string;\n  createdAt: string;\n  embeds: NormalizedEmbed[];\n  postUrl: string;\n}\n\nfunction formatDate(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n  const diffHours = Math.floor(diffMs / 3600000);\n  const diffDays = Math.floor(diffMs / 86400000);\n\n  if (diffMins < 1) return \"just now\";\n  if (diffMins < 60) return `${diffMins}m`;\n  if (diffHours < 24) return `${diffHours}h`;\n  if (diffDays < 7) return `${diffDays}d`;\n\n  return date.toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: date.getFullYear() !== now.getFullYear() ? \"numeric\" : undefined,\n  });\n}\n\nfunction ImageEmbed({ embed }: { embed: NormalizedImage }) {\n  return (\n    <a\n      href={embed.fullsize}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"bsky-image\"\n    >\n      <img src={embed.thumb} alt={embed.alt} loading=\"lazy\" />\n    </a>\n  );\n}\n\nfunction VideoEmbed({ embed }: { embed: NormalizedVideo }) {\n  return (\n    <div className=\"bsky-video\">\n      <video controls preload=\"metadata\">\n        <source src={embed.url} type={embed.mimeType} />\n        Your browser does not support this video format.\n      </video>\n      {embed.alt && <span className=\"bsky-video-alt\">{embed.alt}</span>}\n    </div>\n  );\n}\n\nfunction ExternalEmbed({ embed }: { embed: NormalizedExternal }) {\n  return (\n    <a\n      href={embed.uri}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"bsky-external\"\n    >\n      {embed.thumb && (\n        <img\n          src={embed.thumb}\n          alt=\"\"\n          className=\"bsky-external-thumb\"\n          loading=\"lazy\"\n        />\n      )}\n      <div className=\"bsky-external-content\">\n        <span className=\"bsky-external-title\">{embed.title}</span>\n        <span className=\"bsky-external-description\">{embed.description}</span>\n        <span className=\"bsky-external-uri\">{new URL(embed.uri).hostname}</span>\n      </div>\n    </a>\n  );\n}\n\nfunction QuoteEmbed({ embed }: { embed: NormalizedQuote }) {\n  // Extract handle/postId from URI: at://did:plc:xxx/app.bsky.feed.post/postid\n  const uriParts = embed.uri.split(\"/\");\n  const postId = uriParts[uriParts.length - 1];\n  const did = uriParts[2];\n\n  return (\n    <a\n      href={`https://bsky.app/profile/${did}/post/${postId}`}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"bsky-quote\"\n    >\n      <span className=\"bsky-quote-label\">Quoted post</span>\n    </a>\n  );\n}\n\nfunction EmbedRenderer({ embed }: { embed: NormalizedEmbed }) {\n  switch (embed.type) {\n    case \"image\":\n      return <ImageEmbed embed={embed} />;\n    case \"video\":\n      return <VideoEmbed embed={embed} />;\n    case \"external\":\n      return <ExternalEmbed embed={embed} />;\n    case \"quote\":\n      return <QuoteEmbed embed={embed} />;\n    default:\n      return null;\n  }\n}\n\nfunction PostCard({ post }: { post: BlueskyPost }) {\n  // Group images together for grid layout\n  const images = post.embeds.filter(\n    (e): e is NormalizedImage => e.type === \"image\",\n  );\n  const otherEmbeds = post.embeds.filter((e) => e.type !== \"image\");\n\n  return (\n    <article className=\"bsky-post\">\n      <a\n        href={post.postUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"bsky-post-link\"\n      >\n        <time className=\"bsky-time\" dateTime={post.createdAt}>\n          {formatDate(post.createdAt)}\n        </time>\n        {post.text && <p className=\"bsky-text\">{post.text}</p>}\n      </a>\n\n      {images.length > 0 && (\n        <div\n          className={`bsky-images bsky-images-${Math.min(images.length, 4)}`}\n        >\n          {images.map((img, i) => (\n            <ImageEmbed key={i} embed={img} />\n          ))}\n        </div>\n      )}\n\n      {otherEmbeds.map((embed, i) => (\n        <EmbedRenderer key={i} embed={embed} />\n      ))}\n    </article>\n  );\n}\n\ninterface BlueskyFeedProps {\n  limit?: number;\n}\n\nexport default function BlueskyFeed({ limit = 10 }: BlueskyFeedProps) {\n  const [posts, setPosts] = useState<BlueskyPost[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [errorMsg, setErrorMsg] = useState<string | null>(null);\n\n  useEffect(() => {\n    async function fetchPosts() {\n      setIsLoading(true);\n      setErrorMsg(null);\n      try {\n        const response = await fetch(\n          `/api/bluesky.json?limit=${limit}&_=${Date.now()}`,\n        );\n        if (!response.ok) {\n          throw new Error(`HTTP error! status: ${response.status}`);\n        }\n        const data: BlueskyPost[] = await response.json();\n        console.log(\n          \"Bluesky posts received:\",\n          data.map((p) => ({\n            text: p.text.slice(0, 30),\n            createdAt: p.createdAt,\n          })),\n        );\n        setPosts(data);\n      } catch (error) {\n        const msg =\n          \"Error fetching Bluesky posts: \" +\n          (error instanceof Error ? error.message : String(error));\n        setErrorMsg(msg);\n        console.error(msg);\n      } finally {\n        setIsLoading(false);\n      }\n    }\n\n    fetchPosts();\n  }, [limit]);\n\n  if (isLoading) {\n    return (\n      <div className=\"bsky-container\">\n        <div className=\"bsky-loading\">Loading posts from Bluesky...</div>\n      </div>\n    );\n  }\n\n  if (errorMsg) {\n    return (\n      <div className=\"bsky-container\">\n        <div className=\"bsky-error\">{errorMsg}</div>\n      </div>\n    );\n  }\n\n  if (posts.length === 0) {\n    return (\n      <div className=\"bsky-container\">\n        <div className=\"bsky-empty\">No posts found.</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"bsky-container\">\n      <div className=\"bsky-feed\">\n        {posts.map((post) => (\n          <PostCard key={post.cid} post={post} />\n        ))}\n      </div>\n      <div className=\"bsky-view-all\">\n        <a\n          href=\"https://bsky.app/profile/iammatthias.com\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          View more on Bluesky →\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/Farcaster/Farcaster.css",
    "content": ".fc-container {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.fc-feed {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.fc-post {\n  border: 1px solid var(--color-border);\n  padding: 1rem;\n  display: flex;\n  flex-direction: column;\n  gap: 0.75rem;\n}\n\n.fc-post-link {\n  text-decoration: none;\n  color: inherit;\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.fc-time {\n  color: var(--color-muted);\n  font-size: 0.875rem;\n}\n\n.fc-text {\n  margin: 0;\n  white-space: pre-wrap;\n  word-wrap: break-word;\n  line-height: 1.5;\n}\n\n/* Image grid layouts */\n.fc-images {\n  display: grid;\n  gap: 0.25rem;\n  border-radius: 0.5rem;\n  overflow: hidden;\n}\n\n.fc-images-1 {\n  grid-template-columns: 1fr;\n}\n\n.fc-images-2 {\n  grid-template-columns: 1fr 1fr;\n}\n\n.fc-images-3 {\n  grid-template-columns: 1fr 1fr;\n  grid-template-rows: 1fr 1fr;\n}\n\n.fc-images-3 .fc-image:first-child {\n  grid-row: span 2;\n}\n\n.fc-images-4 {\n  grid-template-columns: 1fr 1fr;\n  grid-template-rows: 1fr 1fr;\n}\n\n.fc-image {\n  display: block;\n  overflow: hidden;\n}\n\n.fc-image img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  transition: opacity 0.2s ease;\n}\n\n.fc-image:hover img {\n  opacity: 0.9;\n}\n\n/* Link embed */\n.fc-link {\n  display: inline-block;\n  padding: 0.5rem 0.75rem;\n  border: 1px solid var(--color-border);\n  border-radius: 0.25rem;\n  text-decoration: none;\n  color: var(--color-muted);\n  font-size: 0.875rem;\n  transition: border-color 0.2s ease;\n}\n\n.fc-link:hover {\n  border-color: var(--color-foreground);\n}\n\n/* Quote embed */\n.fc-quote {\n  display: flex;\n  padding: 0.75rem;\n  border: 1px solid var(--color-border);\n  border-radius: 0.5rem;\n  text-decoration: none;\n  color: inherit;\n  transition: border-color 0.2s ease;\n}\n\n.fc-quote:hover {\n  border-color: var(--color-muted);\n}\n\n.fc-quote-label {\n  font-size: 0.875rem;\n  color: var(--color-muted);\n}\n\n/* Loading/error states */\n.fc-loading,\n.fc-error {\n  text-align: center;\n  padding: 2rem;\n  color: var(--color-muted);\n}\n\n.fc-error {\n  color: var(--color-error, #e74c3c);\n}\n\n/* View all link */\n.fc-view-all {\n  text-align: center;\n  margin-top: 0.5rem;\n}\n\n.fc-view-all a {\n  text-decoration: none;\n  color: inherit;\n  font-weight: 500;\n}\n\n.fc-view-all a:hover {\n  text-decoration: underline;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n  .fc-post {\n    padding: 0.75rem;\n  }\n\n  .fc-images-3 .fc-image:first-child {\n    grid-row: span 1;\n  }\n\n  .fc-images-3 {\n    grid-template-columns: 1fr;\n    grid-template-rows: auto;\n  }\n}\n"
  },
  {
    "path": "src/components/Farcaster/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport \"./Farcaster.css\";\n\ninterface NormalizedEmbed {\n  type: \"url\" | \"cast\";\n  url?: string;\n  castFid?: number;\n  castHash?: string;\n}\n\ninterface FarcasterPost {\n  hash: string;\n  text: string;\n  createdAt: string;\n  embeds: NormalizedEmbed[];\n  postUrl: string;\n}\n\nfunction formatDate(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n  const diffHours = Math.floor(diffMs / 3600000);\n  const diffDays = Math.floor(diffMs / 86400000);\n\n  if (diffMins < 1) return \"just now\";\n  if (diffMins < 60) return `${diffMins}m`;\n  if (diffHours < 24) return `${diffHours}h`;\n  if (diffDays < 7) return `${diffDays}d`;\n\n  return date.toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: date.getFullYear() !== now.getFullYear() ? \"numeric\" : undefined,\n  });\n}\n\nfunction isImageUrl(url: string): boolean {\n  return /\\.(jpg|jpeg|png|gif|webp)$/i.test(url);\n}\n\nfunction UrlEmbed({ url }: { url: string }) {\n  if (isImageUrl(url)) {\n    return (\n      <a href={url} target=\"_blank\" rel=\"noopener noreferrer\" className=\"fc-image\">\n        <img src={url} alt=\"\" loading=\"lazy\" />\n      </a>\n    );\n  }\n\n  return (\n    <a href={url} target=\"_blank\" rel=\"noopener noreferrer\" className=\"fc-link\">\n      {new URL(url).hostname}\n    </a>\n  );\n}\n\nfunction PostCard({ post }: { post: FarcasterPost }) {\n  const imageEmbeds = post.embeds.filter((e) => e.type === \"url\" && e.url && isImageUrl(e.url));\n  const otherEmbeds = post.embeds.filter((e) => !(e.type === \"url\" && e.url && isImageUrl(e.url)));\n\n  return (\n    <article className=\"fc-post\">\n      <a href={post.postUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"fc-post-link\">\n        <time className=\"fc-time\" dateTime={post.createdAt}>\n          {formatDate(post.createdAt)}\n        </time>\n        {post.text && <p className=\"fc-text\">{post.text}</p>}\n      </a>\n\n      {imageEmbeds.length > 0 && (\n        <div className={`fc-images fc-images-${Math.min(imageEmbeds.length, 4)}`}>\n          {imageEmbeds.map((embed, i) => (\n            <UrlEmbed key={i} url={embed.url!} />\n          ))}\n        </div>\n      )}\n\n      {otherEmbeds.map((embed, i) => {\n        if (embed.type === \"url\" && embed.url) {\n          return <UrlEmbed key={i} url={embed.url} />;\n        }\n        if (embed.type === \"cast\" && embed.castHash) {\n          const hashHex = embed.castHash.startsWith(\"0x\") ? embed.castHash.slice(2) : embed.castHash;\n          return (\n            <a\n              key={i}\n              href={`https://warpcast.com/~/conversations/${hashHex}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"fc-quote\"\n            >\n              <span className=\"fc-quote-label\">Quoted cast</span>\n            </a>\n          );\n        }\n        return null;\n      })}\n    </article>\n  );\n}\n\ninterface FarcasterFeedProps {\n  limit?: number;\n  onDataLoaded?: (posts: FarcasterPost[]) => void;\n}\n\nexport default function FarcasterFeed({ limit = 10, onDataLoaded }: FarcasterFeedProps) {\n  const [posts, setPosts] = useState<FarcasterPost[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [errorMsg, setErrorMsg] = useState<string | null>(null);\n\n  useEffect(() => {\n    async function fetchPosts() {\n      setIsLoading(true);\n      setErrorMsg(null);\n      try {\n        const response = await fetch(`/api/farcaster.json?limit=${limit}&_=${Date.now()}`);\n        if (!response.ok) {\n          throw new Error(`HTTP error! status: ${response.status}`);\n        }\n        const data: FarcasterPost[] = await response.json();\n        setPosts(data);\n        onDataLoaded?.(data);\n      } catch (error) {\n        const msg = \"Error fetching Farcaster posts: \" + (error instanceof Error ? error.message : String(error));\n        setErrorMsg(msg);\n        console.error(msg);\n        onDataLoaded?.([]);\n      } finally {\n        setIsLoading(false);\n      }\n    }\n\n    fetchPosts();\n  }, [limit]);\n\n  if (isLoading) {\n    return (\n      <div className=\"fc-container\">\n        <div className=\"fc-loading\">Loading casts from Farcaster...</div>\n      </div>\n    );\n  }\n\n  if (errorMsg) {\n    return (\n      <div className=\"fc-container\">\n        <div className=\"fc-error\">{errorMsg}</div>\n      </div>\n    );\n  }\n\n  if (posts.length === 0) {\n    return null; // Return nothing if no posts (ephemeral behavior)\n  }\n\n  return (\n    <div className=\"fc-container\">\n      <div className=\"fc-feed\">\n        {posts.map((post) => (\n          <PostCard key={post.hash} post={post} />\n        ))}\n      </div>\n      <div className=\"fc-view-all\">\n        <a href=\"https://warpcast.com/iammatthias\" target=\"_blank\" rel=\"noopener noreferrer\">\n          View more on Warpcast →\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/OnchainAnalytics/OnchainAnalytics.css",
    "content": ".onchain-analytics-container {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.onchain-analytics-container > *:not(.onchain-analytics-session-hashes) {\n  max-width: 600px;\n}\n\n.onchain-analytics-loading {\n  text-align: center;\n  color: #888;\n  font-size: 1.1rem;\n}\n\n.onchain-analytics-error {\n  color: #c00;\n  background: #fee;\n  padding: 1rem;\n  border-radius: 0.5rem;\n  margin-bottom: 1rem;\n  text-align: center;\n}\n\n.onchain-analytics-break {\n  word-break: break-all;\n}\n\n.onchain-analytics-session-hashes {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.5rem;\n  margin-top: 0.5rem;\n}\n\n.onchain-analytics-session-hash {\n  font-family: monospace;\n  font-size: 0.75rem;\n  transition: background 0.2s;\n}\n\n.onchain-analytics-session-hash:hover {\n  background: #e0e0e0;\n}\n"
  },
  {
    "path": "src/components/OnchainAnalytics/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { baseSepoliaClient } from \"@lib/viemProvider\";\nimport \"./OnchainAnalytics.css\";\n\n// ABI fragments for the analytics contract\nconst analyticsABI = [\n  {\n    inputs: [],\n    name: \"getAllSessionIds\",\n    outputs: [{ internalType: \"bytes32[]\", name: \"\", type: \"bytes32[]\" }],\n    stateMutability: \"view\",\n    type: \"function\",\n  },\n  {\n    inputs: [],\n    name: \"getSessionCount\",\n    outputs: [{ internalType: \"uint256\", name: \"\", type: \"uint256\" }],\n    stateMutability: \"view\",\n    type: \"function\",\n  },\n  {\n    inputs: [],\n    name: \"getAllPageViews\",\n    outputs: [\n      { internalType: \"string[]\", name: \"\", type: \"string[]\" },\n      { internalType: \"uint256[]\", name: \"\", type: \"uint256[]\" },\n      { internalType: \"uint64[]\", name: \"\", type: \"uint64[]\" },\n    ],\n    stateMutability: \"view\",\n    type: \"function\",\n  },\n  {\n    inputs: [],\n    name: \"getAllEvents\",\n    outputs: [\n      { internalType: \"string[]\", name: \"\", type: \"string[]\" },\n      { internalType: \"uint256[]\", name: \"\", type: \"uint256[]\" },\n      { internalType: \"uint64[]\", name: \"\", type: \"uint64[]\" },\n    ],\n    stateMutability: \"view\",\n    type: \"function\",\n  },\n] as const;\n\ninterface PageView {\n  page: string;\n  views: number;\n}\n\ninterface Event {\n  event: string;\n  count: number;\n}\n\ninterface OnchainAnalyticsProps {\n  contractAddress: `0x${string}`;\n}\n\nexport default function OnchainAnalytics({ contractAddress }: OnchainAnalyticsProps) {\n  const [pageViews, setPageViews] = useState<PageView[]>([]);\n  const [events, setEvents] = useState<Event[]>([]);\n  const [sessionCount, setSessionCount] = useState<bigint>(0n);\n  const [sessionIds, setSessionIds] = useState<`0x${string}`[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [errorMsg, setErrorMsg] = useState<string | null>(null);\n\n  useEffect(() => {\n    async function fetchAnalytics() {\n      setIsLoading(true);\n      setErrorMsg(null);\n      try {\n        const [count, views, eventsData, ids] = await Promise.all([\n          baseSepoliaClient.readContract({\n            address: contractAddress,\n            abi: analyticsABI,\n            functionName: \"getSessionCount\",\n          }),\n          baseSepoliaClient.readContract({\n            address: contractAddress,\n            abi: analyticsABI,\n            functionName: \"getAllPageViews\",\n          }),\n          baseSepoliaClient.readContract({\n            address: contractAddress,\n            abi: analyticsABI,\n            functionName: \"getAllEvents\",\n          }),\n          baseSepoliaClient.readContract({\n            address: contractAddress,\n            abi: analyticsABI,\n            functionName: \"getAllSessionIds\",\n          }),\n        ]);\n\n        setSessionCount(count as bigint);\n\n        // Process page views\n        if (Array.isArray(views) && views.length === 3) {\n          const [pagePathsRaw, pageOccurrencesRaw] = views as readonly [readonly string[], readonly bigint[], unknown];\n          const pagePaths = [...pagePathsRaw];\n          const pageOccurrences = [...(pageOccurrencesRaw as readonly bigint[])].map(Number);\n          if (Array.isArray(pagePaths) && Array.isArray(pageOccurrences)) {\n            setPageViews(\n              pagePaths\n                .map((page, i) => ({\n                  page,\n                  views: pageOccurrences[i],\n                }))\n                .filter(({ page }) => page !== \"/test\" && page !== \"/posts/1546329600000-markdown-test\")\n                .sort((a, b) => b.views - a.views)\n                .slice(0, 5)\n            );\n          }\n        }\n\n        // Process events\n        if (Array.isArray(eventsData) && eventsData.length === 3) {\n          const [eventNamesRaw, eventOccurrencesRaw] = eventsData as readonly [\n            readonly string[],\n            readonly bigint[],\n            unknown,\n          ];\n          const eventNames = [...eventNamesRaw];\n          const eventOccurrences = [...(eventOccurrencesRaw as readonly bigint[])].map(Number);\n          if (Array.isArray(eventNames) && Array.isArray(eventOccurrences)) {\n            setEvents(\n              eventNames\n                .map((event, i) => ({\n                  event,\n                  count: eventOccurrences[i],\n                }))\n                .sort((a, b) => b.count - a.count)\n                .slice(0, 3)\n            );\n          }\n        }\n\n        setSessionIds(ids as `0x${string}`[]);\n      } catch (error) {\n        setErrorMsg(\"Error fetching analytics data: \" + (error instanceof Error ? error.message : String(error)));\n        console.error(errorMsg);\n      } finally {\n        setIsLoading(false);\n      }\n    }\n\n    fetchAnalytics();\n  }, [contractAddress]);\n\n  if (isLoading) {\n    return (\n      <div className='onchain-analytics-container'>\n        <div className='onchain-analytics-loading'>Loading analytics data...</div>\n      </div>\n    );\n  }\n\n  if (errorMsg) {\n    return (\n      <div className='onchain-analytics-container'>\n        <div className='onchain-analytics-error'>{errorMsg}</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className='onchain-analytics-container'>\n      <p>\n        <span id='sessionCount'>{sessionCount.toString()}</span> unique sessions have been recorded.\n      </p>\n\n      <table className='onchain-analytics-table'>\n        <thead>\n          <tr>\n            <th>Top 5 pages</th>\n            <th>Views</th>\n          </tr>\n        </thead>\n        <tbody>\n          {pageViews.map((view, index) => (\n            <tr key={index}>\n              <td className='onchain-analytics-break'>{view.page}</td>\n              <td>{view.views}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n\n      <table className='onchain-analytics-table'>\n        <thead>\n          <tr>\n            <th>Top events</th>\n            <th>Count</th>\n          </tr>\n        </thead>\n        <tbody>\n          {events.map((event, index) => (\n            <tr key={index}>\n              <td>{event.event}</td>\n              <td>{event.count}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n\n      <h4>Session Hashes</h4>\n      <p>\n        The keccack256 hashes generated are technically valid private keys. These private keys are effectively burned,\n        since they are public. As such, they should never be used for anything.\n      </p>\n      <p>Click on a hash to view its corresponding portrait:</p>\n      <div className='onchain-analytics-session-hashes'>\n        {sessionIds.map((sessionId, index) => (\n          <a\n            key={index}\n            href={`/onchain-analytics/${sessionId}`}\n            className='onchain-analytics-session-hash onchain-analytics-break'\n          >\n            {sessionId}\n          </a>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/RecentGlass/RecentGlass.css",
    "content": ".recent-glass-container {\n  margin-top: auto;\n  min-height: calc(50vh - 2rem);\n  border-top: 1px solid var(--color-border);\n  padding: 2rem;\n  @media (max-width: 768px) {\n    padding: 1rem;\n  }\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.recent-glass-container h2 {\n  margin: 0 0 1rem 0;\n  font-size: 1.5rem;\n}\n\n.glass-photo {\n  position: relative;\n  display: block;\n  overflow: hidden;\n  border: 1px solid var(--color-border);\n  text-decoration: none;\n}\n\n.glass-photo img {\n  width: 100%;\n  height: auto;\n  display: block;\n}\n\n.overlay {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.75);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 1rem;\n  opacity: 0;\n  transition: opacity 0.3s ease;\n}\n\n.glass-photo:hover .overlay {\n  opacity: 1;\n}\n\n.exif-info {\n  color: white;\n  text-align: center;\n  font-size: 0.85rem;\n  line-height: 1.5;\n}\n\n.exif-line {\n  margin-bottom: 0.25rem;\n}\n\n.caption-text {\n  margin-top: 0.75rem;\n  font-style: italic;\n  opacity: 0.9;\n}\n\n.recent-glass-loading,\n.recent-glass-error,\n.recent-glass-empty {\n  text-align: center;\n  padding: 2rem;\n  @media (max-width: 768px) {\n    padding: 1rem;\n  }\n  color: var(--color-muted);\n}\n\n.recent-glass-error {\n  color: var(--color-error, #e74c3c);\n}\n\n.view-all {\n  text-align: center;\n  margin-top: 1rem;\n}\n\n.view-all a {\n  text-decoration: none;\n  color: inherit;\n  font-weight: 500;\n}\n\n.view-all a:hover {\n  text-decoration: underline;\n}\n"
  },
  {
    "path": "src/components/RecentGlass/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { Masonry } from \"react-plock\";\nimport \"./RecentGlass.css\";\n\ninterface GlassExif {\n  camera?: string;\n  lens?: string;\n  aperture?: string;\n  focal_length?: string;\n  iso?: string;\n  exposure_time?: string;\n}\n\ninterface GlassPhoto {\n  id: string;\n  width: number;\n  height: number;\n  image640x640: string;\n  share_url: string;\n  created_at: string;\n  caption?: string;\n  exif?: GlassExif;\n}\n\nexport default function RecentGlass() {\n  const [photos, setPhotos] = useState<GlassPhoto[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n  const [errorMsg, setErrorMsg] = useState<string | null>(null);\n\n  useEffect(() => {\n    async function fetchGlassPhotos() {\n      setIsLoading(true);\n      setErrorMsg(null);\n      try {\n        const response = await fetch(\"/api/glass.json?limit=6\");\n        if (!response.ok) {\n          throw new Error(`HTTP error! status: ${response.status}`);\n        }\n        const data: GlassPhoto[] = await response.json();\n        setPhotos(data);\n      } catch (error) {\n        const msg = \"Error fetching Glass photos: \" + (error instanceof Error ? error.message : String(error));\n        setErrorMsg(msg);\n        console.error(msg);\n      } finally {\n        setIsLoading(false);\n      }\n    }\n\n    fetchGlassPhotos();\n  }, []);\n\n  if (isLoading) {\n    return (\n      <div className='recent-glass-container'>\n        <h2>Recent from Glass</h2>\n        <div className='recent-glass-loading'>Loading photos from Glass...</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className='recent-glass-container'>\n      <Masonry\n        items={photos}\n        config={{\n          columns: [2, 4, 6],\n          gap: [16, 16, 32],\n          media: [640, 768, 1024],\n        }}\n        render={(photo) => (\n          <a href={photo.share_url} target='_blank' rel='noopener noreferrer' className='glass-photo' key={photo.id}>\n            <img\n              src={photo.image640x640}\n              alt={photo.caption || \"Photo from Glass\"}\n              width={photo.width}\n              height={photo.height}\n              loading='lazy'\n            />\n            <div className='overlay'>\n              <div className='exif-info'>\n                {photo.exif?.camera && <div className='exif-line'>{photo.exif.camera}</div>}\n                {photo.exif?.lens && <div className='exif-line'>{photo.exif.lens}</div>}\n                {(photo.exif?.aperture || photo.exif?.focal_length || photo.exif?.iso || photo.exif?.exposure_time) && (\n                  <div className='exif-line'>\n                    {[photo.exif.aperture, photo.exif.focal_length, photo.exif.iso, photo.exif.exposure_time]\n                      .filter(Boolean)\n                      .join(\" · \")}\n                  </div>\n                )}\n                {photo.caption && <div className='caption-text'>{photo.caption}</div>}\n              </div>\n            </div>\n          </a>\n        )}\n      />\n      <div className='view-all'>\n        <a href='https://glass.photo/iam' target='_blank' rel='noopener noreferrer'>\n          View more on Glass →\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/SocialFeeds/SocialFeeds.css",
    "content": ".social-feeds {\n    display: grid;\n    gap: 2rem;\n    width: 100%;\n}\n\n.social-feeds-three {\n    grid-template-columns: 1fr 1fr 1fr;\n}\n\n.social-feeds-two {\n    grid-template-columns: 1fr 1fr;\n}\n\n.social-feeds-one {\n    grid-template-columns: 1fr;\n    max-width: 64ch;\n}\n\n@media (max-width: 1024px) {\n    .social-feeds-three {\n        grid-template-columns: 1fr 1fr;\n    }\n}\n\n@media (max-width: 768px) {\n    .social-feeds-three,\n    .social-feeds-two {\n        grid-template-columns: 1fr;\n    }\n}\n\n.social-column {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n}\n\n.social-column summary {\n    cursor: pointer;\n    font-weight: 600;\n    padding: 0.5rem 0;\n    list-style: none;\n}\n\n.social-column summary::-webkit-details-marker {\n    display: none;\n}\n\n.social-column summary::marker {\n    display: none;\n}\n\n.social-feed {\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n}\n\n.social-post {\n    border: 1px solid var(--color-border);\n    padding: 1rem;\n    display: flex;\n    flex-direction: column;\n    gap: 0.75rem;\n}\n\n.social-post-link {\n    text-decoration: none;\n    color: inherit;\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n}\n\n.social-time {\n    color: var(--color-muted);\n    font-size: 0.875rem;\n}\n\n.social-text {\n    margin: 0;\n    white-space: pre-wrap;\n    word-wrap: break-word;\n    line-height: 1.5;\n}\n\n/* Image grid layouts */\n.social-images {\n    display: grid;\n    gap: 0.25rem;\n    border-radius: 0.5rem;\n    overflow: hidden;\n}\n\n.social-images-1 {\n    grid-template-columns: 1fr;\n}\n\n.social-images-2 {\n    grid-template-columns: 1fr 1fr;\n}\n\n.social-images-3 {\n    grid-template-columns: 1fr 1fr;\n    grid-template-rows: 1fr 1fr;\n}\n\n.social-images-3 .social-image:first-child {\n    grid-row: span 2;\n}\n\n.social-images-4 {\n    grid-template-columns: 1fr 1fr;\n    grid-template-rows: 1fr 1fr;\n}\n\n.social-image {\n    display: block;\n    overflow: hidden;\n}\n\n.social-image img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    transition: opacity 0.2s ease;\n}\n\n.social-image:hover img {\n    opacity: 0.9;\n}\n\n/* Video embed */\n.social-video {\n    border-radius: 0.5rem;\n    overflow: hidden;\n}\n\n.social-video video {\n    width: 100%;\n    display: block;\n}\n\n/* External link embed */\n.social-external {\n    display: flex;\n    flex-direction: column;\n    border: 1px solid var(--color-border);\n    border-radius: 0.5rem;\n    overflow: hidden;\n    text-decoration: none;\n    color: inherit;\n    transition: border-color 0.2s ease;\n}\n\n.social-external:hover {\n    border-color: var(--color-muted);\n}\n\n.social-external-thumb {\n    width: 100%;\n    height: auto;\n    max-height: 150px;\n    object-fit: cover;\n}\n\n.social-external-content {\n    padding: 0.75rem;\n    display: flex;\n    flex-direction: column;\n    gap: 0.25rem;\n}\n\n.social-external-title {\n    font-weight: 600;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.social-external-uri {\n    font-size: 0.75rem;\n    color: var(--color-muted);\n}\n\n/* Link embed */\n.social-link {\n    display: inline-block;\n    padding: 0.5rem 0.75rem;\n    border: 1px solid var(--color-border);\n    border-radius: 0.25rem;\n    text-decoration: none;\n    color: var(--color-muted);\n    font-size: 0.875rem;\n    transition: border-color 0.2s ease;\n}\n\n.social-link:hover {\n    border-color: var(--color-foreground);\n}\n\n/* Quote embed */\n.social-quote {\n    display: flex;\n    padding: 0.75rem;\n    border: 1px solid var(--color-border);\n    border-radius: 0.5rem;\n    text-decoration: none;\n    color: var(--color-muted);\n    font-size: 0.875rem;\n    transition: border-color 0.2s ease;\n}\n\n.social-quote:hover {\n    border-color: var(--color-foreground);\n}\n\n/* Glass exif */\n.social-exif {\n    display: flex;\n    flex-direction: column;\n    gap: 0.25rem;\n    font-size: 0.75rem;\n    color: var(--color-muted);\n}\n\n.social-post-glass .social-image {\n    border-radius: 0.5rem;\n}\n\n.social-post-glass .social-image img {\n    height: auto;\n    object-fit: contain;\n}\n\n/* Loading state */\n.social-loading {\n    text-align: center;\n    padding: 2rem;\n    color: var(--color-muted);\n}\n\n/* View all link */\n.social-view-all {\n    text-align: center;\n    margin-top: 0.5rem;\n}\n\n.social-view-all a {\n    text-decoration: none;\n    color: inherit;\n    font-weight: 500;\n}\n\n.social-view-all a:hover {\n    text-decoration: underline;\n}\n\n/* Responsive */\n@media (max-width: 768px) {\n    .social-post {\n        padding: 0.75rem;\n    }\n\n    .social-images-3 .social-image:first-child {\n        grid-row: span 1;\n    }\n\n    .social-images-3 {\n        grid-template-columns: 1fr;\n        grid-template-rows: auto;\n    }\n}\n"
  },
  {
    "path": "src/components/SocialFeeds/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport \"./SocialFeeds.css\";\n\n// Shared types\ninterface BasePost {\n  createdAt: string;\n  postUrl: string;\n}\n\ninterface TextPost extends BasePost {\n  text: string;\n}\n\n// Bluesky types\ninterface BlueskyImage {\n  type: \"image\";\n  thumb: string;\n  fullsize: string;\n  alt: string;\n  mimeType: string;\n}\n\ninterface BlueskyVideo {\n  type: \"video\";\n  url: string;\n  mimeType: string;\n}\n\ninterface BlueskyExternal {\n  type: \"external\";\n  uri: string;\n  title: string;\n  description: string;\n  thumb?: string;\n}\n\ninterface BlueskyQuote {\n  type: \"quote\";\n  uri: string;\n  cid: string;\n}\n\ntype BlueskyEmbed =\n  | BlueskyImage\n  | BlueskyVideo\n  | BlueskyExternal\n  | BlueskyQuote;\n\ninterface BlueskyPost extends TextPost {\n  uri: string;\n  cid: string;\n  embeds: BlueskyEmbed[];\n}\n\n// Farcaster types\ninterface FarcasterEmbed {\n  type: \"url\" | \"cast\";\n  url?: string;\n  castFid?: number;\n  castHash?: string;\n}\n\ninterface FarcasterPost extends TextPost {\n  hash: string;\n  embeds: FarcasterEmbed[];\n}\n\n// Glass types\ninterface GlassExif {\n  camera?: string;\n  lens?: string;\n  aperture?: string;\n  focal_length?: string;\n  iso?: string;\n  exposure_time?: string;\n}\n\ninterface GlassPost extends BasePost {\n  id: string;\n  width: number;\n  height: number;\n  image640x640: string;\n  share_url: string;\n  caption: string;\n  exif?: GlassExif;\n}\n\n// 5 days in milliseconds\nconst STALE_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000;\n\nfunction isPostStale(post: BasePost): boolean {\n  const postTime = new Date(post.createdAt).getTime();\n  return Date.now() - postTime > STALE_THRESHOLD_MS;\n}\n\nfunction filterFreshPosts<T extends BasePost>(posts: T[]): T[] {\n  return posts.filter((post) => !isPostStale(post));\n}\n\nfunction formatDate(dateString: string): string {\n  const date = new Date(dateString);\n  const now = new Date();\n  const diffMs = now.getTime() - date.getTime();\n  const diffMins = Math.floor(diffMs / 60000);\n  const diffHours = Math.floor(diffMs / 3600000);\n  const diffDays = Math.floor(diffMs / 86400000);\n\n  if (diffMins < 1) return \"just now\";\n  if (diffMins < 60) return `${diffMins}m`;\n  if (diffHours < 24) return `${diffHours}h`;\n  if (diffDays < 7) return `${diffDays}d`;\n\n  return date.toLocaleDateString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: date.getFullYear() !== now.getFullYear() ? \"numeric\" : undefined,\n  });\n}\n\nfunction isImageUrl(url: string): boolean {\n  // Check for common image extensions\n  if (/\\.(jpg|jpeg|png|gif|webp)$/i.test(url)) return true;\n  // Check for known image CDNs\n  if (url.includes(\"imagedelivery.net\")) return true;\n  if (url.includes(\"i.imgur.com\")) return true;\n  if (url.includes(\"pbs.twimg.com\")) return true;\n  return false;\n}\n\n// Bluesky post card\nfunction BlueskyPostCard({ post }: { post: BlueskyPost }) {\n  const images = post.embeds.filter(\n    (e): e is BlueskyImage => e.type === \"image\",\n  );\n  const otherEmbeds = post.embeds.filter((e) => e.type !== \"image\");\n\n  return (\n    <article className=\"social-post\">\n      <a\n        href={post.postUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"social-post-link\"\n      >\n        <time className=\"social-time\" dateTime={post.createdAt}>\n          {formatDate(post.createdAt)}\n        </time>\n        {post.text && <p className=\"social-text\">{post.text}</p>}\n      </a>\n\n      {images.length > 0 && (\n        <div\n          className={`social-images social-images-${Math.min(images.length, 4)}`}\n        >\n          {images.map((img, i) => (\n            <a\n              key={i}\n              href={img.fullsize}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-image\"\n            >\n              <img src={img.thumb} alt={img.alt} loading=\"lazy\" />\n            </a>\n          ))}\n        </div>\n      )}\n\n      {otherEmbeds.map((embed, i) => {\n        if (embed.type === \"video\") {\n          return (\n            <div key={i} className=\"social-video\">\n              <video controls preload=\"metadata\">\n                <source src={embed.url} type={embed.mimeType} />\n              </video>\n            </div>\n          );\n        }\n        if (embed.type === \"external\") {\n          return (\n            <a\n              key={i}\n              href={embed.uri}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-external\"\n            >\n              {embed.thumb && (\n                <img\n                  src={embed.thumb}\n                  alt=\"\"\n                  className=\"social-external-thumb\"\n                  loading=\"lazy\"\n                />\n              )}\n              <div className=\"social-external-content\">\n                <span className=\"social-external-title\">{embed.title}</span>\n                <span className=\"social-external-uri\">\n                  {new URL(embed.uri).hostname}\n                </span>\n              </div>\n            </a>\n          );\n        }\n        if (embed.type === \"quote\") {\n          const uriParts = embed.uri.split(\"/\");\n          const postId = uriParts[uriParts.length - 1];\n          const did = uriParts[2];\n          return (\n            <a\n              key={i}\n              href={`https://bsky.app/profile/${did}/post/${postId}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-quote\"\n            >\n              Quoted post\n            </a>\n          );\n        }\n        return null;\n      })}\n    </article>\n  );\n}\n\n// Farcaster post card\nfunction FarcasterPostCard({ post }: { post: FarcasterPost }) {\n  const imageEmbeds = post.embeds.filter(\n    (e) => e.type === \"url\" && e.url && isImageUrl(e.url),\n  );\n  const otherEmbeds = post.embeds.filter(\n    (e) => !(e.type === \"url\" && e.url && isImageUrl(e.url)),\n  );\n\n  return (\n    <article className=\"social-post\">\n      <a\n        href={post.postUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"social-post-link\"\n      >\n        <time className=\"social-time\" dateTime={post.createdAt}>\n          {formatDate(post.createdAt)}\n        </time>\n        {post.text && <p className=\"social-text\">{post.text}</p>}\n      </a>\n\n      {imageEmbeds.length > 0 && (\n        <div\n          className={`social-images social-images-${Math.min(imageEmbeds.length, 4)}`}\n        >\n          {imageEmbeds.map((embed, i) => (\n            <a\n              key={i}\n              href={embed.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-image\"\n            >\n              <img src={embed.url} alt=\"\" loading=\"lazy\" />\n            </a>\n          ))}\n        </div>\n      )}\n\n      {otherEmbeds.map((embed, i) => {\n        if (embed.type === \"url\" && embed.url) {\n          return (\n            <a\n              key={i}\n              href={embed.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-link\"\n            >\n              {new URL(embed.url).hostname}\n            </a>\n          );\n        }\n        if (embed.type === \"cast\" && embed.castHash) {\n          const hashHex = embed.castHash.startsWith(\"0x\")\n            ? embed.castHash.slice(2)\n            : embed.castHash;\n          return (\n            <a\n              key={i}\n              href={`https://warpcast.com/~/conversations/${hashHex}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"social-quote\"\n            >\n              Quoted cast\n            </a>\n          );\n        }\n        return null;\n      })}\n    </article>\n  );\n}\n\n// Glass post card\nfunction GlassPostCard({ post }: { post: GlassPost }) {\n  return (\n    <article className=\"social-post social-post-glass\">\n      <a\n        href={post.postUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"social-post-link\"\n      >\n        <time className=\"social-time\" dateTime={post.createdAt}>\n          {formatDate(post.createdAt)}\n        </time>\n      </a>\n      <a\n        href={post.postUrl}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"social-image\"\n      >\n        <img\n          src={post.image640x640}\n          alt={post.caption || \"Photo from Glass\"}\n          loading=\"lazy\"\n          width={post.width}\n          height={post.height}\n        />\n      </a>\n      {post.caption && <p className=\"social-text\">{post.caption}</p>}\n      {post.exif && (\n        <div className=\"social-exif\">\n          {post.exif.camera && <span>{post.exif.camera}</span>}\n          {post.exif.lens && <span>{post.exif.lens}</span>}\n          {(post.exif.aperture ||\n            post.exif.focal_length ||\n            post.exif.iso ||\n            post.exif.exposure_time) && (\n            <span>\n              {[\n                post.exif.aperture,\n                post.exif.focal_length,\n                post.exif.iso,\n                post.exif.exposure_time,\n              ]\n                .filter(Boolean)\n                .join(\" · \")}\n            </span>\n          )}\n        </div>\n      )}\n    </article>\n  );\n}\n\ninterface SocialFeedsProps {\n  limit?: number;\n}\n\nexport default function SocialFeeds({ limit = 10 }: SocialFeedsProps) {\n  const [blueskyPosts, setBlueskyPosts] = useState<BlueskyPost[]>([]);\n  const [farcasterPosts, setFarcasterPosts] = useState<FarcasterPost[]>([]);\n  const [glassPosts, setGlassPosts] = useState<GlassPost[]>([]);\n  const [blueskyLoading, setBlueskyLoading] = useState(true);\n  const [farcasterLoading, setFarcasterLoading] = useState(true);\n  const [glassLoading, setGlassLoading] = useState(true);\n\n  useEffect(() => {\n    // Fetch Bluesky\n    fetch(`/api/bluesky.json?limit=${limit}&_=${Date.now()}`)\n      .then((res) => res.json())\n      .then((data) => setBlueskyPosts(data))\n      .catch((err) => console.error(\"Bluesky error:\", err))\n      .finally(() => setBlueskyLoading(false));\n\n    // Fetch Farcaster\n    fetch(`/api/farcaster.json?limit=${limit}&_=${Date.now()}`)\n      .then((res) => res.json())\n      .then((data) => setFarcasterPosts(data))\n      .catch((err) => console.error(\"Farcaster error:\", err))\n      .finally(() => setFarcasterLoading(false));\n\n    // Fetch Glass\n    fetch(`/api/glass.json?limit=${limit}&_=${Date.now()}`)\n      .then((res) => res.json())\n      .then((data: any[]) => {\n        // Transform Glass API response to match our types\n        const posts: GlassPost[] = data.map((photo) => ({\n          id: photo.id,\n          width: photo.width,\n          height: photo.height,\n          image640x640: photo.image640x640,\n          share_url: photo.share_url,\n          createdAt: photo.created_at,\n          postUrl: photo.share_url,\n          caption: photo.caption || \"\",\n          exif: photo.exif,\n        }));\n        setGlassPosts(posts);\n      })\n      .catch((err) => console.error(\"Glass error:\", err))\n      .finally(() => setGlassLoading(false));\n  }, [limit]);\n\n  const isLoading = blueskyLoading || farcasterLoading || glassLoading;\n\n  // Filter to only fresh posts (within last 5 days)\n  const freshBlueskyPosts = filterFreshPosts(blueskyPosts);\n  const freshFarcasterPosts = filterFreshPosts(farcasterPosts);\n  const freshGlassPosts = filterFreshPosts(glassPosts);\n\n  const blueskyActive = !blueskyLoading && freshBlueskyPosts.length > 0;\n  const farcasterActive = !farcasterLoading && freshFarcasterPosts.length > 0;\n  const glassActive = !glassLoading && freshGlassPosts.length > 0;\n\n  // Build feeds array with most recent post time for sorting\n  type FeedConfig = {\n    name: string;\n    active: boolean;\n    mostRecent: number;\n    render: () => JSX.Element;\n  };\n\n  const feeds: FeedConfig[] = [\n    {\n      name: \"bluesky\",\n      active: blueskyActive,\n      mostRecent: freshBlueskyPosts[0]\n        ? new Date(freshBlueskyPosts[0].createdAt).getTime()\n        : 0,\n      render: () => (\n        <details className=\"social-column\" open key=\"bluesky\">\n          <summary>Bluesky</summary>\n          <div className=\"social-feed\">\n            {freshBlueskyPosts.map((post) => (\n              <BlueskyPostCard key={post.cid} post={post} />\n            ))}\n          </div>\n          <div className=\"social-view-all\">\n            <a\n              href=\"https://bsky.app/profile/iammatthias.com\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              View more on Bluesky →\n            </a>\n          </div>\n        </details>\n      ),\n    },\n    {\n      name: \"farcaster\",\n      active: farcasterActive,\n      mostRecent: freshFarcasterPosts[0]\n        ? new Date(freshFarcasterPosts[0].createdAt).getTime()\n        : 0,\n      render: () => (\n        <details className=\"social-column\" open key=\"farcaster\">\n          <summary>Farcaster</summary>\n          <div className=\"social-feed\">\n            {freshFarcasterPosts.map((post) => (\n              <FarcasterPostCard key={post.hash} post={post} />\n            ))}\n          </div>\n          <div className=\"social-view-all\">\n            <a\n              href=\"https://warpcast.com/iammatthias\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              View more on Warpcast →\n            </a>\n          </div>\n        </details>\n      ),\n    },\n    {\n      name: \"glass\",\n      active: glassActive,\n      mostRecent: freshGlassPosts[0]\n        ? new Date(freshGlassPosts[0].createdAt).getTime()\n        : 0,\n      render: () => (\n        <details className=\"social-column\" open key=\"glass\">\n          <summary>Glass</summary>\n          <div className=\"social-feed\">\n            {freshGlassPosts.map((post) => (\n              <GlassPostCard key={post.id} post={post} />\n            ))}\n          </div>\n          <div className=\"social-view-all\">\n            <a\n              href=\"https://glass.photo/iam\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              View more on Glass →\n            </a>\n          </div>\n        </details>\n      ),\n    },\n  ];\n\n  // Filter active feeds and sort by most recent post\n  const activeFeeds = feeds\n    .filter((feed) => feed.active)\n    .sort((a, b) => b.mostRecent - a.mostRecent);\n\n  if (isLoading) {\n    return <div className=\"social-loading\">Loading social feeds...</div>;\n  }\n\n  if (activeFeeds.length === 0) {\n    return null; // No active feeds\n  }\n\n  const gridClass =\n    activeFeeds.length === 3\n      ? \"social-feeds-three\"\n      : activeFeeds.length === 2\n        ? \"social-feeds-two\"\n        : \"social-feeds-one\";\n\n  return (\n    <div className={`social-feeds ${gridClass}`}>\n      {activeFeeds.map((feed) => feed.render())}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/footer.astro",
    "content": "---\n\n---\n\n<footer>\n    <p><a href=\"/now\">Now</a></p>\n    <p><a href=\"/tags\">Tags</a></p>\n    <p><a href=\"/rss.xml\">RSS</a></p>\n    <p><a href=\"/sitemap-index.xml\">Sitemap</a></p>\n</footer>\n\n<style>\n    footer {\n        border: 1px solid var(--color-border);\n        display: flex;\n        flex-wrap: wrap;\n        flex-direction: row;\n        margin-top: auto;\n        position: sticky;\n        bottom: 1rem;\n        background: var(--color-background);\n        z-index: 1000;\n    }\n\n    footer > * {\n        border-right: 1px solid var(--color-border);\n        padding: 0.25rem 0.5rem;\n        font-size: 0.8rem;\n        width: fit-content;\n    }\n</style>\n"
  },
  {
    "path": "src/components/homepage/heroSection.astro",
    "content": "---\n\n---\n\n<section>\n  <h1>Hi, I am Matthias</h1>\n  <p>\n    I run DevRel at <a href='https://www.pinata.cloud/' title='Pinata'>Pinata</a> and have a small growth consustancy called\n    <a href='https://day---break.com/' title='day---break'>day---break</a> on the side.\n  </p>\n  <p>Over the years I've worked as a photographer, and have built growth teams in travel, fintech, and ecommerce.</p>\n  <p>\n    This site is a collection notes, recipews, and thoughts. You'll find projects, ongoing and final, and recomendations\n    for all sorts of things.\n  </p>\n  <p>\n    You can find me on various <a href='/social'>social</a> platforms, subscribe to my <a href='/rss.xml'>rss feed</a>,\n    or find me on the small-web via <a href='https://github.com/iammatthias/gemini-server'>Gemini</a>: <code\n      >gemini://gem.iammatthias.com</code\n    >\n  </p>\n</section>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    min-height: calc(50vh - 2rem);\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n  }\n\n  section > * {\n    max-width: 64ch;\n    word-wrap: pretty;\n  }\n</style>\n"
  },
  {
    "path": "src/components/homepage/recentContent.astro",
    "content": "---\nimport { getCollection } from \"astro:content\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n};\n\n// Get all collection names\nconst GITHUB_OWNER = \"iammatthias\";\nconst GITHUB_REPO = \"obsidian_cms\";\nconst GITHUB_TOKEN = import.meta.env.GITHUB;\n\nconst collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n// Get recent entries from each collection. The loader sorts at insertion\n// time, but Astro's content store doesn't guarantee insertion order on\n// read, so re-sort here.\nconst recentByCollection = await Promise.all(\n  collectionNames.map(async (name) => {\n    try {\n      const entries = ((await getCollection(name as any)) as Entry[]).sort(\n        (a, b) =>\n          new Date(b.data.created || 0).getTime() -\n          new Date(a.data.created || 0).getTime(),\n      );\n      return {\n        name,\n        recent: entries.slice(0, 3),\n      };\n    } catch (error) {\n      console.error(`Error loading collection ${name}:`, error);\n      return {\n        name,\n        recent: [] as Entry[],\n      };\n    }\n  })\n);\n\n// Filter out collections with no recent content\nconst collectionsWithContent = recentByCollection\n  .filter((c) => c.recent.length > 0)\n  .sort((a, b) => {\n    const dateA = new Date(a.recent[0].data.created).getTime();\n    const dateB = new Date(b.recent[0].data.created).getTime();\n    return dateB - dateA; // Most recent first\n  });\n---\n\n<section>\n  <div class='grid'>\n    {\n      collectionsWithContent.map((collection) => (\n        <div class='collection-section'>\n          <h3>\n            <a href={`/content/${collection.name}`}>{collection.name}</a>\n          </h3>\n          <ul>\n            {collection.recent.map((entry) => (\n              <li>\n                <a href={`/content/${collection.name}/${entry.id}`}>{entry.data.title}</a>\n                <br />\n                <span class='date'>{new Date(entry.data.created).toLocaleDateString()}</span>\n              </li>\n            ))}\n            <li>\n              <a href={`/content/${collection.name}`}>View all →</a>\n            </li>\n          </ul>\n        </div>\n      ))\n    }\n  </div>\n  {\n    collectionsWithContent.length > 0 && (\n      <div class='view-all'>\n        <a href='/content'>View all content →</a>\n      </div>\n    )\n  }\n</section>\n\n<style>\n  section {\n    margin-top: auto;\n    min-height: calc(50vh - 2rem);\n    border-top: 1px solid var(--color-border);\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n  }\n\n  .grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n    gap: 2rem;\n    @media (max-width: 768px) {\n      gap: 1rem;\n    }\n  }\n\n  .collection-section {\n    border: 1px solid var(--color-border);\n    padding: 1.5rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n  }\n\n  .collection-section h3 {\n    margin: 0 0 0.5rem 0;\n    font-size: 1.5rem;\n  }\n\n  .collection-section h3 a {\n    text-decoration: none;\n    color: inherit;\n    text-transform: capitalize;\n  }\n\n  .collection-section h3 a:hover {\n    text-decoration: underline;\n  }\n\n  .collection-section ul {\n    list-style: none;\n    padding: 0;\n    margin: 0;\n  }\n\n  .collection-section ul li {\n    padding: 0.5rem 0;\n    border-bottom: 1px solid var(--color-border);\n  }\n\n  .collection-section ul li:last-child {\n    border-bottom: none;\n  }\n\n  .collection-section ul li a {\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .collection-section ul li a:hover {\n    text-decoration: underline;\n  }\n\n  .view-all {\n    text-align: center;\n  }\n\n  .view-all a {\n    text-decoration: none;\n    color: inherit;\n    font-weight: 500;\n  }\n\n  .view-all a:hover {\n    text-decoration: underline;\n  }\n\n  .date {\n    color: var(--color-muted);\n    font-size: 0.85rem;\n  }\n</style>\n"
  },
  {
    "path": "src/components/homepage/recentFeeds.astro",
    "content": "---\n\n---\n\n<section>\n  <h2>feeds</h2>\n  <p>blogs I like that have rss</p>\n  <div class='grid'>\n    <ul><li>item 1</li><li>item 2</li><li>item 3</li></ul>\n  </div>\n</section>\n\n<style>\n  section {\n    margin-top: auto;\n    min-height: calc(50vh - 2rem);\n    border-top: 1px solid var(--color-border);\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n  }\n  .grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n    gap: 1rem;\n    grid-wrap: wrap;\n  }\n  .grid > ul {\n    list-style: none;\n    padding: 0;\n    margin: 0;\n  }\n  .grid > ul > li {\n    padding: 0.5rem;\n    border-bottom: 1px solid var(--color-border);\n    &:last-child {\n      border-bottom: none;\n    }\n  }\n</style>\n"
  },
  {
    "path": "src/components/nav.astro",
    "content": "---\n\n---\n\n<nav>\n    <p><a href=\"/\">Home</a></p>\n    <p><a href=\"/content\">Content</a></p>\n    <p><a href=\"/about\">About</a></p>\n    <p><a href=\"/now\">Now</a></p>\n    <p><span id=\"time\"></span></p>\n</nav>\n\n<style>\n    nav {\n        border: 1px solid var(--color-border);\n        display: flex;\n        flex-wrap: wrap;\n        flex-direction: row;\n        position: sticky;\n        top: 1rem;\n        background: var(--color-background);\n        z-index: 1000;\n    }\n\n    nav > * {\n        border-right: 1px solid var(--color-border);\n        padding: 0.25rem 0.5rem;\n        font-size: 0.8rem;\n        width: fit-content;\n        &:last-child {\n            border-right: none;\n            border-left: 1px solid var(--color-border);\n            margin-left: auto;\n        }\n    }\n</style>\n\n<script>\n    // just time, no date. hour and minute. update every minute on the minue. hide seconds.\n    const time = document.getElementById(\"time\")!;\n    time.innerHTML = new Date().toLocaleTimeString(\"en-US\", {\n        hour: \"2-digit\",\n        minute: \"2-digit\",\n    });\n    setInterval(() => {\n        time.innerHTML = new Date().toLocaleTimeString(\"en-US\", {\n            hour: \"2-digit\",\n            minute: \"2-digit\",\n        });\n    }, 60000);\n</script>\n"
  },
  {
    "path": "src/components/sidebar/index.astro",
    "content": "---\n\n---\n\n<details id='sidebar' class='sidebar' open>\n  <summary class='sidebar-toggle' aria-label='Toggle sidebar'>\n    <svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'>\n      <path d='M3 6h18M3 12h18M3 18h18'></path>\n    </svg>\n  </summary>\n  <div class='sidebar-content'>\n    <slot />\n  </div>\n</details>\n\n<style is:global>\n  .sidebar {\n    overflow: hidden;\n    border: 1px solid var(--color-border);\n    display: flex;\n    flex-direction: column;\n  }\n\n  /* Desktop styles */\n  @media (min-width: 800px) {\n    .sidebar {\n      flex-basis: calc(50ch - 32px);\n      flex-grow: 1;\n      max-height: calc(100dvh - 2rem);\n      position: sticky;\n      top: 1rem;\n      padding: 0;\n      transition:\n        flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1),\n        min-width 0.3s cubic-bezier(0.4, 0, 0.2, 1),\n        max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    }\n\n    .sidebar.collapsed {\n      flex-basis: 2.5rem;\n      flex-grow: 0;\n      min-width: 2.5rem;\n      max-width: 2.5rem;\n    }\n\n    .sidebar-content {\n      padding: 1rem;\n      opacity: 1;\n      visibility: visible;\n      margin-top: auto;\n      transition:\n        opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,\n        padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n    }\n\n    .sidebar.collapsed .sidebar-content {\n      opacity: 0;\n      visibility: hidden;\n      pointer-events: none;\n      padding: 0;\n      transition:\n        opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),\n        padding 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s;\n    }\n  }\n\n  /* Mobile styles */\n  @media (max-width: 799px) {\n    .sidebar {\n      position: fixed;\n      bottom: 1rem;\n      left: 1rem;\n      right: 1rem;\n      background: var(--color-background);\n      z-index: 1000;\n      box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);\n    }\n\n    .sidebar:not([open]) {\n      height: auto;\n    }\n\n    .sidebar[open] {\n      height: calc(100dvh - 2rem);\n      display: flex;\n      flex-direction: column;\n    }\n\n    .sidebar-toggle {\n      display: flex;\n      align-items: center;\n      justify-content: flex-end;\n      padding: 0.5rem;\n      min-height: 2.5rem;\n      list-style: none;\n      flex-shrink: 0;\n    }\n\n    .sidebar-toggle::-webkit-details-marker {\n      display: none;\n    }\n\n    .sidebar-toggle svg {\n      margin: 0;\n    }\n\n    .sidebar-content {\n      padding: 0 1rem 1rem 1rem;\n      overflow-y: auto;\n      flex: 1;\n    }\n\n    .sidebar:not([open]) .sidebar-content {\n      display: none;\n    }\n  }\n\n  @media (min-width: 800px) {\n    .sidebar-toggle {\n      cursor: pointer;\n    }\n  }\n\n  .sidebar-toggle {\n    position: absolute;\n    top: 0.5rem;\n    right: 0.5rem;\n    background: none;\n    border: 1px solid var(--color-border);\n    cursor: pointer;\n    padding: 0.25rem;\n    color: var(--color-foreground);\n    transition: background-color 0.2s ease;\n    z-index: 10;\n    width: 1.5rem;\n    height: 1.5rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n\n  .sidebar-toggle:hover {\n    background-color: var(--color-surface);\n  }\n</style>\n\n<script is:inline>\n  // Apply collapsed state immediately to prevent flash (desktop only)\n  (function () {\n    const isDesktop = window.innerWidth >= 800;\n\n    if (isDesktop) {\n      const savedState = localStorage.getItem(\"sidebar-collapsed\");\n      const isCollapsed = savedState === \"true\";\n\n      if (isCollapsed) {\n        const style = document.createElement(\"style\");\n        style.textContent =\n          \".sidebar { flex-basis: 2.5rem !important; min-width: 2.5rem !important; max-width: 2.5rem !important; flex-grow: 0 !important; } .sidebar-content { opacity: 0 !important; visibility: hidden !important; padding: 0 !important; }\";\n        document.head.appendChild(style);\n\n        document.addEventListener(\"DOMContentLoaded\", function () {\n          setTimeout(function () {\n            style.remove();\n          }, 0);\n        });\n      }\n    }\n  })();\n</script>\n\n<script>\n  document.addEventListener(\"DOMContentLoaded\", () => {\n    const sidebar = document.getElementById(\"sidebar\") as HTMLDetailsElement;\n    const toggleButton = document.querySelector(\".sidebar-toggle\") as HTMLElement;\n\n    if (!sidebar || !toggleButton) return;\n\n    const isDesktop = window.innerWidth >= 800;\n\n    if (isDesktop) {\n      // Desktop: use class-based toggle and prevent details behavior\n      const savedState = localStorage.getItem(\"sidebar-collapsed\");\n      const isCollapsed = savedState === \"true\";\n\n      if (isCollapsed) {\n        sidebar.classList.add(\"collapsed\");\n      }\n\n      toggleButton.addEventListener(\"click\", (e) => {\n        e.preventDefault();\n        sidebar.classList.toggle(\"collapsed\");\n        const isNowCollapsed = sidebar.classList.contains(\"collapsed\");\n        localStorage.setItem(\"sidebar-collapsed\", isNowCollapsed.toString());\n      });\n    }\n    // Mobile: native details/summary behavior - no JS needed\n  });\n</script>\n"
  },
  {
    "path": "src/content.config.ts",
    "content": "import { defineCollection } from 'astro:content';\nimport { githubLoader, getGitHubCollections } from './lib/github-loader';\nimport { tagsLoader } from './lib/tags-loader';\n\n// GitHub configuration\nconst GITHUB_OWNER = 'iammatthias';\nconst GITHUB_REPO = 'obsidian_cms';\nconst GITHUB_BRANCH = 'main';\nconst GITHUB_TOKEN = import.meta.env.GITHUB;\n\n// Dynamically fetch collections from GitHub\nconst collectionNames = await getGitHubCollections(\n  GITHUB_OWNER,\n  GITHUB_REPO,\n  GITHUB_TOKEN,\n  GITHUB_BRANCH,\n  'content'\n);\n\n// Create a collection definition for each folder\nconst collections = Object.fromEntries(\n  collectionNames.map((collectionName) => [\n    collectionName,\n    defineCollection({\n      loader: githubLoader({\n        owner: GITHUB_OWNER,\n        repo: GITHUB_REPO,\n        branch: GITHUB_BRANCH,\n        contentPath: 'content',\n        token: GITHUB_TOKEN,\n      }),\n    }),\n  ])\n);\n\n// Add tags collection\ncollections.tags = defineCollection({\n  loader: tagsLoader({\n    owner: GITHUB_OWNER,\n    repo: GITHUB_REPO,\n    token: GITHUB_TOKEN,\n    branch: GITHUB_BRANCH,\n    contentPath: 'content',\n  }),\n});\n\nexport { collections };\n"
  },
  {
    "path": "src/layouts/defaultLayouts.astro",
    "content": "---\nimport \"@src/styles/reset.css\";\nimport \"@src/styles/globals.css\";\nimport Nav from \"@components/nav.astro\";\nimport Footer from \"@components/footer.astro\";\n\ninterface Props {\n  title?: string;\n  description?: string;\n  ogImage?: string;\n}\n\nconst { title = \"@iammatthias\", description = \"Personal site of Matthias Jordan\", ogImage = \"/\" } = Astro.props;\nconst canonicalURL = new URL(Astro.url.pathname, Astro.site);\n// Use OG subdomain for production, localhost for development\nconst ogImageURL = import.meta.env.DEV\n  ? new URL(ogImage, \"http://localhost:8787\")\n  : new URL(ogImage, \"https://og.iammatthias.com\");\n---\n\n<html lang='en'>\n  <head>\n    <meta charset='utf-8' />\n    <meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />\n\n    <meta name='color-scheme' content='light dark' />\n    <meta name='theme-color' content='#0f1419' />\n\n    <title>{title}</title>\n    <meta name='description' content={description} />\n    <link rel='canonical' href={canonicalURL} />\n\n    <!-- Open Graph / Facebook -->\n    <meta property='og:type' content='website' />\n    <meta property='og:url' content={canonicalURL} />\n    <meta property='og:title' content={title} />\n    <meta property='og:description' content={description} />\n    <meta property='og:image' content={ogImageURL} />\n\n    <!-- Twitter -->\n    <meta property='twitter:card' content='summary_large_image' />\n    <meta property='twitter:url' content={canonicalURL} />\n    <meta property='twitter:title' content={title} />\n    <meta property='twitter:description' content={description} />\n    <meta property='twitter:image' content={ogImageURL} />\n\n    <link rel='alternate' type='application/rss+xml' title='iammatthias - All Content' href='/rss.xml' />\n    <link rel='sitemap' href='/sitemap-index.xml' />\n\n    <link\n      rel='icon'\n      href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>☼</text></svg>'\n    />\n  </head>\n  <body>\n    <div class='with-sidebar'>\n      <main>\n        <Nav />\n        <slot name='main' />\n        <Footer />\n      </main>\n      <slot name='sidebar' />\n    </div>\n    <div class='veil' aria-hidden='true'>\n      <div class='veil-overlay'></div>\n    </div>\n  </body>\n</html>\n\n<style>\n  .with-sidebar {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    min-height: calc(100dvh - 2rem);\n    &:before {\n      content: \"\";\n      position: fixed;\n      top: 0;\n      left: 0;\n      right: 0;\n      bottom: 0;\n      box-shadow:\n        inset 0px 0px 0px 16px var(--color-background),\n        inset 0px 0px 0px 17px var(--color-caret);\n      pointer-events: none;\n      z-index: 9999;\n    }\n  }\n\n  .with-sidebar > :first-child {\n    flex-basis: 0;\n    flex-grow: 999;\n    min-inline-size: 50%;\n    display: flex;\n    flex-direction: column;\n  }\n\n  /* .with-sidebar > * {\n    border: 1px solid var(--color-border);\n  } */\n\n  main > :not(nav):not(footer) {\n    flex: 1;\n  }\n\n  .veil {\n    display: none;\n  }\n  @supports (-webkit-touch-callout: none) {\n    /* .with-sidebar:before {\n      display: none;\n    } */\n\n    .veil {\n      position: sticky;\n      top: 0;\n      z-index: 10000;\n      display: block;\n      pointer-events: none;\n    }\n    .veil-overlay {\n      position: fixed;\n      top: 0;\n      left: 0;\n      width: 100%;\n      height: 100%;\n      opacity: 0;\n      backdrop-filter: blur(1px);\n    }\n  }\n</style>\n"
  },
  {
    "path": "src/lib/github-loader.ts",
    "content": "import type { Loader, LoaderContext } from \"astro/loaders\";\nimport { z } from \"astro/zod\";\n\ninterface GitHubLoaderOptions {\n  owner: string;\n  repo: string;\n  branch?: string;\n  contentPath?: string;\n  token: string;\n}\n\nexport const githubContentSchema = z.object({\n  title: z.string(),\n  slug: z.string(),\n  published: z.boolean(),\n  created: z.string(),\n  updated: z.string(),\n  tags: z.array(z.string()).optional(),\n  excerpt: z.string().optional(),\n});\n\nexport type GitHubContentData = z.infer<typeof githubContentSchema>;\n\n// ============================================================================\n// CACHE SYSTEM - Single fetch, shared across all loaders\n// ============================================================================\n\ninterface CachedFile {\n  name: string;\n  content: string;\n  frontmatter: Record<string, any>;\n  body: string;\n}\n\ninterface CachedCollection {\n  name: string;\n  files: CachedFile[];\n}\n\ninterface ContentCache {\n  collections: CachedCollection[];\n  collectionNames: string[];\n  tagMap: Map<string, Set<string>>;\n  fetched: boolean;\n}\n\n// Global cache - populated once, used by all loaders\nconst contentCache: ContentCache = {\n  collections: [],\n  collectionNames: [],\n  tagMap: new Map(),\n  fetched: false,\n};\n\n/**\n * Fetch all content from GitHub in a single API call and populate the cache.\n * This is called once and the results are shared across all loaders.\n */\nasync function populateCache(\n  owner: string,\n  repo: string,\n  token: string,\n  branch: string,\n  contentPath: string,\n): Promise<void> {\n  if (contentCache.fetched) {\n    return;\n  }\n\n  const isDev = import.meta.env.DEV;\n\n  // Single GraphQL query to get ALL content\n  const query = `\n    query($owner: String!, $repo: String!, $expression: String!) {\n      repository(owner: $owner, name: $repo) {\n        object(expression: $expression) {\n          ... on Tree {\n            entries {\n              name\n              type\n              object {\n                ... on Tree {\n                  entries {\n                    name\n                    type\n                    object {\n                      ... on Blob {\n                        text\n                      }\n                    }\n                  }\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  `;\n\n  const response = await fetch(\"https://api.github.com/graphql\", {\n    method: \"POST\",\n    headers: {\n      Authorization: `Bearer ${token}`,\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      query,\n      variables: {\n        owner,\n        repo,\n        expression: `${branch}:${contentPath}`,\n      },\n    }),\n  });\n\n  const result = await response.json();\n\n  if (result.errors) {\n    throw new Error(`Failed to fetch content: ${result.errors[0].message}`);\n  }\n\n  const entries = result.data?.repository?.object?.entries || [];\n\n  // Process each collection directory\n  for (const entry of entries) {\n    if (entry.type !== \"tree\") continue;\n\n    const collectionName = entry.name;\n    const markdownFiles =\n      entry.object?.entries?.filter(\n        (file: any) => file.type === \"blob\" && file.name.endsWith(\".md\"),\n      ) || [];\n\n    const cachedFiles: CachedFile[] = [];\n    let hasPublishedContent = false;\n\n    for (const file of markdownFiles) {\n      const content = file.object?.text || \"\";\n      const { frontmatter, body } = parseFrontmatter(content);\n\n      // Check if published (or in dev mode)\n      const isPublished = isDev || frontmatter.published === true;\n      if (isPublished) {\n        hasPublishedContent = true;\n      }\n\n      cachedFiles.push({\n        name: file.name,\n        content,\n        frontmatter,\n        body,\n      });\n\n      // Build tag map while we're at it\n      if (isPublished && frontmatter.tags && Array.isArray(frontmatter.tags)) {\n        for (const tag of frontmatter.tags) {\n          if (!contentCache.tagMap.has(tag)) {\n            contentCache.tagMap.set(tag, new Set());\n          }\n          contentCache.tagMap.get(tag)!.add(collectionName);\n        }\n      }\n    }\n\n    // Only include collections with published content\n    if (hasPublishedContent) {\n      contentCache.collectionNames.push(collectionName);\n      contentCache.collections.push({\n        name: collectionName,\n        files: cachedFiles,\n      });\n    }\n  }\n\n  contentCache.fetched = true;\n}\n\n/**\n * Get cached collection data\n */\nexport function getCachedCollection(\n  collectionName: string,\n): CachedCollection | undefined {\n  return contentCache.collections.find((c) => c.name === collectionName);\n}\n\n/**\n * Get cached tag map\n */\nexport function getCachedTagMap(): Map<string, Set<string>> {\n  return contentCache.tagMap;\n}\n\n/**\n * Ensure cache is populated\n */\nexport async function ensureCachePopulated(\n  owner: string,\n  repo: string,\n  token: string,\n  branch = \"main\",\n  contentPath = \"content\",\n): Promise<void> {\n  await populateCache(owner, repo, token, branch, contentPath);\n}\n\n// ============================================================================\n// GITHUB LOADER\n// ============================================================================\n\nexport function githubLoader(options: GitHubLoaderOptions): Loader {\n  const {\n    owner,\n    repo,\n    branch = \"main\",\n    contentPath = \"content\",\n    token,\n  } = options;\n\n  return {\n    name: \"github-markdown-loader\",\n    load: async ({\n      collection,\n      store,\n      logger,\n      parseData,\n      generateDigest,\n      renderMarkdown,\n    }: LoaderContext) => {\n      logger.info(`Loading collection '${collection}' from GitHub`);\n\n      const isDev = import.meta.env.DEV;\n\n      try {\n        // Ensure cache is populated\n        await ensureCachePopulated(owner, repo, token, branch, contentPath);\n\n        // Get cached collection data\n        const cachedCollection = getCachedCollection(collection);\n\n        if (!cachedCollection) {\n          logger.warn(`Collection '${collection}' not found in cache`);\n          return;\n        }\n\n        // Collect all entries for sorting\n        const allEntries: Array<{\n          id: string;\n          data: GitHubContentData;\n          body: string;\n          rendered: any;\n          digest: string;\n        }> = [];\n\n        // Process each cached file\n        for (const file of cachedCollection.files) {\n          const { frontmatter, body } = file;\n\n          // Skip unpublished entries in production\n          if (!isDev && frontmatter.published !== true) {\n            logger.info(`Skipping unpublished entry: ${file.name}`);\n            continue;\n          }\n\n          // Use slug from frontmatter as ID, or fall back to filename\n          const id = frontmatter.slug || file.name.replace(/\\.md$/, \"\");\n\n          // Transform IPFS URIs to CDN URLs before rendering\n          const processedBody = body.replace(\n            /ipfs:\\/\\//g,\n            \"https://cdn.iammatthias.com/ipfs/\",\n          );\n\n          // Render markdown to HTML\n          const rendered = await renderMarkdown(processedBody);\n\n          // Parse and validate data according to schema\n          const data = (await parseData({\n            id,\n            data: {\n              ...frontmatter,\n            },\n          })) as GitHubContentData;\n\n          // Generate digest for caching\n          const digest = generateDigest(data);\n\n          allEntries.push({\n            id,\n            data,\n            body: processedBody,\n            rendered,\n            digest,\n          });\n        }\n\n        // Sort by created date (most recent first)\n        allEntries.sort((a, b) => {\n          const dateA = new Date(a.data.created || 0).getTime();\n          const dateB = new Date(b.data.created || 0).getTime();\n          return dateB - dateA;\n        });\n\n        // Store sorted entries\n        store.clear();\n        for (const entry of allEntries) {\n          store.set({\n            id: entry.id,\n            data: entry.data,\n            body: entry.body,\n            rendered: entry.rendered,\n            digest: entry.digest,\n          });\n          logger.info(`Loaded: ${entry.id}`);\n        }\n\n        logger.info(\n          `Successfully loaded ${allEntries.length} entries for ${collection} (${isDev ? \"dev\" : \"prod\"} mode)`,\n        );\n      } catch (error) {\n        logger.error(`Error loading collection ${collection}: ${error}`);\n        throw error;\n      }\n    },\n    schema: githubContentSchema,\n  };\n}\n\n// ============================================================================\n// HELPERS\n// ============================================================================\n\n/**\n * Parse YAML frontmatter from markdown content\n */\nfunction parseFrontmatter(content: string): {\n  frontmatter: Record<string, any>;\n  body: string;\n} {\n  const frontmatterRegex = /^---\\s*\\n([\\s\\S]*?)\\n---\\s*\\n([\\s\\S]*)$/;\n  const match = content.match(frontmatterRegex);\n\n  if (!match) {\n    return { frontmatter: {}, body: content };\n  }\n\n  const [, frontmatterText, body] = match;\n  const frontmatter = parseYaml(frontmatterText);\n\n  return { frontmatter, body };\n}\n\n/**\n * Simple YAML parser for frontmatter\n */\nfunction parseYaml(yaml: string): Record<string, any> {\n  const result: Record<string, any> = {};\n  const lines = yaml.split(\"\\n\");\n  let currentKey: string | null = null;\n  let currentArray: any[] = [];\n\n  for (const line of lines) {\n    const trimmed = line.trim();\n\n    if (!trimmed) continue;\n\n    // Array item\n    if (trimmed.startsWith(\"- \")) {\n      const value = trimmed.slice(2).trim();\n      currentArray.push(value);\n      continue;\n    }\n\n    // Key-value pair\n    const colonIndex = trimmed.indexOf(\":\");\n    if (colonIndex !== -1) {\n      // Save previous array if exists\n      if (currentKey && currentArray.length > 0) {\n        result[currentKey] = currentArray;\n        currentArray = [];\n      }\n\n      const key = trimmed.slice(0, colonIndex).trim();\n      let value = trimmed.slice(colonIndex + 1).trim();\n\n      // Remove quotes if present\n      if (\n        (value.startsWith('\"') && value.endsWith('\"')) ||\n        (value.startsWith(\"'\") && value.endsWith(\"'\"))\n      ) {\n        value = value.slice(1, -1);\n      }\n\n      currentKey = key;\n\n      // Check if it's a boolean\n      if (value === \"true\") {\n        result[key] = true;\n      } else if (value === \"false\") {\n        result[key] = false;\n      } else if (value === \"\") {\n        // Empty value means an array might follow\n        currentArray = [];\n      } else {\n        result[key] = value;\n      }\n    }\n  }\n\n  // Save final array if exists\n  if (currentKey && currentArray.length > 0) {\n    result[currentKey] = currentArray;\n  }\n\n  return result;\n}\n\n/**\n * Get all available collections from the GitHub repo that have published content.\n * Uses cached data - only fetches from GitHub once.\n */\nexport async function getGitHubCollections(\n  owner: string,\n  repo: string,\n  token: string,\n  branch = \"main\",\n  contentPath = \"content\",\n): Promise<string[]> {\n  await ensureCachePopulated(owner, repo, token, branch, contentPath);\n  return contentCache.collectionNames;\n}\n"
  },
  {
    "path": "src/lib/og-renderer.ts",
    "content": "import satori from \"satori\";\nimport { html } from \"satori-html\";\nimport { Resvg } from \"@cf-wasm/resvg\";\n\n/**\n * Renders HTML to PNG using @cf-wasm/resvg (works in both Node.js and Cloudflare Workers)\n */\nexport async function renderToPng(\n  htmlContent: string,\n  options: {\n    width: number;\n    height: number;\n    fonts: Array<{\n      name: string;\n      data: ArrayBuffer | Uint8Array;\n      style: string;\n      weight: number;\n    }>;\n  }\n): Promise<Uint8Array> {\n  // Generate SVG with satori\n  const svg = await satori(html(htmlContent), options);\n\n  // Convert SVG to PNG using @cf-wasm/resvg\n  const resvg = new Resvg(svg);\n  const pngData = resvg.render();\n  return pngData.asPng();\n}\n"
  },
  {
    "path": "src/lib/tags-loader.ts",
    "content": "import type { Loader, LoaderContext } from \"astro/loaders\";\nimport { z } from \"astro/zod\";\nimport { ensureCachePopulated, getCachedTagMap } from \"./github-loader\";\n\ninterface TagsLoaderOptions {\n  owner: string;\n  repo: string;\n  token: string;\n  branch?: string;\n  contentPath?: string;\n}\n\nexport const tagsSchema = z.object({\n  name: z.string(),\n  count: z.number(),\n  collections: z.array(z.string()),\n});\n\nexport type TagData = z.infer<typeof tagsSchema>;\n\nexport function tagsLoader(options: TagsLoaderOptions): Loader {\n  const {\n    owner,\n    repo,\n    token,\n    branch = \"main\",\n    contentPath = \"content\",\n  } = options;\n\n  return {\n    name: \"tags-loader\",\n    load: async ({\n      store,\n      logger,\n      parseData,\n      generateDigest,\n    }: LoaderContext) => {\n      logger.info(\"Loading tags from all collections\");\n\n      try {\n        // Ensure cache is populated (this is a no-op if already done)\n        await ensureCachePopulated(owner, repo, token, branch, contentPath);\n\n        // Get the pre-built tag map from the cache\n        const tagMap = getCachedTagMap();\n\n        // Convert tag map to entries\n        store.clear();\n        for (const [tagName, collections] of tagMap.entries()) {\n          const data = (await parseData({\n            id: tagName,\n            data: {\n              name: tagName,\n              count: collections.size,\n              collections: Array.from(collections),\n            },\n          })) as TagData;\n\n          const digest = generateDigest(data);\n\n          store.set({\n            id: tagName,\n            data,\n            digest,\n          });\n\n          logger.info(\n            `Loaded tag: ${tagName} (${collections.size} collections)`,\n          );\n        }\n\n        logger.info(`Successfully loaded ${tagMap.size} tags`);\n      } catch (error) {\n        logger.error(`Error loading tags: ${error}`);\n        throw error;\n      }\n    },\n    schema: tagsSchema,\n  };\n}\n"
  },
  {
    "path": "src/lib/viemProvider.ts",
    "content": "import { createPublicClient, http } from 'viem';\nimport { baseSepolia } from 'viem/chains';\n\nexport const baseSepoliaClient = createPublicClient({\n  chain: baseSepolia,\n  transport: http(),\n});\n"
  },
  {
    "path": "src/pages/404.astro",
    "content": "---\nexport const prerender = false;\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport BlackHole from \"@src/components/BlackHole\";\nimport { keccak_256 } from \"@noble/hashes/sha3.js\";\nimport { bytesToHex } from \"@noble/hashes/utils.js\";\n\n// Generate seed from session details\nconst sessionData = [\n  Astro.request.headers.get(\"user-agent\") || \"\",\n  Astro.request.headers.get(\"accept-language\") || \"\",\n  Astro.clientAddress || \"\",\n  Date.now().toString(),\n].join(\"|\");\n\nconst hash = keccak_256(new TextEncoder().encode(sessionData));\nconst seed = parseInt(bytesToHex(hash).slice(0, 8), 16);\n\nconst title = \"404 - Page Not Found\";\nconst description = \"The page you're looking for doesn't exist\";\nconst ogImage = \"/404\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <section>\n      <BlackHole seed={seed} client:load />\n      <h1>404</h1>\n      <p>Page not found</p>\n      <a href='/'>Return home</a>\n    </section>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    position: relative;\n    z-index: 1;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    height: calc(100dvh - 7rem + 3px);\n    color: white;\n    text-align: center;\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    margin: 0 auto;\n  }\n\n  section h1 {\n    font-size: 6rem;\n    margin: 0;\n    font-weight: 900;\n  }\n\n  section p {\n    font-size: 1.5rem;\n    margin: 1rem 0 2rem 0;\n    opacity: 0.8;\n  }\n\n  section a {\n    color: white;\n    text-decoration: none;\n    padding: 0.75rem 1.5rem;\n    border: 1px solid white;\n    transition: background 0.2s;\n  }\n\n  section a:hover {\n    background: rgba(255, 255, 255, 0.1);\n  }\n</style>\n"
  },
  {
    "path": "src/pages/about.astro",
    "content": "---\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\n\nconst title = \"About - @iammatthias\";\nconst description =\n    \"Hi, I am Matthias. I'm a photographer turned growth engineer working on the future of the web.\";\nconst ogImage = \"/about\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n    <Fragment slot=\"main\">\n        <section>\n            <p>Hi, I am Matthias.</p>\n            <p>\n                I'm a photographer turned developer relations engineer. I've\n                gotten to do some really cool things along the way, and now I'm\n                working on the future of the web.\n            </p>\n            <p>\n                This site is my cozy corner on the web. It is a collection of\n                thoughts, projects, and ideas. You can keep up with my career by\n                checking out my <a href=\"/resume\">resume</a>, or follow along a\n                bit more ephemerally by checking out what I'm up to <a\n                    href=\"/now\">now</a\n                >.\n            </p>\n            <p>\n                Currently based in Southern California, where I spend a lot of\n                time cooking or behind the lens when I'm not at my desk.\n            </p>\n        </section>\n    </Fragment>\n</DefaultLayout>\n\n<style>\n    section {\n        padding: 2rem;\n        @media (max-width: 768px) {\n            padding: 1rem;\n        }\n        max-width: 1200px;\n        width: 100%;\n        margin: 0 auto;\n        display: flex;\n        flex-direction: column;\n        gap: 1rem;\n        height: fit-content;\n        min-height: calc(50vh - 5rem);\n    }\n\n    section > * {\n        max-width: 64ch;\n        word-wrap: pretty;\n    }\n\n    #social ul {\n        list-style: none;\n        padding: 0;\n        margin: 0;\n    }\n\n    #social li {\n        border-bottom: 1px solid var(--color-border);\n        padding-bottom: 1rem;\n    }\n\n    #social li:last-child {\n        border-bottom: none;\n    }\n\n    #social li a {\n        text-decoration: none;\n        color: inherit;\n        font-weight: 700;\n    }\n\n    #social li a:hover {\n        text-decoration: underline;\n    }\n\n    .username {\n        font-weight: 400;\n        color: var(--color-muted);\n    }\n</style>\n"
  },
  {
    "path": "src/pages/api/bluesky.json.ts",
    "content": "import type { APIRoute } from \"astro\";\n\n// PDS configuration\nconst USER_DID = \"did:plc:p5xem22ammiafn5kxonaksfa\";\nconst PDS_HOST = \"https://pds.iammatthias.com\";\n\n// Raw record types from PDS\ninterface BlobRef {\n  $type: \"blob\";\n  ref: { $link: string };\n  mimeType: string;\n  size: number;\n}\n\ninterface RawImageEmbed {\n  $type: \"app.bsky.embed.images\";\n  images: Array<{\n    alt: string;\n    image: BlobRef;\n    aspectRatio?: { width: number; height: number };\n  }>;\n}\n\ninterface RawVideoEmbed {\n  $type: \"app.bsky.embed.video\";\n  video: BlobRef;\n  alt?: string;\n  aspectRatio?: { width: number; height: number };\n}\n\ninterface RawExternalEmbed {\n  $type: \"app.bsky.embed.external\";\n  external: {\n    uri: string;\n    title: string;\n    description: string;\n    thumb?: BlobRef;\n  };\n}\n\ninterface RawRecordEmbed {\n  $type: \"app.bsky.embed.record\";\n  record: {\n    uri: string;\n    cid: string;\n  };\n}\n\ninterface RawRecordWithMediaEmbed {\n  $type: \"app.bsky.embed.recordWithMedia\";\n  record: RawRecordEmbed;\n  media: RawImageEmbed | RawVideoEmbed | RawExternalEmbed;\n}\n\ntype RawEmbed =\n  | RawImageEmbed\n  | RawVideoEmbed\n  | RawExternalEmbed\n  | RawRecordEmbed\n  | RawRecordWithMediaEmbed;\n\ninterface RawPostRecord {\n  $type: \"app.bsky.feed.post\";\n  text: string;\n  createdAt: string;\n  embed?: RawEmbed;\n  reply?: {\n    root: { uri: string; cid: string };\n    parent: { uri: string; cid: string };\n  };\n  langs?: string[];\n  facets?: Array<{\n    index: { byteStart: number; byteEnd: number };\n    features: Array<{\n      $type: string;\n      uri?: string;\n      did?: string;\n      tag?: string;\n    }>;\n  }>;\n}\n\ninterface PDSRecord {\n  uri: string;\n  cid: string;\n  value: RawPostRecord;\n}\n\ninterface ListRecordsResponse {\n  records: PDSRecord[];\n  cursor?: string;\n}\n\n// Output types - normalized for the component\ninterface NormalizedImage {\n  type: \"image\";\n  thumb: string;\n  fullsize: string;\n  alt: string;\n  aspectRatio?: { width: number; height: number };\n  mimeType: string;\n}\n\ninterface NormalizedVideo {\n  type: \"video\";\n  url: string;\n  alt?: string;\n  aspectRatio?: { width: number; height: number };\n  mimeType: string;\n}\n\ninterface NormalizedExternal {\n  type: \"external\";\n  uri: string;\n  title: string;\n  description: string;\n  thumb?: string;\n}\n\ninterface NormalizedQuote {\n  type: \"quote\";\n  uri: string;\n  cid: string;\n}\n\ntype NormalizedEmbed =\n  | NormalizedImage\n  | NormalizedVideo\n  | NormalizedExternal\n  | NormalizedQuote;\n\n// Build blob URL from PDS\nfunction getBlobUrl(did: string, cid: string): string {\n  return `${PDS_HOST}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;\n}\n\nfunction normalizeEmbed(embed: RawEmbed, did: string): NormalizedEmbed[] {\n  const embeds: NormalizedEmbed[] = [];\n\n  switch (embed.$type) {\n    case \"app.bsky.embed.images\":\n      for (const img of embed.images) {\n        const blobCid = img.image.ref.$link;\n        const url = getBlobUrl(did, blobCid);\n        embeds.push({\n          type: \"image\",\n          thumb: url,\n          fullsize: url,\n          alt: img.alt || \"\",\n          aspectRatio: img.aspectRatio,\n          mimeType: img.image.mimeType,\n        });\n      }\n      break;\n\n    case \"app.bsky.embed.video\":\n      const videoCid = embed.video.ref.$link;\n      embeds.push({\n        type: \"video\",\n        url: getBlobUrl(did, videoCid),\n        alt: embed.alt,\n        aspectRatio: embed.aspectRatio,\n        mimeType: embed.video.mimeType,\n      });\n      break;\n\n    case \"app.bsky.embed.external\":\n      embeds.push({\n        type: \"external\",\n        uri: embed.external.uri,\n        title: embed.external.title,\n        description: embed.external.description,\n        thumb: embed.external.thumb\n          ? getBlobUrl(did, embed.external.thumb.ref.$link)\n          : undefined,\n      });\n      break;\n\n    case \"app.bsky.embed.record\":\n      embeds.push({\n        type: \"quote\",\n        uri: embed.record.uri,\n        cid: embed.record.cid,\n      });\n      break;\n\n    case \"app.bsky.embed.recordWithMedia\":\n      // Process the media portion\n      if (embed.media) {\n        embeds.push(...normalizeEmbed(embed.media, did));\n      }\n      // Process the quoted record\n      if (embed.record) {\n        embeds.push(...normalizeEmbed(embed.record, did));\n      }\n      break;\n  }\n\n  return embeds;\n}\n\nexport const prerender = false;\n\nexport const GET: APIRoute = async ({ url }) => {\n  try {\n    // Get limit from query params, default to 10\n    const limitParam = url.searchParams.get(\"limit\");\n    const limit = limitParam ? parseInt(limitParam) : 10;\n\n    // Fetch records directly from PDS - no auth needed for public records\n    const recordsUrl = `${PDS_HOST}/xrpc/com.atproto.repo.listRecords?repo=${USER_DID}&collection=app.bsky.feed.post&limit=${limit}&reverse=true`;\n\n    const response = await fetch(recordsUrl, {\n      method: \"GET\",\n      headers: {\n        Accept: \"application/json\",\n        \"User-Agent\": \"iammatthias.com/1.0\",\n      },\n    });\n\n    if (!response.ok) {\n      throw new Error(\n        `PDS API responded with ${response.status}: ${response.statusText}`,\n      );\n    }\n\n    const data: ListRecordsResponse = await response.json();\n\n    // Process and transform the posts, sort by date descending (newest first)\n    const posts = data.records\n      .filter((record) => {\n        // Filter out replies, only show original posts\n        return !record.value.reply;\n      })\n      .sort(\n        (a, b) =>\n          new Date(b.value.createdAt).getTime() -\n          new Date(a.value.createdAt).getTime(),\n      )\n      .map((record) => {\n        const post = record.value;\n\n        // Normalize all embeds\n        const embeds: NormalizedEmbed[] = post.embed\n          ? normalizeEmbed(post.embed, USER_DID)\n          : [];\n\n        // Extract post ID from URI for the link\n        const uriParts = record.uri.split(\"/\");\n        const postId = uriParts[uriParts.length - 1];\n\n        return {\n          uri: record.uri,\n          cid: record.cid,\n          text: post.text,\n          createdAt: post.createdAt,\n          embeds,\n          facets: post.facets,\n          // No engagement counts from raw PDS records\n          postUrl: `https://bsky.app/profile/${USER_DID}/post/${postId}`,\n        };\n      })\n      .slice(0, limit);\n\n    return new Response(JSON.stringify(posts), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching from PDS:\", error);\n\n    // Return empty array on error to allow the site to function\n    return new Response(JSON.stringify([]), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "src/pages/api/farcaster.json.ts",
    "content": "import type { APIRoute } from \"astro\";\n\n// Farcaster Hub configuration\nconst HUB_URL = \"https://hub.merv.fun\";\nconst USER_FID = 2728;\n\n// Farcaster epoch starts Jan 1, 2021 00:00:00 UTC\nconst FARCASTER_EPOCH = 1609459200;\n\ninterface FarcasterEmbed {\n  url?: string;\n  castId?: {\n    fid: number;\n    hash: string;\n  };\n}\n\ninterface FarcasterCastAddBody {\n  text: string;\n  embeds: FarcasterEmbed[];\n  mentions: number[];\n  mentionsPositions: number[];\n  parentCastId?: {\n    fid: number;\n    hash: string;\n  };\n  parentUrl?: string;\n}\n\ninterface FarcasterMessage {\n  data: {\n    type: string;\n    fid: number;\n    timestamp: number;\n    castAddBody?: FarcasterCastAddBody;\n  };\n  hash: string;\n}\n\ninterface FarcasterResponse {\n  messages: FarcasterMessage[];\n  nextPageToken?: string;\n}\n\n// Output types\ninterface NormalizedEmbed {\n  type: \"url\" | \"cast\";\n  url?: string;\n  castFid?: number;\n  castHash?: string;\n}\n\nfunction farcasterTimestampToDate(timestamp: number): Date {\n  return new Date((FARCASTER_EPOCH + timestamp) * 1000);\n}\n\nexport const prerender = false;\n\nexport const GET: APIRoute = async ({ url }) => {\n  try {\n    const limitParam = url.searchParams.get(\"limit\");\n    const limit = limitParam ? parseInt(limitParam) : 10;\n\n    // Fetch more casts than needed since we filter out replies\n    const fetchSize = Math.max(limit * 3, 30);\n\n    // Fetch casts from Farcaster hub with timeout\n    const controller = new AbortController();\n    const timeout = setTimeout(() => controller.abort(), 15000);\n\n    const response = await fetch(\n      `${HUB_URL}/v1/castsByFid?fid=${USER_FID}&pageSize=${fetchSize}&reverse=true`,\n      {\n        method: \"GET\",\n        headers: {\n          Accept: \"application/json\",\n        },\n        signal: controller.signal,\n      },\n    );\n\n    clearTimeout(timeout);\n\n    if (!response.ok) {\n      throw new Error(\n        `Farcaster hub responded with ${response.status}: ${response.statusText}`,\n      );\n    }\n\n    const data: FarcasterResponse = await response.json();\n\n    // Process casts - filter to only top-level posts (no replies)\n    const posts = data.messages\n      .filter((msg) => {\n        // Only include cast adds that are not replies\n        return (\n          msg.data.type === \"MESSAGE_TYPE_CAST_ADD\" &&\n          msg.data.castAddBody &&\n          !msg.data.castAddBody.parentCastId &&\n          !msg.data.castAddBody.parentUrl\n        );\n      })\n      .map((msg) => {\n        const cast = msg.data.castAddBody!;\n        const createdAt = farcasterTimestampToDate(msg.data.timestamp);\n\n        // Normalize embeds\n        const embeds: NormalizedEmbed[] = cast.embeds\n          .map((embed) => {\n            if (embed.url) {\n              return { type: \"url\" as const, url: embed.url };\n            }\n            if (embed.castId) {\n              return {\n                type: \"cast\" as const,\n                castFid: embed.castId.fid,\n                castHash: embed.castId.hash,\n              };\n            }\n            return { type: \"url\" as const };\n          })\n          .filter((e) => e.url || e.castHash);\n\n        // Build warpcast URL\n        const hashHex = msg.hash.startsWith(\"0x\")\n          ? msg.hash.slice(2)\n          : msg.hash;\n        const postUrl = `https://warpcast.com/~/conversations/${hashHex}`;\n\n        return {\n          hash: msg.hash,\n          text: cast.text,\n          createdAt: createdAt.toISOString(),\n          embeds,\n          postUrl,\n        };\n      })\n      .slice(0, limit);\n\n    return new Response(JSON.stringify(posts), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching from Farcaster:\", error);\n\n    return new Response(JSON.stringify([]), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"no-cache, no-store, must-revalidate\",\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "src/pages/api/glass.json.ts",
    "content": "import type { APIRoute } from \"astro\";\nimport pLimit from \"p-limit\";\n\n// Glass API types - matches actual API response structure\ninterface GlassExif {\n  camera?: string;\n  lens?: string;\n  aperture?: string;\n  focal_length?: string;\n  iso?: string;\n  exposure_time?: string;\n}\n\ninterface GlassPhoto {\n  id: string;\n  width: number;\n  height: number;\n  image640x640: string;\n  share_url: string;\n  created_at: string;\n  description?: string;\n  exif?: GlassExif;\n}\n\n// Limit concurrent requests to Glass API\nconst glassLimit = pLimit(2);\n\nexport const prerender = false;\n\nexport const GET: APIRoute = async ({ url }) => {\n  try {\n    // Get limit from query params, default to 50\n    const limitParam = url.searchParams.get(\"limit\");\n    const limit = limitParam ? parseInt(limitParam) : 50;\n\n    // Fetch directly from Glass API\n    const glassResponse = await glassLimit(async () => {\n      // Glass.photo API endpoint - this may need to be adjusted based on their actual API\n      // Using the user profile API for @iam\n      const glassApiUrl = `https://glass.photo/api/v3/users/iam/posts?limit=${limit}`;\n\n      const response = await fetch(glassApiUrl, {\n        method: \"GET\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          \"User-Agent\": \"iammatthias.com/1.0\", // Identify our site\n        },\n      });\n\n      if (!response.ok) {\n        throw new Error(`Glass API responded with ${response.status}: ${response.statusText}`);\n      }\n\n      return response.json();\n    });\n\n    // Process and validate the photos - API returns array directly, not wrapped in an object\n    const photos: GlassPhoto[] = Array.isArray(glassResponse) ? glassResponse : [];\n\n    const validPhotos = photos\n      .filter((photo): photo is GlassPhoto => {\n        return (\n          photo &&\n          typeof photo.id === \"string\" &&\n          typeof photo.width === \"number\" &&\n          typeof photo.height === \"number\" &&\n          typeof photo.image640x640 === \"string\" &&\n          typeof photo.share_url === \"string\" &&\n          typeof photo.created_at === \"string\"\n        );\n      })\n      .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())\n      .slice(0, limit)\n      .map((photo) => ({\n        id: photo.id,\n        width: photo.width,\n        height: photo.height,\n        image640x640: photo.image640x640,\n        share_url: photo.share_url,\n        created_at: photo.created_at,\n        caption: photo.description || \"\",\n        exif: photo.exif\n          ? {\n              camera: photo.exif.camera,\n              lens: photo.exif.lens,\n              aperture: photo.exif.aperture,\n              focal_length: photo.exif.focal_length,\n              iso: photo.exif.iso,\n              exposure_time: photo.exif.exposure_time,\n            }\n          : undefined,\n      }));\n\n    return new Response(JSON.stringify(validPhotos), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"public, max-age=300\", // Cache for 5 minutes\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching from Glass API:\", error);\n\n    // For development/fallback, return empty array instead of error\n    // This allows the site to function even if Glass API is unavailable\n    return new Response(JSON.stringify([]), {\n      status: 200,\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"Cache-Control\": \"public, max-age=60\", // Shorter cache for errors\n      },\n    });\n  }\n};\n"
  },
  {
    "path": "src/pages/content/[collection]/[slug].astro",
    "content": "---\nimport { getCollection, render } from \"astro:content\";\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\nimport Sidebar from \"@components/sidebar/index.astro\";\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n};\n\nexport async function getStaticPaths() {\n  const GITHUB_OWNER = \"iammatthias\";\n  const GITHUB_REPO = \"obsidian_cms\";\n  const GITHUB_TOKEN = import.meta.env.GITHUB;\n\n  const collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n  const paths = await Promise.all(\n    collectionNames.map(async (collectionName) => {\n      const entries = (await getCollection(collectionName as any)) as Entry[];\n      return entries.map((entry) => ({\n        params: {\n          collection: collectionName,\n          slug: entry.id,\n        },\n        props: { entry },\n      }));\n    })\n  );\n\n  return paths.flat();\n}\n\ninterface Props {\n  entry: Entry;\n}\n\nconst { entry } = Astro.props;\nconst { collection, slug } = Astro.params;\nconst { Content } = await render(entry as any);\nconst title = entry.data.title || slug;\nconst description = entry.data.excerpt || `${title} - ${collection}`;\nconst ogImage = `/${collection}-${encodeURIComponent(title || \"\")}`;\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <article>\n      <nav class='breadcrumb'>\n        <a href='/content'>All Content</a> / <a href={`/content/${collection}`}>{collection}</a> / {slug}\n      </nav>\n\n      <header>\n        <h1>{entry.data.title}</h1>\n\n        <div class='meta'>\n          <time datetime={entry.data.created}>\n            Created: {new Date(entry.data.created).toLocaleDateString()}\n          </time>\n          <time datetime={entry.data.updated}>\n            Updated: {new Date(entry.data.updated).toLocaleDateString()}\n          </time>\n        </div>\n\n        {\n          entry.data.tags && entry.data.tags.length > 0 && (\n            <div class='tags'>\n              {entry.data.tags.map((tag: string) => (\n                <a href={`/tags/${tag}`} class='tag'>\n                  {tag}\n                </a>\n              ))}\n            </div>\n          )\n        }\n\n        {entry.data.excerpt && <p class='excerpt'>{entry.data.excerpt}</p>}\n      </header>\n\n      <div class='content'>\n        <Content />\n      </div>\n    </article>\n  </Fragment>\n  <!-- <Sidebar slot='sidebar'>\n    <h2>sidebar</h2>\n  </Sidebar> -->\n</DefaultLayout>\n\n<style>\n  article {\n    padding: 1rem;\n    max-width: 800px;\n    margin: 0 auto 5rem;\n  }\n\n  .breadcrumb {\n    font-size: 0.8rem;\n    color: var(--color-muted);\n    margin-bottom: 2rem;\n  }\n\n  .breadcrumb a {\n    color: inherit;\n    text-decoration: none;\n  }\n\n  .breadcrumb a:hover {\n    text-decoration: underline;\n  }\n\n  header {\n    margin-bottom: 2rem;\n    padding-bottom: 2rem;\n    border-bottom: 1px solid var(--color-border);\n  }\n\n  .meta {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    font-size: 0.8rem;\n    color: var(--color-muted);\n    margin-bottom: 1rem;\n  }\n\n  .tags {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n    margin-bottom: 1rem;\n  }\n\n  .tag {\n    background: var(--color-surface);\n    padding: 0.25rem 0.75rem;\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .tag:hover {\n    background: var(--color-surface);\n    opacity: 0.8;\n  }\n\n  .excerpt {\n    font-style: italic;\n    line-height: 1.6;\n    margin: 0;\n    font-size: 0.9rem;\n  }\n\n  .content {\n    max-width: 100%;\n    display: flex;\n    flex-direction: column;\n    gap: 1.5rem;\n  }\n</style>\n"
  },
  {
    "path": "src/pages/content/[collection]/index.astro",
    "content": "---\nimport { getCollection } from \"astro:content\";\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\n\nexport async function getStaticPaths() {\n  const GITHUB_OWNER = \"iammatthias\";\n  const GITHUB_REPO = \"obsidian_cms\";\n  const GITHUB_TOKEN = import.meta.env.GITHUB;\n\n  const collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n  return collectionNames.map((collection) => ({\n    params: { collection },\n  }));\n}\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n};\n\nconst { collection } = Astro.params;\n\n// Get all entries in this collection. Re-sort by created desc — the loader\n// sorts at insertion time, but Astro's content store doesn't guarantee\n// insertion order on read.\nconst entries = ((await getCollection(collection as any)) as Entry[]).sort(\n  (a, b) =>\n    new Date(b.data.created || 0).getTime() -\n    new Date(a.data.created || 0).getTime(),\n);\n\n// Group by tags if available\nconst byTag = new Map<string, Entry[]>();\nentries.forEach((entry) => {\n  if (entry.data.tags) {\n    entry.data.tags.forEach((tag: string) => {\n      if (!byTag.has(tag)) {\n        byTag.set(tag, []);\n      }\n      byTag.get(tag)!.push(entry);\n    });\n  }\n});\n\nconst title = `${collection} - @iammatthias`;\nconst description = `Browse ${entries.length} items in ${collection}`;\nconst ogImage = `/${collection}`;\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <section>\n      <nav class='breadcrumb'>\n        <a href='/content'>All Content</a> / {collection}\n      </nav>\n\n      <h1>{collection}</h1>\n      <div class='header-meta'>\n        <p class='count'>{entries.length} items</p>\n        <a href={`/content/${collection}/rss.xml`} class='rss-link'>RSS</a>\n      </div>\n\n      <div class='entries-list'>\n        {\n          entries.map((entry) => (\n            <article class='entry-card'>\n              <h2>\n                <a href={`/content/${collection}/${entry.id}`}>{entry.data.title}</a>\n              </h2>\n              {entry.data.excerpt && <p class='excerpt'>{entry.data.excerpt}</p>}\n              <div class='meta'>\n                <time datetime={entry.data.created}>Created: {new Date(entry.data.created).toLocaleDateString()}</time>\n                {entry.data.tags && entry.data.tags.length > 0 && (\n                  <div class='tags'>\n                    {entry.data.tags.map((tag: string) => (\n                      <a href={`/tags/${tag}`} class='tag'>\n                        {tag}\n                      </a>\n                    ))}\n                  </div>\n                )}\n              </div>\n            </article>\n          ))\n        }\n      </div>\n\n      {\n        byTag.size > 0 && (\n          <aside class='tags-sidebar'>\n            <h3>Filter by tag</h3>\n            <div class='tag-cloud'>\n              {Array.from(byTag.entries())\n                .sort((a, b) => b[1].length - a[1].length)\n                .map(([tag, items]) => (\n                  <button class='tag-filter' data-tag={tag}>\n                    {tag} <span class='tag-count'>({items.length})</span>\n                  </button>\n                ))}\n            </div>\n          </aside>\n        )\n      }\n    </section>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    margin: 0 auto;\n  }\n\n  .breadcrumb {\n    font-size: 0.9rem;\n    color: var(--color-muted);\n    margin-bottom: 1rem;\n  }\n\n  .breadcrumb a {\n    color: inherit;\n    text-decoration: none;\n  }\n\n  .breadcrumb a:hover {\n    text-decoration: underline;\n  }\n\n  h1 {\n    margin-bottom: 0.5rem;\n    text-transform: capitalize;\n  }\n\n  .header-meta {\n    display: flex;\n    align-items: center;\n    gap: 1rem;\n    margin-bottom: 2rem;\n  }\n\n  .count {\n    color: var(--color-muted);\n    margin: 0;\n  }\n\n  .rss-link {\n    font-size: 0.85rem;\n    text-decoration: none;\n    color: inherit;\n    border: 1px solid var(--color-border);\n    padding: 0.25rem 0.5rem;\n  }\n\n  .rss-link:hover {\n    background: var(--color-surface);\n  }\n\n  .entries-list {\n    display: flex;\n    flex-direction: column;\n    gap: 1.5rem;\n  }\n\n  .entry-card {\n    border: 1px solid var(--color-border);\n    padding: 1.5rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n  }\n\n  .entry-card h2 {\n    margin: 0 0 0.5rem 0;\n    font-size: 1.5rem;\n  }\n\n  .entry-card h2 a {\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .entry-card h2 a:hover {\n    text-decoration: underline;\n  }\n\n  .excerpt {\n    margin: 0 0 1rem 0;\n    line-height: 1.6;\n  }\n\n  .meta {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    align-items: center;\n    font-size: 0.9rem;\n    color: var(--color-muted);\n  }\n\n  .tags {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n  }\n\n  .tag {\n    background: var(--color-surface);\n    padding: 0.25rem 0.5rem;\n\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .tag:hover {\n    background: var(--color-surface);\n    opacity: 0.8;\n  }\n\n  .tags-sidebar {\n    margin-top: 3rem;\n    padding-top: 2rem;\n    border-top: 1px solid var(--color-border);\n  }\n\n  .tags-sidebar h3 {\n    margin: 0 0 1rem 0;\n  }\n\n  .tag-cloud {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n  }\n\n  .tag-filter {\n    background: var(--color-surface);\n    border: 1px solid var(--color-border);\n    padding: 0.5rem 1rem;\n    color: var(--color-foreground);\n    cursor: pointer;\n    font-size: 0.9rem;\n  }\n\n  .tag-filter:hover {\n    background: var(--color-surface);\n    opacity: 0.8;\n  }\n\n  .tag-count {\n    color: var(--color-muted);\n  }\n</style>\n"
  },
  {
    "path": "src/pages/content/[collection]/rss.xml.ts",
    "content": "import rss from '@astrojs/rss';\nimport { getCollection } from 'astro:content';\nimport { getGitHubCollections, type GitHubContentData } from '@lib/github-loader';\nimport type { APIContext } from 'astro';\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n};\n\nexport async function getStaticPaths() {\n  const GITHUB_OWNER = 'iammatthias';\n  const GITHUB_REPO = 'obsidian_cms';\n  const GITHUB_TOKEN = import.meta.env.GITHUB;\n\n  const collectionNames = await getGitHubCollections(\n    GITHUB_OWNER,\n    GITHUB_REPO,\n    GITHUB_TOKEN,\n    'main',\n    'content'\n  );\n\n  return collectionNames.map((collection) => ({\n    params: { collection },\n  }));\n}\n\nexport async function GET(context: APIContext) {\n  const { collection } = context.params;\n\n  if (!collection) {\n    return new Response('Collection not found', { status: 404 });\n  }\n\n  const entries = ((await getCollection(collection as any)) as Entry[]).sort(\n    (a, b) =>\n      new Date(b.data.created || 0).getTime() -\n      new Date(a.data.created || 0).getTime(),\n  );\n\n  return rss({\n    title: `${collection} - iammatthias`,\n    description: `Latest content from ${collection}`,\n    site: context.site?.toString() || 'https://iammatthias.com',\n    items: entries.map((entry) => ({\n      title: entry.data.title,\n      pubDate: new Date(entry.data.created),\n      description: entry.data.excerpt || '',\n      link: `/content/${collection}/${entry.id}/`,\n      categories: entry.data.tags || [],\n    })),\n    customData: '<language>en-us</language>',\n    stylesheet: '/rss.xml.xsl',\n  });\n}\n"
  },
  {
    "path": "src/pages/content/index.astro",
    "content": "---\nimport { getCollection } from \"astro:content\";\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n};\n\n// Get all collection names\nconst GITHUB_OWNER = \"iammatthias\";\nconst GITHUB_REPO = \"obsidian_cms\";\nconst GITHUB_TOKEN = import.meta.env.GITHUB;\n\nconst collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n// Get all entries from all collections and flatten into one array\n// Note: Entries are already filtered by published status and sorted by recency at the loader level\nconst allEntriesWithCollection = (\n  await Promise.all(\n    collectionNames.map(async (name) => {\n      const entries = (await getCollection(name as any)) as Entry[];\n      return entries.map((entry) => ({\n        ...entry,\n        collection: name,\n      }));\n    })\n  )\n)\n  .flat()\n  .sort((a, b) => {\n    const dateA = new Date(a.data.created).getTime();\n    const dateB = new Date(b.data.created).getTime();\n    return dateB - dateA; // Most recent first\n  });\n\nconst title = \"All Content - @iammatthias\";\nconst description = `Browse all ${allEntriesWithCollection.length} items across collections`;\nconst ogImage = \"/content\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <section>\n      <h1>All Content</h1>\n      <div class='header-meta'>\n        <p class='count'>{allEntriesWithCollection.length} items</p>\n        <a href='/rss.xml' class='rss-link'>RSS</a>\n      </div>\n\n      <div class='entries-list'>\n        {\n          allEntriesWithCollection.map((entry) => (\n            <article class='entry-card'>\n              <h2>\n                <a href={`/content/${entry.collection}/${entry.id}`}>{entry.data.title}</a>\n              </h2>\n              {entry.data.excerpt && <p class='excerpt'>{entry.data.excerpt}</p>}\n              <div class='meta'>\n                <time datetime={entry.data.created}>Created: {new Date(entry.data.created).toLocaleDateString()}</time>\n                <a href={`/content/${entry.collection}`} class='collection-link'>\n                  {entry.collection}\n                </a>\n                {entry.data.tags && entry.data.tags.length > 0 && (\n                  <div class='tags'>\n                    {entry.data.tags.map((tag: string) => (\n                      <a href={`/tags/${tag}`} class='tag'>\n                        {tag}\n                      </a>\n                    ))}\n                  </div>\n                )}\n              </div>\n            </article>\n          ))\n        }\n      </div>\n    </section>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    margin: 0 auto;\n  }\n\n  h1 {\n    margin-bottom: 0.5rem;\n  }\n\n  .header-meta {\n    display: flex;\n    align-items: center;\n    gap: 1rem;\n    margin-bottom: 2rem;\n  }\n\n  .count {\n    color: var(--color-muted);\n    margin: 0;\n  }\n\n  .rss-link {\n    font-size: 0.85rem;\n    text-decoration: none;\n    color: inherit;\n    border: 1px solid var(--color-border);\n    padding: 0.25rem 0.5rem;\n  }\n\n  .rss-link:hover {\n    background: var(--color-surface);\n  }\n\n  .entries-list {\n    display: flex;\n    flex-direction: column;\n    gap: 1.5rem;\n  }\n\n  .entry-card {\n    border: 1px solid var(--color-border);\n    padding: 1.5rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n  }\n\n  .entry-card h2 {\n    margin: 0 0 0.5rem 0;\n    font-size: 1.5rem;\n  }\n\n  .entry-card h2 a {\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .entry-card h2 a:hover {\n    text-decoration: underline;\n  }\n\n  .excerpt {\n    margin: 0 0 1rem 0;\n    line-height: 1.6;\n  }\n\n  .meta {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    align-items: center;\n    font-size: 0.9rem;\n    color: var(--color-muted);\n  }\n\n  .collection-link {\n    text-decoration: none;\n    color: inherit;\n    text-transform: capitalize;\n    padding: 0.25rem 0.5rem;\n    background: var(--color-surface);\n    border: 1px solid var(--color-border);\n  }\n\n  .collection-link:hover {\n    opacity: 0.8;\n  }\n\n  .tags {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n  }\n\n  .tag {\n    background: var(--color-surface);\n    padding: 0.25rem 0.5rem;\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .tag:hover {\n    background: var(--color-surface);\n    opacity: 0.8;\n  }\n</style>\n"
  },
  {
    "path": "src/pages/index.astro",
    "content": "---\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport HeroSection from \"@components/homepage/heroSection.astro\";\nimport RecentContent from \"@components/homepage/recentContent.astro\";\nimport RecentGlass from \"@components/RecentGlass\";\n// import RecentFeeds from \"@components/homepage/recentFeeds.astro\";\n\nconst title = \"@iammatthias\";\nconst description = \"Personal site of Matthias Jordan - photographer turned growth engineer\";\nconst ogImage = \"/\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <HeroSection />\n    <RecentContent />\n    <RecentGlass client:load />\n    <!-- <RecentFeeds /> -->\n  </Fragment>\n</DefaultLayout>\n"
  },
  {
    "path": "src/pages/now.astro",
    "content": "---\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport SocialFeeds from \"@components/SocialFeeds/index.tsx\";\n\nconst title = \"Now - @iammatthias\";\nconst description =\n    \"Hi, I am Matthias. I'm a photographer turned growth engineer working on the future of the web.\";\nconst ogImage = \"/now\";\n\nconst socialLinks = [\n\n    {\n        name: \"Glass\",\n        url: \"https://glass.photo/iam\",\n        username: \"@iam\",\n        blurb: \"Glass is a photography-centric platform, and is where I post most of my pictures.\",\n    },\n    {\n        name: \"Farcaster\",\n        url: \"https://farcaster.xyz/iammatthias\",\n        username: \"@iammatthias\",\n        blurb: 'Farcaster is a \"sufficiently decentralized\" social protocol. There are multiple clients, but I use Warpcast.',\n    },\n    {\n        name: \"Bluesky\",\n        url: \"https://bsky.app/profile/iammatthias.com\",\n        username: \"@iammatthias.com\",\n        blurb: 'Not as active as it used to be',\n    },\n    {\n        name: \"Instagram\",\n        url: \"https://instagram.com/iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"Not as active as it used to be, I share pictures on Glass these days.\",\n    },\n    {\n        name: \"Threads\",\n        url: \"https://www.threads.net/@iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"Mostly inactive.\",\n    },\n    {\n        name: \"GitHub\",\n        url: \"https://github.com/iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"Work and side projects.\",\n    },\n    {\n        name: \"LinkedIn\",\n        url: \"https://linkedin.com/in/iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"An up-to-date snapshot of my professional career.\",\n    },\n    {\n        name: \"Mastodon\",\n        url: \"https://mastodon.social/@iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"I maintain an account, but I am not active.\",\n    },\n    {\n        name: \"Twitter\",\n        url: \"https://twitter.com/iammatthias\",\n        username: \"@iammatthias\",\n        blurb: \"I maintain an account, but I have deleted almost all content.\",\n    },\n];\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n    <Fragment slot=\"main\">\n        <section>\n            <h1>Now</h1>\n            <p>A short list of things I'm working on:</p>\n            <ul>\n                <li>DevRel @ pinata</li>\n                <li>Building <a href=\"https://llm-txt.fun\">LLM TXT</a></li>\n                <li>\n                    Cooking - check out my collection of <a href=\"/recipes\"\n                        >recipes</a\n                    >\n                </li>\n            </ul>\n            <p class=\"muted\">Last updated April 1st, 2026</p>\n        </section>\n        <section>\n            <h3>Updates</h3>\n            <SocialFeeds client:load limit={5} />\n        </section>\n        <section>\n            <h2>Find me online</h2>\n            <ul>\n                {\n                    socialLinks.map((link) => (\n                        <li>\n                            <>\n                                <a href={link.url}>\n                                    {link.name}{\" \"}\n                                    <span class=\"username\">\n                                        {link.username}\n                                    </span>\n                                </a>\n                                <br />\n                            </>\n                            <!-- <small>{link.blurb}</small> -->\n                        </li>\n                    ))\n                }\n            </ul>\n        </section>\n    </Fragment>\n</DefaultLayout>\n\n<style>\n    section {\n        padding: 2rem;\n        @media (max-width: 768px) {\n            padding: 1rem;\n        }\n        max-width: 1200px;\n        width: 100%;\n        margin: 0 auto;\n        display: flex;\n        flex-direction: column;\n        gap: 1rem;\n        height: fit-content;\n    }\n\n    h1 {\n        margin: 0;\n        font-size: 1.5rem;\n    }\n</style>\n"
  },
  {
    "path": "src/pages/onchain-analytics/[hash]/index.astro",
    "content": "---\nexport const prerender = false;\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\n\nfunction hashToNumbers(hash: string): number[] {\n  const cleanHash = hash.replace(\"0x\", \"\");\n  return Array.from({ length: cleanHash.length / 2 }, (_, i) => parseInt(cleanHash.slice(i * 2, (i + 1) * 2), 16));\n}\n\nfunction generatePatternData(hash: string) {\n  const numbers = hashToNumbers(hash);\n\n  // Generate background fill pattern\n  function generateBackgroundPattern(): string[] {\n    const patterns: string[] = [];\n\n    const gridSizeX = 24;\n    const gridSizeY = 28; // Adjusted for 6:7 ratio\n    for (let x = 0; x < gridSizeX; x++) {\n      for (let y = 0; y < gridSizeY; y++) {\n        const baseX = (x / gridSizeX) * 120;\n        const baseY = (y / gridSizeY) * 140;\n        const offsetX = (numbers[(x + y) % numbers.length] / 255 - 0.5) * 4;\n        const offsetY = (numbers[(x + y + 1) % numbers.length] / 255 - 0.5) * 4;\n        const size = 0.15 + (numbers[(x * y) % numbers.length] / 255) * 0.2;\n\n        patterns.push(\n          `M ${baseX + offsetX},${baseY + offsetY} m ${-size},0 a ${size},${size} 0 1,0 ${size * 2},0 a ${size},${size} 0 1,0 ${-size * 2},0`\n        );\n      }\n    }\n    return patterns;\n  }\n\n  function generatePrimaryRegions(): { centers: [number, number][]; paths: string[] } {\n    const centers: [number, number][] = [];\n    const numRegions = 12 + (numbers[0] % 4); // Increased for larger canvas\n\n    // Create a grid of centers adjusted for 6:7 ratio\n    const gridSizeX = 5;\n    const gridSizeY = 6;\n    for (let x = 0; x < gridSizeX; x++) {\n      for (let y = 0; y < gridSizeY; y++) {\n        const baseX = 20 + (x * 80) / (gridSizeX - 1);\n        const baseY = 20 + (y * 100) / (gridSizeY - 1);\n        const offsetX = (numbers[(x + y) % numbers.length] / 255 - 0.5) * 15;\n        const offsetY = (numbers[(x + y + 1) % numbers.length] / 255 - 0.5) * 15;\n        centers.push([baseX + offsetX, baseY + offsetY]);\n      }\n    }\n\n    // Add random centers for organic feel\n    for (let i = 0; i < numRegions - gridSizeX * gridSizeY; i++) {\n      const angle = (numbers[i] / 255) * Math.PI * 2;\n      const distance = 20 + (numbers[i + 1] / 255) * 45;\n      centers.push([60 + Math.cos(angle) * distance, 70 + Math.sin(angle) * distance]);\n    }\n\n    const paths: string[] = centers.map((center, i) => {\n      const points: [number, number][] = [];\n      const numPoints = 24;\n\n      for (let j = 0; j < numPoints; j++) {\n        const angle = (j / numPoints) * Math.PI * 2;\n        const noise = (numbers[(i + j) % numbers.length] / 255) * 20;\n        const baseDistance = 35 + (numbers[(i * 2 + j) % numbers.length] / 255) * 25;\n        const distance = baseDistance + noise;\n\n        points.push([center[0] + Math.cos(angle) * distance, center[1] + Math.sin(angle) * distance]);\n      }\n\n      return (\n        `M ${points[0][0]},${points[0][1]} ` +\n        points\n          .slice(1)\n          .map((p) => `L ${p[0]},${p[1]}`)\n          .join(\" \") +\n        \" Z\"\n      );\n    });\n\n    return { centers, paths };\n  }\n\n  function generateCellPacking(regionCenter: [number, number], regionIndex: number): string[] {\n    const cells: string[] = [];\n    const regionRadius = 55;\n\n    // Create dense spiral patterns\n    const numSpirals = 4 + (numbers[regionIndex] % 3);\n    for (let s = 0; s < numSpirals; s++) {\n      const startAngle = (numbers[s * regionIndex] / 255) * Math.PI * 2;\n      let angle = startAngle;\n      let radius = 1;\n\n      const maxDots = 60;\n      let dotCount = 0;\n\n      while (radius < regionRadius && dotCount < maxDots) {\n        const angleNoise = (numbers[(Math.floor(angle * 5) + s) % numbers.length] / 255 - 0.5) * 0.4;\n        const radiusStep = 1.2 + numbers[(s * dotCount) % numbers.length] / 255;\n\n        angle += 0.3 + angleNoise;\n        radius += radiusStep;\n\n        const px = regionCenter[0] + Math.cos(angle) * radius;\n        const py = regionCenter[1] + Math.sin(angle) * radius;\n\n        const dotSize = 0.5 + (numbers[(dotCount * s) % numbers.length] / 255) * 0.7;\n\n        cells.push(\n          `M ${px},${py} m ${-dotSize},0 a ${dotSize},${dotSize} 0 1,0 ${dotSize * 2},0 a ${dotSize},${dotSize} 0 1,0 ${-dotSize * 2},0`\n        );\n\n        dotCount++;\n      }\n    }\n\n    // Add dense scattered fill\n    const numScatterDots = 40 + (numbers[regionIndex] % 20);\n    for (let i = 0; i < numScatterDots; i++) {\n      const angle = (numbers[(regionIndex * i) % numbers.length] / 255) * Math.PI * 2;\n      const radius = (numbers[(regionIndex * i + 1) % numbers.length] / 255) * regionRadius;\n\n      const px = regionCenter[0] + Math.cos(angle) * radius;\n      const py = regionCenter[1] + Math.sin(angle) * radius;\n\n      const dotSize = 0.3 + (numbers[(i * 3) % numbers.length] / 255) * 0.5;\n\n      cells.push(\n        `M ${px},${py} m ${-dotSize},0 a ${dotSize},${dotSize} 0 1,0 ${dotSize * 2},0 a ${dotSize},${dotSize} 0 1,0 ${-dotSize * 2},0`\n      );\n    }\n\n    return cells;\n  }\n\n  function generateTuringPattern(regionCenter: [number, number], regionIndex: number): string[] {\n    const lines: string[] = [];\n    const regionRadius = 55;\n\n    // Generate dense line coverage\n    const numLines = 15 + (numbers[regionIndex] % 10);\n    for (let i = 0; i < numLines; i++) {\n      const points: [number, number][] = [];\n      let x = regionCenter[0] + (numbers[i] / 255 - 0.5) * regionRadius;\n      let y = regionCenter[1] + (numbers[i + 1] / 255 - 0.5) * regionRadius;\n\n      const numSegments = 20 + (numbers[i * 2] % 15);\n      for (let j = 0; j < numSegments; j++) {\n        points.push([x, y]);\n\n        const angleChange = (numbers[(i * j) % numbers.length] / 255 - 0.5) * Math.PI * 1.8;\n        const stepSize = 2.5 + (numbers[(i * j + 1) % numbers.length] / 255) * 4;\n\n        x += Math.cos(angleChange) * stepSize;\n        y += Math.sin(angleChange) * stepSize;\n      }\n\n      if (points.length > 2) {\n        const path = points.reduce((acc, point, idx) => {\n          if (idx === 0) return `M ${point[0]} ${point[1]}`;\n          if (idx % 2 === 0) {\n            const prev = points[idx - 1];\n            const ctrlX = prev[0] + (numbers[(idx * regionIndex) % numbers.length] / 255 - 0.5) * 10;\n            const ctrlY = prev[1] + (numbers[(idx * regionIndex + 1) % numbers.length] / 255 - 0.5) * 10;\n            return `${acc} Q ${ctrlX} ${ctrlY}, ${point[0]} ${point[1]}`;\n          }\n          return acc;\n        }, \"\");\n        lines.push(path);\n      }\n    }\n\n    return lines;\n  }\n\n  const primaryRegions = generatePrimaryRegions();\n  const backgroundPattern = generateBackgroundPattern();\n  const patterns = primaryRegions.centers.map((center, i) => {\n    // Adjust pattern type distribution for more balanced coverage\n    const patternType = numbers[i] > 100; // Changed from 128 to 100 for more cell patterns\n    return {\n      type: patternType ? \"cells\" : \"turing\",\n      elements: patternType ? generateCellPacking(center, i) : generateTuringPattern(center, i),\n    };\n  });\n\n  return {\n    background: backgroundPattern,\n    regions: primaryRegions.paths,\n    patterns,\n  };\n}\n\nfunction generateColors(hash: string) {\n  const numbers = hashToNumbers(hash);\n  const palette: string[] = [];\n\n  // Generate fully hash-derived colors\n  for (let i = 0; i < 4; i++) {\n    const hue = (numbers[i * 3] / 255) * 360;\n    const saturation = 60 + (numbers[i * 3 + 1] / 255) * 40; // Range: 60-100%\n    const lightness = 20 + (numbers[i * 3 + 2] / 255) * 65; // Range: 20-85%\n    palette.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);\n  }\n\n  // Sort colors by lightness to ensure background is darkest\n  const sorted = [...palette].sort((a, b) => {\n    const getLightness = (color: string) => {\n      const match = color.match(/(\\d+)%\\)/);\n      return match ? parseInt(match[1]) : 0;\n    };\n    return getLightness(a) - getLightness(b);\n  });\n\n  return sorted;\n}\n\nconst { hash } = Astro.params;\nif (!hash) throw new Error(\"Hash parameter is required\");\n\nconst patterns = generatePatternData(hash);\nconst colors = generateColors(hash);\n\nconst title = `Session ${hash.slice(0, 8)}... - @iammatthias`;\nconst description = `Unique generative art pattern for session ${hash}`;\nconst ogImage = `/og-session-${hash}.png`;\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <article>\n      <a href='/onchain-analytics' class='back'>Back</a>\n      <svg\n        viewBox='-10 -10 140 160'\n        preserveAspectRatio='xMidYMid meet'\n        xmlns='http://www.w3.org/2000/svg'\n        style='display: block;'\n      >\n        <defs>\n          <clipPath id='clip-bg'>\n            <rect x='-10' y='-10' width='140' height='160'></rect>\n          </clipPath>\n        </defs>\n        <!-- Background -->\n        <rect x='-10' y='-10' width='140' height='160' fill={colors[0]} opacity='0.15'></rect>\n        <g clip-path='url(#clip-bg)'>\n          <!-- Background Pattern -->\n          {patterns.background.map((path) => <path d={path} fill={colors[1]} opacity='0.15' />)}\n\n          <!-- Primary Regions with Patterns -->\n          {\n            patterns.patterns.map((pattern, i) => (\n              <>\n                {pattern.type === \"cells\"\n                  ? pattern.elements.map((path) => (\n                      <path d={path} fill={colors[1 + (i % (colors.length - 1))]} opacity='0.95' />\n                    ))\n                  : pattern.elements.map((path) => (\n                      <path\n                        d={path}\n                        fill='none'\n                        stroke={colors[1 + (i % (colors.length - 1))]}\n                        stroke-width='0.4'\n                        opacity='0.9'\n                      />\n                    ))}\n              </>\n            ))\n          }\n        </g>\n      </svg>\n      <p>{hash}</p>\n    </article>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  article {\n    padding: 1rem;\n    max-width: 600px;\n    margin: 0 auto 5rem;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 1rem;\n    width: 100%;\n    text-align: center;\n  }\n\n  svg {\n    width: 100%;\n    height: auto;\n    margin: 0 auto;\n    display: block;\n    aspect-ratio: 7 / 8;\n    max-height: 70vh;\n  }\n\n  p {\n    word-break: break-all;\n    font-family: monospace;\n    font-size: 0.8rem;\n  }\n</style>\n"
  },
  {
    "path": "src/pages/onchain-analytics/index.astro",
    "content": "---\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport OnchainAnalytics from \"@components/OnchainAnalytics\";\n\nconst CONTRACT_ADDRESS = import.meta.env.PUBLIC_ANALYTICS_CONTRACT;\n\nconst title = \"Onchain Analytics - @iammatthias\";\nconst description =\n    \"Homebrew analytics system built on anonymous session management with keccak256 hashes and blockchain\";\nconst ogImage = \"/onchain-analytics\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n    <Fragment slot=\"main\">\n        <article>\n            <nav class=\"breadcrumb\">\n                <a href=\"/content\">All Content</a> / Onchain Analytics\n            </nav>\n\n            <div class=\"content\">\n                <h1>Onchain Analytics</h1>\n                <p>\n                    Recording started at block number <a\n                        href=\"https://sepolia.basescan.org/tx/0xc6fffb63218605b36b5cb7b07df1e66be77015e4ede9fe03b0356dd18d1fb8f8\"\n                        >12315477\n                    </a>, and is no longer collecting data. The <a\n                        href=`https://sepolia.basescan.org/address/${CONTRACT_ADDRESS}#code#L1`\n                    >\n                        smart contracts\n                    </a> are verified on Basescan.\n                </p>\n                <p>\n                    This site briefly used a homebrew analytics system. It was\n                    built on an annonymous session management system that\n                    leveraged keccak256 hashes and the blockchain — you can <a\n                        class=\"break\"\n                        href=\"https://iammatthias.com/content/posts/1712329304675-onchain-hit-counter/\"\n                        >read more it here</a\n                    >.\n                </p>\n                <OnchainAnalytics\n                    client:only=\"react\"\n                    contractAddress={CONTRACT_ADDRESS}\n                />\n            </div>\n        </article>\n    </Fragment>\n    <!-- <Sidebar slot='sidebar'>\n    <h2>sidebar</h2>\n  </Sidebar> -->\n</DefaultLayout>\n\n<style>\n    article {\n        padding: 2rem;\n        @media (max-width: 768px) {\n            padding: 1rem;\n        }\n        @media (max-width: 768px) {\n            padding: 1rem;\n        }\n        max-width: 1200px;\n        margin: 0 auto;\n        max-width: 800px;\n        margin: 0 auto 5rem;\n    }\n\n    .breadcrumb {\n        font-size: 0.8rem;\n        color: var(--color-muted);\n        margin-bottom: 2rem;\n    }\n\n    .breadcrumb a {\n        color: inherit;\n        text-decoration: none;\n    }\n\n    .breadcrumb a:hover {\n        text-decoration: underline;\n    }\n\n    header {\n        margin-bottom: 2rem;\n        padding-bottom: 2rem;\n        border-bottom: 1px solid var(--color-border);\n    }\n\n    .meta {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 1rem;\n        font-size: 0.8rem;\n        color: var(--color-muted);\n        margin-bottom: 1rem;\n    }\n\n    .tags {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.5rem;\n        margin-bottom: 1rem;\n    }\n\n    .tag {\n        background: var(--color-surface);\n        padding: 0.25rem 0.75rem;\n        text-decoration: none;\n        color: inherit;\n    }\n\n    .tag:hover {\n        background: var(--color-surface);\n        opacity: 0.8;\n    }\n\n    .excerpt {\n        font-style: italic;\n        line-height: 1.6;\n        margin: 0;\n        font-size: 0.9rem;\n    }\n\n    .content {\n        max-width: 100%;\n        display: flex;\n        flex-direction: column;\n        gap: 1.5rem;\n    }\n</style>\n"
  },
  {
    "path": "src/pages/resume.astro",
    "content": "---\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\n\nconst resumeData = [\n  {\n    section: \"Present\",\n    entries: [\n      {\n        date: \"2025 ➵ Current\",\n        company: \"Pinata\",\n        position: \"DevRel\",\n      },\n      {\n        date: \"2024 ➵ Current\",\n        company: \"day---break\",\n        position: \"Owner\",\n      },\n      {\n        date: \"2012 ➵ Current\",\n        company: \"Photographer\",\n        position: \"Fine art & freelance\",\n      },\n    ],\n  },\n  {\n    section: \"Historic\",\n    entries: [\n      {\n        date: \"2023 ➵ 2024\",\n        company: \"Ice Barrel (Contractor)\",\n        position: \"Web Development & Performance Marketing Manager\",\n      },\n      {\n        date: \"2022 ➵ 2024\",\n        company: \"Opul (Contractor)\",\n        position: \"Design System Engineer\",\n      },\n      {\n        date: \"2021 ➵ 2022\",\n        company: \"Tornado\",\n        position: \"Growth Engineer\",\n      },\n      {\n        date: \"2018 ➵ 2021\",\n        company: \"Aspiration\",\n        position: \"CRM Architect\",\n      },\n      {\n        date: \"2016 ➵ 2018\",\n        company: \"Surf Air\",\n        position: \"Product & Marketing Coordinator\",\n      },\n    ],\n  },\n\n  {\n    section: \"Education\",\n    entries: [\n      {\n        date: \"2010 ➵ 2014\",\n        company: \"Brooks Institute\",\n        position: \"Bachelors Degree, Commercial Photography\",\n      },\n    ],\n  },\n];\n\nconst title = \"Resume - @iammatthias\";\nconst description = \"Professional experience and education history\";\nconst ogImage = \"/resume\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    {\n      resumeData.map((section) => (\n        <section>\n          <h1>{section.section}</h1>\n          {section.entries.map((entry) => (\n            <div>\n              <h2>{entry.company}</h2>\n              <p>\n                <strong>{entry.position}</strong>\n              </p>\n              <p>{entry.date}</p>\n            </div>\n          ))}\n        </section>\n      ))\n    }\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    margin: 0 auto;\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n    height: fit-content;\n    min-height: calc(50vh - 5rem);\n  }\n\n  section > * {\n    max-width: 64ch;\n    word-wrap: pretty;\n  }\n\n  #social li {\n    border-bottom: 1px solid var(--color-border);\n    padding-bottom: 1rem;\n  }\n\n  #social li:last-child {\n    border-bottom: none;\n  }\n\n  #social li a {\n    text-decoration: none;\n    color: inherit;\n    font-weight: 700;\n  }\n\n  #social li a:hover {\n    text-decoration: underline;\n  }\n\n  .username {\n    font-weight: 400;\n    color: var(--color-muted);\n  }\n</style>\n"
  },
  {
    "path": "src/pages/robots.txt.ts",
    "content": "import type { APIRoute } from \"astro\";\n\nconst getRobotsTxt = (sitemapURL: URL) => `\nUser-agent: *\nAllow: /\n\nSitemap: ${sitemapURL.href}\n`;\n\nexport const GET: APIRoute = ({ site }) => {\n  const sitemapURL = new URL(\"sitemap-index.xml\", site);\n  return new Response(getRobotsTxt(sitemapURL));\n};\n"
  },
  {
    "path": "src/pages/rss.xml.ts",
    "content": "import rss from \"@astrojs/rss\";\nimport { getCollection } from \"astro:content\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\nimport type { APIContext } from \"astro\";\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n  collection: string;\n};\n\nexport async function GET(context: APIContext) {\n  const GITHUB_OWNER = \"iammatthias\";\n  const GITHUB_REPO = \"obsidian_cms\";\n  const GITHUB_TOKEN = import.meta.env.GITHUB;\n\n  const collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n  // Gather all entries from all collections\n  const allEntries: Entry[] = [];\n\n  for (const collectionName of collectionNames) {\n    const entries = (await getCollection(collectionName as any)) as Entry[];\n    entries.forEach((entry) => {\n      allEntries.push({\n        ...entry,\n        collection: collectionName,\n      });\n    });\n  }\n\n  // Sort by created date (most recent first)\n  allEntries.sort((a, b) => {\n    const dateA = new Date(a.data.created || 0).getTime();\n    const dateB = new Date(b.data.created || 0).getTime();\n    return dateB - dateA;\n  });\n\n  return rss({\n    title: \"iammatthias\",\n    description: \"All content from iammatthias.com\",\n    site: context.site?.toString() || \"https://iammatthias.com\",\n    items: allEntries.map((entry) => ({\n      title: entry.data.title,\n      pubDate: new Date(entry.data.created),\n      description: entry.data.excerpt || \"\",\n      link: `/content/${entry.collection}/${entry.id}/`,\n      categories: entry.data.tags || [],\n    })),\n    customData: \"<language>en-us</language>\",\n    stylesheet: \"/rss.xml.xsl\",\n  });\n}\n"
  },
  {
    "path": "src/pages/tags/[tag]/index.astro",
    "content": "---\nimport { getCollection } from \"astro:content\";\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport { getGitHubCollections, type GitHubContentData } from \"@lib/github-loader\";\nimport type { TagData } from \"@lib/tags-loader\";\n\ntype TagEntry = {\n  id: string;\n  data: TagData;\n};\n\ntype Entry = {\n  id: string;\n  data: GitHubContentData;\n  collection: string;\n};\n\nexport async function getStaticPaths() {\n  const tags = (await getCollection(\"tags\")) as TagEntry[];\n\n  return tags.map((tag) => ({\n    params: { tag: tag.id },\n    props: { tag },\n  }));\n}\n\ninterface Props {\n  tag: TagEntry;\n}\n\nconst { tag } = Astro.props;\nconst tagName = Astro.params.tag;\n\n// Get all collections\nconst GITHUB_OWNER = \"iammatthias\";\nconst GITHUB_REPO = \"obsidian_cms\";\nconst GITHUB_TOKEN = import.meta.env.GITHUB;\nconst collectionNames = await getGitHubCollections(GITHUB_OWNER, GITHUB_REPO, GITHUB_TOKEN, \"main\", \"content\");\n\n// Get all entries from all collections and filter by tag\nconst entriesWithTag: Entry[] = [];\n\nfor (const collectionName of collectionNames) {\n  const entries = (await getCollection(collectionName as any)) as Entry[];\n\n  for (const entry of entries) {\n    if (entry.data.tags?.includes(tagName as string)) {\n      entriesWithTag.push({\n        ...entry,\n        collection: collectionName,\n      });\n    }\n  }\n}\n\n// Sort by created date (most recent first)\nentriesWithTag.sort((a, b) => {\n  const dateA = new Date(a.data.created || 0).getTime();\n  const dateB = new Date(b.data.created || 0).getTime();\n  return dateB - dateA;\n});\n\nconst title = `#${tagName} - @iammatthias`;\nconst description = `${entriesWithTag.length} ${entriesWithTag.length === 1 ? \"entry\" : \"entries\"} tagged with ${tagName}`;\nconst ogImage = `/og-tags-${encodeURIComponent(tagName as string)}.png`;\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <section>\n      <nav class='breadcrumb'>\n        <a href='/tags'>All Tags</a> / {tagName}\n      </nav>\n\n      <h1>#{tagName}</h1>\n      <div class='header-meta'>\n        <p class='count'>{entriesWithTag.length} {entriesWithTag.length === 1 ? \"entry\" : \"entries\"}</p>\n      </div>\n\n      <div class='entries-list'>\n        {\n          entriesWithTag.map((entry) => (\n            <article class='entry-card'>\n              <h2>\n                <a href={`/content/${entry.collection}/${entry.id}`}>{entry.data.title}</a>\n              </h2>\n              <p class='collection'>{entry.collection}</p>\n              {entry.data.excerpt && <p class='excerpt'>{entry.data.excerpt}</p>}\n              <div class='meta'>\n                <time datetime={entry.data.created}>{new Date(entry.data.created).toLocaleDateString()}</time>\n              </div>\n            </article>\n          ))\n        }\n      </div>\n    </section>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    margin: 0 auto;\n  }\n\n  .breadcrumb {\n    font-size: 0.9rem;\n    color: var(--color-muted);\n    margin-bottom: 1rem;\n  }\n\n  .breadcrumb a {\n    color: inherit;\n    text-decoration: none;\n  }\n\n  .breadcrumb a:hover {\n    text-decoration: underline;\n  }\n\n  h1 {\n    margin-bottom: 0.5rem;\n  }\n\n  .header-meta {\n    display: flex;\n    align-items: center;\n    gap: 1rem;\n    margin-bottom: 2rem;\n  }\n\n  .count {\n    color: var(--color-muted);\n    margin: 0;\n  }\n\n  .rss-link {\n    font-size: 0.85rem;\n    text-decoration: none;\n    color: inherit;\n    border: 1px solid var(--color-border);\n    padding: 0.25rem 0.5rem;\n  }\n\n  .rss-link:hover {\n    background: var(--color-surface);\n  }\n\n  .entries-list {\n    display: flex;\n    flex-direction: column;\n    gap: 1.5rem;\n  }\n\n  .entry-card {\n    border: 1px solid var(--color-border);\n    padding: 1.5rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n  }\n\n  .entry-card h2 {\n    margin: 0 0 0.5rem 0;\n    font-size: 1.5rem;\n  }\n\n  .entry-card h2 a {\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .entry-card h2 a:hover {\n    text-decoration: underline;\n  }\n\n  .collection {\n    color: var(--color-muted);\n    margin: 0 0 0.5rem 0;\n    text-transform: capitalize;\n  }\n\n  .excerpt {\n    color: var(--color-invisibles);\n    margin: 0 0 1rem 0;\n    line-height: 1.6;\n  }\n\n  .meta {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 1rem;\n    align-items: center;\n    font-size: 0.9rem;\n    color: var(--color-muted);\n  }\n</style>\n"
  },
  {
    "path": "src/pages/tags/index.astro",
    "content": "---\nimport { getCollection } from \"astro:content\";\nimport DefaultLayout from \"@layouts/defaultLayouts.astro\";\nimport type { TagData } from \"@lib/tags-loader\";\n\ntype TagEntry = {\n  id: string;\n  data: TagData;\n};\n\nconst tags = (await getCollection(\"tags\")) as TagEntry[];\n\n// Sort by count (most used first)\ntags.sort((a, b) => b.data.count - a.data.count);\n\nconst title = \"All Tags - @iammatthias\";\nconst description = `Browse ${tags.length} tags`;\nconst ogImage = \"/tags\";\n---\n\n<DefaultLayout title={title} description={description} ogImage={ogImage}>\n  <Fragment slot='main'>\n    <section>\n      <h1>All Tags</h1>\n      <p>{tags.length} tags</p>\n\n      <div class='tags-grid'>\n        {\n          tags.map((tag) => (\n            <a href={`/tags/${tag.id}`} class='tag-card'>\n              <h2>{tag.data.name}</h2>\n              <p class='count'>\n                {tag.data.count} {tag.data.count === 1 ? \"collection\" : \"collections\"}\n              </p>\n            </a>\n          ))\n        }\n      </div>\n    </section>\n  </Fragment>\n</DefaultLayout>\n\n<style>\n  section {\n    padding: 2rem;\n    @media (max-width: 768px) {\n      padding: 1rem;\n    }\n    max-width: 1200px;\n    width: 100%;\n    margin: 0 auto;\n  }\n\n  h1 {\n    margin-bottom: 0.5rem;\n  }\n\n  .tags-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n    gap: 1rem;\n    margin-top: 2rem;\n  }\n\n  .tag-card {\n    border: 1px solid var(--color-border);\n    padding: 1rem;\n    text-decoration: none;\n    color: inherit;\n  }\n\n  .tag-card:hover {\n    background: var(--color-surface);\n  }\n\n  .tag-card h2 {\n    margin: 0 0 0.5rem 0;\n    font-size: 1.25rem;\n  }\n\n  .count {\n    color: var(--color-muted);\n    font-size: 0.9rem;\n    margin: 0;\n  }\n</style>\n"
  },
  {
    "path": "src/styles/globals.css",
    "content": ":root {\n    /* Previous theme - commented out */\n    /* --color-background: #121113;\n  --color-foreground: #ffffff;\n  --color-caret: #ffffff;\n  --color-invisibles: #333333;\n  --color-line-highlight: #22222255;\n  --color-selection: #222222;\n  --color-selection-foreground: #000000;\n  --color-comment: #888888;\n  --color-string: #fbcb97;\n  --color-number: #e78a53;\n  --color-constant: #e78a53;\n  --color-variable: #c1c1c1;\n  --color-keyword: #5f8787;\n  --color-storage: #5f8787;\n  --color-storage-type: #aaaaaa;\n  --color-class: #fbcb97;\n  --color-function: #aaaaaa;\n  --color-parameter: #999999;\n  --color-tag: #5f8787;\n  --color-attribute: #999999;\n  --color-punctuation: #ffffff;\n  --color-text: #c1c1c1;\n  --color-language-literal: #999999;\n  --color-invalid: #5f8787;\n  --color-invalid-deprecated: #e78a53;\n  --color-deleted: #5f8787;\n  --color-inserted: #fbcb97;\n  --color-changed: #e78a53;\n  --color-ignored: #888888;\n  --color-untracked: #333333;\n  --color-border: var(--color-string);\n  --color-surface: #1a1819;\n  --color-muted: var(--color-comment);\n  --color-accent: var(--color-string);\n  --color-accent-secondary: var(--color-number); */\n\n    /* Mediterranean Night Sailboat Theme */\n    /* Base theme colors */\n    --color-background: #0f1419;\n    --color-background: color(display-p3 0.06 0.08 0.1);\n    --color-foreground: #f1faee;\n    --color-foreground: color(display-p3 0.95 0.98 0.94);\n    --color-caret: #ffb800;\n    --color-caret: color(display-p3 1 0.75 0);\n    --color-invisibles: #1b4965;\n    --color-invisibles: color(display-p3 0.1 0.3 0.42);\n    --color-line-highlight: #1a1d2355;\n    --color-line-highlight: color(display-p3 0.1 0.11 0.14 / 0.33);\n    --color-selection: #1b4965;\n    --color-selection: color(display-p3 0.1 0.3 0.42);\n    --color-selection-foreground: #fefae0;\n    --color-selection-foreground: color(display-p3 1 0.98 0.88);\n\n    /* Syntax colors */\n    --color-comment: #6b8ca3;\n    --color-comment: color(display-p3 0.42 0.55 0.64);\n    --color-string: #ffb800;\n    --color-string: color(display-p3 1 0.75 0);\n    --color-number: #ff5733;\n    --color-number: color(display-p3 1 0.35 0.2);\n    --color-constant: #ff5733;\n    --color-constant: color(display-p3 1 0.35 0.2);\n    --color-variable: #d4d4d4;\n    --color-variable: color(display-p3 0.84 0.84 0.84);\n    --color-keyword: #2a6f97;\n    --color-keyword: color(display-p3 0.16 0.45 0.62);\n    --color-storage: #2a6f97;\n    --color-storage: color(display-p3 0.16 0.45 0.62);\n    --color-storage-type: #a68a64;\n    --color-storage-type: color(display-p3 0.66 0.56 0.42);\n    --color-class: #ff9a3d;\n    --color-class: color(display-p3 1 0.62 0.24);\n    --color-function: #a68a64;\n    --color-function: color(display-p3 0.66 0.56 0.42);\n    --color-parameter: #8b9bb3;\n    --color-parameter: color(display-p3 0.55 0.62 0.72);\n    --color-tag: #2a6f97;\n    --color-tag: color(display-p3 0.16 0.45 0.62);\n    --color-attribute: #8b9bb3;\n    --color-attribute: color(display-p3 0.55 0.62 0.72);\n    --color-punctuation: #f1faee;\n    --color-punctuation: color(display-p3 0.95 0.98 0.94);\n    --color-text: #d4d4d4;\n    --color-text: color(display-p3 0.84 0.84 0.84);\n    --color-language-literal: #8b9bb3;\n    --color-language-literal: color(display-p3 0.55 0.62 0.72);\n\n    /* Status colors */\n    --color-invalid: #2a6f97;\n    --color-invalid: color(display-p3 0.16 0.45 0.62);\n    --color-invalid-deprecated: #ff5733;\n    --color-invalid-deprecated: color(display-p3 1 0.35 0.2);\n    --color-deleted: #2a6f97;\n    --color-deleted: color(display-p3 0.16 0.45 0.62);\n    --color-inserted: #ffb800;\n    --color-inserted: color(display-p3 1 0.75 0);\n    --color-changed: #ff5733;\n    --color-changed: color(display-p3 1 0.35 0.2);\n    --color-ignored: #6b8ca3;\n    --color-ignored: color(display-p3 0.42 0.55 0.64);\n    --color-untracked: #1b4965;\n    --color-untracked: color(display-p3 0.1 0.3 0.42);\n\n    /* Semantic colors derived from theme */\n    --color-border: var(--color-string);\n    --color-surface: #1a1d23;\n    --color-surface: color(display-p3 0.1 0.11 0.14);\n    --color-muted: var(--color-comment);\n    --color-accent: var(--color-string);\n    --color-accent-secondary: var(--color-number);\n}\n\nbody {\n    font-size: 16px;\n    padding: 1rem;\n    padding-top: calc(env(safe-area-inset-top) + 1rem);\n    padding-right: calc(env(safe-area-inset-right) + 1rem);\n    padding-bottom: calc(env(safe-area-inset-bottom) + 1rem);\n    padding-left: calc(env(safe-area-inset-left) + 1rem);\n    font-family: -apple-system-ui-serif, ui-serif, \"Georgia\", serif;\n    background-color: var(--color-background);\n    color: var(--color-foreground);\n    caret-color: var(--color-caret);\n}\n\n::selection {\n    background-color: var(--color-selection);\n    color: var(--color-selection-foreground);\n}\n\n::-moz-selection {\n    background-color: var(--color-selection);\n    color: var(--color-selection-foreground);\n}\n\n/* Typography - Content styles for markdown rendering */\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n    line-height: 1.2;\n    font-weight: 700;\n}\n\nh1 {\n    font-size: 3.5em;\n}\n\nh2 {\n    font-size: 1.75rem;\n}\n\nh3 {\n    font-size: 1.5rem;\n}\n\nh4 {\n    font-size: 1.25rem;\n}\n\nh5 {\n    font-size: 1.1rem;\n}\n\nh6 {\n    font-size: 1rem;\n}\n\np {\n    line-height: 1.6;\n}\n\na {\n    color: inherit;\n    text-decoration: underline;\n}\n\na:hover {\n    opacity: 0.7;\n}\n\nol,\nul {\n    list-style: none;\n    display: flex;\n    flex-direction: column;\n    gap: 1rem;\n    margin-left: 2rem;\n}\n\nol {\n    list-style: decimal;\n}\n\nul {\n    list-style: disc;\n}\n\nli {\n    font-family:\n        ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\",\n        \"Ubuntu Mono\", monospace;\n    font-size: 0.8rem;\n}\n\nblockquote {\n    padding: 0.5rem 0.75rem;\n    border-left: 4px solid var(--color-border);\n    font-family:\n        -apple-system, BlinkMacSystemFont, \"Helvetica Neue\", Helvetica, Arial,\n        sans-serif;\n}\n\ncode,\npre,\ntable {\n    font-family:\n        ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\",\n        \"Ubuntu Mono\", monospace;\n    font-size: 0.75rem;\n}\n\ncode {\n    font-size: 0.9em;\n    padding: 0.2rem 0.4rem;\n    background: var(--color-surface);\n}\n\npre {\n    padding: 1.5rem;\n    @media (max-width: 768px) {\n        padding: 1rem;\n    }\n    background: var(--color-surface);\n    overflow-x: auto;\n}\n\npre code {\n    padding: 0;\n    background: transparent;\n}\n\ntable {\n    width: 100%;\n    border-collapse: collapse;\n}\n\nth,\ntd {\n    padding: 0.25rem;\n    border: 1px solid var(--color-border);\n    text-align: left;\n    vertical-align: top;\n}\n\nth {\n    background: var(--color-surface);\n}\n\nimg {\n    max-width: 100%;\n    height: auto;\n}\n\nhr {\n    border: none;\n    border-top: 1px solid var(--color-border);\n}\n\nfigcaption {\n    font-size: 0.9rem;\n    color: var(--color-muted);\n    text-align: center;\n}\n\n.heavy {\n    font-weight: 900;\n}\n\n.tag {\n    font-family:\n        ui-monospace, SFMono-Regular, ui-monospace, Monaco, \"Andale Mono\",\n        \"Ubuntu Mono\", monospace;\n    font-size: 0.6rem;\n}\n\n.muted {\n    color: var(--color-muted);\n}\n\n.muted p {\n    font-size: 0.9rem;\n}\n"
  },
  {
    "path": "src/styles/reset.css",
    "content": "/* Box sizing rules */\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n  text-wrap: pretty;\n}\n\n/* Prevent font size inflation */\nhtml {\n  -moz-text-size-adjust: none;\n  -webkit-text-size-adjust: none;\n  text-size-adjust: none;\n}\n\n/* Remove default margin in favour of better control in authored CSS */\nbody,\nh1,\nh2,\nh3,\nh4,\np,\nfigure,\nblockquote,\ndl,\ndd {\n  margin-block-end: 0;\n}\n\n/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */\nul[role=\"list\"],\nol[role=\"list\"] {\n  list-style: none;\n}\n\n/* Set core body defaults */\nbody {\n  min-height: 100dvh;\n  line-height: 1.5;\n}\n\n/* Set shorter line heights on headings and interactive elements */\nh1,\nh2,\nh3,\nh4,\nbutton,\ninput,\nlabel {\n  line-height: 1.1;\n}\n\n/* Balance text wrapping on headings */\nh1,\nh2,\nh3,\nh4 {\n  text-wrap: balance;\n}\n\n/* A elements that don't have a class get default styles */\na:not([class]) {\n  text-decoration-skip-ink: auto;\n  color: currentColor;\n}\n\n/* Make images easier to work with */\nimg,\npicture {\n  max-width: 100%;\n  display: block;\n}\n\n/* Inherit fonts for inputs and buttons */\ninput,\nbutton,\ntextarea,\nselect {\n  font-family: inherit;\n  font-size: inherit;\n}\n\n/* Make sure textareas without a rows attribute are not tiny */\ntextarea:not([rows]) {\n  min-height: 10em;\n}\n\n/* Anything that has been anchored to should have extra scroll margin */\n:target {\n  scroll-margin-block: 5ex;\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"astro/tsconfigs/strict\",\n  \"include\": [\".astro/types.d.ts\", \"**/*\"],\n  \"exclude\": [\"dist\", \".mastra\"],\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"react\",\n    \"paths\": {\n      \"@src/*\": [\"./src/*\"],\n      \"@styles/*\": [\"./src/styles/*\"],\n      \"@components/*\": [\"./src/components/*\"],\n      \"@layouts/*\": [\"./src/layouts/*\"],\n      \"@pages/*\": [\"./src/pages/*\"],\n      \"@mastra/*\": [\"./src/mastra/*\"],\n      \"@actions/*\": [\"./src/actions/*\"],\n      \"@lib/*\": [\"./src/lib/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "wrangler.toml",
    "content": "name = \"com\"\nmain = \"@astrojs/cloudflare/entrypoints/server\"\ncompatibility_date = \"2024-09-23\"\ncompatibility_flags = [\"nodejs_compat\"]\n\n# Session storage. wrangler auto-provisions a KV namespace on first deploy.\n# After the first successful deploy, run `wrangler kv namespace list`, copy\n# the id, and add `id = \"...\"` below so future deploys reuse it.\n[[kv_namespaces]]\nbinding = \"SESSION\"\n\n# IMAGES binding is added automatically by @astrojs/cloudflare.\n#\n# All env values used by this site are read at *build time* via\n# `import.meta.env.*` (Vite inlines them into the bundle), so they belong\n# in the dashboard's Build environment, NOT in [vars] here. The runtime\n# Worker doesn't need any user-set env vars.\n#\n# Required Build env (Dashboard -> Worker -> Settings -> Build ->\n# \"Build variables and secrets\"):\n#   - GITHUB                    (Secret)   GitHub PAT for obsidian_cms repo\n#   - PUBLIC_ANALYTICS_CONTRACT (Variable) 0x29867a886F96e84196fa47DEe2Fb4a5853412C03\n"
  }
]