diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 58c2189..05e3c6c 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -1,13 +1,13 @@ # 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. -> 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 -- **Aşama:** 6 tamamlandı (Hakkımda, İletişim, yasal, 404). Sıradaki: **Aşama 7 — Kalite**. -- **Tamamlanan:** Aşama 0–6. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`. -- **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şama:** 7 tamamlandı (kalite). Sıradaki: **Aşama 8 — Deploy hazırlığı**. +- **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 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ı. ## 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/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) +- `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/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//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 | 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 | 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 | 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 | @@ -90,6 +100,7 @@ ## 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 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` diff --git a/astro.config.mjs b/astro.config.mjs index c8c116e..4d9736d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -30,6 +30,8 @@ export default defineConfig({ // Static by default; only routes with `export const prerender = false` (view counter API) run on the server. output: 'static', 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], markdown: { processor: satteri({ hastPlugins: [figurePlugin, externalLinksPlugin] }), diff --git a/package-lock.json b/package-lock.json index c59628d..f24911c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,6 @@ "@astrojs/node": "^11.1.8", "@astrojs/rss": "^4.0.19", "@astrojs/sitemap": "^3.7.4", - "@fontsource-variable/inter": "^5.3.0", - "@fontsource-variable/rubik": "^5.3.0", "@iconify-json/tabler": "^1.2.42", "@tailwindcss/vite": "^4.3.3", "astro": "^7.4.0", @@ -24,12 +22,19 @@ }, "devDependencies": { "@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", "@types/leaflet": "^1.9.22", "pagefind": "^1.5.2", "prettier": "^3.9.10", "prettier-plugin-astro": "^1.1.1", "prettier-plugin-tailwindcss": "^0.8.1", + "satori": "^0.60.0", + "sharp": "^0.35.5", + "subset-font": "^2.9.0", "typescript": "^6.0.3" }, "engines": { @@ -862,6 +867,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -871,6 +877,27 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource-variable/rubik/-/rubik-5.3.0.tgz", "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", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -921,8 +948,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2028,6 +2055,30 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "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": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -2907,6 +2958,13 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -3032,12 +3090,32 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3140,6 +3218,13 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3196,6 +3281,40 @@ "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", @@ -3212,6 +3331,18 @@ "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": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -3464,6 +3595,16 @@ "dev": true, "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3687,6 +3828,17 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -3777,6 +3929,13 @@ "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": { "version": "9.0.5", "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" } }, + "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4218,6 +4390,17 @@ "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": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -4613,6 +4796,24 @@ "@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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -4707,6 +4908,13 @@ "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": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", @@ -5215,6 +5423,37 @@ "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": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", @@ -5302,8 +5541,8 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -5455,6 +5694,13 @@ "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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5500,6 +5746,54 @@ "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": { "version": "2.5.3", "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==", "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": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6321,6 +6626,37 @@ "dev": true, "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": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index 0eaf59a..a3abbf6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "format": "prettier --write .", "format:check": "prettier --check .", "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": { "@astrojs/markdown-satteri": "^0.4.4", @@ -21,8 +24,6 @@ "@astrojs/node": "^11.1.8", "@astrojs/rss": "^4.0.19", "@astrojs/sitemap": "^3.7.4", - "@fontsource-variable/inter": "^5.3.0", - "@fontsource-variable/rubik": "^5.3.0", "@iconify-json/tabler": "^1.2.42", "@tailwindcss/vite": "^4.3.3", "astro": "^7.4.0", @@ -32,12 +33,19 @@ }, "devDependencies": { "@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", "@types/leaflet": "^1.9.22", "pagefind": "^1.5.2", "prettier": "^3.9.10", "prettier-plugin-astro": "^1.1.1", "prettier-plugin-tailwindcss": "^0.8.1", + "satori": "^0.60.0", + "sharp": "^0.35.5", + "subset-font": "^2.9.0", "typescript": "^6.0.3" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..69d7594 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 7f48a94..a63098a 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg index f157bd1..2fa73ae 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,9 +1 @@ - - - - + \ No newline at end of file diff --git a/public/fonts/inter-latin-ext.woff2 b/public/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000..a2079f2 Binary files /dev/null and b/public/fonts/inter-latin-ext.woff2 differ diff --git a/public/fonts/inter-latin.woff2 b/public/fonts/inter-latin.woff2 new file mode 100644 index 0000000..be59e47 Binary files /dev/null and b/public/fonts/inter-latin.woff2 differ diff --git a/public/fonts/rubik-latin-ext.woff2 b/public/fonts/rubik-latin-ext.woff2 new file mode 100644 index 0000000..6c7ca75 Binary files /dev/null and b/public/fonts/rubik-latin-ext.woff2 differ diff --git a/public/fonts/rubik-latin.woff2 b/public/fonts/rubik-latin.woff2 new file mode 100644 index 0000000..0813bb5 Binary files /dev/null and b/public/fonts/rubik-latin.woff2 differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..7b20c09 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..d295f95 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..aec75b1 Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs new file mode 100644 index 0000000..5b6d757 --- /dev/null +++ b/scripts/check-site.mjs @@ -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(/]/g) ?? []).length; + if (h1 !== 1) report(url, `${h1}

elements`); + if (!/]+lang="/.test(src)) report(url, 'missing '); + if (!/[^<]+<\/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('&', '&'), `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'); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..3cd4c26 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -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/'); diff --git a/scripts/subset-fonts.mjs b/scripts/subset-fonts.mjs new file mode 100644 index 0000000..ef5a0df --- /dev/null +++ b/scripts/subset-fonts.mjs @@ -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'); diff --git a/src/components/Seo.astro b/src/components/Seo.astro index c5f0234..2b8e445 100644 --- a/src/components/Seo.astro +++ b/src/components/Seo.astro @@ -5,9 +5,12 @@ export interface Props { /** Page title without the site suffix. Omit on the home page. */ title?: 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; imageAlt?: string; + /** Pixel size of `image`, when known. */ + imageWidth?: number; + imageHeight?: number; type?: 'website' | 'article'; publishedTime?: Date; modifiedTime?: Date; @@ -21,6 +24,8 @@ const { description = site.description, image, imageAlt, + imageWidth, + imageHeight, type = 'website', publishedTime, modifiedTime, @@ -30,7 +35,11 @@ const { const fullTitle = title ? `${title}${site.titleSeparator}${site.name}` : site.title; 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 personId = `${site.url}/#person`; @@ -61,8 +70,11 @@ const graph = { <title>{fullTitle} - -{noindex && } +{noindex ? ( + +) : ( + +)} @@ -70,16 +82,19 @@ const graph = { -{imageUrl && } -{imageUrl && imageAlt && } + +{ogWidth && } +{ogHeight && } +{ogAlt && } {publishedTime && } {modifiedTime && } - + {twitterHandle && } {twitterHandle && } -{imageUrl && } + +{ogAlt && }