feat: add OG images, favicon set, manifest and performance tuning

- Build-time Open Graph cards per post and a default card (Satori + Sharp)
- Favicon/ICO/apple-touch/manifest icons generated from one SVG (npm run icons)
- robots.txt and web manifest generated from site.json
- Subset self-hosted fonts to Latin + Turkish (188 KB -> 66 KB) with preload
- Inline the stylesheet to remove the render-blocking request
- Post-build site audit (npm run check:site): h1, alt, SEO tags, links
- Omit canonical on noindex pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 02:36:56 +03:00
parent df9c40847d
commit 7d8fcce2e1
28 changed files with 914 additions and 41 deletions
+15 -4
View File
@@ -1,13 +1,13 @@
# PROJECT_MEMORY — ta1sph.info # PROJECT_MEMORY — ta1sph.info
> Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur. > Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur.
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 6) > Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 7)
## 1. Durum ## 1. Durum
- **Aşama:** 6 tamamlandı (Hakkımda, İletişim, yasal, 404). Sıradaki: **Aşama 7 — Kalite**. - **Aşama:** 7 tamamlandı (kalite). Sıradaki: **Aşama 8 — Deploy hazırlığı**.
- **Tamamlanan:** Aşama 0–6. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`. - **Tamamlanan:** Aşama 0–7. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`. Lighthouse (mobil, 3 koşu ortancası, yerel): A11y/BP/SEO 100 her sayfada; Perf temiz koşullarda 98–100, LCP ~1,4–1,9 sn. Yayında PageSpeed Insights ile doğrulanmalı.
- **Açık işler:** Aşama 7–8. Favicon + `og-default.png` yok (A7); kapaksız yazılarda og:image yok. 3 örnek yazı (`örnek` etiketli) kullanıcı isteyince silinecek. Hakkımda'ya tanıtım paragrafı kullanıcı metin verirse eklenecek (`about.json` → `lead`/yeni alan). - **Açık işler:** Aşama 8. Node sunucusu yanıtları sıkıştırmıyor → A8'de Traefik `compress` middleware veya eşdeğeri şart. 3 örnek yazı (`örnek` etiketli) kullanıcı isteyince silinecek. Hakkımda tanıtım paragrafı kullanıcı metin verirse.
- **Bilinen sorunlar:** Arama (Pagefind) `astro dev`'de çalışmaz, yalnızca build sonrası. Kullanıcının elle başlattığı dev sunucusu yeni koleksiyonu görmüyorsa yeniden başlatılmalı. `npm audit`: 2 orta seviye (postcss-selector-parser 6.0.10, @tailwindcss/typography sabitliyor) — yalnızca build zamanı, düzeltme typography'yi eski sürüme düşürdüğü için uygulanmadı. - **Bilinen sorunlar:** Arama (Pagefind) `astro dev`'de çalışmaz, yalnızca build sonrası. Kullanıcının elle başlattığı dev sunucusu yeni koleksiyonu görmüyorsa yeniden başlatılmalı. `npm audit`: 2 orta seviye (postcss-selector-parser 6.0.10, @tailwindcss/typography sabitliyor) — yalnızca build zamanı, düzeltme typography'yi eski sürüme düşürdüğü için uygulanmadı.
## 2. Ortam ## 2. Ortam
@@ -43,6 +43,11 @@
- `src/pages/hakkimda.astro` (ProfilePage JSON-LD) · `src/pages/iletisim.astro` (Leaflet + OSM, görünür olunca dinamik import; `divIcon` içinde `.tower-pin`) · `src/pages/[legal].astro` + `src/content/legal/{gizlilik,lisans,sorumluluk-reddi}.md` (koleksiyon `legal`, frontmatter `updated`) · `src/pages/404.astro` - `src/pages/hakkimda.astro` (ProfilePage JSON-LD) · `src/pages/iletisim.astro` (Leaflet + OSM, görünür olunca dinamik import; `divIcon` içinde `.tower-pin`) · `src/pages/[legal].astro` + `src/content/legal/{gizlilik,lisans,sorumluluk-reddi}.md` (koleksiyon `legal`, frontmatter `updated`) · `src/pages/404.astro`
- `src/components/PageHeader.astro` (khaki başlık + tek h1; blog listesi de kullanır) · `src/components/CodeCopy.astro` (Markdown'ı sarar, kod bloklarına Kopyala düğmesi) - `src/components/PageHeader.astro` (khaki başlık + tek h1; blog listesi de kullanır) · `src/components/CodeCopy.astro` (Markdown'ı sarar, kod bloklarına Kopyala düğmesi)
- `LICENSE` (MIT) · `LICENSE-CONTENT` (CC BY-SA 4.0, resmî legalcode.txt'den) - `LICENSE` (MIT) · `LICENSE-CONTENT` (CC BY-SA 4.0, resmî legalcode.txt'den)
- `src/lib/og.ts` — Satori + Sharp ile 1200×630 OG PNG (khaki, grid çizgileri, kule ikonu); latin-ext fontlar ayrı aile adıyla yedek (`RubikExt`, `InterExt`) · `src/pages/og/[slug].png.ts` (yazı başına) · `src/pages/og-default.png.ts` (diğer sayfalar). `Seo` varsayılanı `/og-default.png`; kapaklı yazıda kapak 1200px JPEG
- `src/pages/site.webmanifest.ts`, `src/pages/robots.txt.ts` (`/api/` kapalı, sitemap satırı) — `site.json`'dan
- `public/fonts/*.woff2` + `src/styles/fonts.css` — **üretilir:** `npm run fonts` (`scripts/subset-fonts.mjs`, subset-font; Latin+Türkçe+sembol, wght Rubik 400–900 / Inter 400–700; 188 KB → 66 KB). Kaynak `@fontsource-variable/*` (devDeps). BaseLayout iki latin dosyasını preload eder
- `public/favicon.{svg,ico}`, `apple-touch-icon.png`, `icon-192/512.png`, `icon-maskable-512.png` — **üretilir:** `npm run icons` (`scripts/generate-icons.mjs`; kırmızı zemin + siyah kule, 16px için kalın varyant)
- `scripts/check-site.mjs` — `npm run check:site` (build sonrası): tek h1, alt, title/description/canonical, tekrar eden id, iç link + anchor
- `src/dev/Styleguide.astro` — `astro.config.mjs`'deki `devRoutes` entegrasyonu ile sadece `astro dev`'de `/styleguide/` (`_` önekli sayfalar Astro'da rota olmaz) - `src/dev/Styleguide.astro` — `astro.config.mjs`'deki `devRoutes` entegrasyonu ile sadece `astro dev`'de `/styleguide/` (`_` önekli sayfalar Astro'da rota olmaz)
- `src/lib/slug.ts` — Türkçe karakter dönüştürücü `slugify` - `src/lib/slug.ts` — Türkçe karakter dönüştürücü `slugify`
- `scripts/new-post.mjs` — `npm run new:post -- "Başlık"` → `src/content/blog/<slug>/index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder - `scripts/new-post.mjs` — `npm run new:post -- "Başlık"` → `src/content/blog/<slug>/index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder
@@ -63,6 +68,11 @@
| 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme | | 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik | | 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
| 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold | | 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold |
| 2026-10-16 | Yazı başına OG görseli build'de (Satori) — kullanıcı kararı bana bıraktı | Paylaşımlarda başlıklı kart; harici servis yok |
| 2026-10-16 | Favicon = kırmızı kare + siyah anten kulesi (Tabler) | "TA1" 16px'te okunmuyor; vurgu rengi + İletişim ikonuyla tutarlı |
| 2026-10-16 | Fontlar alt kümelenip `public/fonts`'tan; `build.inlineStylesheets: 'always'` | LCP'yi geciktiren render-blocking CSS + 190 KB font; ölçümde FCP 2,3 → ~1 sn |
| 2026-10-16 | Leaflet CSS iletisim.astro'da statik import | `inlineStylesheets: 'always'` dinamik CSS importunu 404'e düşürüyordu |
| 2026-10-16 | noindex sayfalarda canonical yok (404) | 404'ün `/404/` canonical'ı var olmayan URL'yi gösteriyordu |
| 2026-10-16 | Yasal metinler Markdown koleksiyonu (`src/content/legal`), tek dinamik rota | Uzun metin için prose; yeni yasal sayfa = yeni .md | | 2026-10-16 | Yasal metinler Markdown koleksiyonu (`src/content/legal`), tek dinamik rota | Uzun metin için prose; yeni yasal sayfa = yeni .md |
| 2026-10-16 | Leaflet işaretçisinde döndürme iç elemanda (`.tower-pin`) | CSS `rotate` özelliği Leaflet'in `translate3d`'sinden önce uygulanıp konumu kaydırıyordu | | 2026-10-16 | Leaflet işaretçisinde döndürme iç elemanda (`.tower-pin`) | CSS `rotate` özelliği Leaflet'in `translate3d`'sinden önce uygulanıp konumu kaydırıyordu |
| 2026-10-16 | Karanlık harita = karolara CSS `invert` filtresi | İkinci karo sağlayıcısı / API anahtarı gerekmez | | 2026-10-16 | Karanlık harita = karolara CSS `invert` filtresi | İkinci karo sağlayıcısı / API anahtarı gerekmez |
@@ -90,6 +100,7 @@
## 6. Değişiklik Günlüğü (en yeni üstte) ## 6. Değişiklik Günlüğü (en yeni üstte)
- 2026-10-16 | Siteyi Kur A7 | OG görselleri (yazı başına + varsayılan), favicon seti + manifest, robots.txt, font alt kümeleme + preload, CSS inline, site denetim script'i, Lighthouse | `src/lib/og.ts`, `src/pages/{og,og-default.png,robots.txt,site.webmanifest}*`, `scripts/{generate-icons,subset-fonts,check-site}.mjs`, `public/`, `src/styles/fonts.css`, `Seo.astro`, `BaseLayout.astro`, `astro.config.mjs`
- 2026-10-16 | Siteyi Kur A6 | Hakkımda, İletişim (Leaflet harita, gizlenmiş e-posta), Gizlilik/Lisans/Sorumluluk Reddi, 404, LICENSE dosyaları; PageHeader + CodeCopy ortak bileşenleri; axe temiz | `src/pages/{hakkimda,iletisim,[legal],404}.astro`, `src/content/legal/`, `src/data/{about,contact,pages}.json`, `src/components/{PageHeader,CodeCopy,Card}.astro`, `LICENSE*`, `global.css` - 2026-10-16 | Siteyi Kur A6 | Hakkımda, İletişim (Leaflet harita, gizlenmiş e-posta), Gizlilik/Lisans/Sorumluluk Reddi, 404, LICENSE dosyaları; PageHeader + CodeCopy ortak bileşenleri; axe temiz | `src/pages/{hakkimda,iletisim,[legal],404}.astro`, `src/content/legal/`, `src/data/{about,contact,pages}.json`, `src/components/{PageHeader,CodeCopy,Card}.astro`, `LICENSE*`, `global.css`
- 2026-10-16 | Siteyi Kur A5 | Görüntülenme sayacı (node:sqlite, anonim, 24 sa tekil, bot filtresi), top API, sidebar öne çıkanlar API + yedek; uçtan uca test | `src/lib/views.ts`, `src/pages/api/views/`, `src/components/blog/Sidebar.astro`, `src/pages/blog/[slug].astro`, `src/lib/posts.ts` - 2026-10-16 | Siteyi Kur A5 | Görüntülenme sayacı (node:sqlite, anonim, 24 sa tekil, bot filtresi), top API, sidebar öne çıkanlar API + yedek; uçtan uca test | `src/lib/views.ts`, `src/pages/api/views/`, `src/components/blog/Sidebar.astro`, `src/pages/blog/[slug].astro`, `src/lib/posts.ts`
- 2026-10-16 | Siteyi Kur A4 | Blog: koleksiyon, liste/kategori/etiket arşivleri + sayfalama, detay (paylaş, TOC, DIY, kod kopyala, lisans satırı), sidebar (Pagefind arama), RSS, ana sayfa son yazılar, 3 örnek yazı; axe temiz | `src/content*`, `src/pages/blog/`, `src/pages/rss.xml.ts`, `src/components/blog/`, `src/lib/{posts,dates,markdown-plugins}`, `src/data/{blog,categories}.json`, `astro.config.mjs`, `global.css` - 2026-10-16 | Siteyi Kur A4 | Blog: koleksiyon, liste/kategori/etiket arşivleri + sayfalama, detay (paylaş, TOC, DIY, kod kopyala, lisans satırı), sidebar (Pagefind arama), RSS, ana sayfa son yazılar, 3 örnek yazı; axe temiz | `src/content*`, `src/pages/blog/`, `src/pages/rss.xml.ts`, `src/components/blog/`, `src/lib/{posts,dates,markdown-plugins}`, `src/data/{blog,categories}.json`, `astro.config.mjs`, `global.css`
+2
View File
@@ -30,6 +30,8 @@ export default defineConfig({
// Static by default; only routes with `export const prerender = false` (view counter API) run on the server. // Static by default; only routes with `export const prerender = false` (view counter API) run on the server.
output: 'static', output: 'static',
adapter: node({ mode: 'standalone' }), adapter: node({ mode: 'standalone' }),
// One small stylesheet: inlining it removes a render-blocking request on every page.
build: { inlineStylesheets: 'always' },
integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes], integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes],
markdown: { markdown: {
processor: satteri({ hastPlugins: [figurePlugin, externalLinksPlugin] }), processor: satteri({ hastPlugins: [figurePlugin, externalLinksPlugin] }),
+340 -4
View File
@@ -13,8 +13,6 @@
"@astrojs/node": "^11.1.8", "@astrojs/node": "^11.1.8",
"@astrojs/rss": "^4.0.19", "@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4", "@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/rubik": "^5.3.0",
"@iconify-json/tabler": "^1.2.42", "@iconify-json/tabler": "^1.2.42",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"astro": "^7.4.0", "astro": "^7.4.0",
@@ -24,12 +22,19 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/rubik": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/rubik": "^5.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"pagefind": "^1.5.2", "pagefind": "^1.5.2",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"prettier-plugin-astro": "^1.1.1", "prettier-plugin-astro": "^1.1.1",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"satori": "^0.60.0",
"sharp": "^0.35.5",
"subset-font": "^2.9.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"engines": { "engines": {
@@ -862,6 +867,7 @@
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"dev": true,
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
@@ -871,6 +877,27 @@
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.3.0.tgz",
"integrity": "sha512-yQUf8qle24MJYA+EuauyF+cqjjN6I715nGjitt3sGmwZA0XfA4fWJcLq1C/diREZl4Qzn1E80nLMc0ipC/a4Lg==", "integrity": "sha512-yQUf8qle24MJYA+EuauyF+cqjjN6I715nGjitt3sGmwZA0XfA4fWJcLq1C/diREZl4Qzn1E80nLMc0ipC/a4Lg==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/rubik": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/rubik/-/rubik-5.3.0.tgz",
"integrity": "sha512-Nks2mn7vlE1jZVHuVgoKU2Ejqjvd+GJzLRdraCjo1xc79oo7O6vJ8pcvSfmKPGX3/Ln7nt09XWFzPeu2jWf0xQ==",
"dev": true,
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
@@ -921,8 +948,8 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2028,6 +2055,30 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@shuding/opentype.js": {
"version": "1.4.0-beta.0",
"resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
"integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fflate": "^0.7.3",
"string.prototype.codepointat": "^0.2.1"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@shuding/opentype.js/node_modules/fflate": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz",
"integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2907,6 +2958,13 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -3032,12 +3090,32 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ccount": { "node_modules/ccount": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -3140,6 +3218,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -3196,6 +3281,40 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/css-background-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
"integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
"dev": true,
"license": "MIT"
},
"node_modules/css-box-shadow": {
"version": "1.0.0-3",
"resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
"integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
"dev": true,
"license": "MIT"
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/css-gradient-parser": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
"integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz",
@@ -3212,6 +3331,18 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -3464,6 +3595,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/emoji-regex-xs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
"integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/encodeurl": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -3687,6 +3828,17 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/fontverter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz",
"integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"wawoff2": "^2.0.0",
"woff2sfnt-sfnt2woff": "^1.0.0"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -3777,6 +3929,13 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/harfbuzzjs": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.0.tgz",
"integrity": "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==",
"dev": true,
"license": "MIT"
},
"node_modules/hast-util-to-html": { "node_modules/hast-util-to-html": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -3813,6 +3972,19 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hex-rgb": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
"integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -4218,6 +4390,17 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.3", "version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
@@ -4613,6 +4796,24 @@
"@pagefind/windows-x64": "1.5.2" "@pagefind/windows-x64": "1.5.2"
} }
}, },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"dev": true,
"license": "MIT"
},
"node_modules/parse-css-color": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
"integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "^1.1.4",
"hex-rgb": "^4.1.0"
}
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -4707,6 +4908,13 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.10", "version": "3.9.10",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
@@ -5215,6 +5423,37 @@
"sass-formatter": "dist/cli.js" "sass-formatter": "dist/cli.js"
} }
}, },
"node_modules/satori": {
"version": "0.60.0",
"resolved": "https://registry.npmjs.org/satori/-/satori-0.60.0.tgz",
"integrity": "sha512-OSnpxSr166mj2M9i6Cm3kFWYG6i36FnYDfQU0SR8nsZe5zgp/PskLkyfNAqV8phdGkDfQdFHyJZvrtdss3HAVw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"@shuding/opentype.js": "1.4.0-beta.0",
"css-background-parser": "^0.1.0",
"css-box-shadow": "1.0.0-3",
"css-gradient-parser": "^0.0.17",
"css-to-react-native": "^3.0.0",
"emoji-regex-xs": "^2.0.1",
"escape-html": "^1.0.3",
"fflate": "0.7.3",
"harfbuzzjs": "0.10.0",
"linebreak": "^1.1.0",
"parse-css-color": "^0.2.1",
"postcss-value-parser": "^4.2.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/satori/node_modules/fflate": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz",
"integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==",
"dev": true,
"license": "MIT"
},
"node_modules/satteri": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@@ -5302,8 +5541,8 @@
"version": "0.35.5", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -5455,6 +5694,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -5500,6 +5746,54 @@
"anynum": "^1.0.1" "anynum": "^1.0.1"
} }
}, },
"node_modules/subset-font": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.9.0.tgz",
"integrity": "sha512-WJulQeFwbewrXIGcLJbSZFWI44fHW8gNRuPzLywfDL8w38IPRbN1JFxwG76aoohw9kcFdvjVIn2QmID1iy119A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"fontverter": "^2.0.0",
"harfbuzzjs": "^1.6.1",
"p-limit": "^3.1.0"
}
},
"node_modules/subset-font/node_modules/harfbuzzjs": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-1.6.3.tgz",
"integrity": "sha512-R5Mfo1UUvQmZJmtScP7fBLKYKnYu82ZdtFT7WbQYeGb3Ut5PR3xfFCJ5/FjbcW5SAfb9cqCSny/KNjp+NJBx0w==",
"dev": true,
"license": "MIT"
},
"node_modules/subset-font/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/subset-font/node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/suf-log": { "node_modules/suf-log": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz",
@@ -5700,6 +5994,17 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -6321,6 +6626,37 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wawoff2": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz",
"integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"woff2_compress.js": "bin/woff2_compress.js",
"woff2_decompress.js": "bin/woff2_decompress.js"
}
},
"node_modules/woff2sfnt-sfnt2woff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz",
"integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"pako": "^1.0.7"
}
},
"node_modules/woff2sfnt-sfnt2woff/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true,
"license": "(MIT AND Zlib)"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+11 -3
View File
@@ -13,7 +13,10 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"new:post": "node scripts/new-post.mjs", "new:post": "node scripts/new-post.mjs",
"astro": "astro" "astro": "astro",
"icons": "node scripts/generate-icons.mjs",
"check:site": "node scripts/check-site.mjs",
"fonts": "node scripts/subset-fonts.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/markdown-satteri": "^0.4.4", "@astrojs/markdown-satteri": "^0.4.4",
@@ -21,8 +24,6 @@
"@astrojs/node": "^11.1.8", "@astrojs/node": "^11.1.8",
"@astrojs/rss": "^4.0.19", "@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4", "@astrojs/sitemap": "^3.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/rubik": "^5.3.0",
"@iconify-json/tabler": "^1.2.42", "@iconify-json/tabler": "^1.2.42",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"astro": "^7.4.0", "astro": "^7.4.0",
@@ -32,12 +33,19 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/rubik": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/rubik": "^5.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@types/leaflet": "^1.9.22", "@types/leaflet": "^1.9.22",
"pagefind": "^1.5.2", "pagefind": "^1.5.2",
"prettier": "^3.9.10", "prettier": "^3.9.10",
"prettier-plugin-astro": "^1.1.1", "prettier-plugin-astro": "^1.1.1",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"satori": "^0.60.0",
"sharp": "^0.35.5",
"subset-font": "^2.9.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 2.7 KiB

+1 -9
View File
@@ -1,9 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#c95748"/><g transform="translate(4.48 4.48) scale(0.96)"><g fill="none" stroke="#111012" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.4"><path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/><path d="M16.616 13.924a5 5 0 1 0-9.23 0"/><path d="M20.307 15.469a9 9 0 1 0-16.615 0"/><path d="m9 21l3-9l3 9m-5-2h4"/></g></g></svg>
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+75
View File
@@ -0,0 +1,75 @@
// Post-build audit of dist/client: one h1, alt text, SEO tags, unique ids, internal links/anchors.
// Usage: npm run build && npm run check:site
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = 'dist/client';
const SERVER_ROUTES = [/^\/api\//]; // on-demand routes, not files
const walk = (dir) =>
readdirSync(dir).flatMap((f) => {
const p = join(dir, f);
return statSync(p).isDirectory() ? walk(p) : [p];
});
const pages = walk(ROOT).filter((f) => f.endsWith('.html') && !f.includes('/pagefind/'));
const idsByPage = new Map();
const problems = [];
const report = (page, msg) => problems.push(`${page}: ${msg}`);
const urlOf = (file) =>
'/' +
relative(ROOT, file)
.replace(/index\.html$/, '')
.replace(/\.html$/, '');
const html = new Map(pages.map((f) => [urlOf(f), readFileSync(f, 'utf8')]));
for (const [url, src] of html) {
const ids = [...src.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]);
idsByPage.set(url, new Set(ids));
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) report(url, `duplicate id(s): ${[...new Set(dupes)].join(', ')}`);
const h1 = (src.match(/<h1[\s>]/g) ?? []).length;
if (h1 !== 1) report(url, `${h1} <h1> elements`);
if (!/<html[^>]+lang="/.test(src)) report(url, 'missing <html lang>');
if (!/<title>[^<]+<\/title>/.test(src)) report(url, 'missing <title>');
if (url !== '/404' && !/<meta name="description" content="[^"]+"/.test(src))
report(url, 'missing meta description');
if (url !== '/404' && !/<link rel="canonical" href="https:\/\/ta1sph\.info\//.test(src))
report(url, 'missing canonical');
for (const img of src.match(/<img\b[^>]*>/g) ?? []) {
if (!/\salt="/.test(img)) report(url, `img without alt: ${img.slice(0, 80)}`);
}
}
for (const [url, src] of html) {
const refs = [...src.matchAll(/\s(?:href|src)="([^"]+)"/g)].map((m) => m[1]);
for (const ref of refs) {
if (/^(https?:|mailto:|tel:|data:|javascript:)/.test(ref) || ref.startsWith('//')) {
if (!ref.startsWith('https://ta1sph.info')) continue;
}
const target = new URL(ref.replaceAll('&amp;', '&'), `https://ta1sph.info${url}`);
if (target.host !== 'ta1sph.info') continue;
const path = decodeURIComponent(target.pathname);
if (SERVER_ROUTES.some((r) => r.test(path))) continue;
const file = path.endsWith('/')
? join(ROOT, path, 'index.html')
: ([join(ROOT, path), join(ROOT, `${path}.html`)].find(existsSync) ?? join(ROOT, path));
if (!existsSync(file)) {
report(url, `broken link → ${path}`);
continue;
}
if (target.hash && idsByPage.has(path)) {
const id = decodeURIComponent(target.hash.slice(1));
if (!idsByPage.get(path).has(id)) report(url, `missing anchor → ${path}${target.hash}`);
}
}
}
console.log(`Checked ${html.size} pages.`);
if (problems.length) {
console.error(problems.map((p) => ` ✗ ${p}`).join('\n'));
process.exit(1);
}
console.log(' ✓ no problems found');
+76
View File
@@ -0,0 +1,76 @@
// Regenerates the favicon set in public/ from the Tabler antenna-tower icon.
// Usage: npm run icons
import { writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import sharp from 'sharp';
const require = createRequire(import.meta.url);
const { icons } = require('@iconify-json/tabler/icons.json');
const RED = '#c95748';
const INK = '#111012';
const tower = (stroke) =>
icons['building-broadcast-tower'].body
.replaceAll('currentColor', INK)
.replaceAll('stroke-width="2"', `stroke-width="${stroke}"`);
/**
* Square icon: red background, ink tower.
* `pad` = icon inset (0..1), `radius` in viewBox units, `stroke` = Tabler stroke width.
*/
const svg = ({ pad = 0.14, radius = 6, stroke = 2.4 } = {}) => {
const size = 32;
const inner = size * (1 - pad * 2);
const offset = size * pad;
const scale = inner / 24;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" rx="${radius}" fill="${RED}"/><g transform="translate(${offset} ${offset}) scale(${scale})">${tower(stroke)}</g></svg>`;
};
const png = (source, px) =>
sharp(Buffer.from(source), { density: 72 * (px / 32) * 4 })
.resize(px, px)
.png()
.toBuffer();
/** ICO container with embedded PNG images. */
function ico(images) {
const header = Buffer.alloc(6 + images.length * 16);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(images.length, 4);
let offset = header.length;
images.forEach(({ size, data }, i) => {
const e = 6 + i * 16;
header.writeUInt8(size >= 256 ? 0 : size, e);
header.writeUInt8(size >= 256 ? 0 : size, e + 1);
header.writeUInt16LE(1, e + 4); // colour planes
header.writeUInt16LE(32, e + 6); // bits per pixel
header.writeUInt32LE(data.length, e + 8);
header.writeUInt32LE(offset, e + 12);
offset += data.length;
});
return Buffer.concat([header, ...images.map((i) => i.data)]);
}
const rounded = svg();
const tiny = svg({ pad: 0.06, radius: 4, stroke: 3.2 }); // 16 px tab icon: bigger, bolder
const square = svg({ radius: 0 }); // platforms apply their own mask
const maskable = svg({ pad: 0.24, radius: 0 }); // keep the icon inside the 80% safe zone
writeFileSync('public/favicon.svg', rounded);
writeFileSync(
'public/favicon.ico',
ico(
await Promise.all(
[16, 32, 48].map(async (size) => ({
size,
data: await png(size === 16 ? tiny : rounded, size),
})),
),
),
);
writeFileSync('public/apple-touch-icon.png', await png(svg({ pad: 0.18, radius: 0 }), 180));
writeFileSync('public/icon-192.png', await png(square, 192));
writeFileSync('public/icon-512.png', await png(square, 512));
writeFileSync('public/icon-maskable-512.png', await png(maskable, 512));
console.log('Icons written to public/');
+82
View File
@@ -0,0 +1,82 @@
// Builds small self-hosted variable fonts (Latin + Turkish + symbols used in posts) into public/fonts/.
// Sources: @fontsource-variable/{rubik,inter} (SIL OFL 1.1). Usage: npm run fonts
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import subsetFont from 'subset-font';
const require = createRequire(import.meta.url);
const ranges = [
[0x20, 0x7e], // Basic Latin
[0xa0, 0xff], // Latin-1 (ç ö ü · ° × …)
[0x11e, 0x11f], // Ğ ğ
[0x130, 0x131], // İ ı
[0x15e, 0x15f], // Ş ş
[0x394, 0x394], // Δ
[0x3a9, 0x3a9], // Ω
[0x3bb, 0x3bc], // λ μ
[0x2013, 0x2014], // – —
[0x2018, 0x201e], // ‘ ’ ‚ “ ” „
[0x2022, 0x2022], // •
[0x2026, 0x2026], // …
[0x2032, 0x2033], // ′ ″
[0x20ba, 0x20ba], // ₺
[0x2122, 0x2122], // ™
[0x2190, 0x2193], // ← ↑ → ↓
[0x2212, 0x2212], // −
[0x2248, 0x2248], // ≈
[0x2264, 0x2265], // ≤ ≥
];
const text = ranges
.flatMap(([a, b]) => Array.from({ length: b - a + 1 }, (_, i) => String.fromCodePoint(a + i)))
.join('');
// Fontsource ships each family split by unicode-range (latin / latin-ext). Each part is subset
// on its own and keeps Fontsource's unicode-range, so browsers fetch only the parts a page needs.
const families = [
{ pkg: '@fontsource-variable/rubik', family: 'Rubik Variable', slug: 'rubik', wght: [400, 900] },
{ pkg: '@fontsource-variable/inter', family: 'Inter Variable', slug: 'inter', wght: [400, 700] },
];
const parts = ['latin', 'latin-ext'];
/** unicode-range of one Fontsource part, read from the package's own CSS. */
function unicodeRange(pkg, slug, part) {
const css = readFileSync(require.resolve(`${pkg}/index.css`), 'utf8');
const block = css
.split('@font-face')
.find((b) => b.includes(`${slug}-${part}-wght-normal.woff2`));
const m = block?.match(/unicode-range:\s*([^;]+);/);
if (!m) throw new Error(`unicode-range not found for ${slug} ${part}`);
return m[1].trim();
}
mkdirSync('public/fonts', { recursive: true });
const faces = [];
for (const f of families) {
for (const part of parts) {
const source = readFileSync(
require.resolve(`${f.pkg}/files/${f.slug}-${part}-wght-normal.woff2`),
);
const out = await subsetFont(source, text, {
targetFormat: 'woff2',
variationAxes: { wght: { min: f.wght[0], max: f.wght[1] } },
});
const file = `${f.slug}-${part}.woff2`;
writeFileSync(`public/fonts/${file}`, out);
console.log(`${file}: ${source.length} → ${out.length} bytes`);
faces.push(`@font-face {
font-family: '${f.family}';
font-style: normal;
font-display: swap;
font-weight: ${f.wght[0]} ${f.wght[1]};
src: url('/fonts/${file}') format('woff2-variations');
unicode-range: ${unicodeRange(f.pkg, f.slug, part)};
}`);
}
}
writeFileSync(
'src/styles/fonts.css',
`/* Generated by scripts/subset-fonts.mjs — do not edit. Fonts: SIL OFL 1.1 (Rubik, Inter). */\n${faces.join('\n')}\n`,
);
console.log('Wrote src/styles/fonts.css');
+22 -7
View File
@@ -5,9 +5,12 @@ export interface Props {
/** Page title without the site suffix. Omit on the home page. */ /** Page title without the site suffix. Omit on the home page. */
title?: string; title?: string;
description?: string; description?: string;
/** Absolute or root-relative image URL for OG/Twitter. */ /** Absolute or root-relative image URL for OG/Twitter. Defaults to the generated /og-default.png. */
image?: string; image?: string;
imageAlt?: string; imageAlt?: string;
/** Pixel size of `image`, when known. */
imageWidth?: number;
imageHeight?: number;
type?: 'website' | 'article'; type?: 'website' | 'article';
publishedTime?: Date; publishedTime?: Date;
modifiedTime?: Date; modifiedTime?: Date;
@@ -21,6 +24,8 @@ const {
description = site.description, description = site.description,
image, image,
imageAlt, imageAlt,
imageWidth,
imageHeight,
type = 'website', type = 'website',
publishedTime, publishedTime,
modifiedTime, modifiedTime,
@@ -30,7 +35,11 @@ const {
const fullTitle = title ? `${title}${site.titleSeparator}${site.name}` : site.title; const fullTitle = title ? `${title}${site.titleSeparator}${site.name}` : site.title;
const canonical = new URL(Astro.url.pathname, Astro.site ?? site.url).href; const canonical = new URL(Astro.url.pathname, Astro.site ?? site.url).href;
const imageUrl = image ? new URL(image, Astro.site ?? site.url).href : undefined; // Pages without their own image share the default card (1200×630).
const ogImage = image ?? '/og-default.png';
const imageUrl = new URL(ogImage, Astro.site ?? site.url).href;
const [ogWidth, ogHeight] = image ? [imageWidth, imageHeight] : [1200, 630];
const ogAlt = imageAlt ?? (image ? undefined : site.title);
const twitterHandle = social.find((s) => s.id === 'x')?.handle; const twitterHandle = social.find((s) => s.id === 'x')?.handle;
const personId = `${site.url}/#person`; const personId = `${site.url}/#person`;
@@ -61,8 +70,11 @@ const graph = {
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
{noindex ? (
<meta name="robots" content="noindex, nofollow" />
) : (
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />} )}
<meta property="og:type" content={type} /> <meta property="og:type" content={type} />
<meta property="og:site_name" content={site.title} /> <meta property="og:site_name" content={site.title} />
@@ -70,16 +82,19 @@ const graph = {
<meta property="og:title" content={fullTitle} /> <meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
{imageUrl && <meta property="og:image" content={imageUrl} />} <meta property="og:image" content={imageUrl} />
{imageUrl && imageAlt && <meta property="og:image:alt" content={imageAlt} />} {ogWidth && <meta property="og:image:width" content={String(ogWidth)} />}
{ogHeight && <meta property="og:image:height" content={String(ogHeight)} />}
{ogAlt && <meta property="og:image:alt" content={ogAlt} />}
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />} {publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />} {modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} /> <meta name="twitter:card" content="summary_large_image" />
{twitterHandle && <meta name="twitter:site" content={twitterHandle} />} {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
{twitterHandle && <meta name="twitter:creator" content={twitterHandle} />} {twitterHandle && <meta name="twitter:creator" content={twitterHandle} />}
<meta name="twitter:title" content={fullTitle} /> <meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
{imageUrl && <meta name="twitter:image" content={imageUrl} />} <meta name="twitter:image" content={imageUrl} />
{ogAlt && <meta name="twitter:image:alt" content={ogAlt} />}
<script type="application/ld+json" set:html={JSON.stringify(graph)} /> <script type="application/ld+json" set:html={JSON.stringify(graph)} />
+5 -1
View File
@@ -17,10 +17,14 @@ const props = Astro.props;
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<ThemeInit /> <ThemeInit />
<link rel="preload" href="/fonts/rubik-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
<meta name="theme-color" content="#f2efee" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#f2efee" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#111012" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#111012" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="32x32" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="alternate" type="application/rss+xml" title={site.title} href="/rss.xml" /> <link rel="alternate" type="application/rss+xml" title={site.title} href="/rss.xml" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<Seo {...props} /> <Seo {...props} />
+164
View File
@@ -0,0 +1,164 @@
/**
* Build-time Open Graph images (1200×630 PNG) with Satori + Sharp.
* Used by the prerendered /og/<slug>.png and /og-default.png endpoints.
*/
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import satori from 'satori';
import sharp from 'sharp';
import { site } from './data';
const require = createRequire(import.meta.url);
const font = (pkg: string, file: string) => readFileSync(require.resolve(`${pkg}/files/${file}`));
// Satori needs woff/ttf (not woff2). latin-ext (İ, Ş, Ğ…) gets its own family name and is
// listed as a fallback in fontFamily; same-name entries are not used as glyph fallbacks.
const fonts = [
{
name: 'Rubik',
weight: 900 as const,
data: font('@fontsource/rubik', 'rubik-latin-900-normal.woff'),
},
{
name: 'RubikExt',
weight: 900 as const,
data: font('@fontsource/rubik', 'rubik-latin-ext-900-normal.woff'),
},
{
name: 'Rubik',
weight: 700 as const,
data: font('@fontsource/rubik', 'rubik-latin-700-normal.woff'),
},
{
name: 'RubikExt',
weight: 700 as const,
data: font('@fontsource/rubik', 'rubik-latin-ext-700-normal.woff'),
},
{
name: 'Inter',
weight: 600 as const,
data: font('@fontsource/inter', 'inter-latin-600-normal.woff'),
},
{
name: 'InterExt',
weight: 600 as const,
data: font('@fontsource/inter', 'inter-latin-ext-600-normal.woff'),
},
];
// Image-only palette (Huemint Magazine); theme tokens are CSS-only.
const C = {
paper: '#f2efee',
khaki: '#d5d09b',
ink: '#111012',
red: '#c95748',
line: 'rgba(17,16,18,0.18)',
};
function towerIcon(color: string): string {
const { icons, width = 24, height = 24 } = require('@iconify-json/tabler/icons.json');
const body: string = icons['building-broadcast-tower'].body.replaceAll('currentColor', color);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
type Node = { type: string; props: Record<string, unknown> & { children?: unknown } };
const h = (type: string, style: Record<string, unknown>, children?: unknown, extra = {}): Node => ({
type,
props: { style, children, ...extra },
});
export interface OgInput {
title: string;
eyebrow?: string;
}
export async function renderOg({ title, eyebrow }: OgInput): Promise<Buffer> {
const upper = title.toLocaleUpperCase('tr-TR');
const size = upper.length <= 28 ? 96 : upper.length <= 48 ? 78 : 62;
const tree = h(
'div',
{
width: 1200,
height: 630,
display: 'flex',
flexDirection: 'column',
position: 'relative',
backgroundColor: C.khaki,
color: C.ink,
fontFamily: 'Rubik, RubikExt',
},
[
// Magazine grid lines
...[140, 380, 700, 1000].map((x) =>
h('div', {
position: 'absolute',
top: 0,
bottom: 0,
left: x,
width: 2,
backgroundColor: C.line,
}),
),
h(
'div',
{ display: 'flex', flexDirection: 'column', flex: 1, padding: '56px 72px 40px 72px' },
[
h('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, [
h('div', { display: 'flex', alignItems: 'center', gap: 16 }, [
h('img', { width: 56, height: 56 }, undefined, {
src: towerIcon(C.ink),
width: 56,
height: 56,
}),
h('div', { fontSize: 44, fontWeight: 900, letterSpacing: -1 }, site.author.callsign),
]),
eyebrow
? h('div', { display: 'flex', alignItems: 'center', gap: 16 }, [
h(
'div',
{ fontSize: 26, fontWeight: 700, letterSpacing: 1 },
eyebrow.toLocaleUpperCase('tr-TR'),
),
h('div', { width: 64, height: 4, backgroundColor: C.red }),
])
: h('div', {}, ''),
]),
h('div', { display: 'flex', flex: 1, alignItems: 'center' }, [
h(
'div',
{
fontSize: size,
fontWeight: 900,
lineHeight: 0.98,
letterSpacing: -2,
maxWidth: 1040,
},
upper,
),
]),
h(
'div',
{
display: 'flex',
justifyContent: 'space-between',
fontFamily: 'Inter, InterExt',
fontWeight: 600,
fontSize: 26,
},
[h('div', {}, new URL(site.url).host), h('div', {}, `${site.author.locator} · 73`)],
),
],
),
h('div', { height: 16, backgroundColor: C.red }),
],
);
const svg = await satori(tree as Parameters<typeof satori>[0], {
width: 1200,
height: 630,
fonts,
});
return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
}
+10 -6
View File
@@ -37,10 +37,12 @@ const { Content, headings } = await render(post);
const category = categoryOf(post); const category = categoryOf(post);
const url = new URL(postUrl(post), site.url).href; const url = new URL(postUrl(post), site.url).href;
// 1200px OG/Twitter image from the cover (absolute URL in Seo). // Share image: 1200px JPEG of the cover, else the generated /og/<slug>.png card.
const ogImage = cover const coverOg = cover ? await getImage({ src: cover, width: 1200, format: 'jpeg' }) : undefined;
? (await getImage({ src: cover, width: 1200, format: 'jpeg' })).src const ogImage = coverOg?.src ?? `/og/${post.id}.png`;
: undefined; const [ogWidth, ogHeight] = coverOg
? [Number(coverOg.attributes.width), Number(coverOg.attributes.height)]
: [1200, 630];
const crumbs = [ const crumbs = [
{ label: site.ui.home, href: '/' }, { label: site.ui.home, href: '/' },
@@ -64,7 +66,7 @@ const jsonLd = [
publisher: { '@id': `${site.url}/#person` }, publisher: { '@id': `${site.url}/#person` },
articleSection: category.name, articleSection: category.name,
keywords: tags.join(', '), keywords: tags.join(', '),
...(ogImage && { image: new URL(ogImage, site.url).href }), image: new URL(ogImage, site.url).href,
}, },
{ {
'@type': 'BreadcrumbList', '@type': 'BreadcrumbList',
@@ -83,7 +85,9 @@ const jsonLd = [
description={description} description={description}
type="article" type="article"
image={ogImage} image={ogImage}
imageAlt={coverAlt} imageAlt={coverAlt ?? title}
imageWidth={ogWidth}
imageHeight={ogHeight}
publishedTime={pubDate} publishedTime={pubDate}
modifiedTime={updatedDate} modifiedTime={updatedDate}
jsonLd={jsonLd} jsonLd={jsonLd}
+3 -4
View File
@@ -1,4 +1,6 @@
--- ---
// Static import: inlined on this page only (a dynamic CSS import breaks with inlineStylesheets).
import 'leaflet/dist/leaflet.css';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import Container from '../components/Container.astro'; import Container from '../components/Container.astro';
import ExternalLink from '../components/ExternalLink.astro'; import ExternalLink from '../components/ExternalLink.astro';
@@ -116,10 +118,7 @@ const sectionTitle =
const el = document.getElementById('map'); const el = document.getElementById('map');
if (el) { if (el) {
const init = async () => { const init = async () => {
const [{ default: L }] = await Promise.all([ const { default: L } = await import('leaflet');
import('leaflet'),
import('leaflet/dist/leaflet.css'),
]);
const { lat, lng, zoom, popup = '' } = el.dataset; const { lat, lng, zoom, popup = '' } = el.dataset;
const center: [number, number] = [Number(lat), Number(lng)]; const center: [number, number] = [Number(lat), Number(lng)];
el.querySelector('[data-map-loading]')?.remove(); el.querySelector('[data-map-loading]')?.remove();
+9
View File
@@ -0,0 +1,9 @@
import type { APIRoute } from 'astro';
import { home } from '../lib/data';
import { renderOg } from '../lib/og';
// /og-default.png — share image for pages without their own.
export const GET: APIRoute = async () => {
const png = await renderOg({ title: home.hero.title });
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
};
+17
View File
@@ -0,0 +1,17 @@
import type { APIRoute, GetStaticPaths } from 'astro';
import { renderOg } from '../../lib/og';
import { categoryOf, getPosts, type Post } from '../../lib/posts';
// /og/<slug>.png — per-post share image, generated at build time.
export const getStaticPaths = (async () => {
const posts = await getPosts();
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}) satisfies GetStaticPaths;
export const GET: APIRoute<{ post: Post }> = async ({ props }) => {
const png = await renderOg({
title: props.post.data.title,
eyebrow: categoryOf(props.post).name,
});
return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } });
};
+15
View File
@@ -0,0 +1,15 @@
import type { APIRoute } from 'astro';
import { site } from '../lib/data';
export const GET: APIRoute = () =>
new Response(
[
'User-agent: *',
'Allow: /',
'Disallow: /api/',
'',
`Sitemap: ${site.url}/sitemap-index.xml`,
'',
].join('\n'),
{ headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
+22
View File
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { site } from '../lib/data';
export const GET: APIRoute = () =>
Response.json(
{
name: site.title,
short_name: site.name,
description: site.description,
lang: site.lang,
start_url: '/',
display: 'minimal-ui',
background_color: '#f2efee',
theme_color: '#d5d09b',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
{ headers: { 'Content-Type': 'application/manifest+json' } },
);
+43
View File
@@ -0,0 +1,43 @@
/* Generated by scripts/subset-fonts.mjs — do not edit. Fonts: SIL OFL 1.1 (Rubik, Inter). */
@font-face {
font-family: 'Rubik Variable';
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url('/fonts/rubik-latin.woff2') format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Rubik Variable';
font-style: normal;
font-display: swap;
font-weight: 400 900;
src: url('/fonts/rubik-latin-ext.woff2') format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('/fonts/inter-latin.woff2') format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('/fonts/inter-latin-ext.woff2') format('woff2-variations');
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
+1 -2
View File
@@ -1,6 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@fontsource-variable/rubik'; @import './fonts.css'; /* generated: npm run fonts */
@import '@fontsource-variable/inter';
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));