From db832fa8961147e2c16492966d434c3066bea990 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:14:57 +0200 Subject: [PATCH 01/11] feat: add blog content collection on the Astro 5 content layer Posts are markdown/MDX files under src/content/blog with a zod-validated schema (title, description, pubDate, tags, optional cover, draft). All listing surfaces read through getPublishedPosts() so draft filtering has a single owner. @astrojs/mdx is pinned to ^4.x: the npm latest (v6) requires Astro 6 and would break this tree. --- site/astro.config.mjs | 2 + site/package.json | 3 + site/pnpm-lock.yaml | 616 +++++++++++++++++++++++++++++++++++++ site/src/content.config.ts | 24 ++ site/src/lib/blog.ts | 50 +++ 5 files changed, 695 insertions(+) create mode 100644 site/src/content.config.ts create mode 100644 site/src/lib/blog.ts 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/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/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)); +} From 8221c28cc37483f64d2d28bf5dbb763bc921b68a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:14:57 +0200 Subject: [PATCH 02/11] feat: share one article typography between learn and the blog Extracts the Learn layout's arbitrary-variant prose blob into a single .article-prose class, registered in @layer components so page-level utilities can still override it. Fixes the invalid counter-reset syntax that rendered every ordered-list item as 00., adds table styles, hover heading anchors and a reading-progress bar. --- site/src/styles/global.css | 236 +++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) 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
)
+   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  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);

From bd51ef40ff7fa9ff41a20468c53a00d2b88ed70c Mon Sep 17 00:00:00 2001
From: Matthew Meszaros 
Date: Wed, 10 Jun 2026 17:14:57 +0200
Subject: [PATCH 03/11] feat: add the blog post page

Article shell with a sticky meta bar carrying reading progress, a right
rail (chapter scroll-spy built from server-rendered heading ids, post
facts, neighbors), prev/next cards and BlogPosting JSON-LD with real
publish dates. MDX posts can embed the Callout component.
---
 site/src/components/blog/Callout.astro |  41 ++++
 site/src/layouts/BlogPost.astro        | 255 +++++++++++++++++++++++++
 site/src/pages/blog/[...id].astro      |  20 ++
 3 files changed, 316 insertions(+)
 create mode 100644 site/src/components/blog/Callout.astro
 create mode 100644 site/src/layouts/BlogPost.astro
 create mode 100644 site/src/pages/blog/[...id].astro

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/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/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); +--- + + + From 67f773cced5a843423192c8ba057aeb6ea71618d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:15:17 +0200 Subject: [PATCH 04/11] feat: add a newsroom-style blog index with search and topic pages Compact header, then a cover-image card grid with topic tabs, client-side search and batched load-more. Every card is server-rendered; search and load-more only toggle visibility, so crawlers and no-JS readers see the full archive. Posts without a cover image get deterministic generated sky art seeded from the slug. Tags get their own filtered pages. --- site/src/components/blog/BlogCover.astro | 56 +++++++++ site/src/components/blog/PostGrid.astro | 138 +++++++++++++++++++++++ site/src/components/blog/TagFilter.astro | 29 +++++ site/src/pages/blog/index.astro | 87 ++++++++++++++ site/src/pages/blog/tag/[tag].astro | 50 ++++++++ 5 files changed, 360 insertions(+) create mode 100644 site/src/components/blog/BlogCover.astro create mode 100644 site/src/components/blog/PostGrid.astro create mode 100644 site/src/components/blog/TagFilter.astro create mode 100644 site/src/pages/blog/index.astro create mode 100644 site/src/pages/blog/tag/[tag].astro 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/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'} +
+
+ + + + + + + + + +
+ + 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/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 }, +]; +--- + + +
+
+
Blog
+
+

+ The Warmbly blog. +

+

+ RSS feed at /blog/rss.xml +

+
+

+ Deliverability, sending infrastructure, and what it takes to run an + open-source cold email platform. +

+
+
+ + +
+
+ + + +
+
+ + +
+
+
+
+
Topics
+
+ {tags.map(({ tag, count }) => ( + + {tag} + {count} + + ))} +
+
+
+
Elsewhere
+
+ {follow.map((f) => ( + + + {f.title} + + ))} +
+
+
+
+
+ + +
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); +--- + + +
+
+
+ Blog · topic +
+
+

{tag}

+

+ {posts.length} {posts.length === 1 ? 'post' : 'posts'} +

+
+
+
+ +
+
+ + + +
+
+ + +
From 0edc1d4f67fd97d0084af1a2569558a55c5b4ccd Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:15:17 +0200 Subject: [PATCH 05/11] feat: publish the blog RSS feed Description-only feed at /blog/rss.xml (full-content RSS cannot render MDX components), excluding drafts unconditionally, plus an autodiscovery link in the global head. The feed is the subscription: no newsletter. --- site/src/layouts/Layout.astro | 1 + site/src/pages/blog/rss.xml.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 site/src/pages/blog/rss.xml.js 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 = { + + + From 0f5fd64580d9cb6a1e7b4fc0eb268ff4aa6561dd Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:16:07 +0200 Subject: [PATCH 10/11] feat: give learn essays editorial headlines and repair broken copy Every essay passes a real display headline instead of surfacing its SEO title as the H1. Repairs roughly forty sentences left broken by an earlier em-dash strip ('The safest assumption. for them. is spammer.') using colons, commas and real sentence splits. Drops the internal DEK term from the customer-facing glossary. --- site/src/pages/learn/cold-email-rules.astro | 15 ++++----- site/src/pages/learn/email-warmup.astro | 17 +++++----- site/src/pages/learn/glossary.astro | 4 +-- site/src/pages/learn/inbox-placement.astro | 13 ++++---- site/src/pages/learn/personalization.astro | 3 +- .../src/pages/learn/reputation-recovery.astro | 7 +++-- site/src/pages/learn/spf-dkim-dmarc.astro | 31 ++++++++++--------- site/src/pages/learn/warmup-pools.astro | 7 +++-- 8 files changed, 51 insertions(+), 46 deletions(-) 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'; --- @@ -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.

-

United States. CAN-SPAM (2003)

+

United States: CAN-SPAM (2003)

CAN-SPAM applies to "commercial electronic mail messages". It does not require prior consent for cold B2B email. It does require:

@@ -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.

-

European Union and UK. GDPR + PECR

+

European Union and UK: GDPR + PECR

GDPR applies to personal data of EU/UK residents. PECR (in the UK) and ePrivacy directives in EU member states specifically cover electronic marketing.

@@ -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.

-

Canada. CASL (2014)

+

Canada: CASL (2014)

CASL is the strictest of the major regimes. It requires express or implied consent before sending commercial electronic messages to Canadian recipients.

@@ -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.

-

Australia. Spam Act 2003

+

Australia: Spam Act 2003

Requires consent (express, inferred or implied), accurate sender identification and a functional unsubscribe. Inferred consent is generous for B2B but not consumer marketing.

@@ -77,11 +78,11 @@ import Learn from '../../layouts/Learn.astro';
  1. Use a real From: name and a domain you actually own.
  2. Include a physical mailing address in your sender profile / footer.
  3. -
  4. Provide a clear opt-out. "Reply STOP to opt out" or a one-click link.
  5. +
  6. Provide a clear opt-out: "Reply STOP to opt out" or a one-click link.
  7. Auto-suppress recipients who opt out, across all future sequences and campaigns.
  8. Record where each contact was sourced and your basis for contacting them.
  9. Only contact role accounts and named business addresses for B2B; avoid personal addresses unless you have explicit consent.
  10. -
  11. Customise the message for relevance. boilerplate to thousands of strangers is the loudest possible compliance and reputation risk.
  12. +
  13. Customise the message for relevance: boilerplate to thousands of strangers is the loudest possible compliance and reputation risk.
  14. Skip Canadian and EU consumer-mail recipients unless you have documented consent.
  15. Honour opt-outs within 24 hours, not the legal maximum.
  16. If you operate a privacy policy, link to it from your footer.
  17. 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'; ---

    - 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.

    Why providers care about it

    - 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.

    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';

    What good warmup looks like

    Gradual volume growth

    - 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.

    Realistic spacing

    @@ -41,7 +42,7 @@ import Learn from '../../layouts/Learn.astro';

    Quality recipients

    - 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.

    Token verification

    @@ -52,7 +53,7 @@ import Learn from '../../layouts/Learn.astro';

    What bad warmup looks like

    • Synthetic blasting: sending 200 identical messages a day to the same handful of recipients. Looks great on a vendor dashboard. Looks awful to Gmail.
    • -
    • Throwaway pools: 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.
    • +
    • Throwaway pools: 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.
    • Open/click theatre: 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.
    • Stopping at "ready": many tools stop warmup the moment campaigns begin. Reputation has to be maintained, not earned once and forgotten.
    @@ -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.

    - What matters is not finishing 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 finishing 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.

    Free pools vs paid pools

    @@ -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.

    - 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.

    When to quarantine a mailbox

    @@ -81,7 +82,7 @@ import Learn from '../../layouts/Learn.astro';
  18. Watch: spam placement ≥ 10% over a 20-delivery sample. Lower volume, increase spacing.
  19. Quarantine: spam placement ≥ 20%, or complaint rate ≥ 0.10%, or bounce rate ≥ 5%. 7-day removal from the paid pool.
  20. Block: spam placement ≥ 40%, or complaint rate ≥ 0.30%. 30-day block, manual review to re-enter.
  21. -
  22. Catastrophic: spam placement ≥ 80%. Long block plus reputation reset. no automatic re-entry.
  23. +
  24. Catastrophic: spam placement ≥ 80%. Long block plus reputation reset, with no automatic re-entry.
  25. Practical checklist

    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'; --- 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.

    D

    -

    DEK (Data Encryption Key)

    -

    A symmetric key used to encrypt application-level data. Generated by KMS and stored encrypted; the plaintext form is briefly cached for hot paths.

    -

    DKIM

    Cryptographic signature that proves a message was authorised by the domain's key holder and not modified in transit.

    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'; ---

    Delivery vs placement

    - 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.

    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';

    How placement is measured

    Warmup probes

    - 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.

    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';

    Seed lists

    - 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.

    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';

  26. Unauthenticated mail (missing or misaligned SPF, DKIM, DMARC).
  27. Sudden volume spikes from an otherwise quiet mailbox.
  28. Cold mail with heavy HTML, unsolicited attachments, or tracking-pixel-only bodies.
  29. -
  30. Word-for-word identical content across thousands of sends. content fingerprinting catches it.
  31. +
  32. Word-for-word identical content across thousands of sends: content fingerprinting catches it.
  33. Long URLs to suspicious TLDs, or URL shorteners that have been abused.
  34. Recipients consistently deleting without opening.
  35. Recipients marking as spam (the worst signal of all).
  36. @@ -55,10 +56,10 @@ import Learn from '../../layouts/Learn.astro';
    • Plain text bodies for cold sequences.
    • Sensible volume relative to historical baseline for the mailbox.
    • -
    • Real replies from real recipients. this is the strongest positive signal.
    • +
    • Real replies from real recipients: this is the strongest positive signal.
    • Variation in subject and opening line across recipients.
    • Tight, well-targeted contact lists. Sending to obviously-bad addresses (parsed from web pages, role accounts, etc.) inflates bounces and complaint risk.
    • -
    • Consistent sending pattern. same business hours every day, with weekends off.
    • +
    • Consistent sending pattern: same business hours every day, with weekends off.

    Targets to aim for

    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)"; --- DKIM: selector resolves? Key length ≥ 1024? Rotated within the last year?
  37. DMARC: exists? Aligned? Reporting address receives mail? Policy appropriate for your maturity?
  38. PTR: sending IP has a reverse DNS record that resolves back?
  39. -
  40. Mailbox content: last 90 days of sent mail. any HTML-heavy, image-only, or link-bait messages?
  41. +
  42. Mailbox content: last 90 days of sent mail: any HTML-heavy, image-only, or link-bait messages?

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';

  • Recipients who replied positively in the past 6 months.
  • Contacts at organisations you have customer relationships with.
  • -
  • Subscribers (newsletter, podcast, blog). addresses you've already had positive engagement from.
  • +
  • Subscribers (newsletter, podcast, blog): addresses you've already had positive engagement from.

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:

    -
  • Retire the mailbox and create a new one on the same domain. re-warm from scratch.
  • +
  • Retire the mailbox and create a new one on the same domain, re-warming from scratch.
  • Move cold sending to a new sub-domain (e.g. outreach2.acme.com) and start with fresh SPF/DKIM/DMARC.
  • If the whole domain is the problem, register a new sending sub-domain and warm it for 6 weeks before reusing.
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'; --- -

SPF. Sender Policy Framework

+

SPF: Sender Policy Framework

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.

An example for a domain that sends through Google Workspace and Warmbly:

v=spf1 include:_spf.google.com include:spf.warmbly.com ~all
    -
  • v=spf1. version.
  • -
  • include:. defer to another SPF record (Google's, ours).
  • -
  • ~all. softfail anything not listed. Receivers downgrade reputation but may still deliver.
  • +
  • v=spf1: version.
  • +
  • include:: defer to another SPF record (Google's, ours).
  • +
  • ~all: softfail anything not listed. Receivers downgrade reputation but may still deliver.

Watch out for: the 10 DNS lookup limit. Every include: counts. Stack too many and SPF returns PermError, which DMARC treats as fail.

-

DKIM. DomainKeys Identified Mail

+

DKIM: DomainKeys Identified Mail

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.

@@ -42,7 +43,7 @@ import Learn from '../../layouts/Learn.astro'; The google._ 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.

-

DMARC. the policy that ties it together

+

DMARC: the policy that ties it together

DMARC sits on top of SPF and DKIM. It does three things:

@@ -54,10 +55,10 @@ import Learn from '../../layouts/Learn.astro';

A starter DMARC record at _dmarc.acme.com:

v=DMARC1; p=none; rua=mailto:dmarc-reports@acme.com; ruf=mailto:dmarc-reports@acme.com; pct=100; aspf=r; adkim=r
    -
  • p=none. monitor only. Start here.
  • -
  • rua. aggregate report mailbox.
  • -
  • ruf. forensic report mailbox.
  • -
  • aspf=r, adkim=r. relaxed alignment. s is strict.
  • +
  • p=none: monitor only. Start here.
  • +
  • rua: aggregate report mailbox.
  • +
  • ruf: forensic report mailbox.
  • +
  • aspf=r, adkim=r: relaxed alignment. s is strict.

Alignment in plain English

@@ -84,19 +85,19 @@ import Learn from '../../layouts/Learn.astro';

Common mistakes

    -
  • Multiple SPF records on the same domain. receivers treat that as PermError. There must be exactly one SPF record.
  • +
  • Multiple SPF records on the same domain: receivers treat that as PermError. There must be exactly one SPF record.
  • Forgetting include: for a new sending service. Mail still leaves; reputation slowly tanks.
  • 1024-bit DKIM keys never rotated. Mailbox providers are starting to penalise stale keys.
  • -
  • DMARC p=reject on a domain with services you forgot about. legitimate mail starts disappearing.
  • +
  • DMARC p=reject on a domain with services you forgot about: legitimate mail starts disappearing.
  • Cold mail from your transactional domain. Reputation issues bleed across.

Recommended sub-domain pattern

For a company at acme.com:

    -
  • acme.com. transactional and human mail. DMARC reject after rollout.
  • -
  • outreach.acme.com. cold campaigns. Its own SPF, DKIM, DMARC.
  • -
  • news.acme.com. newsletters. Separate authentication and reputation.
  • +
  • acme.com: transactional and human mail. DMARC reject after rollout.
  • +
  • outreach.acme.com: cold campaigns. Its own SPF, DKIM, DMARC.
  • +
  • news.acme.com: newsletters. Separate authentication and reputation.

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'; ---

- 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.

Why pool quality matters more than size

@@ -46,7 +47,7 @@ import Learn from '../../layouts/Learn.astro';
  • Never silently mix free-tier mailboxes into the premium recipient set.
  • - 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.

    What "dedicated" should mean

    @@ -62,7 +63,7 @@ import Learn from '../../layouts/Learn.astro';
    • Bypass per-mailbox health rules.
    • Let you ignore complaint or bounce thresholds.
    • -
    • Magically warm a brand-new IP. dedicated IPs need their own warmup ramp.
    • +
    • Magically warm a brand-new IP. Dedicated IPs need their own warmup ramp.

    Re-entry after quarantine

    From 9cc42546a4e8234b5d632c6a281bd1c40339ac12 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 10 Jun 2026 17:16:07 +0200 Subject: [PATCH 11/11] feat: align the deliverability handbook with the registry and real caps The hand-rolled flagship page now reads the shared learn registry for its rail (it had drifted and was missing an essay), emits the BlogPosting JSON-LD every other essay already had, and corrects its repeated 100/day per-mailbox claim: the product default is 50/day, with 100 only as the validation ceiling under positive reputation evidence. --- site/src/pages/learn/deliverability.astro | 48 +++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) 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, +}; ---