diff --git a/site/astro.config.mjs b/site/astro.config.mjs index aa5e9cb3..6d1c486e 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -2,11 +2,13 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; import sitemap from '@astrojs/sitemap'; +import mdx from '@astrojs/mdx'; // https://astro.build/config export default defineConfig({ site: 'https://warmbly.com', integrations: [ + mdx(), sitemap({ // The 404 page is noindex and should never appear in the sitemap. filter: (page) => !page.includes('/404'), diff --git a/site/package.json b/site/package.json index 8627337c..d2fb17c6 100644 --- a/site/package.json +++ b/site/package.json @@ -10,11 +10,14 @@ "astro": "astro" }, "dependencies": { + "@astrojs/mdx": "^4.3.14", + "@astrojs/rss": "^4.0.18", "@astrojs/sitemap": "^3.4.1", "@fontsource/inter": "^5.2.6", "@tailwindcss/vite": "^4.1.10", "astro": "^5.9.3", "motion": "^12.34.0", + "reading-time": "^1.5.0", "tailwindcss": "^4.1.10" } } diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index d67b1bca..26c3a0b4 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@astrojs/mdx': + specifier: ^4.3.14 + version: 4.3.14(astro@5.18.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3)) + '@astrojs/rss': + specifier: ^4.0.18 + version: 4.0.18 '@astrojs/sitemap': specifier: ^3.4.1 version: 3.7.2 @@ -23,6 +29,9 @@ importers: motion: specifier: ^12.34.0 version: 12.40.0 + reading-time: + specifier: ^1.5.0 + version: 1.5.0 tailwindcss: specifier: ^4.1.10 version: 4.3.0 @@ -38,10 +47,19 @@ packages: '@astrojs/markdown-remark@6.3.11': resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} + '@astrojs/mdx@4.3.14': + resolution: {integrity: sha512-FBrqJQORVm+rkRa2TS5CjU9PBA6hkhrwLVBSS9A77gN2+iehvjq1w6yya/d0YKC7osiVorKkr3Qd9wNbl0ZkGA==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + peerDependencies: + astro: ^5.0.0 + '@astrojs/prism@3.3.0': resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/rss@4.0.18': + resolution: {integrity: sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==} + '@astrojs/sitemap@3.7.2': resolution: {integrity: sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==} @@ -418,89 +436,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -541,6 +575,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -587,66 +627,79 @@ packages: resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.4': resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} @@ -737,24 +790,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -792,6 +849,9 @@ packages: '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -804,6 +864,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -816,12 +879,20 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -846,6 +917,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.0: + resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -859,6 +933,10 @@ packages: array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + astro@5.18.1: resolution: {integrity: sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} @@ -901,6 +979,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -917,6 +998,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1042,6 +1126,12 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1056,6 +1146,24 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -1068,6 +1176,13 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1135,9 +1250,15 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} @@ -1162,9 +1283,21 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1174,6 +1307,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -1234,24 +1370,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1282,6 +1422,10 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1312,6 +1456,18 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -1354,12 +1510,30 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} @@ -1390,6 +1564,9 @@ packages: micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} @@ -1493,12 +1670,19 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -1535,6 +1719,23 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -1550,6 +1751,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -1559,6 +1763,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -1621,6 +1828,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -1646,6 +1857,15 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strnum@2.4.0: + resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -1724,6 +1944,9 @@ packages: unist-util-modify-children@4.0.0: resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} @@ -1876,6 +2099,10 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -1947,10 +2174,35 @@ snapshots: transitivePeerDependencies: - supports-color + '@astrojs/mdx@4.3.14(astro@5.18.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3))': + dependencies: + '@astrojs/markdown-remark': 6.3.11 + '@mdx-js/mdx': 3.1.1 + acorn: 8.16.0 + astro: 5.18.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3) + es-module-lexer: 1.7.0 + estree-util-visit: 2.0.0 + hast-util-to-html: 9.0.5 + piccolore: 0.1.3 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-smartypants: 3.0.2 + source-map: 0.7.6 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@astrojs/prism@3.3.0': dependencies: prismjs: 1.30.0 + '@astrojs/rss@4.0.18': + dependencies: + fast-xml-parser: 5.8.0 + piccolore: 0.1.3 + zod: 4.4.3 + '@astrojs/sitemap@3.7.2': dependencies: sitemap: 9.0.1 @@ -2265,6 +2517,38 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.14 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@nodable/entities@2.1.1': {} + '@oslojs/encoding@1.1.0': {} '@rollup/pluginutils@5.3.0(rollup@4.60.4)': @@ -2455,6 +2739,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -2467,6 +2755,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdx@2.0.14': {} + '@types/ms@2.1.0': {} '@types/nlcst@2.0.3': @@ -2481,10 +2771,16 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/unist@2.0.11': {} + '@types/unist@3.0.3': {} '@ungap/structured-clone@1.3.1': {} + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} ansi-align@3.0.1: @@ -2502,6 +2798,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + anynum@1.0.0: {} + arg@5.0.2: {} argparse@2.0.1: {} @@ -2510,6 +2808,8 @@ snapshots: array-iterate@2.0.1: {} + astring@1.9.0: {} + astro@5.18.1(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(typescript@5.9.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -2643,6 +2943,8 @@ snapshots: character-entities@2.0.2: {} + character-reference-invalid@2.0.1: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -2653,6 +2955,8 @@ snapshots: clsx@2.1.1: {} + collapse-white-space@2.1.0: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -2758,6 +3062,20 @@ snapshots: es-module-lexer@1.7.0: {} + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2818,6 +3136,35 @@ snapshots: escape-string-regexp@5.0.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -2828,6 +3175,19 @@ snapshots: extend@3.0.2: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.4.0 + xml-naming: 0.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2913,6 +3273,27 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -2927,6 +3308,26 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + hast-util-to-parse5@8.0.1: dependencies: '@types/hast': 3.0.4 @@ -2964,12 +3365,25 @@ snapshots: import-meta-resolve@4.2.0: {} + inline-style-parser@0.2.7: {} + iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + is-docker@3.0.0: {} is-fullwidth-code-point@3.0.0: {} + is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -3051,6 +3465,8 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 + markdown-extensions@2.0.0: {} + markdown-table@3.0.4: {} mdast-util-definitions@6.0.0: @@ -3140,6 +3556,55 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -3254,6 +3719,57 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -3267,6 +3783,18 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -3319,6 +3847,16 @@ snapshots: micromark-util-encode@2.0.1: {} + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + micromark-util-html-tag-name@2.0.1: {} micromark-util-normalize-identifier@2.0.1: @@ -3430,6 +3968,16 @@ snapshots: package-manager-detector@1.6.0: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-latin@7.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -3443,6 +3991,8 @@ snapshots: dependencies: entities: 6.0.1 + path-expression-matcher@1.5.0: {} + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -3470,6 +4020,37 @@ snapshots: readdirp@5.0.0: {} + reading-time@1.5.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -3492,6 +4073,14 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 @@ -3516,6 +4105,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -3662,6 +4258,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} stream-replace-string@2.0.0: {} @@ -3691,6 +4289,18 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strnum@2.4.0: + dependencies: + anynum: 1.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -3766,6 +4376,10 @@ snapshots: '@types/unist': 3.0.3 array-iterate: 2.0.1 + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -3852,6 +4466,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + xml-naming@0.1.0: {} + xxhash-wasm@1.1.0: {} yargs-parser@21.1.1: {} diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index b5ae66c8..9fc6ea12 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -43,6 +43,7 @@ const groups: { title: string; links: { label: string; href: string }[] }[] = [ title: 'Company', links: [ { label: 'About', href: '/about/' }, + { label: 'Blog', href: '/blog/' }, { label: 'Contact', href: '/contact/' }, { label: 'Brand', href: '/brand/' }, { label: 'Trust', href: '/trust/' }, @@ -103,6 +104,13 @@ const year = new Date().getFullYear(); github.com/warmbly/warmbly · + + + RSS + + ·

Open source.

diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 5c9bb234..63b1838b 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -62,6 +62,7 @@ const nav: NavItem[] = [ { label: 'Deliverability',href: '/deliverability/',match: '/deliverability' }, { label: 'Pricing', href: '/pricing/', match: '/pricing' }, { label: 'Learn', href: '/learn/', match: '/learn' }, + { label: 'Blog', href: '/blog/', match: '/blog' }, ]; function active(match?: string) { diff --git a/site/src/components/blog/BlogCover.astro b/site/src/components/blog/BlogCover.astro new file mode 100644 index 00000000..44529998 --- /dev/null +++ b/site/src/components/blog/BlogCover.astro @@ -0,0 +1,56 @@ +--- +/** + * Post cover art. Uses the frontmatter `cover` image when one is set; + * otherwise composes a deterministic sky scene from the post id, so every + * post gets stable, on-brand artwork without anyone designing it. + */ +import Cloud from '../Cloud.astro'; +import type { BlogPost } from '../../lib/blog'; + +interface Props { + post: BlogPost; + /** Lazy-load by default; the featured card should pass eager. */ + eager?: boolean; +} +const { post, eager = false } = Astro.props; + +let hash = 0; +for (const ch of post.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973; + +const skies = [ + // bright hero sky + 'radial-gradient(ellipse 140% 140% at 72% 25%, #7dd3fc 0%, #38bdf8 18%, #0ea5e9 36%, #0284c7 58%, #075985 82%)', + // deep band + 'radial-gradient(ellipse 130% 140% at 72% 28%, #0284c7 0%, #0369a1 25%, #075985 50%, #0c4a6e 80%, #0a3d5c 100%)', + // morning + 'radial-gradient(ellipse 130% 120% at 30% 0%, #bae6fd 0%, #7dd3fc 35%, #38bdf8 70%, #0ea5e9 100%)', + // dusk + 'radial-gradient(ellipse 130% 120% at 70% 10%, #a5b4fc 0%, #818cf8 30%, #38bdf8 70%, #0284c7 100%)', + // noon + 'radial-gradient(ellipse 150% 130% at 50% -20%, #e0f2fe 0%, #7dd3fc 40%, #0ea5e9 80%, #0284c7 100%)', +]; + +const sky = skies[hash % skies.length]; +const cloudA = ((hash % 5) + 1) as 1 | 2 | 3 | 4 | 5; +const cloudB = (((hash >> 3) % 5) + 1) as 1 | 2 | 3 | 4 | 5; +const flip = hash % 2 === 0; +--- +{post.data.cover ? ( + +) : ( +
+
+ +
+
+ +
+
+
+)} diff --git a/site/src/components/blog/Callout.astro b/site/src/components/blog/Callout.astro new file mode 100644 index 00000000..3deb449e --- /dev/null +++ b/site/src/components/blog/Callout.astro @@ -0,0 +1,41 @@ +--- +/** + * Inline callout for MDX blog posts. + * + * import Callout from '../../components/blog/Callout.astro'; + * ... + * + * Variants: 'note' (sky), 'warn' (amber). + */ +interface Props { + title?: string; + variant?: 'note' | 'warn'; +} +const { title, variant = 'note' } = Astro.props; + +const tone = + variant === 'warn' + ? { ring: 'ring-amber-200', bg: 'bg-amber-50/60', label: 'text-amber-700', bar: 'bg-amber-400' } + : { ring: 'ring-sky-200', bg: 'bg-sky-50/60', label: 'text-sky-700', bar: 'bg-sky-400' }; +--- + + + diff --git a/site/src/components/blog/PostGrid.astro b/site/src/components/blog/PostGrid.astro new file mode 100644 index 00000000..f6330f3d --- /dev/null +++ b/site/src/components/blog/PostGrid.astro @@ -0,0 +1,138 @@ +--- +/** + * Newsroom-style post grid: cover image, date + tag, title. Client-side + * search and batched "Load more" only toggle visibility; every card is + * server-rendered for crawlers and no-JS readers. + * + * Topic tabs come through the `filters` slot so the index and tag pages + * can mark their own active tab. + */ +import BlogCover from './BlogCover.astro'; +import { isoDate, readingTimeOf, type BlogPost } from '../../lib/blog'; + +interface Props { + posts: BlogPost[]; + /** Cards shown initially and added per "Load more" click. */ + batch?: number; +} +const { posts, batch = 12 } = Astro.props; +--- +
+ +
+ +
+ + {posts.length} {posts.length === 1 ? 'post' : 'posts'} +
+
+ + +
+ {posts.map((post, i) => ( + +
+ +
+
+ + {post.data.tags.slice(0, 2).map((tag) => ( + {tag} + ))} + {readingTimeOf(post)} +
+

+ {post.data.title} +

+

{post.data.description}

+
+ ))} +
+ + + + + + +
+ + diff --git a/site/src/components/blog/TagFilter.astro b/site/src/components/blog/TagFilter.astro new file mode 100644 index 00000000..82901c1b --- /dev/null +++ b/site/src/components/blog/TagFilter.astro @@ -0,0 +1,29 @@ +--- +interface Props { + tags: { tag: string; count: number }[]; + total: number; + /** Currently active tag, or undefined on the unfiltered archive. */ + active?: string; +} +const { tags, total, active } = Astro.props; + +const base = 'inline-flex items-center gap-1.5 h-7 px-3 rounded-full text-[12px] font-medium ring-1 transition-colors'; +const on = 'bg-foreground text-background ring-foreground'; +const off = 'bg-white text-foreground/70 ring-[color:var(--border)] hover:text-foreground hover:bg-[color:var(--surface-2)]'; +--- +
+ + All + {total} + + {tags.map(({ tag, count }) => ( + + {tag} + {count} + + ))} +
diff --git a/site/src/content.config.ts b/site/src/content.config.ts new file mode 100644 index 00000000..b4174e3d --- /dev/null +++ b/site/src/content.config.ts @@ -0,0 +1,24 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +// Blog posts: markdown/MDX files under src/content/blog. +// The file name (without extension) becomes the URL slug, e.g. +// src/content/blog/quarantine-early.mdx -> /blog/quarantine-early/ +const blog = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), + schema: z.object({ + title: z.string(), + description: z.string(), + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + author: z.string().default('Warmbly team'), + // Short topic labels rendered as chips; not pages. Keep them few and reused. + tags: z.array(z.string()).default([]), + // Optional card/header image (path under /public, e.g. "/blog/foo.webp"). + // Posts without one get a generated sky cover. + cover: z.string().optional(), + draft: z.boolean().default(false), + }), +}); + +export const collections = { blog }; diff --git a/site/src/content/blog/mailbox-budgets-not-server-settings.md b/site/src/content/blog/mailbox-budgets-not-server-settings.md new file mode 100644 index 00000000..d7a2303f --- /dev/null +++ b/site/src/content/blog/mailbox-budgets-not-server-settings.md @@ -0,0 +1,24 @@ +--- +title: 'Your send limit is a mailbox budget, not a server setting' +description: 'People keep asking us to raise their daily limit. The honest answer is that the limit they are asking about does not exist.' +pubDate: 2026-06-10 +tags: ['sending', 'deliverability'] +--- + +A support question we get a lot, in various forms: "can you raise my sending limit to 500 a day?" + +Sure. Technically that takes us about thirty seconds. The scheduler reads a number from a column, we change the number. But the question assumes the limit is ours, like an API quota we could lift for paying customers. It isn't. The real limit is set by Gmail and Outlook, per mailbox, based on how that mailbox has behaved. We just default to a number that keeps you under it. + +That default is 50 campaign emails per mailbox per day, with a minimum of 600 seconds between sends from the same mailbox. You can push a mailbox to 100 in settings, and the validation will stop you there, but the default is 50 because that is roughly where a normal, healthy mailbox with a few months of history stops looking suspicious. + +The 600-second gap matters more than people think. Mailbox providers see timing. A human with a busy morning sends 12 emails between 9:00 and 11:30 with messy spacing. A script sends 40 emails at 9:00:00, 9:00:01, 9:00:02. You can have a pristine domain and still get foldered for that pattern alone. + +## So how do you actually send more? + +More mailboxes. That's the whole answer. + +If you need 1,000 sends a day, that is twenty mailboxes at the default cap, ideally spread over a few domains. It is genuinely more setup work than typing 1000 into a box, and it is also the only version that survives contact with Gmail. + +This shapes our infrastructure too, in a way that took us a while to get right. Workers (the processes that actually deliver mail) do not have their own send limits. A worker's capacity is the sum of the budgets of the mailboxes assigned to it, nothing more. Early on we were tempted to give workers a global throttle as a safety net, and we ended up removing the idea: it either duplicates the per-mailbox math or silently overrides it, and both are bugs. What we kept instead is an assignment rule. We don't pile many active sending mailboxes onto one worker, because every worker is also an IP address, and concentrating traffic through one IP recreates the exact problem the mailbox budgets were preventing. + +One more thing about raising the cap, since the settings page will let you go to 100. Before you do, look at three numbers for that mailbox: complaint rate, hard bounce rate, and where your warmup messages have been landing. If all three have been quiet for a few weeks, going from 50 to 60 or 70 is reasonable. If you don't know what those numbers are, that is itself the answer. diff --git a/site/src/content/blog/quarantine-early.mdx b/site/src/content/blog/quarantine-early.mdx new file mode 100644 index 00000000..63a30074 --- /dev/null +++ b/site/src/content/blog/quarantine-early.mdx @@ -0,0 +1,36 @@ +--- +title: 'Quarantine early: how we protect shared warmup pools' +description: 'The industry default is to pull a mailbox from warmup when it is already landing in spam 80% of the time. We think that is roughly 70 points too late.' +pubDate: 2026-06-09 +tags: ['warmup', 'policy'] +--- +import Callout from '../../components/blog/Callout.astro'; + +While building our warmup pools we looked at how other tools decide when a mailbox no longer belongs in one. The pattern we kept finding: a mailbox gets removed when its spam placement crosses something like 80%. + +Think about what 80% means in a shared pool. The pool works by having participating mailboxes send to and reply to each other, so providers see normal conversational traffic. A mailbox that is landing in spam four times out of five has been poisoning those interactions for weeks. Its partners have been opening junk folders to fish its messages out (or not), its replies have been going unanswered, and every healthy mailbox paired with it has absorbed a little of that damage. Removing it at 80% is not a policy. It is a memorial service. + +So our bands are set much lower, and they went through a few revisions before we were happy: + +| Band | Trigger | What happens | +| --- | --- | --- | +| Watch | 10% spam placement | volume down, spacing up | +| Quarantine | 20% placement, or 0.10% complaints, or 5% bounces | out of the pool, 7 days | +| Block | 40% placement, or 0.30% complaints | out 30 days, manual review | +| Catastrophic | 80% placement | long block, full reset | + +Two design decisions worth explaining. + +First, the signals stay separate. An earlier draft combined everything into one health score, and we walked away from it because a single number hides what is actually wrong. A bounce problem is a list-quality problem. A complaint problem is a content or targeting problem. A token problem (every warmup message we send carries a verification token, and we count attempts with missing or forged ones) is usually someone trying to game the pool. Same quarantine, different ways back out. + +Second, none of this fires on one bad data point. Placement is judged on at least 20 warmup deliveries across a week. A single message in spam on a Tuesday does not quarantine anyone. + + + Google asks senders to keep reported spam under 0.10% and to never reach 0.30%. Our + quarantine and block triggers sit at exactly those two numbers, on purpose: by the time a + mailbox is there, the question is not whether to act but whether we already acted too late. + + +The part people push back on is re-entry. The 7 days expiring does not put a mailbox back in the pool. It has to requalify: authentication intact, no new complaint or bounce spikes, clean token behaviour, and placement back under 10% on a fresh probation sample, starting from low volume again. We had a few users annoyed by this, usually with one specific mailbox they were sure was fine. Checking the data, it mostly wasn't. + +If you only take one number from this post, take 20%, not 80%. That is the point where a mailbox stops being a participant in a shared pool and starts being a cost to everyone else in it. diff --git a/site/src/content/blog/reading-the-bulk-sender-rules.md b/site/src/content/blog/reading-the-bulk-sender-rules.md new file mode 100644 index 00000000..99a34c71 --- /dev/null +++ b/site/src/content/blog/reading-the-bulk-sender-rules.md @@ -0,0 +1,20 @@ +--- +title: "What Google's bulk sender rules actually ask of cold emailers" +description: 'We re-read the requirements so you do not have to. Most of it is things you should have been doing anyway, but two details catch people off guard.' +pubDate: 2026-06-08 +tags: ['deliverability'] +--- + +Every few months the bulk sender requirements make the rounds again and someone asks us whether cold email is "dead now". We have read these documents more times than we would like. Here is what they actually say, and the two places where senders genuinely get caught out. + +The headline requirements: SPF and DKIM on your sending domain, a DMARC policy published, alignment between the From domain and what got authenticated, one-click unsubscribe on bulk mail, and a user-reported spam rate that stays under 0.10% and never touches 0.30%. + +Most of that list is hygiene that predates the rules. The two that bite: + +**Alignment, not just authentication.** This is the one we see most often in support. A domain can pass SPF and DKIM and still fail DMARC, because the passing signature belongs to a sending service's domain instead of yours. Dashboards love showing green checkmarks for "SPF: pass, DKIM: pass" while alignment is quietly broken. The only check we trust is opening the headers of a real delivered message and reading the `Authentication-Results` line yourself. If `dkim=pass` is followed by a `header.d=` that is not your domain, you have work to do. + +**The spam rate math at small volume.** 0.10% sounds like a generous allowance until you do the division. A mailbox sending 50 cold emails a day sends about 1,500 a month. Staying under 0.10% means at most one spam complaint per month per mailbox. One. There is no clever trick that gets you under that threshold; the message has to be relevant enough that essentially nobody who receives it is angry about receiving it. Every other deliverability technique is downstream of that. + +On unsubscribe: yes, the one-click requirement (RFC 8058, the List-Unsubscribe headers) is written for marketing mail to subscribers, and you can argue about whether a two-line intro email is "bulk". We would skip the argument. The recipient who wants out has exactly two buttons available, and one of them reports you to Google. Giving them the other one is not compliance, it is self-interest. Warmbly adds the headers and treats any opt-out as workspace-wide suppression, not a per-campaign flag. + +Worth reading alongside this: Microsoft's guidance for Exchange Online, which says more or less openly that bulk commercial mail does not belong on their platform and should go through specialized providers. People read that as hostility. We read it as a description of the line both providers are drawing: traffic that looks like a person corresponding survives, traffic that looks like infrastructure gets pushed out. The bulk sender rules are that same line, written down with numbers attached. diff --git a/site/src/layouts/BlogPost.astro b/site/src/layouts/BlogPost.astro new file mode 100644 index 00000000..c01a1513 --- /dev/null +++ b/site/src/layouts/BlogPost.astro @@ -0,0 +1,255 @@ +--- +import type { MarkdownHeading } from 'astro'; +import Layout from './Layout.astro'; +import Icon from '../components/Icon.astro'; +import CTA from '../components/CTA.astro'; +import { formatDate, isoDate, readingTimeOf, type BlogPost } from '../lib/blog'; + +interface Props { + post: BlogPost; + prev?: BlogPost; + next?: BlogPost; + headings?: MarkdownHeading[]; +} +const { post, prev, next, headings = [] } = Astro.props; +const { title, description, pubDate, updatedDate, author, tags } = post.data; +const reading = readingTimeOf(post); +const chapters = headings.filter((h) => h.depth === 2); + +const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, ''); +const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://warmbly.com').toString(); + +const articleJsonLd = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: title, + description, + image: `${siteOrigin}/og-image.png`, + datePublished: pubDate.toISOString(), + dateModified: (updatedDate ?? pubDate).toISOString(), + author: + author === 'Warmbly team' + ? { '@id': `${siteOrigin}#org` } + : { '@type': 'Person', name: author }, + publisher: { '@id': `${siteOrigin}#org` }, + mainEntityOfPage: { '@type': 'WebPage', '@id': canonical }, + isAccessibleForFree: true, +}; + +const neighbors = [ + prev ? { label: 'Previously', post: prev } : null, + next ? { label: 'Up next', post: next } : null, +].filter(Boolean) as { label: string; post: BlogPost }[]; +--- + + +
+
+ + + Blog + +
+ + + {reading} + {updatedDate && } + {updatedDate && } +
+
+
+
+ +
+ +
+
+
+
+ Blog + {tags.map((tag) => ( + {tag} + ))} +
+

{title}

+

{description}

+
+ + {author} + + +
+
+
+
+ + +
+
+
+ +
+ + + +
+
+ + + {neighbors.length > 0 && ( + + )} +
+ + + + +
diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index aae87c12..86c6d203 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -139,6 +139,7 @@ const websiteJsonLd = { + + + diff --git a/site/src/lib/blog.ts b/site/src/lib/blog.ts new file mode 100644 index 00000000..8ef148a1 --- /dev/null +++ b/site/src/lib/blog.ts @@ -0,0 +1,50 @@ +import { getCollection, type CollectionEntry } from 'astro:content'; +import getReadingTime from 'reading-time'; + +export type BlogPost = CollectionEntry<'blog'>; + +/** + * All publishable posts, newest first. Draft filtering is conventional in + * Astro 5 (there is no built-in draft support), so every surface that lists + * posts (index, post routes, RSS, prev/next) must go through this helper + * rather than calling getCollection('blog') directly. + * + * Drafts stay visible in `astro dev` so they can be previewed locally. + */ +export async function getPublishedPosts(): Promise { + const posts = await getCollection('blog', ({ data }) => + import.meta.env.PROD ? data.draft !== true : true, + ); + return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); +} + +/** "4 min read", computed from the raw markdown body at build time. */ +export function readingTimeOf(post: BlogPost): string { + return getReadingTime(post.body ?? '').text; +} + +/** Editorial date for article headers: "June 10, 2026". */ +export function formatDate(date: Date): string { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); +} + +/** Compact mono-label date for list rows, matching the changelog: "2026-06-10". */ +export function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Distinct tags across published posts with usage counts, most-used first. */ +export function tagCounts(posts: BlogPost[]): { tag: string; count: number }[] { + const counts = new Map(); + for (const post of posts) { + for (const tag of post.data.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + return [...counts.entries()] + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); +} diff --git a/site/src/lib/learn.ts b/site/src/lib/learn.ts new file mode 100644 index 00000000..5d4e6c12 --- /dev/null +++ b/site/src/lib/learn.ts @@ -0,0 +1,130 @@ +/** + * Single source of truth for the Learn library. + * + * Order matters: it is the reading sequence, and drives the left-rail list, + * the index rows, and the prev/next footer on every article. Keep this file + * in sync with the actual pages under src/pages/learn/. + */ +export interface LearnArticle { + href: string; + /** Display title used in rails, lists and cards (not the SEO ). */ + title: string; + topic: 'Foundations' | 'Sending well'; + level: 'Essential' | 'Beginner' | 'Practical' | 'Advanced'; + /** Reading time in minutes. */ + read: number; + /** One-line summary shown on the library index. */ + body: string; + /** Icon.astro name for cards. */ + icon: string; + featured?: boolean; +} + +export const learnArticles: LearnArticle[] = [ + { + href: '/learn/deliverability/', + title: 'The deliverability handbook', + topic: 'Foundations', + level: 'Essential', + read: 14, + body: 'A full mental model for getting cold mail into the inbox and keeping it there. Authentication, warmup, volume discipline and reputation, in one place.', + icon: 'shield', + featured: true, + }, + { + href: '/learn/email-warmup/', + title: 'Email warmup, properly explained', + topic: 'Foundations', + level: 'Beginner', + read: 11, + body: 'Why warmup exists, how providers read it, and how to do it without faking signals.', + icon: 'flame', + }, + { + href: '/learn/spf-dkim-dmarc/', + title: 'SPF, DKIM and DMARC, end to end', + topic: 'Foundations', + level: 'Beginner', + read: 12, + body: 'The three authentication records and the alignment rules that actually matter for cold mail.', + icon: 'key', + }, + { + href: '/learn/inbox-placement/', + title: 'Inbox placement, not just delivery', + topic: 'Foundations', + level: 'Beginner', + read: 8, + body: 'Delivery is a 200 OK. Placement is whether the recipient actually sees the message.', + icon: 'inbox', + }, + { + href: '/learn/cold-email-rules/', + title: 'Cold email rules across regions', + topic: 'Sending well', + level: 'Practical', + read: 10, + body: 'CAN-SPAM, GDPR, CASL, PECR. What each says and how to comply without lawyering up.', + icon: 'list', + }, + { + href: '/learn/warmup-pools/', + title: 'Warmup pools: free, premium, dedicated', + topic: 'Sending well', + level: 'Practical', + read: 7, + body: 'How shared warmup pools work and why pool quality matters more than pool size.', + icon: 'users', + }, + { + href: '/learn/reputation-recovery/', + title: 'Recovering a burned mailbox', + topic: 'Sending well', + level: 'Advanced', + read: 9, + body: 'A step-by-step plan to bring a quarantined mailbox back to healthy.', + icon: 'workflow', + }, + { + href: '/learn/personalization/', + title: 'Personalization & conditionals', + topic: 'Sending well', + level: 'Practical', + read: 9, + body: 'Merge variables, custom fields, and if/else conditionals so one template reads naturally for every recipient.', + icon: 'list', + }, +]; + +export const glossaryHref = '/learn/glossary/'; + +/** The articles around `href` in reading order, for "Previously / Up next". */ +export function neighborsOf(href: string): { prev?: LearnArticle; next?: LearnArticle } { + const i = learnArticles.findIndex((a) => a.href === href); + if (i === -1) { + // Pages outside the sequence (e.g. the glossary) suggest the start of it. + return { next: learnArticles[0] }; + } + return { + prev: i > 0 ? learnArticles[i - 1] : undefined, + next: i < learnArticles.length - 1 ? learnArticles[i + 1] : undefined, + }; +} + +/** Chip/dot tones per level, shared by the index cards and article headers. */ +export function levelTone(level: LearnArticle['level']): { dot: string; chip: string } { + if (level === 'Essential') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; + if (level === 'Beginner') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (level === 'Practical') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; + return { dot: 'bg-violet-500', chip: 'bg-violet-50 text-violet-700' }; +} + +const NUMBER_WORDS = [ + 'Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', + 'Eleven', 'Twelve', +]; + +/** "Seven" for 7; falls back to digits past twelve. */ +export function numberWord(n: number): string { + return NUMBER_WORDS[n] ?? String(n); +} diff --git a/site/src/pages/blog/[...id].astro b/site/src/pages/blog/[...id].astro new file mode 100644 index 00000000..7094823a --- /dev/null +++ b/site/src/pages/blog/[...id].astro @@ -0,0 +1,20 @@ +--- +import { render } from 'astro:content'; +import BlogPostLayout from '../../layouts/BlogPost.astro'; +import { getPublishedPosts } from '../../lib/blog'; + +export async function getStaticPaths() { + const posts = await getPublishedPosts(); + // posts are newest-first; prev = newer, next = older. + return posts.map((post, i) => ({ + params: { id: post.id }, + props: { post, prev: posts[i - 1], next: posts[i + 1] }, + })); +} + +const { post, prev, next } = Astro.props; +const { Content, headings } = await render(post); +--- +<BlogPostLayout post={post} prev={prev} next={next} headings={headings}> + <Content /> +</BlogPostLayout> diff --git a/site/src/pages/blog/index.astro b/site/src/pages/blog/index.astro new file mode 100644 index 00000000..598c2aac --- /dev/null +++ b/site/src/pages/blog/index.astro @@ -0,0 +1,87 @@ +--- +import Layout from '../../layouts/Layout.astro'; +import Icon from '../../components/Icon.astro'; +import CTA from '../../components/CTA.astro'; +import PostGrid from '../../components/blog/PostGrid.astro'; +import TagFilter from '../../components/blog/TagFilter.astro'; +import { getPublishedPosts, tagCounts } from '../../lib/blog'; + +const posts = await getPublishedPosts(); +const tags = tagCounts(posts); + +const follow = [ + { href: '/changelog/', title: 'Changelog', icon: 'list', external: false }, + { href: 'https://github.com/warmbly/warmbly', title: 'Source code', icon: 'code', external: true }, + { href: 'https://github.com/warmbly/warmbly/discussions', title: 'Discussions', icon: 'reply', external: true }, +]; +--- +<Layout + title="Blog | Warmbly" + description="Deliverability, sending infrastructure, and what it takes to run an open-source cold email platform." +> + <!-- ============================================================ + HEADER — compact, newsroom style; the posts are the page. + ============================================================ --> + <section class="bg-white"> + <div class="container-page pt-12 md:pt-16 pb-8 md:pb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3">Blog</div> + <div class="flex flex-wrap items-end justify-between gap-x-8 gap-y-3"> + <h1 class="text-[34px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.04] text-heading"> + The Warmbly blog. + </h1> + <p class="text-[12px] font-mono text-muted-foreground pb-1.5"> + RSS feed at <a href="/blog/rss.xml" class="text-[#0369a1] hover:text-[#075985]">/blog/rss.xml</a> + </p> + </div> + <p class="mt-3 text-[15px] md:text-[16px] text-foreground/70 max-w-2xl leading-relaxed"> + Deliverability, sending infrastructure, and what it takes to run an + open-source cold email platform. + </p> + </div> + </section> + + <!-- ============================================================ + POSTS — tabs + search + cover-image grid + ============================================================ --> + <section class="bg-white pb-16 md:pb-20"> + <div class="container-page"> + <PostGrid posts={posts}> + <TagFilter slot="filters" tags={tags} total={posts.length} /> + </PostGrid> + </div> + </section> + + <!-- ============================================================ + TOPICS + ELSEWHERE — one compact band + ============================================================ --> + <section class="bg-[color:var(--surface-2)]/40 border-y border-[color:var(--border)] py-10 md:py-14"> + <div class="container-page"> + <div class="grid lg:grid-cols-2 gap-10 lg:gap-16"> + <div> + <div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">Topics</div> + <div class="flex flex-wrap gap-2.5"> + {tags.map(({ tag, count }) => ( + <a href={`/blog/tag/${tag}/`} class="group inline-flex items-center gap-2 h-9 px-3.5 rounded-[10px] bg-white ring-1 ring-[color:var(--border)] hover:ring-[#0284c7]/40 hover:-translate-y-0.5 transition-all duration-200 text-[13px] font-medium text-foreground/80 hover:text-[#0369a1]"> + {tag} + <span class="font-mono text-[10.5px] text-muted-foreground">{count}</span> + </a> + ))} + </div> + </div> + <div> + <div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground mb-4">Elsewhere</div> + <div class="flex flex-wrap gap-2.5"> + {follow.map((f) => ( + <a href={f.href} {...(f.external ? { target: '_blank', rel: 'noopener' } : {})} class="group inline-flex items-center gap-2 h-9 px-3.5 rounded-[10px] bg-white ring-1 ring-[color:var(--border)] hover:ring-[#0284c7]/40 hover:-translate-y-0.5 transition-all duration-200 text-[13px] font-medium text-foreground/80 hover:text-[#0369a1]"> + <Icon name={f.icon} size={13} class="text-[#0284c7]" /> + {f.title} + </a> + ))} + </div> + </div> + </div> + </div> + </section> + + <CTA /> +</Layout> diff --git a/site/src/pages/blog/rss.xml.js b/site/src/pages/blog/rss.xml.js new file mode 100644 index 00000000..72ad1e88 --- /dev/null +++ b/site/src/pages/blog/rss.xml.js @@ -0,0 +1,23 @@ +import rss from '@astrojs/rss'; +import { getCollection } from 'astro:content'; + +export async function GET(context) { + // Filter drafts unconditionally: the feed should never carry them, + // even when generated from a dev server. + const posts = (await getCollection('blog', ({ data }) => data.draft !== true)).sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), + ); + return rss({ + title: 'Warmbly Blog', + description: + 'Engineering notes, policy decisions and sending guidance from the team building Warmbly.', + site: context.site, + items: posts.map((post) => ({ + title: post.data.title, + description: post.data.description, + pubDate: post.data.pubDate, + link: `/blog/${post.id}/`, + })), + customData: '<language>en-us</language>', + }); +} diff --git a/site/src/pages/blog/tag/[tag].astro b/site/src/pages/blog/tag/[tag].astro new file mode 100644 index 00000000..1debf3a8 --- /dev/null +++ b/site/src/pages/blog/tag/[tag].astro @@ -0,0 +1,50 @@ +--- +import Layout from '../../../layouts/Layout.astro'; +import CTA from '../../../components/CTA.astro'; +import PostGrid from '../../../components/blog/PostGrid.astro'; +import TagFilter from '../../../components/blog/TagFilter.astro'; +import { getPublishedPosts, tagCounts } from '../../../lib/blog'; + +export async function getStaticPaths() { + const posts = await getPublishedPosts(); + return tagCounts(posts).map(({ tag }) => ({ + params: { tag }, + props: { posts: posts.filter((p) => p.data.tags.includes(tag)) }, + })); +} + +const { tag } = Astro.params; +const { posts } = Astro.props; + +const all = await getPublishedPosts(); +const tags = tagCounts(all); +--- +<Layout + title={`${tag} | Warmbly Blog`} + description={`Posts about ${tag} from the team building Warmbly.`} +> + <!-- HEADER — compact, mirrors the blog index --> + <section class="bg-white"> + <div class="container-page pt-12 md:pt-16 pb-8 md:pb-10"> + <div class="text-[11px] uppercase tracking-[0.18em] font-mono text-[#0284c7] mb-3"> + <a href="/blog/" class="hover:text-[#075985]">Blog</a> · topic + </div> + <div class="flex flex-wrap items-end justify-between gap-x-8 gap-y-3"> + <h1 class="text-[34px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.04] text-heading">{tag}</h1> + <p class="text-[12px] font-mono text-muted-foreground pb-1.5"> + {posts.length} {posts.length === 1 ? 'post' : 'posts'} + </p> + </div> + </div> + </section> + + <section class="bg-white pb-16 md:pb-20"> + <div class="container-page"> + <PostGrid posts={posts}> + <TagFilter slot="filters" tags={tags} total={all.length} active={tag} /> + </PostGrid> + </div> + </section> + + <CTA /> +</Layout> diff --git a/site/src/pages/learn/cold-email-rules.astro b/site/src/pages/learn/cold-email-rules.astro index 65506397..721bd8cd 100644 --- a/site/src/pages/learn/cold-email-rules.astro +++ b/site/src/pages/learn/cold-email-rules.astro @@ -3,7 +3,8 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="Cold Email Rules & Compliance | Warmbly" - description="CAN-SPAM, GDPR, CASL, PECR. what each actually says, and how to send cold mail compliantly without hiring a lawyer." + headline="Cold email rules across regions" + description="CAN-SPAM, GDPR, CASL, PECR: what each actually says, and how to send cold mail compliantly without hiring a lawyer." reading="10 min read" updated="2026-05-02" > @@ -15,7 +16,7 @@ import Learn from '../../layouts/Learn.astro'; This guide is general information, not legal advice. If you're sending at scale to regulated jurisdictions, run your specific approach past counsel. </p> - <h2>United States. CAN-SPAM (2003)</h2> + <h2>United States: CAN-SPAM (2003)</h2> <p> CAN-SPAM applies to "commercial electronic mail messages". It does <em>not</em> require prior consent for cold B2B email. It does require: </p> @@ -30,7 +31,7 @@ import Learn from '../../layouts/Learn.astro'; For Warmbly's defaults, this means: include a physical address in your sender profile, never spoof a name or domain, and ensure unsubscribe replies are auto-suppressed. </p> - <h2>European Union and UK. GDPR + PECR</h2> + <h2>European Union and UK: GDPR + PECR</h2> <p> GDPR applies to personal data of EU/UK residents. PECR (in the UK) and ePrivacy directives in EU member states specifically cover electronic marketing. </p> @@ -57,7 +58,7 @@ import Learn from '../../layouts/Learn.astro'; Keep a record of where you sourced each contact and the legitimate interest you relied on. Warmbly's contact import wizard captures source and import notes for exactly this. </p> - <h2>Canada. CASL (2014)</h2> + <h2>Canada: CASL (2014)</h2> <p> CASL is the strictest of the major regimes. It requires <em>express or implied consent</em> before sending commercial electronic messages to Canadian recipients. </p> @@ -68,7 +69,7 @@ import Learn from '../../layouts/Learn.astro'; Every message must include sender identification, contact info, and a working unsubscribe. Penalties for non-compliance can reach CAD $10M per violation for organisations. </p> - <h2>Australia. Spam Act 2003</h2> + <h2>Australia: Spam Act 2003</h2> <p> Requires consent (express, inferred or implied), accurate sender identification and a functional unsubscribe. Inferred consent is generous for B2B but not consumer marketing. </p> @@ -77,11 +78,11 @@ import Learn from '../../layouts/Learn.astro'; <ol> <li>Use a real From: name and a domain you actually own.</li> <li>Include a physical mailing address in your sender profile / footer.</li> - <li>Provide a clear opt-out. "Reply STOP to opt out" or a one-click link.</li> + <li>Provide a clear opt-out: "Reply STOP to opt out" or a one-click link.</li> <li>Auto-suppress recipients who opt out, across all future sequences and campaigns.</li> <li>Record where each contact was sourced and your basis for contacting them.</li> <li>Only contact role accounts and named business addresses for B2B; avoid personal addresses unless you have explicit consent.</li> - <li>Customise the message for relevance. boilerplate to thousands of strangers is the loudest possible compliance and reputation risk.</li> + <li>Customise the message for relevance: boilerplate to thousands of strangers is the loudest possible compliance and reputation risk.</li> <li>Skip Canadian and EU consumer-mail recipients unless you have documented consent.</li> <li>Honour opt-outs within 24 hours, not the legal maximum.</li> <li>If you operate a privacy policy, link to it from your footer.</li> diff --git a/site/src/pages/learn/deliverability.astro b/site/src/pages/learn/deliverability.astro index b7ab00e3..55d086dd 100644 --- a/site/src/pages/learn/deliverability.astro +++ b/site/src/pages/learn/deliverability.astro @@ -1,6 +1,7 @@ --- import Layout from '../../layouts/Layout.astro'; import Icon from '../../components/Icon.astro'; +import { learnArticles } from '../../lib/learn'; const chapters = [ { num: '01', id: 'definition', title: 'Definition' }, @@ -13,21 +14,36 @@ const chapters = [ { num: '08', id: 'problem', title: 'Incident' }, ]; -// All learn essays (kept in sync with Learn.astro) -const related = [ - { href: '/learn/email-warmup/', title: 'Email warmup, properly explained', read: '11 min' }, - { href: '/learn/deliverability/', title: 'The deliverability handbook', read: '14 min' }, - { href: '/learn/spf-dkim-dmarc/', title: 'SPF, DKIM and DMARC, end to end', read: '12 min' }, - { href: '/learn/inbox-placement/', title: 'Inbox placement, not just delivery', read: '8 min' }, - { href: '/learn/cold-email-rules/', title: 'Cold email rules across regions', read: '10 min' }, - { href: '/learn/warmup-pools/', title: 'Warmup pools: free, premium, dedicated', read: '7 min' }, - { href: '/learn/reputation-recovery/', title: 'Recovering a burned mailbox', read: '9 min' }, -]; +// All learn essays, from the shared registry +const related = learnArticles; const path = Astro.url.pathname; + +// BlogPosting structured data, mirroring what Learn.astro emits for the +// other essays. This page hand-rolls its shell, so it declares its own. +const headline = 'The deliverability handbook'; +const description = 'A full mental model for getting cold mail into the inbox, and keeping it there. Authentication, warmup, volume, reputation.'; +const updated = '2026-05-08'; +const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, ''); +const canonical = new URL(path, Astro.site ?? 'https://warmbly.com').toString(); +const articleJsonLd = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline, + description, + image: `${siteOrigin}/og-image.png`, + dateModified: updated, + datePublished: updated, + author: { '@id': `${siteOrigin}#org` }, + publisher: { '@id': `${siteOrigin}#org` }, + mainEntityOfPage: { '@type': 'WebPage', '@id': canonical }, + isAccessibleForFree: true, +}; --- <Layout title="The Deliverability Handbook | Warmbly" - description="A full mental model for getting cold mail into the inbox, and keeping it there. Authentication, warmup, volume, reputation." + description={description} + ogType="article" + jsonLd={articleJsonLd} > <!-- ============================================================ TOP BAR — minimal nav @@ -96,7 +112,7 @@ const path = Astro.url.pathname; {isCurrent ? ( <span class="w-1.5 h-1.5 rounded-full bg-[#0284c7] shrink-0"></span> ) : ( - <span class="font-mono text-[10px] text-muted-foreground/55 shrink-0">{r.read.replace(' min', 'm')}</span> + <span class="font-mono text-[10px] text-muted-foreground/55 shrink-0">{r.read}m</span> )} </a> </li> @@ -354,10 +370,10 @@ const path = Astro.url.pathname; Reputation does not live at the worker. It lives at the IP, the domain, and the From mailbox. A worker that holds 200 mailboxes is 200 reputations, not one. Reasoning about a worker as a unit (worker.cap = 5000) ignores where the actual reputation signal accrues. </p> <p class="mt-4 text-[15.5px] leading-[1.7] text-foreground/85"> - Warmbly's defaults reflect this: the cold-send cap is per mailbox (100/day) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as <code>Σ mailbox.coldBudget</code> over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~1,000 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. + Warmbly's defaults reflect this: the cold-send cap is per mailbox (50/day by default, raisable to at most 100/day with positive reputation evidence) and the per-send gap is per mailbox (600 seconds). A worker's outbound budget is computed as <code>Σ mailbox.coldBudget</code> over its assigned mailboxes. The system surfaces a concentration warning when a shared worker holds more than ~10 actively-sending cold mailboxes at default settings (~500 cold sends/day), because past that point a single bad mailbox poisons the worker's IP for everyone else on it. </p> <p class="mt-4 text-[15.5px] leading-[1.7] text-foreground/85"> - Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campaign that wants 5,000 cold sends/day should be assigned to 50+ healthy mailboxes at the 100/day default, not 5 mailboxes pushed to 1,000/day. The first plan is invisible to any anomaly heuristic; the second is the textbook example. + Scaling volume up means adding mailboxes, not raising per-mailbox caps. A campaign that wants 5,000 cold sends/day should be assigned to 100+ healthy mailboxes at the 50/day default, not 5 mailboxes pushed to 1,000/day. The first plan is invisible to any anomaly heuristic; the second is the textbook example. </p> </div> <div class="xl:pl-4 space-y-3"> @@ -392,7 +408,7 @@ const path = Astro.url.pathname; The DMARC ramp matters: start at <code>p=none; rua=mailto:dmarc@yourdomain</code>, watch reports for 14 days to confirm no third-party services were quietly relying on your domain (this is the most common surprise), then move to <code>p=quarantine; pct=10</code> for a week, then increase pct to 100, then <code>p=reject</code>. Skipping <code>pct</code> ramping is how legitimate mail gets quarantined by an aggressive DMARC change. </p> <p class="mt-4 text-[15.5px] leading-[1.7] text-foreground/85"> - The 100/day per-mailbox cap and 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. + The 50/day default per-mailbox cap (raisable to 100/day only with positive reputation evidence) and the 600-second gap are not magic numbers. They are derived from observing that mailboxes which send a new message every 10+ minutes look indistinguishable from human typing cadence to the receiver, and from the empirical observation that Gmail's anomaly model starts flagging mailboxes sending more than ~150/day from a previously low baseline. </p> </div> <div class="xl:pl-4"> @@ -401,7 +417,7 @@ const path = Astro.url.pathname; 'Use a sub-domain for cold sending (outreach.acme.com). Reputation issues stay scoped.', 'Configure SPF, DKIM and DMARC on the sub-domain. Start at p=none, then quarantine after 2 weeks.', 'Warm every cold sending mailbox for at least 3 weeks before campaigns start.', - 'Cap each mailbox at 100 cold emails per day with a 10-minute minimum gap.', + 'Cap each mailbox at 50 cold emails per day (up to 100 only with proven reputation) with a 10-minute minimum gap.', 'Spread campaign sends across multiple mailboxes. Do not concentrate volume.', 'Plain text by default. No tracking pixels unless you need opens.', 'Two or three follow-ups, not eight.', diff --git a/site/src/pages/learn/email-warmup.astro b/site/src/pages/learn/email-warmup.astro index 59ddaf88..a29451cc 100644 --- a/site/src/pages/learn/email-warmup.astro +++ b/site/src/pages/learn/email-warmup.astro @@ -3,6 +3,7 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="Email Warmup - How It Works | Warmbly" + headline="Email warmup, properly explained" description="Why warmup exists, how providers actually read it, and how to do it without faking signals." reading="11 min read" updated="2026-05-12" @@ -12,12 +13,12 @@ import Learn from '../../layouts/Learn.astro'; Warmup is the practice of sending small, conversational volumes of mail from a new (or recently dormant) mailbox to gradually build a positive reputation with mailbox providers like Google, Microsoft, Yahoo and Apple. The goal isn't a one-off score. It's a track record. Mailbox providers reward consistency. </p> <p> - Modern warmup tools generate this traffic between participating mailboxes. your mailbox sends short, plain-text messages to other warmed mailboxes, those mailboxes open and reply, and the cycle repeats. Done well, the pattern looks like a real human starting to use a new inbox. + Modern warmup tools generate this traffic between participating mailboxes: your mailbox sends short, plain-text messages to other warmed mailboxes, those mailboxes open and reply, and the cycle repeats. Done well, the pattern looks like a real human starting to use a new inbox. </p> <h2>Why providers care about it</h2> <p> - Inbox placement is reputation-driven. When a brand-new domain or mailbox starts sending dozens of cold messages to strangers, Gmail and Outlook treat it as suspicious by default. They don't know whether you're a small startup or a spammer. The safest assumption. for them. is spammer. + Inbox placement is reputation-driven. When a brand-new domain or mailbox starts sending dozens of cold messages to strangers, Gmail and Outlook treat it as suspicious by default. They don't know whether you're a small startup or a spammer. The safest assumption, for them, is spammer. </p> <p> Warmup gives them signal. Mailboxes that send small volumes, get opens, get replies, and don't trigger complaints look like real human inboxes. Providers loosen the screws as that pattern persists. @@ -26,7 +27,7 @@ import Learn from '../../layouts/Learn.astro'; <h2>What good warmup looks like</h2> <h3>Gradual volume growth</h3> <p> - Start low and ramp slowly. Warmbly's defaults are 10 emails per day to start, +1 per day, with a ceiling of 40 per day. That ceiling exists on purpose. warmup that climbs to 100/day looks unnatural and starts to look like a bot. + Start low and ramp slowly. Warmbly's defaults are 10 emails per day to start, +1 per day, with a ceiling of 40 per day. That ceiling exists on purpose: warmup that climbs to 100/day looks unnatural and starts to look like a bot. </p> <h3>Realistic spacing</h3> @@ -41,7 +42,7 @@ import Learn from '../../layouts/Learn.astro'; <h3>Quality recipients</h3> <p> - Warmup recipients should be other warmed, healthy mailboxes. not free trial mailboxes, not throwaways, not lookalikes of catch-all domains. Pool quality matters more than pool size. + Warmup recipients should be other warmed, healthy mailboxes: not free trial mailboxes, not throwaways, not lookalikes of catch-all domains. Pool quality matters more than pool size. </p> <h3>Token verification</h3> @@ -52,7 +53,7 @@ import Learn from '../../layouts/Learn.astro'; <h2>What bad warmup looks like</h2> <ul> <li><strong>Synthetic blasting:</strong> sending 200 identical messages a day to the same handful of recipients. Looks great on a vendor dashboard. Looks awful to Gmail.</li> - <li><strong>Throwaway pools:</strong> generating recipient mailboxes from free providers on the fly. Providers know what disposable inboxes look like and weight signals from them at zero. or worse.</li> + <li><strong>Throwaway pools:</strong> generating recipient mailboxes from free providers on the fly. Providers know what disposable inboxes look like and weight signals from them at zero, or worse.</li> <li><strong>Open/click theatre:</strong> simulating opens and clicks at unrealistic rates. Apple Mail Privacy already pollutes open rates; pumping the number from a bot makes the entire signal worthless.</li> <li><strong>Stopping at "ready":</strong> many tools stop warmup the moment campaigns begin. Reputation has to be maintained, not earned once and forgotten.</li> </ul> @@ -62,7 +63,7 @@ import Learn from '../../layouts/Learn.astro'; Domain warmup commonly takes 3–6 weeks to reach a stable state. New domains take longer than re-warming an existing one. New mailboxes on warmed domains often stabilise within a week or two if the domain reputation is already healthy. </p> <p> - What matters is not <em>finishing</em> warmup. it's running it indefinitely at a low volume in parallel with your cold sending, so any reputation dip is caught and offset before campaigns hit it. + What matters is not <em>finishing</em> warmup: it's running it indefinitely at a low volume in parallel with your cold sending, so any reputation dip is caught and offset before campaigns hit it. </p> <h2>Free pools vs paid pools</h2> @@ -70,7 +71,7 @@ import Learn from '../../layouts/Learn.astro'; Free warmup pools are useful for testing but mix higher-risk participants. Paid pools should be stricter: only vetted, monitored mailboxes participate, and misbehaving senders are quarantined fast. </p> <p> - In Warmbly, free-tier mailboxes never silently appear in the premium pool. Even dedicated-worker customers participate in the premium pool by default. dedicated infrastructure isn't a reputation shortcut, it's an isolation tool. + In Warmbly, free-tier mailboxes never silently appear in the premium pool. Even dedicated-worker customers participate in the premium pool by default: dedicated infrastructure isn't a reputation shortcut, it's an isolation tool. </p> <h2>When to quarantine a mailbox</h2> @@ -81,7 +82,7 @@ import Learn from '../../layouts/Learn.astro'; <li><strong>Watch:</strong> spam placement ≥ 10% over a 20-delivery sample. Lower volume, increase spacing.</li> <li><strong>Quarantine:</strong> spam placement ≥ 20%, or complaint rate ≥ 0.10%, or bounce rate ≥ 5%. 7-day removal from the paid pool.</li> <li><strong>Block:</strong> spam placement ≥ 40%, or complaint rate ≥ 0.30%. 30-day block, manual review to re-enter.</li> - <li><strong>Catastrophic:</strong> spam placement ≥ 80%. Long block plus reputation reset. no automatic re-entry.</li> + <li><strong>Catastrophic:</strong> spam placement ≥ 80%. Long block plus reputation reset, with no automatic re-entry.</li> </ul> <h2>Practical checklist</h2> diff --git a/site/src/pages/learn/glossary.astro b/site/src/pages/learn/glossary.astro index 392bad5b..27d6afd6 100644 --- a/site/src/pages/learn/glossary.astro +++ b/site/src/pages/learn/glossary.astro @@ -3,6 +3,7 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="Cold Email Glossary | Warmbly" + headline="The cold email glossary" description="Plain-English definitions of cold email and deliverability terms: SPF, DKIM, DMARC, inbox placement, sender reputation, warmup, bounce and complaint rate." reading="5 min read" updated="2026-04-12" @@ -29,9 +30,6 @@ import Learn from '../../layouts/Learn.astro'; <p>Percentage of delivered messages marked as spam by recipients. Google's bulk-sender threshold is 0.10%. Above 0.30% is in the danger zone.</p> <h2>D</h2> - <h3>DEK (Data Encryption Key)</h3> - <p>A symmetric key used to encrypt application-level data. Generated by KMS and stored encrypted; the plaintext form is briefly cached for hot paths.</p> - <h3>DKIM</h3> <p>Cryptographic signature that proves a message was authorised by the domain's key holder and not modified in transit.</p> diff --git a/site/src/pages/learn/inbox-placement.astro b/site/src/pages/learn/inbox-placement.astro index 062caed0..59014072 100644 --- a/site/src/pages/learn/inbox-placement.astro +++ b/site/src/pages/learn/inbox-placement.astro @@ -3,13 +3,14 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="Inbox Placement vs. Delivery | Warmbly" + headline="Inbox placement, not just delivery" description="Delivery is a 200 OK from an SMTP server. Placement is whether the recipient actually sees the message." reading="8 min read" updated="2026-04-22" > <h2>Delivery vs placement</h2> <p> - Most cold-email tools report "delivery rate". the percentage of messages that the receiving SMTP server accepted. That number is almost always close to 100% and tells you almost nothing. + Most cold-email tools report "delivery rate": the percentage of messages that the receiving SMTP server accepted. That number is almost always close to 100% and tells you almost nothing. </p> <p> A receiving server can accept a message (delivery) and then immediately filter it into Spam, Promotions, or out of the inbox entirely. Placement is the share of accepted messages that land in the recipient's primary inbox. @@ -21,7 +22,7 @@ import Learn from '../../layouts/Learn.astro'; <h2>How placement is measured</h2> <h3>Warmup probes</h3> <p> - Warmbly's warmup pool is also a placement probe. Pool members report where each warmup message arrived. inbox, spam, promotions. We aggregate per sending mailbox over a rolling sample window. + Warmbly's warmup pool is also a placement probe. Pool members report where each warmup message arrived: inbox, spam, promotions. We aggregate per sending mailbox over a rolling sample window. </p> <p> Probe accuracy depends on pool diversity. A pool that only contains Gmail addresses can't tell you anything about Outlook placement. Our premium pool spans Gmail, Outlook, Yahoo, iCloud and self-hosted IMAP servers in proportion to the broader receiver landscape. @@ -29,7 +30,7 @@ import Learn from '../../layouts/Learn.astro'; <h3>Seed lists</h3> <p> - Some teams maintain a "seed list". a fixed set of mailboxes across providers that always receive a copy of every campaign send. The placement on the seed list approximates placement for the full campaign. + Some teams maintain a "seed list": a fixed set of mailboxes across providers that always receive a copy of every campaign send. The placement on the seed list approximates placement for the full campaign. </p> <p> Seed lists are useful but not free of bias. They tend to be older, more engaged inboxes than your real cold recipients. Treat them as a directional signal. @@ -45,7 +46,7 @@ import Learn from '../../layouts/Learn.astro'; <li>Unauthenticated mail (missing or misaligned SPF, DKIM, DMARC).</li> <li>Sudden volume spikes from an otherwise quiet mailbox.</li> <li>Cold mail with heavy HTML, unsolicited attachments, or tracking-pixel-only bodies.</li> - <li>Word-for-word identical content across thousands of sends. content fingerprinting catches it.</li> + <li>Word-for-word identical content across thousands of sends: content fingerprinting catches it.</li> <li>Long URLs to suspicious TLDs, or URL shorteners that have been abused.</li> <li>Recipients consistently deleting without opening.</li> <li>Recipients marking as spam (the worst signal of all).</li> @@ -55,10 +56,10 @@ import Learn from '../../layouts/Learn.astro'; <ul> <li>Plain text bodies for cold sequences.</li> <li>Sensible volume relative to historical baseline for the mailbox.</li> - <li>Real replies from real recipients. this is the strongest positive signal.</li> + <li>Real replies from real recipients: this is the strongest positive signal.</li> <li>Variation in subject and opening line across recipients.</li> <li>Tight, well-targeted contact lists. Sending to obviously-bad addresses (parsed from web pages, role accounts, etc.) inflates bounces and complaint risk.</li> - <li>Consistent sending pattern. same business hours every day, with weekends off.</li> + <li>Consistent sending pattern: same business hours every day, with weekends off.</li> </ul> <h2>Targets to aim for</h2> diff --git a/site/src/pages/learn/index.astro b/site/src/pages/learn/index.astro index 001068e8..34f87cbf 100644 --- a/site/src/pages/learn/index.astro +++ b/site/src/pages/learn/index.astro @@ -3,100 +3,11 @@ import Layout from '../../layouts/Layout.astro'; import HeroAtmosphere from '../../components/HeroAtmosphere.astro'; import Cloud from '../../components/Cloud.astro'; import Icon from '../../components/Icon.astro'; +import { learnArticles, levelTone, numberWord } from '../../lib/learn'; -// All articles with topic + level + read time -const articles = [ - { - href: '/learn/deliverability/', - title: 'The deliverability handbook', - topic: 'Foundations', - level: 'Essential', - read: 14, - body: 'A full mental model for getting cold mail into the inbox and keeping it there. Authentication, warmup, volume discipline and reputation, in one place.', - icon: 'shield', - featured: true, - }, - { - href: '/learn/email-warmup/', - title: 'Email warmup, properly explained', - topic: 'Foundations', - level: 'Beginner', - read: 11, - body: 'Why warmup exists, how providers read it, and how to do it without faking signals.', - icon: 'flame', - }, - { - href: '/learn/spf-dkim-dmarc/', - title: 'SPF, DKIM and DMARC, end to end', - topic: 'Foundations', - level: 'Beginner', - read: 12, - body: 'The three authentication records and the alignment rules that actually matter for cold mail.', - icon: 'key', - }, - { - href: '/learn/inbox-placement/', - title: 'Inbox placement, not just delivery', - topic: 'Foundations', - level: 'Beginner', - read: 8, - body: 'Delivery is a 200 OK. Placement is whether the recipient actually sees the message.', - icon: 'inbox', - }, - { - href: '/learn/cold-email-rules/', - title: 'Cold email rules across regions', - topic: 'Sending well', - level: 'Practical', - read: 10, - body: 'CAN-SPAM, GDPR, CASL, PECR. What each says and how to comply without lawyering up.', - icon: 'list', - }, - { - href: '/learn/warmup-pools/', - title: 'Warmup pools: free, premium, dedicated', - topic: 'Sending well', - level: 'Practical', - read: 7, - body: 'How shared warmup pools work and why pool quality matters more than pool size.', - icon: 'users', - }, - { - href: '/learn/reputation-recovery/', - title: 'Recovering a burned mailbox', - topic: 'Sending well', - level: 'Advanced', - read: 9, - body: 'A step-by-step plan to bring a quarantined mailbox back to healthy.', - icon: 'workflow', - }, - { - href: '/learn/personalization/', - title: 'Personalization & conditionals', - topic: 'Sending well', - level: 'Practical', - read: 6, - body: 'Merge variables, custom fields, and if/else conditionals so one template reads naturally for every recipient.', - icon: 'list', - }, -]; - -const featured = articles.find((a) => a.featured)!; -const rest = articles.filter((a) => !a.featured); - -const topics = ['All', 'Foundations', 'Sending well']; -const counts: Record<string, number> = { - 'All': articles.length, - 'Foundations': articles.filter(a => a.topic === 'Foundations').length, - 'Sending well': articles.filter(a => a.topic === 'Sending well').length, -}; - -const levelTone = (l: string) => { - if (l === 'Essential') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; - if (l === 'Beginner') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (l === 'Practical') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - return { dot: 'bg-violet-500', chip: 'bg-violet-50 text-violet-700' }; -}; +// Single source of truth for the library lives in src/lib/learn.ts. +const featured = learnArticles.find((a) => a.featured)!; +const rest = learnArticles.filter((a) => !a.featured); --- <Layout title="Cold Email & Deliverability Guides | Warmbly" @@ -173,7 +84,7 @@ const levelTone = (l: string) => { </div> <div class="text-[60px] md:text-[80px] font-semibold text-white tracking-[-0.04em] leading-none drop-shadow-[0_2px_8px_rgba(2,32,71,0.3)]">{featured.read}</div> <div class="mt-1 text-[10.5px] uppercase tracking-[0.2em] font-mono text-white/75">minutes</div> - <div class="mt-5 text-[12.5px] font-mono text-white/85">7 sections · 1 worked example</div> + <div class="mt-5 text-[12.5px] font-mono text-white/85">{featured.topic} · the place to start</div> </div> </div> </div> @@ -190,7 +101,7 @@ const levelTone = (l: string) => { <div class="mb-12 max-w-3xl"> <div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">The library</div> <h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.04] text-heading"> - Six essays and a reference. + {numberWord(rest.length)} essays and a reference. </h2> </div> @@ -203,28 +114,36 @@ const levelTone = (l: string) => { <span class="text-[10.5px] font-mono text-muted-foreground/70">{rest.length}</span> </div> <div class="divide-y divide-[color:var(--border)]"> - {rest.map((a, i) => ( - <a href={a.href} class="article-row group grid grid-cols-[36px_1fr_auto] gap-4 py-5 items-baseline hover:bg-[color:var(--surface-1)]/60 -mx-3 px-3 rounded-[8px] transition-colors"> - <span class="text-[11px] font-mono text-muted-foreground tracking-[0.06em] pt-1">{String(i + 1).padStart(2, '0')}</span> - <div> - <h3 class="text-[17px] md:text-[19px] font-semibold tracking-[-0.015em] leading-[1.25] text-heading group-hover:text-[#0369a1] transition-colors"> - {a.title} - </h3> - <div class="mt-1.5 flex items-center gap-3 text-[11.5px] font-mono text-muted-foreground"> - <span class="uppercase tracking-[0.12em]">{a.topic}</span> - <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> - <span>{a.read} min</span> + {rest.map((a, i) => { + const tone = levelTone(a.level); + return ( + <a href={a.href} class="article-row group grid grid-cols-[36px_1fr_auto] gap-4 py-5 items-baseline hover:bg-[color:var(--surface-2)]/60 -mx-3 px-3 rounded-[8px] transition-colors"> + <span class="text-[11px] font-mono text-muted-foreground tracking-[0.06em] pt-1">{String(i + 1).padStart(2, '0')}</span> + <div class="min-w-0"> + <h3 class="text-[17px] md:text-[19px] font-semibold tracking-[-0.015em] leading-[1.25] text-heading group-hover:text-[#0369a1] transition-colors"> + {a.title} + </h3> + <p class="mt-1.5 text-[13px] text-foreground/65 leading-relaxed max-w-xl">{a.body}</p> + <div class="mt-2 flex items-center gap-3 text-[11.5px] font-mono text-muted-foreground"> + <span class={`inline-flex items-center gap-1.5 h-5 px-2 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.1em] ${tone.chip}`}> + <span class={`w-1 h-1 rounded-full ${tone.dot}`}></span> + {a.level} + </span> + <span class="uppercase tracking-[0.12em]">{a.topic}</span> + <span class="w-1 h-1 rounded-full bg-[color:var(--border)]"></span> + <span>{a.read} min</span> + </div> </div> - </div> - <Icon name="arrowRight" size={13} class="text-muted-foreground group-hover:text-[#0369a1] group-hover:translate-x-1 transition-all self-center" /> - </a> - ))} + <Icon name="arrowRight" size={13} class="text-muted-foreground group-hover:text-[#0369a1] group-hover:translate-x-1 transition-all self-center" /> + </a> + ); + })} </div> </div> <!-- Reference: inline glossary panel --> <aside class="lg:sticky lg:top-24 self-start" style="align-self: start;"> - <div class="rounded-[14px] bg-[color:var(--surface-1)] ring-1 ring-[color:var(--border)] p-6 md:p-7"> + <div class="rounded-[14px] bg-[color:var(--surface-2)] ring-1 ring-[color:var(--border)] p-6 md:p-7"> <div class="flex items-baseline justify-between mb-4"> <div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">Reference</div> <a href="/learn/glossary/" class="text-[11.5px] font-medium text-[#0369a1] hover:text-[#075985]">Open all →</a> @@ -253,7 +172,7 @@ const levelTone = (l: string) => { </div> <div class="mt-5 pt-5 border-t border-[color:var(--border)] flex items-center justify-between text-[11.5px] font-mono text-muted-foreground"> - <span>42 terms total</span> + <span>Plain definitions, A to Z</span> <span>5 min read</span> </div> </div> diff --git a/site/src/pages/learn/personalization.astro b/site/src/pages/learn/personalization.astro index 54ce7224..e75d7f85 100644 --- a/site/src/pages/learn/personalization.astro +++ b/site/src/pages/learn/personalization.astro @@ -67,11 +67,12 @@ const exExprAnd = `and (gtf .confidence 0.8) (eq .intent "positive")`; const exExprAction = `{{if gt .confidence 0.8}}hot lead{{else}}follow up{{end}}`; -const preClass = "not-prose my-4 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-1)] p-4 text-[12.5px] leading-relaxed text-foreground/80 whitespace-pre-wrap"; +const preClass = "my-4 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-1)] p-4 text-[12.5px] leading-relaxed text-foreground/80 whitespace-pre-wrap"; const preStyle = "font-family: var(--font-mono)"; --- <Learn title="Personalization & conditionals | Warmbly" + headline="Personalization & conditionals" description="Merge variables, custom fields, if/else conditionals, fallbacks, functions, and spintax so one cold-email template reads naturally for every recipient." eyebrow="Guide" reading="9 min read" diff --git a/site/src/pages/learn/reputation-recovery.astro b/site/src/pages/learn/reputation-recovery.astro index 257a5468..7aa8aac1 100644 --- a/site/src/pages/learn/reputation-recovery.astro +++ b/site/src/pages/learn/reputation-recovery.astro @@ -3,6 +3,7 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="Sender Reputation Recovery | Warmbly" + headline="Recovering a burned mailbox" description="A step-by-step plan to bring a quarantined or low-placement mailbox back to healthy without making things worse." reading="9 min read" updated="2026-05-18" @@ -31,7 +32,7 @@ import Learn from '../../layouts/Learn.astro'; <li><strong>DKIM:</strong> selector resolves? Key length ≥ 1024? Rotated within the last year?</li> <li><strong>DMARC:</strong> exists? Aligned? Reporting address receives mail? Policy appropriate for your maturity?</li> <li><strong>PTR:</strong> sending IP has a reverse DNS record that resolves back?</li> - <li><strong>Mailbox content:</strong> last 90 days of sent mail. any HTML-heavy, image-only, or link-bait messages?</li> + <li><strong>Mailbox content:</strong> last 90 days of sent mail: any HTML-heavy, image-only, or link-bait messages?</li> </ol> <p> Fix anything that's broken before you start the volume work. Recovery on a misconfigured domain is futile. @@ -56,7 +57,7 @@ import Learn from '../../layouts/Learn.astro'; <ul> <li>Recipients who replied positively in the past 6 months.</li> <li>Contacts at organisations you have customer relationships with.</li> - <li>Subscribers (newsletter, podcast, blog). addresses you've already had positive engagement from.</li> + <li>Subscribers (newsletter, podcast, blog): addresses you've already had positive engagement from.</li> </ul> <p> Reply-rich first sends rebuild engagement signal fast. Cold sends to brand-new contact lists during recovery are the easiest way to undo the work. @@ -77,7 +78,7 @@ import Learn from '../../layouts/Learn.astro'; If after 8 weeks of disciplined recovery the mailbox still hovers below 80% placement, the address has likely picked up reputation damage that won't reverse. Options: </p> <ul> - <li>Retire the mailbox and create a new one on the same domain. re-warm from scratch.</li> + <li>Retire the mailbox and create a new one on the same domain, re-warming from scratch.</li> <li>Move cold sending to a new sub-domain (e.g. <code>outreach2.acme.com</code>) and start with fresh SPF/DKIM/DMARC.</li> <li>If the whole domain is the problem, register a new sending sub-domain and warm it for 6 weeks before reusing.</li> </ul> diff --git a/site/src/pages/learn/spf-dkim-dmarc.astro b/site/src/pages/learn/spf-dkim-dmarc.astro index 2e1c1995..9da2f639 100644 --- a/site/src/pages/learn/spf-dkim-dmarc.astro +++ b/site/src/pages/learn/spf-dkim-dmarc.astro @@ -3,6 +3,7 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="SPF, DKIM & DMARC Setup | Warmbly" + headline="SPF, DKIM and DMARC, end to end" description="The three authentication records every cold-email sender needs, the alignment rules nobody explains, and the practical step-by-step setup." reading="12 min read" updated="2026-04-30" @@ -19,20 +20,20 @@ import Learn from '../../layouts/Learn.astro'; SPF answers the first. DKIM answers both. DMARC ties the answers to the visible "From" address and tells the receiver what to do if anything is off. </p> - <h2>SPF. Sender Policy Framework</h2> + <h2>SPF: Sender Policy Framework</h2> <p> SPF is a TXT record on your sending domain that lists the servers and services allowed to send mail "on behalf" of you. Receivers check the SMTP envelope sender ("MAIL FROM") domain against your SPF record. </p> <p>An example for a domain that sends through Google Workspace and Warmbly:</p> <pre><code>v=spf1 include:_spf.google.com include:spf.warmbly.com ~all</code></pre> <ul> - <li><code>v=spf1</code>. version.</li> - <li><code>include:</code>. defer to another SPF record (Google's, ours).</li> - <li><code>~all</code>. softfail anything not listed. Receivers downgrade reputation but may still deliver.</li> + <li><code>v=spf1</code>: version.</li> + <li><code>include:</code>: defer to another SPF record (Google's, ours).</li> + <li><code>~all</code>: softfail anything not listed. Receivers downgrade reputation but may still deliver.</li> </ul> <p><strong>Watch out for:</strong> the 10 DNS lookup limit. Every <code>include:</code> counts. Stack too many and SPF returns PermError, which DMARC treats as fail.</p> - <h2>DKIM. DomainKeys Identified Mail</h2> + <h2>DKIM: DomainKeys Identified Mail</h2> <p> DKIM signs outgoing mail with a cryptographic signature. The signature lives in a header; the public key lives in DNS under a "selector". When a receiver gets your message, it fetches your public key, verifies the signature, and confirms the message wasn't altered in flight. </p> @@ -42,7 +43,7 @@ import Learn from '../../layouts/Learn.astro'; The <code>google._</code> part is the selector. You'll have one per sending service, so different providers don't trample each other's keys. Use 2048-bit keys when the provider supports them; 1024 is the minimum. </p> - <h2>DMARC. the policy that ties it together</h2> + <h2>DMARC: the policy that ties it together</h2> <p> DMARC sits on top of SPF and DKIM. It does three things: </p> @@ -54,10 +55,10 @@ import Learn from '../../layouts/Learn.astro'; <p>A starter DMARC record at <code>_dmarc.acme.com</code>:</p> <pre><code>v=DMARC1; p=none; rua=mailto:dmarc-reports@acme.com; ruf=mailto:dmarc-reports@acme.com; pct=100; aspf=r; adkim=r</code></pre> <ul> - <li><code>p=none</code>. monitor only. Start here.</li> - <li><code>rua</code>. aggregate report mailbox.</li> - <li><code>ruf</code>. forensic report mailbox.</li> - <li><code>aspf=r</code>, <code>adkim=r</code>. relaxed alignment. <code>s</code> is strict.</li> + <li><code>p=none</code>: monitor only. Start here.</li> + <li><code>rua</code>: aggregate report mailbox.</li> + <li><code>ruf</code>: forensic report mailbox.</li> + <li><code>aspf=r</code>, <code>adkim=r</code>: relaxed alignment. <code>s</code> is strict.</li> </ul> <h3>Alignment in plain English</h3> @@ -84,19 +85,19 @@ import Learn from '../../layouts/Learn.astro'; <h2>Common mistakes</h2> <ul> - <li>Multiple SPF records on the same domain. receivers treat that as PermError. There must be exactly one SPF record.</li> + <li>Multiple SPF records on the same domain: receivers treat that as PermError. There must be exactly one SPF record.</li> <li>Forgetting <code>include:</code> for a new sending service. Mail still leaves; reputation slowly tanks.</li> <li>1024-bit DKIM keys never rotated. Mailbox providers are starting to penalise stale keys.</li> - <li>DMARC <code>p=reject</code> on a domain with services you forgot about. legitimate mail starts disappearing.</li> + <li>DMARC <code>p=reject</code> on a domain with services you forgot about: legitimate mail starts disappearing.</li> <li>Cold mail from your transactional domain. Reputation issues bleed across.</li> </ul> <h2>Recommended sub-domain pattern</h2> <p>For a company at <code>acme.com</code>:</p> <ul> - <li><code>acme.com</code>. transactional and human mail. DMARC reject after rollout.</li> - <li><code>outreach.acme.com</code>. cold campaigns. Its own SPF, DKIM, DMARC.</li> - <li><code>news.acme.com</code>. newsletters. Separate authentication and reputation.</li> + <li><code>acme.com</code>: transactional and human mail. DMARC reject after rollout.</li> + <li><code>outreach.acme.com</code>: cold campaigns. Its own SPF, DKIM, DMARC.</li> + <li><code>news.acme.com</code>: newsletters. Separate authentication and reputation.</li> </ul> <p> The sub-domain pattern keeps cold reputation issues from contaminating the inboxes you use to run the company. It also lets you change cold-email providers without disturbing transactional mail. diff --git a/site/src/pages/learn/warmup-pools.astro b/site/src/pages/learn/warmup-pools.astro index ec8098c9..2ce51c1c 100644 --- a/site/src/pages/learn/warmup-pools.astro +++ b/site/src/pages/learn/warmup-pools.astro @@ -3,6 +3,7 @@ import Learn from '../../layouts/Learn.astro'; --- <Learn title="How Warmup Pools Work | Warmbly" + headline="Warmup pools: free, premium, dedicated" description="How shared warmup pools actually work, why pool quality matters more than pool size, and what 'dedicated' should and shouldn't mean." reading="7 min read" updated="2026-04-19" @@ -12,7 +13,7 @@ import Learn from '../../layouts/Learn.astro'; A warmup pool is a group of mailboxes that send warmup mail to one another. Your mailbox sends a short message to another mailbox in the pool. That mailbox opens it, sometimes replies, and the pattern reverses with mail from another pool member. </p> <p> - From a mailbox provider's point of view, the pattern looks like normal individual mailbox activity. low volume, conversational, mixed senders. + From a mailbox provider's point of view, the pattern looks like normal individual mailbox activity: low volume, conversational, mixed senders. </p> <h2>Why pool quality matters more than size</h2> @@ -46,7 +47,7 @@ import Learn from '../../layouts/Learn.astro'; <li>Never silently mix free-tier mailboxes into the premium recipient set.</li> </ul> <p> - In Warmbly, free and premium pools are physically separated. Dedicated-worker customers still participate in the premium pool by default. dedicated infrastructure is for isolation and IP control, not a reputation shortcut. + In Warmbly, free and premium pools are physically separated. Dedicated-worker customers still participate in the premium pool by default. Dedicated infrastructure is for isolation and IP control, not a reputation shortcut. </p> <h2>What "dedicated" should mean</h2> @@ -62,7 +63,7 @@ import Learn from '../../layouts/Learn.astro'; <ul> <li>Bypass per-mailbox health rules.</li> <li>Let you ignore complaint or bounce thresholds.</li> - <li>Magically warm a brand-new IP. dedicated IPs need their own warmup ramp.</li> + <li>Magically warm a brand-new IP. Dedicated IPs need their own warmup ramp.</li> </ul> <h2>Re-entry after quarantine</h2> diff --git a/site/src/styles/global.css b/site/src/styles/global.css index 5918325a..ae3e8d8c 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -474,6 +474,242 @@ body { -webkit-mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%); } +/* ---------------------------------------------------------------- + Article typography — shared by the Learn essays and the blog. + Lives in @layer components so single-class Tailwind utilities on + elements inside an article (e.g. a custom <pre class="bg-...">) + still win over these defaults. +---------------------------------------------------------------- */ + +@layer components { + .article-prose { + color: var(--foreground); + } + + .article-prose h2 { + position: relative; + margin-top: 3.5rem; + margin-bottom: 1.25rem; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.15; + color: var(--heading); + scroll-margin-top: 6.5rem; + } + .article-prose h2:first-child { margin-top: 0; } + @media (min-width: 768px) { + .article-prose h2 { font-size: 28px; } + /* Section glyph sits in the left margin; gated to md+ so it cannot + poke past the px-6 container edge on phones. */ + .article-prose h2::before { + content: '§'; + position: absolute; + left: -1.75rem; + top: 0.25rem; + font-family: var(--font-mono); + font-size: 18px; + color: color-mix(in oklab, #0284c7 30%, transparent); + } + } + + /* Hover anchor for deep links; the <a> is injected by the article script. */ + .article-prose h2 .heading-anchor { + position: absolute; + right: 100%; + top: 0; + bottom: 0; + display: inline-flex; + align-items: center; + padding-right: 0.4rem; + font-family: var(--font-mono); + font-size: 15px; + font-weight: 400; + color: #0284c7; + opacity: 0; + text-decoration: none; + transition: opacity 160ms ease; + } + .article-prose h2:hover .heading-anchor, + .article-prose h2 .heading-anchor:focus-visible { opacity: 1; } + @media (min-width: 768px) { + /* When the § glyph is visible, the anchor replaces it on hover. */ + .article-prose h2:hover::before { opacity: 0; } + } + + .article-prose h3 { + margin-top: 2rem; + margin-bottom: 0.5rem; + font-size: 17px; + font-weight: 600; + color: var(--heading); + } + + .article-prose p { + margin-top: 1rem; + font-size: 15.5px; + line-height: 1.75; + color: color-mix(in oklab, var(--foreground) 85%, transparent); + } + .article-prose p:first-child { margin-top: 0; } + @media (min-width: 768px) { + .article-prose p { font-size: 16px; } + } + + .article-prose ul { + margin-top: 1rem; + font-size: 15px; + color: color-mix(in oklab, var(--foreground) 85%, transparent); + } + .article-prose ul > li { + position: relative; + padding-left: 1.25rem; + line-height: 1.6; + } + .article-prose ul > li + li { margin-top: 0.5rem; } + .article-prose ul > li::before { + content: '·'; + position: absolute; + left: 0.25rem; + color: #0284c7; + font-weight: 700; + } + + .article-prose ol { + margin-top: 1rem; + list-style: none; + counter-reset: step; + font-size: 15px; + color: color-mix(in oklab, var(--foreground) 85%, transparent); + } + .article-prose ol > li { + counter-increment: step; + position: relative; + padding-left: 2.5rem; + line-height: 1.6; + } + .article-prose ol > li + li { margin-top: 0.5rem; } + .article-prose ol > li::before { + content: counter(step, decimal-leading-zero) '.'; + position: absolute; + left: 0; + top: 0; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: #0284c7; + } + + .article-prose a { + color: #0369a1; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: color-mix(in oklab, #0284c7 30%, transparent); + } + .article-prose a:hover { color: #075985; } + + .article-prose strong { + color: var(--heading); + font-weight: 600; + } + + .article-prose code { + font-family: var(--font-mono); + font-size: 0.92em; + padding: 0.125rem 0.375rem; + border-radius: 4px; + background: var(--surface-2); + box-shadow: 0 0 0 1px var(--border); + } + + .article-prose blockquote { + margin-top: 2rem; + margin-bottom: 2rem; + border-left: 2px solid #0284c7; + padding: 0.5rem 0 0.5rem 1.5rem; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.35; + color: var(--heading); + } + @media (min-width: 768px) { + .article-prose blockquote { font-size: 22px; } + } + .article-prose blockquote p { + margin: 0; + font-size: inherit; + line-height: inherit; + color: inherit; + } + + .article-prose pre { + margin-top: 1.25rem; + margin-bottom: 1.25rem; + background: #0c1224; + color: rgba(255, 255, 255, 0.85); + padding: 1.25rem; + border-radius: 10px; + overflow-x: auto; + font-size: 13px; + line-height: 1.6; + } + .article-prose pre code { + background: transparent; + box-shadow: none; + padding: 0; + font-size: inherit; + } + + .article-prose table { + width: 100%; + margin-top: 1.25rem; + margin-bottom: 1.25rem; + border-collapse: collapse; + font-size: 13.5px; + line-height: 1.5; + } + .article-prose thead th { + text-align: left; + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted-foreground); + padding: 0 0.75rem 0.5rem 0; + border-bottom: 1px solid var(--border); + } + .article-prose tbody td { + padding: 0.625rem 0.75rem 0.625rem 0; + border-bottom: 1px solid var(--border); + color: color-mix(in oklab, var(--foreground) 80%, transparent); + vertical-align: top; + } + .article-prose tbody td:first-child { + color: var(--heading); + font-weight: 500; + } + + .article-prose hr { + margin-top: 2.5rem; + margin-bottom: 2.5rem; + border: 0; + border-top: 1px solid var(--border); + } +} + +/* 2px sky reading-progress bar pinned under the article top bar. */ +.reading-progress { + position: absolute; + left: 0; + bottom: -1px; + height: 2px; + width: 0%; + background: linear-gradient(to right, #38bdf8, #0284c7); + transition: width 80ms linear; +} + /* Inline code style for the docs/learn pages. */ .kbd { font-family: var(--font-mono);