feat: add blog with archives, pagination, search, sharing and RSS
- Content collection (one folder per post) with zod schema - /blog, category and tag archives with /sayfa/<n>/ pagination - Post page: breadcrumbs, date badges, share bar, TOC, DIY notice, code copy, license line, prev/next, BlogPosting + BreadcrumbList JSON-LD - Sidebar: Pagefind search, categories, featured fallback, tag cloud - Sätteri HAST plugins for figure/figcaption and external links - Dual-theme Shiki, themed prose, RSS feed, latest posts on home - Three sample posts marked as examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+40
-25
@@ -1,14 +1,14 @@
|
|||||||
# 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 3)
|
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 4)
|
||||||
|
|
||||||
## 1. Durum
|
## 1. Durum
|
||||||
|
|
||||||
- **Aşama:** 3 tamamlandı (ana sayfa). Sıradaki: **Aşama 4 — Blog altyapısı**.
|
- **Aşama:** 4 tamamlandı (blog altyapısı). Sıradaki: **Aşama 5 — Görüntülenme sayacı**.
|
||||||
- **Tamamlanan:** Aşama 0–3. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
|
- **Tamamlanan:** Aşama 0–4. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
|
||||||
- **Açık işler:** Aşama 4–8. Ana sayfa `LatestPosts` şimdilik `hasPosts={false}` (boş durum) → Aşama 4'te PostCard'larla bağlanacak. Footer yasal sayfalar + `/rss.xml` yok (A4/A6). Favicon varsayılan (A7). `LICENSE` dosyaları A6.
|
- **Açık işler:** Aşama 5–8. Sidebar "Öne Çıkan" şimdilik frontmatter `featured` (yoksa en yeni 3) → A5'te API ile doldurulacak (`ol[data-featured]`). Yasal sayfalar yok (A6). Favicon + `og-default.png` yok (A7); kapaksız yazılarda og:image yok. `LICENSE` dosyaları A6. 3 örnek yazı (`örnek` etiketli) kullanıcı isteyince silinecek.
|
||||||
- **Bilinen sorunlar:** —
|
- **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ı.
|
||||||
|
|
||||||
## 2. Ortam
|
## 2. Ortam
|
||||||
|
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
- `src/pages/index.astro` — sections'ı sırayla render eder; arka plan dönüşümlü (hero khaki → paper, khaki…)
|
- `src/pages/index.astro` — sections'ı sırayla render eder; arka plan dönüşümlü (hero khaki → paper, khaki…)
|
||||||
- `src/components/Section.astro` (tam genişlik bölüm + GridLines, `alt` = khaki) · `src/components/home/` — `Hero`, `FeatureSection` (numara + ikon | çağrı işareti/alan adı | başlık+metin+CTA; 3 sütun grid çizgileriyle hizalı), `CardGrid`, `LatestPosts` (slot), `Numeral` (dev rakam `::before` ile → a11y/kontrast dışı)
|
- `src/components/Section.astro` (tam genişlik bölüm + GridLines, `alt` = khaki) · `src/components/home/` — `Hero`, `FeatureSection` (numara + ikon | çağrı işareti/alan adı | başlık+metin+CTA; 3 sütun grid çizgileriyle hizalı), `CardGrid`, `LatestPosts` (slot), `Numeral` (dev rakam `::before` ile → a11y/kontrast dışı)
|
||||||
- `Button` — `external` prop'u (target/rel + sr-only "yeni sekmede açılır")
|
- `Button` — `external` prop'u (target/rel + sr-only "yeni sekmede açılır")
|
||||||
|
- `src/content.config.ts` — `blog` koleksiyonu (glob `**/index.{md,mdx}`, id = klasör adı), zod şeması (kapak varsa `coverAlt` zorunlu)
|
||||||
|
- `src/data/categories.json` (slug/ad/açıklama/ikon) · `src/data/blog.json` (blog arayüz metinleri, paylaşım URL şablonları `{url}`/`{title}`); `data.ts` → `categories`, `blog`, `fmt()`
|
||||||
|
- `src/lib/posts.ts` — `getPosts` (prod'da draft hariç, bilinmeyen kategori build'i kırar), URL yardımcıları, `readingMinutes` (body/200), tag/kategori sayımları, `featuredPosts`, `adjacentPosts`, `chunk`/`pageUrl`/`pageParam` · `src/lib/dates.ts` — `dateBadges`, `formatDate` (tr-TR, Europe/Istanbul), `isoDay`
|
||||||
|
- `src/lib/markdown-plugins.mjs` — Sätteri HAST eklentileri: `figurePlugin` (`` → figure/figcaption), `externalLinksPlugin` (dış link → yeni sekme)
|
||||||
|
- `src/pages/blog/[...page].astro` (/blog/, /blog/sayfa/n/) · `blog/[slug].astro` (detay + BlogPosting/BreadcrumbList JSON-LD, kod kopyala script'i) · `blog/kategori/[category]/[...page].astro` · `blog/etiket/[tag]/[...page].astro` · `src/pages/rss.xml.ts`
|
||||||
|
- `src/components/blog/` — `BlogListing` (liste düzeni + sidebar), `PostCard` (row/stack, kapaksızsa kategori ikonu), `DateBadges`, `Pager`, `Sidebar`, `SearchBox` (Pagefind JS API, özel UI), `ShareBar`, `Toc`, `Breadcrumbs`, `DiyNotice`
|
||||||
|
- `global.css` — `.article-prose` (prose token eşlemesi), Shiki çift tema, `.code-copy`
|
||||||
- `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
|
||||||
@@ -38,32 +45,40 @@
|
|||||||
|
|
||||||
## 4. Kararlar (ADR)
|
## 4. Kararlar (ADR)
|
||||||
|
|
||||||
| Tarih | Karar | Gerekçe |
|
| Tarih | Karar | Gerekçe |
|
||||||
| ---------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
| 2026-10-15 | Statik çıktı + `@astrojs/node` ile sadece `/api/views` dinamik | "Öne çıkan = en çok okunan" için sayaç gerekli; site geri kalanı statik ve hızlı |
|
| 2026-10-15 | Statik çıktı + `@astrojs/node` ile sadece `/api/views` dinamik | "Öne çıkan = en çok okunan" için sayaç gerekli; site geri kalanı statik ve hızlı |
|
||||||
| 2026-10-15 | Sayaç: SQLite (`better-sqlite3`), volume `/app/data` | Harici servis yok, ev sunucusunda basit ve kalıcı |
|
| 2026-10-15 | Sayaç: SQLite (`better-sqlite3`), volume `/app/data` | Harici servis yok, ev sunucusunda basit ve kalıcı |
|
||||||
| 2026-10-15 | Arama: Pagefind | Statik, sunucusuz, Türkçe destekli |
|
| 2026-10-15 | Arama: Pagefind | Statik, sunucusuz, Türkçe destekli |
|
||||||
| 2026-10-15 | Harita: Leaflet + OpenStreetMap | API anahtarı / ücret yok, KVKK dostu |
|
| 2026-10-15 | Harita: Leaflet + OpenStreetMap | API anahtarı / ücret yok, KVKK dostu |
|
||||||
| 2026-10-15 | İkonlar: astro-icon + Tabler | Tutarlı çizgi seti, anten kulesi ve marka ikonları mevcut |
|
| 2026-10-15 | İkonlar: astro-icon + Tabler | Tutarlı çizgi seti, anten kulesi ve marka ikonları mevcut |
|
||||||
| 2026-10-15 | Lisans: Kod MIT, içerik CC BY-SA 4.0 | Kaynak gösterilerek serbest kullanım talebi |
|
| 2026-10-15 | Lisans: Kod MIT, içerik CC BY-SA 4.0 | Kaynak gösterilerek serbest kullanım talebi |
|
||||||
| 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 | Dekoratif dev rakamlar CSS `::before` içeriği | axe/Lighthouse aria-hidden metinde bile kontrast hatası veriyordu |
|
| 2026-10-16 | Astro 7 Markdown = Sätteri; eklentiler `markdown.processor: satteri({ hastPlugins })` | remark/rehype eklentileri bu işlemcide çalışmaz |
|
||||||
| 2026-10-16 | Kuruluş/site tanıtımları yalnızca sitelerin kendi ifadelerinden | Uydurma bilgi yok; Telegram kartlarında açıklama yok (doğrulanmadı) |
|
| 2026-10-16 | Okuma süresi `post.body` kelime sayısından (remark eklentisi yok) | Liste sayfalarında `render()` gerekmez, Sätteri'de remark yok |
|
||||||
| 2026-10-16 | Mobil menü native `popover` | Escape/odak/light-dismiss JS'siz |
|
| 2026-10-16 | Sayfalama `[...page]` + `sayfa/<n>` (Astro `paginate()` değil) | Spec URL'leri `/blog/sayfa/n/`; tek dosyada 1. sayfa + diğerleri |
|
||||||
| 2026-10-16 | Pagefind CLI postbuild (`astro-pagefind` yok) | Daha az bağımlılık; node adapter çıktısı `dist/client` |
|
| 2026-10-16 | Arama UI'ı kendi bileşenimiz (Pagefind JS API), indeks yalnızca `data-pagefind-body` (yazı gövdeleri) | Tasarımla uyum, Türkçe metinler JSON'da |
|
||||||
| 2026-10-16 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
|
| 2026-10-16 | Shiki `github-*-high-contrast`, zemin `--surface` | Kod renkleri AA kontrastı |
|
||||||
| 2026-10-15 | Locator KN41KB99HW → 41.08307 N, 28.91094 E | Harita merkezi |
|
| 2026-10-16 | Dekoratif dev rakamlar CSS `::before` içeriği | axe/Lighthouse aria-hidden metinde bile kontrast hatası veriyordu |
|
||||||
|
| 2026-10-16 | Kuruluş/site tanıtımları yalnızca sitelerin kendi ifadelerinden | Uydurma bilgi yok; Telegram kartlarında açıklama yok (doğrulanmadı) |
|
||||||
|
| 2026-10-16 | Mobil menü native `popover` | Escape/odak/light-dismiss JS'siz |
|
||||||
|
| 2026-10-16 | Pagefind CLI postbuild (`astro-pagefind` yok) | Daha az bağımlılık; node adapter çıktısı `dist/client` |
|
||||||
|
| 2026-10-16 | Skill repoya kopyalanmadı, kullanıcı düzeyinde | Çift kopya senkron riski |
|
||||||
|
| 2026-10-15 | Locator KN41KB99HW → 41.08307 N, 28.91094 E | Harita merkezi |
|
||||||
|
|
||||||
## 5. İçerik Envanteri
|
## 5. İçerik Envanteri
|
||||||
|
|
||||||
| Slug | Başlık | Kategori | Oluşturma | Güncelleme | Durum |
|
| Slug | Başlık | Kategori | Oluşturma | Güncelleme | Durum |
|
||||||
| ---- | ------ | -------- | --------- | ---------- | ----- |
|
| ------------------------------ | ----------------------------------------------------- | -------- | ---------- | ---------- | -------------------------- |
|
||||||
| — | — | — | — | — | — |
|
| dmr-temel-kavramlar | DMR'ye Giriş: Talkgroup, Timeslot ve Color Code | dmr | 2026-10-01 | — | yayında · örnek · featured |
|
||||||
|
| roleler-ve-ctcss-tonu | Röle Kullanırken CTCSS Tonu Neden Gerekli? | roleler | 2026-10-08 | 2026-10-15 | yayında · örnek |
|
||||||
|
| uhf-ground-plane-anten-mantigi | UHF Ground Plane Anten: Çalışma Mantığı ve Boy Hesabı | antenler | 2026-10-14 | — | yayında · örnek · diy |
|
||||||
|
|
||||||
## 6. Değişiklik Günlüğü (en yeni üstte)
|
## 6. Değişiklik Günlüğü (en yeni üstte)
|
||||||
|
|
||||||
|
- 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 A3 | Ana sayfa: hero, 4 kuruluş, Telegram, son yazılar (boş durum), site tanıtımları; axe temiz | `src/data/home.json`, `src/components/home/`, `src/components/Section.astro`, `src/pages/index.astro`, `src/lib/data.ts`
|
- 2026-10-16 | Siteyi Kur A3 | Ana sayfa: hero, 4 kuruluş, Telegram, son yazılar (boş durum), site tanıtımları; axe temiz | `src/data/home.json`, `src/components/home/`, `src/components/Section.astro`, `src/pages/index.astro`, `src/lib/data.ts`
|
||||||
- 2026-10-16 | Siteyi Kur A2 | Token'lar, tema anahtarı, SEO bileşeni, layout, header/footer, temel bileşenler, site/social JSON, dev styleguide; axe temiz (2 tema) | `src/styles/`, `src/components/`, `src/layouts/`, `src/data/`, `src/lib/data.ts`, `src/dev/`, `astro.config.mjs`
|
- 2026-10-16 | Siteyi Kur A2 | Token'lar, tema anahtarı, SEO bileşeni, layout, header/footer, temel bileşenler, site/social JSON, dev styleguide; axe temiz (2 tema) | `src/styles/`, `src/components/`, `src/layouts/`, `src/data/`, `src/lib/data.ts`, `src/dev/`, `astro.config.mjs`
|
||||||
- 2026-10-16 | Siteyi Kur A1 | İskelet: Tailwind 4, mdx, sitemap, node, icon, rss, fontlar, pagefind, prettier, new:post | `astro.config.mjs`, `package.json`, `scripts/`, `src/lib/slug.ts`, `src/styles/`, `README.md`
|
- 2026-10-16 | Siteyi Kur A1 | İskelet: Tailwind 4, mdx, sitemap, node, icon, rss, fontlar, pagefind, prettier, new:post | `astro.config.mjs`, `package.json`, `scripts/`, `src/lib/slug.ts`, `src/styles/`, `README.md`
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import mdx from '@astrojs/mdx';
|
|||||||
import sitemap from '@astrojs/sitemap';
|
import sitemap from '@astrojs/sitemap';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
import icon from 'astro-icon';
|
import icon from 'astro-icon';
|
||||||
|
import { satteri } from '@astrojs/markdown-satteri';
|
||||||
|
import { externalLinksPlugin, figurePlugin } from './src/lib/markdown-plugins.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-only routes (style preview); never part of a production build.
|
* Dev-only routes (style preview); never part of a production build.
|
||||||
@@ -29,6 +31,15 @@ export default defineConfig({
|
|||||||
output: 'static',
|
output: 'static',
|
||||||
adapter: node({ mode: 'standalone' }),
|
adapter: node({ mode: 'standalone' }),
|
||||||
integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes],
|
integrations: [mdx(), sitemap(), icon({ include: { tabler: ['*'] } }), devRoutes],
|
||||||
|
markdown: {
|
||||||
|
processor: satteri({ hastPlugins: [figurePlugin, externalLinksPlugin] }),
|
||||||
|
// Dual themes; colours switch with [data-theme] in global.css.
|
||||||
|
shikiConfig: {
|
||||||
|
themes: { light: 'github-light-high-contrast', dark: 'github-dark-high-contrast' },
|
||||||
|
defaultColor: false,
|
||||||
|
wrap: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
// Shared layout for /blog/, category and tag archives (all paginated).
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { blog, fmt } from '../../lib/data';
|
||||||
|
import type { Post } from '../../lib/posts';
|
||||||
|
import Container from '../Container.astro';
|
||||||
|
import GridLines from '../GridLines.astro';
|
||||||
|
import SectionHeading from '../SectionHeading.astro';
|
||||||
|
import Pager from './Pager.astro';
|
||||||
|
import PostCard from './PostCard.astro';
|
||||||
|
import Sidebar from './Sidebar.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Posts on this page. */
|
||||||
|
posts: Post[];
|
||||||
|
/** All published posts, for the sidebar. */
|
||||||
|
allPosts: Post[];
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
base: string;
|
||||||
|
activeCategory?: string;
|
||||||
|
activeTag?: string;
|
||||||
|
jsonLd?: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
posts,
|
||||||
|
allPosts,
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
base,
|
||||||
|
activeCategory,
|
||||||
|
activeTag,
|
||||||
|
jsonLd,
|
||||||
|
} = Astro.props;
|
||||||
|
const seoTitle = current > 1 ? fmt(blog.list.pageTitle, { title, n: current }) : title;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={seoTitle} description={description} jsonLd={jsonLd}>
|
||||||
|
<section class="surface-alt relative overflow-hidden">
|
||||||
|
<GridLines />
|
||||||
|
<Container class="relative py-14 md:py-20">
|
||||||
|
<SectionHeading level={1} eyebrow={eyebrow} title={title} />
|
||||||
|
<p class="mt-4 max-w-2xl text-lg">{description}</p>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Container class="grid gap-12 py-14 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
|
<div>
|
||||||
|
{posts.length ? (
|
||||||
|
<ul class="flex flex-col gap-8">
|
||||||
|
{posts.map((post, i) => (
|
||||||
|
<li>
|
||||||
|
<PostCard post={post} priority={i === 0} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p class="border-fg/30 border-2 border-dashed p-8 text-center text-lg">{blog.list.empty}</p>
|
||||||
|
)}
|
||||||
|
<Pager current={current} total={total} base={base} />
|
||||||
|
</div>
|
||||||
|
<Sidebar posts={allPosts} activeCategory={activeCategory} activeTag={activeTag} />
|
||||||
|
</Container>
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { blog } from '../../lib/data';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/** Last item is the current page (no href needed). */
|
||||||
|
items: { label: string; href?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav aria-label={blog.post.breadcrumbLabel} class="text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li class="flex items-center gap-1">
|
||||||
|
{i > 0 && (
|
||||||
|
<Icon name="tabler:chevron-right" class="text-fg-muted size-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{i === items.length - 1 ? (
|
||||||
|
<span aria-current="page" class="text-fg-muted line-clamp-1">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="decoration-accent font-semibold underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
import { blog, fmt } from '../../lib/data';
|
||||||
|
import { dateBadges, formatDate, isoDay, type DateBadge } from '../../lib/dates';
|
||||||
|
import Badge from '../Badge.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
pubDate: Date;
|
||||||
|
updatedDate?: Date;
|
||||||
|
/** Reading time in minutes, shown after the dates. */
|
||||||
|
minutes?: number;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pubDate, updatedDate, minutes, class: className } = Astro.props;
|
||||||
|
const { primary, secondary } = dateBadges(pubDate, updatedDate);
|
||||||
|
|
||||||
|
const meta = (b: DateBadge) =>
|
||||||
|
b.kind === 'updated'
|
||||||
|
? { label: blog.dates.updated, icon: 'tabler:refresh' }
|
||||||
|
: { label: blog.dates.created, icon: 'tabler:calendar-plus' };
|
||||||
|
const p = meta(primary);
|
||||||
|
const s = secondary && meta(secondary);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={['flex flex-wrap items-center gap-2', className]}>
|
||||||
|
<Badge variant="solid" icon={p.icon}>
|
||||||
|
{p.label}
|
||||||
|
<time datetime={isoDay(primary.date)}>{formatDate(primary.date)}</time>
|
||||||
|
</Badge>
|
||||||
|
{secondary && s && (
|
||||||
|
<Badge variant="outline" icon={s.icon}>
|
||||||
|
{s.label}
|
||||||
|
<time datetime={isoDay(secondary.date)}>{formatDate(secondary.date)}</time>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{minutes && (
|
||||||
|
<Badge variant="outline" icon="tabler:clock">
|
||||||
|
{fmt(blog.card.readingTime, { n: minutes })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { blog } from '../../lib/data';
|
||||||
|
---
|
||||||
|
|
||||||
|
<aside
|
||||||
|
role="note"
|
||||||
|
aria-labelledby="diy-title"
|
||||||
|
class="border-accent bg-surface flex gap-4 border-l-4 p-5"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:alert-triangle" class="text-accent-text size-7 shrink-0" aria-hidden="true" />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p id="diy-title" class="font-display font-bold tracking-wide uppercase">
|
||||||
|
{blog.diy.title}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">{blog.diy.body}</p>
|
||||||
|
<a
|
||||||
|
href={blog.diy.link.href}
|
||||||
|
class="decoration-accent text-sm font-semibold underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{blog.diy.link.label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { blog, fmt } from '../../lib/data';
|
||||||
|
import { pageUrl } from '../../lib/posts';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
/** URL of page 1, e.g. `/blog/`. */
|
||||||
|
base: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current, total, base } = Astro.props;
|
||||||
|
|
||||||
|
// 1 … (current-1) current (current+1) … total
|
||||||
|
const numbers: (number | 'gap')[] = [];
|
||||||
|
for (let n = 1; n <= total; n++) {
|
||||||
|
if (n === 1 || n === total || Math.abs(n - current) <= 1) numbers.push(n);
|
||||||
|
else if (numbers.at(-1) !== 'gap') numbers.push('gap');
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = [
|
||||||
|
{ label: blog.pager.first, icon: 'tabler:chevrons-left', page: 1, enabled: current > 1 },
|
||||||
|
{ label: blog.pager.prev, icon: 'tabler:chevron-left', page: current - 1, enabled: current > 1 },
|
||||||
|
];
|
||||||
|
const after = [
|
||||||
|
{
|
||||||
|
label: blog.pager.next,
|
||||||
|
icon: 'tabler:chevron-right',
|
||||||
|
page: current + 1,
|
||||||
|
enabled: current < total,
|
||||||
|
},
|
||||||
|
{ label: blog.pager.last, icon: 'tabler:chevrons-right', page: total, enabled: current < total },
|
||||||
|
];
|
||||||
|
const item =
|
||||||
|
'font-display inline-flex h-10 min-w-10 items-center justify-center gap-1 border px-3 text-sm font-bold';
|
||||||
|
---
|
||||||
|
|
||||||
|
{total > 1 && (
|
||||||
|
<nav aria-label={blog.pager.label} class="mt-12 flex justify-center">
|
||||||
|
<ul class="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
{controls.map((c) => (
|
||||||
|
<li>
|
||||||
|
{c.enabled ? (
|
||||||
|
<a href={pageUrl(base, c.page)} class:list={[item, 'border-line hover:border-fg']}>
|
||||||
|
{c.icon.includes('chevrons') ? null : (
|
||||||
|
<Icon name={c.icon} class="size-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{c.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-disabled="true"
|
||||||
|
class:list={[item, 'border-line text-fg-muted cursor-not-allowed border-dashed']}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{numbers.map((n) => (
|
||||||
|
<li>
|
||||||
|
{n === 'gap' ? (
|
||||||
|
<span class="px-1" aria-hidden="true">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : n === current ? (
|
||||||
|
<span aria-current="page" class:list={[item, 'bg-fg text-bg border-fg']}>
|
||||||
|
<span class="sr-only">{fmt(blog.pager.page, { n })}</span>
|
||||||
|
<span aria-hidden="true">{n}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a href={pageUrl(base, n)} class:list={[item, 'border-line hover:border-fg']}>
|
||||||
|
<span class="sr-only">{fmt(blog.pager.page, { n })}</span>
|
||||||
|
<span aria-hidden="true">{n}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{after.map((c) => (
|
||||||
|
<li>
|
||||||
|
{c.enabled ? (
|
||||||
|
<a href={pageUrl(base, c.page)} class:list={[item, 'border-line hover:border-fg']}>
|
||||||
|
{c.label}
|
||||||
|
{c.icon.includes('chevrons') ? null : (
|
||||||
|
<Icon name={c.icon} class="size-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-disabled="true"
|
||||||
|
class:list={[item, 'border-line text-fg-muted cursor-not-allowed border-dashed']}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
import { Image } from 'astro:assets';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { categoryOf, categoryUrl, postUrl, readingMinutes, type Post } from '../../lib/posts';
|
||||||
|
import Badge from '../Badge.astro';
|
||||||
|
import Card from '../Card.astro';
|
||||||
|
import DateBadges from './DateBadges.astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
post: Post;
|
||||||
|
/** `row`: blog list (cover beside text on md+); `stack`: grid card. */
|
||||||
|
layout?: 'row' | 'stack';
|
||||||
|
headingLevel?: 2 | 3;
|
||||||
|
/** Eager-load the cover (first card above the fold). */
|
||||||
|
priority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { post, layout = 'row', headingLevel = 2, priority = false } = Astro.props;
|
||||||
|
const { title, description, cover, coverAlt, pubDate, updatedDate } = post.data;
|
||||||
|
const category = categoryOf(post);
|
||||||
|
const Heading = `h${headingLevel}` as 'h2' | 'h3';
|
||||||
|
const row = layout === 'row';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Card
|
||||||
|
interactive
|
||||||
|
class:list={['w-full', row && 'md:grid md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]']}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class:list={['bg-bg-alt relative aspect-[1200/630] overflow-hidden', row && 'md:aspect-auto']}
|
||||||
|
>
|
||||||
|
{cover ? (
|
||||||
|
<Image
|
||||||
|
src={cover}
|
||||||
|
alt={coverAlt ?? ''}
|
||||||
|
widths={[400, 640, 960]}
|
||||||
|
sizes={row ? '(min-width: 768px) 320px, 100vw' : '(min-width: 1024px) 400px, 100vw'}
|
||||||
|
loading={priority ? 'eager' : 'lazy'}
|
||||||
|
fetchpriority={priority ? 'high' : 'auto'}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="flex h-full min-h-40 items-center justify-center" aria-hidden="true">
|
||||||
|
<Icon name={category.icon} class="text-fg/40 size-16" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-3 p-6">
|
||||||
|
<Badge variant="category" href={categoryUrl(category.slug)} class="relative z-10">
|
||||||
|
{category.name}
|
||||||
|
</Badge>
|
||||||
|
<Heading class:list={['leading-tight font-bold', row ? 'text-2xl' : 'text-xl']}>
|
||||||
|
<a href={postUrl(post)} class="after:absolute after:inset-0 hover:underline">
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
</Heading>
|
||||||
|
<p class="text-fg-muted">{description}</p>
|
||||||
|
<DateBadges
|
||||||
|
pubDate={pubDate}
|
||||||
|
updatedDate={updatedDate}
|
||||||
|
minutes={readingMinutes(post)}
|
||||||
|
class="mt-auto pt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
// Pagefind search (index is built after `astro build`; unavailable in `astro dev`).
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { blog } from '../../lib/data';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-search
|
||||||
|
data-no-results={blog.sidebar.searchNoResults}
|
||||||
|
data-results={blog.sidebar.searchResults}
|
||||||
|
data-unavailable={blog.sidebar.searchUnavailable}
|
||||||
|
>
|
||||||
|
<form role="search" class="relative">
|
||||||
|
<label for="search-input" class="sr-only">
|
||||||
|
{blog.sidebar.searchLabel}
|
||||||
|
</label>
|
||||||
|
<Icon
|
||||||
|
name="tabler:search"
|
||||||
|
class="text-fg-muted pointer-events-none absolute top-1/2 left-3 size-5 -translate-y-1/2"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="search-input"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={blog.sidebar.searchPlaceholder}
|
||||||
|
class="border-fg bg-surface placeholder:text-fg-muted w-full border py-2.5 pr-3 pl-10"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<p data-search-status aria-live="polite" class="text-fg-muted mt-2 text-sm empty:hidden"></p>
|
||||||
|
<ul data-search-results class="mt-3 flex flex-col gap-3 empty:hidden"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
type PagefindResult = {
|
||||||
|
url: string;
|
||||||
|
excerpt: string;
|
||||||
|
meta: { title?: string };
|
||||||
|
};
|
||||||
|
type Pagefind = {
|
||||||
|
options: (o: Record<string, unknown>) => Promise<void>;
|
||||||
|
debouncedSearch: (
|
||||||
|
q: string,
|
||||||
|
o?: unknown,
|
||||||
|
ms?: number,
|
||||||
|
) => Promise<{ results: { data: () => Promise<PagefindResult> }[] } | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const root of document.querySelectorAll<HTMLElement>('[data-search]')) {
|
||||||
|
const input = root.querySelector('input')!;
|
||||||
|
root.querySelector('form')!.addEventListener('submit', (e) => e.preventDefault());
|
||||||
|
const status = root.querySelector<HTMLElement>('[data-search-status]')!;
|
||||||
|
const list = root.querySelector<HTMLUListElement>('[data-search-results]')!;
|
||||||
|
let pagefind: Pagefind | null | undefined;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (pagefind !== undefined) return pagefind;
|
||||||
|
try {
|
||||||
|
const url = '/pagefind/pagefind.js';
|
||||||
|
pagefind = (await import(/* @vite-ignore */ url)) as Pagefind;
|
||||||
|
await pagefind.options({ excerptLength: 20 });
|
||||||
|
} catch {
|
||||||
|
pagefind = null;
|
||||||
|
}
|
||||||
|
return pagefind;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('focus', () => void load(), { once: true });
|
||||||
|
input.addEventListener('input', async () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
const pf = await load();
|
||||||
|
if (!pf) {
|
||||||
|
status.textContent = root.dataset.unavailable ?? '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const search = await pf.debouncedSearch(q, {}, 250);
|
||||||
|
if (search === null) return; // superseded by a newer keystroke
|
||||||
|
list.replaceChildren();
|
||||||
|
if (!q) {
|
||||||
|
status.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = await Promise.all(search.results.slice(0, 6).map((r) => r.data()));
|
||||||
|
status.textContent = items.length
|
||||||
|
? (root.dataset.results ?? '').replace('{n}', String(search.results.length))
|
||||||
|
: (root.dataset.noResults ?? '');
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = item.url;
|
||||||
|
a.className = 'font-bold hover:underline';
|
||||||
|
a.textContent = item.meta.title ?? item.url;
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'text-fg-muted text-sm [&_mark]:bg-khaki [&_mark]:text-ink';
|
||||||
|
p.innerHTML = item.excerpt; // Pagefind escapes content; only <mark> is HTML
|
||||||
|
li.append(a, p);
|
||||||
|
list.append(li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { blog, site } from '../../lib/data';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, title } = Astro.props;
|
||||||
|
const fill = (tpl: string) =>
|
||||||
|
tpl.replace('{url}', encodeURIComponent(url)).replace('{title}', encodeURIComponent(title));
|
||||||
|
const btn =
|
||||||
|
'border-line hover:border-fg hover:bg-fg hover:text-bg inline-flex size-11 cursor-pointer items-center justify-center border transition-colors';
|
||||||
|
---
|
||||||
|
|
||||||
|
<div data-share data-url={url} data-title={title} data-copied={blog.post.copied}>
|
||||||
|
<p class="font-display mb-3 text-sm font-bold tracking-wide uppercase" id="share-label">
|
||||||
|
{blog.post.shareLabel}
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-wrap gap-2" aria-labelledby="share-label">
|
||||||
|
{blog.share.map((s) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={fill(s.url)}
|
||||||
|
target={s.id === 'email' ? undefined : '_blank'}
|
||||||
|
rel={s.id === 'email' ? undefined : 'noopener noreferrer'}
|
||||||
|
title={s.label}
|
||||||
|
class={btn}
|
||||||
|
>
|
||||||
|
<Icon name={s.icon} class="size-5" aria-hidden="true" />
|
||||||
|
<span class="sr-only">
|
||||||
|
{s.label}
|
||||||
|
{s.id !== 'email' && ` ${site.ui.newTab}`}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<button type="button" data-share-copy title={blog.post.copyLink} class={btn}>
|
||||||
|
<Icon name="tabler:link" class="size-5" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{blog.post.copyLink}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li hidden data-share-native-item>
|
||||||
|
<button type="button" data-share-native title={blog.post.nativeShare} class={btn}>
|
||||||
|
<Icon name="tabler:share" class="size-5" aria-hidden="true" />
|
||||||
|
<span class="sr-only">{blog.post.nativeShare}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
data-share-status
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="text-accent-text mt-2 min-h-5 text-sm font-semibold"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
for (const root of document.querySelectorAll<HTMLElement>('[data-share]')) {
|
||||||
|
const { url = location.href, title = document.title, copied = '' } = root.dataset;
|
||||||
|
const status = root.querySelector<HTMLElement>('[data-share-status]')!;
|
||||||
|
|
||||||
|
root.querySelector('[data-share-copy]')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
status.textContent = copied;
|
||||||
|
setTimeout(() => (status.textContent = ''), 2500);
|
||||||
|
} catch {
|
||||||
|
prompt('', url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof navigator.share === 'function') {
|
||||||
|
root.querySelector<HTMLElement>('[data-share-native-item]')!.hidden = false;
|
||||||
|
root.querySelector('[data-share-native]')!.addEventListener('click', () => {
|
||||||
|
navigator.share({ url, title }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
import { blog } from '../../lib/data';
|
||||||
|
import {
|
||||||
|
categoryCounts,
|
||||||
|
categoryUrl,
|
||||||
|
featuredPosts,
|
||||||
|
postUrl,
|
||||||
|
tagCounts,
|
||||||
|
tagUrl,
|
||||||
|
type Post,
|
||||||
|
} from '../../lib/posts';
|
||||||
|
import SearchBox from './SearchBox.astro';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/** All published posts (not just the current page). */
|
||||||
|
posts: Post[];
|
||||||
|
/** Highlights the current category in the list. */
|
||||||
|
activeCategory?: string;
|
||||||
|
activeTag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { posts, activeCategory, activeTag } = Astro.props;
|
||||||
|
const cats = categoryCounts(posts);
|
||||||
|
const featured = featuredPosts(posts);
|
||||||
|
const tags = tagCounts(posts);
|
||||||
|
const max = Math.max(1, ...tags.map((t) => t.count));
|
||||||
|
// 4 size steps by relative frequency.
|
||||||
|
const tagSize = (count: number) =>
|
||||||
|
['text-sm', 'text-base', 'text-lg', 'text-xl'][Math.min(3, Math.floor((count / max) * 3.999))];
|
||||||
|
const box = 'border-line bg-surface border p-5';
|
||||||
|
const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold tracking-wide uppercase';
|
||||||
|
---
|
||||||
|
|
||||||
|
<aside aria-label={blog.sidebar.label} class="flex flex-col gap-6">
|
||||||
|
<section class={box} aria-labelledby="sb-search">
|
||||||
|
<h2 id="sb-search" class={title}>
|
||||||
|
{blog.sidebar.search}
|
||||||
|
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
|
||||||
|
</h2>
|
||||||
|
<SearchBox />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{cats.length > 0 && (
|
||||||
|
<section class={box} aria-labelledby="sb-categories">
|
||||||
|
<h2 id="sb-categories" class={title}>
|
||||||
|
{blog.sidebar.categories}
|
||||||
|
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
{cats.map(({ category, count }) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={categoryUrl(category.slug)}
|
||||||
|
aria-current={activeCategory === category.slug ? 'page' : undefined}
|
||||||
|
class="border-line hover:text-accent-text aria-[current=page]:text-accent-text flex items-center gap-2 border-b py-2 aria-[current=page]:font-bold"
|
||||||
|
>
|
||||||
|
<Icon name={category.icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="flex-1">{category.name}</span>
|
||||||
|
<span class="font-display text-fg-muted text-sm font-bold">{count}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{featured.length > 0 && (
|
||||||
|
<section class={box} aria-labelledby="sb-featured">
|
||||||
|
<h2 id="sb-featured" class={title}>
|
||||||
|
{blog.sidebar.featured}
|
||||||
|
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
|
||||||
|
</h2>
|
||||||
|
<ol data-featured class="flex flex-col gap-4">
|
||||||
|
{featured.map((post, i) => (
|
||||||
|
<li class="flex gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="font-display text-accent-text text-2xl leading-none font-black"
|
||||||
|
>
|
||||||
|
{String(i + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<a href={postUrl(post)} class="leading-snug font-semibold hover:underline">
|
||||||
|
{post.data.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<section class={box} aria-labelledby="sb-tags">
|
||||||
|
<h2 id="sb-tags" class={title}>
|
||||||
|
{blog.sidebar.tags}
|
||||||
|
<span aria-hidden="true" class="bg-accent h-0.5 w-8"></span>
|
||||||
|
</h2>
|
||||||
|
<ul class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={tagUrl(t.tag)}
|
||||||
|
aria-current={activeTag === t.slug ? 'page' : undefined}
|
||||||
|
class:list={[
|
||||||
|
'hover:text-accent-text aria-[current=page]:text-accent-text font-semibold hover:underline',
|
||||||
|
tagSize(t.count),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
#{t.tag}
|
||||||
|
<span class="sr-only">({t.count})</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import type { MarkdownHeading } from 'astro';
|
||||||
|
import { blog } from '../../lib/data';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
headings: MarkdownHeading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Astro.props.headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||||
|
---
|
||||||
|
|
||||||
|
{items.length >= 2 && (
|
||||||
|
<details open class="border-line bg-surface group border p-5">
|
||||||
|
<summary class="font-display cursor-pointer text-sm font-bold tracking-wide uppercase">
|
||||||
|
{blog.post.toc}
|
||||||
|
</summary>
|
||||||
|
<nav aria-label={blog.post.toc} class="mt-4">
|
||||||
|
<ol class="flex flex-col gap-2">
|
||||||
|
{items.map((h) => (
|
||||||
|
<li class:list={[h.depth === 3 && 'pl-5 text-sm']}>
|
||||||
|
<a href={`#${h.slug}`} class="decoration-accent underline-offset-4 hover:underline">
|
||||||
|
{h.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
import { z } from 'astro/zod';
|
||||||
|
|
||||||
|
// One folder per post: src/content/blog/<slug>/index.md(x) + its images.
|
||||||
|
const blog = defineCollection({
|
||||||
|
loader: glob({
|
||||||
|
pattern: '**/index.{md,mdx}',
|
||||||
|
base: './src/content/blog',
|
||||||
|
generateId: ({ entry }) => entry.replace(/\/index\.mdx?$/, ''),
|
||||||
|
}),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
title: z.string().max(70),
|
||||||
|
description: z.string().min(50).max(160),
|
||||||
|
pubDate: z.coerce.date(),
|
||||||
|
updatedDate: z.coerce.date().optional(),
|
||||||
|
category: z.string(), // must exist in src/data/categories.json (checked in lib/posts.ts)
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
cover: image().optional(),
|
||||||
|
coverAlt: z.string().optional(),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
diy: z.boolean().default(false),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.refine((d) => !d.cover || !!d.coverAlt, {
|
||||||
|
message: 'coverAlt is required when cover is set',
|
||||||
|
path: ['coverAlt'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { blog };
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
title: "DMR'ye Giriş: Talkgroup, Timeslot ve Color Code"
|
||||||
|
description: "DMR'de talkgroup, timeslot, color code ve codeplug kavramlarına kısa bir giriş. Dijital sese yeni başlayanlar için temel terimler."
|
||||||
|
pubDate: 2026-10-01
|
||||||
|
category: 'dmr'
|
||||||
|
tags: ['dmr', 'talkgroup', 'codeplug', 'örnek']
|
||||||
|
featured: true
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Not:** Bu bir **örnek yazıdır**; sitenin blog altyapısını göstermek için eklenmiştir ve daha sonra kaldırılabilir.
|
||||||
|
|
||||||
|
DMR (Digital Mobile Radio), ETSI tarafından standartlaştırılmış dijital bir telsiz haberleşme sistemidir. Analog FM'den farklı olarak sesi sayısal veriye çevirir ve aynı kanalı zaman paylaşımıyla iki konuşmaya böler.
|
||||||
|
|
||||||
|
## Timeslot (Zaman Dilimi)
|
||||||
|
|
||||||
|
DMR, TDMA (Time Division Multiple Access) kullanır. 12,5 kHz'lik bir kanal **iki zaman dilimine** (TS1 ve TS2) ayrılır; böylece aynı frekans üzerinden iki ayrı konuşma aynı anda taşınabilir.
|
||||||
|
|
||||||
|
## Talkgroup
|
||||||
|
|
||||||
|
Talkgroup (TG), bir grup kullanıcının ortak olarak dinlediği sanal bir "konuşma odası"dır. Cihazınızda bir TG seçtiğinizde, yalnızca o gruba yapılan çağrıları duyarsınız.
|
||||||
|
|
||||||
|
| Kavram | Kısaca |
|
||||||
|
| ------------ | ---------------------------------- |
|
||||||
|
| Talkgroup | Grup çağrısının hedefi |
|
||||||
|
| Private Call | Tek bir DMR ID'ye yapılan çağrı |
|
||||||
|
| Timeslot | Kanalın iki zaman diliminden biri |
|
||||||
|
| Color Code | Röle/kanal ayırt edici kodu (0–15) |
|
||||||
|
|
||||||
|
## Color Code
|
||||||
|
|
||||||
|
Color Code, analogdaki CTCSS tonuna benzer bir işlev görür: cihaz yalnızca doğru color code ile gelen sinyali çözer. Röleye erişirken rölenin kullandığı color code ile aynı değeri ayarlamanız gerekir.
|
||||||
|
|
||||||
|
## Codeplug
|
||||||
|
|
||||||
|
Codeplug, cihazınızın kanal, kontak (talkgroup listesi), zone ve tarama ayarlarını içeren yapılandırma dosyasıdır. Çoğu cihazda üreticinin programlama yazılımıyla hazırlanır ve kablo ile cihaza yüklenir.
|
||||||
|
|
||||||
|
## Sonuç
|
||||||
|
|
||||||
|
DMR'ye başlarken bu dört kavramı oturtmak, codeplug hazırlamayı ve röle kullanımını çok kolaylaştırır.
|
||||||
|
|
||||||
|
73 de TA1SPH
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: 'Röle Kullanırken CTCSS Tonu Neden Gerekli?'
|
||||||
|
description: 'Analog röleye erişimde CTCSS (alt ses tonu) ne işe yarar, Tx ve Rx tonu arasındaki fark nedir? Kısa ve pratik bir açıklama.'
|
||||||
|
pubDate: 2026-10-08
|
||||||
|
updatedDate: 2026-10-15
|
||||||
|
category: 'roleler'
|
||||||
|
tags: ['röle', 'ctcss', 'uhf', 'örnek']
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Not:** Bu bir **örnek yazıdır**; sitenin blog altyapısını göstermek için eklenmiştir ve daha sonra kaldırılabilir.
|
||||||
|
|
||||||
|
Birçok analog röle, yalnızca belirli bir **alt ses tonu** (CTCSS) taşıyan sinyallere yanıt verir. Bu sayede aynı frekansı paylaşan istasyonlar ya da parazitler röleyi gereksiz yere açmaz.
|
||||||
|
|
||||||
|
## CTCSS nedir?
|
||||||
|
|
||||||
|
CTCSS (Continuous Tone-Coded Squelch System), konuşmanın altına eklenen ve kulağın pek fark etmediği düşük frekanslı bir tondur (yaklaşık 67–254 Hz aralığında standart değerler). Röle bu tonu duymadan susturucusunu (squelch) açmaz.
|
||||||
|
|
||||||
|
## Tx tonu ve Rx tonu
|
||||||
|
|
||||||
|
- **Tx tonu:** Sizin gönderdiğiniz ton. Röleye erişmek için genellikle bu gereklidir.
|
||||||
|
- **Rx tonu:** Cihazınızın yalnızca bu tonu taşıyan yayınları duyurmasını sağlar. Zorunlu değildir; ama röle de ton gönderiyorsa gürültüyü azaltır.
|
||||||
|
|
||||||
|
Röle listelerinde "Tx CTCSS 88.5 Hz" gibi bir ifade, cihazınızın gönderirken 88,5 Hz tonu eklemesi gerektiği anlamına gelir.
|
||||||
|
|
||||||
|
## Sık yapılan hata
|
||||||
|
|
||||||
|
Röleyi açamıyorsanız önce şunları kontrol edin:
|
||||||
|
|
||||||
|
1. Frekans ve kayma (shift/offset) doğru mu?
|
||||||
|
2. Tx CTCSS tonu açık mı ve değeri doğru mu?
|
||||||
|
3. Cihaz yanlışlıkla DCS moduna mı ayarlı?
|
||||||
|
|
||||||
|
## Sonuç
|
||||||
|
|
||||||
|
Doğru ton ayarı, röleye sorunsuz erişimin en basit ama en çok atlanan adımıdır.
|
||||||
|
|
||||||
|
73 de TA1SPH
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: 'UHF Ground Plane Anten: Çalışma Mantığı ve Boy Hesabı'
|
||||||
|
description: 'Çeyrek dalga ground plane antenin çalışma mantığı, radyallerin görevi ve UHF için yaklaşık eleman boyunun nasıl hesaplandığı.'
|
||||||
|
pubDate: 2026-10-14
|
||||||
|
category: 'antenler'
|
||||||
|
tags: ['anten', 'uhf', 'ground plane', 'diy', 'örnek']
|
||||||
|
diy: true
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Not:** Bu bir **örnek yazıdır**; sitenin blog altyapısını göstermek için eklenmiştir ve daha sonra kaldırılabilir.
|
||||||
|
|
||||||
|
Ground plane, yapımı en kolay dikey antenlerden biridir: bir adet çeyrek dalga boyunda dikey eleman ve onun altında genellikle dört adet radyal.
|
||||||
|
|
||||||
|
## Çalışma mantığı
|
||||||
|
|
||||||
|
Dikey eleman ışıyan kısımdır. Radyaller ise yapay bir "toprak düzlemi" oluşturur. Radyallere aşağı doğru yaklaşık 45° eğim verildiğinde antenin besleme empedansı 50 Ω'a yaklaşır; yatay radyallerde bu değer daha düşüktür.
|
||||||
|
|
||||||
|
## Boy hesabı
|
||||||
|
|
||||||
|
Serbest uzayda dalga boyu `λ = 300 / f(MHz)` metredir. Çeyrek dalga için bunun dörtte birini alır, iletkenin kısalma etkisi için yaklaşık %5 düşeriz:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def quarter_wave_cm(freq_mhz: float, k: float = 0.95) -> float:
|
||||||
|
"""Approximate quarter-wave element length in centimetres."""
|
||||||
|
return 300 / freq_mhz / 4 * k * 100
|
||||||
|
|
||||||
|
print(round(quarter_wave_cm(435.0), 1)) # ≈ 16.4 cm
|
||||||
|
```
|
||||||
|
|
||||||
|
Bu değer bir **başlangıç noktasıdır**; son ayar SWR ölçerek, elemanı azar azar kısaltarak yapılır.
|
||||||
|
|
||||||
|
## Sonuç
|
||||||
|
|
||||||
|
Ground plane, anten yapımına başlamak için ideal bir projedir: az malzeme, basit hesap ve öğretici bir ayar süreci.
|
||||||
|
|
||||||
|
73 de TA1SPH
|
||||||
@@ -6,6 +6,8 @@ import { z } from 'astro/zod';
|
|||||||
import siteJson from '../data/site.json';
|
import siteJson from '../data/site.json';
|
||||||
import socialJson from '../data/social.json';
|
import socialJson from '../data/social.json';
|
||||||
import homeJson from '../data/home.json';
|
import homeJson from '../data/home.json';
|
||||||
|
import categoriesJson from '../data/categories.json';
|
||||||
|
import blogJson from '../data/blog.json';
|
||||||
|
|
||||||
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
|
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
|
||||||
const icon = z.string().startsWith('tabler:');
|
const icon = z.string().startsWith('tabler:');
|
||||||
@@ -111,6 +113,73 @@ const homeSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const categoriesSchema = z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
slug: z.string().regex(/^[a-z0-9-]+$/),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
icon,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1);
|
||||||
|
|
||||||
|
/** Object of required string keys; keeps the key names in the inferred type. */
|
||||||
|
const strings = <const K extends string>(keys: readonly K[]) =>
|
||||||
|
z.object(Object.fromEntries(keys.map((k) => [k, z.string()])) as Record<K, z.ZodString>);
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
seo: z.object({ title: z.string(), description: z.string().max(160) }),
|
||||||
|
list: strings([
|
||||||
|
'eyebrow',
|
||||||
|
'title',
|
||||||
|
'categoryEyebrow',
|
||||||
|
'tagEyebrow',
|
||||||
|
'tagTitle',
|
||||||
|
'categoryDescription',
|
||||||
|
'tagDescription',
|
||||||
|
'pageTitle',
|
||||||
|
'empty',
|
||||||
|
]),
|
||||||
|
card: strings(['readMore', 'readingTime']),
|
||||||
|
dates: strings(['created', 'updated']),
|
||||||
|
pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']),
|
||||||
|
sidebar: strings([
|
||||||
|
'label',
|
||||||
|
'search',
|
||||||
|
'searchLabel',
|
||||||
|
'searchPlaceholder',
|
||||||
|
'searchNoResults',
|
||||||
|
'searchResults',
|
||||||
|
'searchUnavailable',
|
||||||
|
'categories',
|
||||||
|
'featured',
|
||||||
|
'tags',
|
||||||
|
]),
|
||||||
|
post: strings([
|
||||||
|
'breadcrumbLabel',
|
||||||
|
'toc',
|
||||||
|
'shareLabel',
|
||||||
|
'copyLink',
|
||||||
|
'copied',
|
||||||
|
'nativeShare',
|
||||||
|
'copyCode',
|
||||||
|
'codeCopied',
|
||||||
|
'category',
|
||||||
|
'tags',
|
||||||
|
'adjacentLabel',
|
||||||
|
'prev',
|
||||||
|
'next',
|
||||||
|
'license',
|
||||||
|
'licenseName',
|
||||||
|
'licenseHref',
|
||||||
|
]),
|
||||||
|
diy: z.object({ title: z.string(), body: z.string(), link }),
|
||||||
|
share: z.array(
|
||||||
|
z.object({ id: z.string(), label: z.string(), icon, url: z.string().includes('{url}') }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(data);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -121,8 +190,16 @@ function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
|
|||||||
|
|
||||||
export const site = parse('site.json', siteSchema, siteJson);
|
export const site = parse('site.json', siteSchema, siteJson);
|
||||||
export const home = parse('home.json', homeSchema, homeJson);
|
export const home = parse('home.json', homeSchema, homeJson);
|
||||||
|
export const categories = parse('categories.json', categoriesSchema, categoriesJson);
|
||||||
|
export const blog = parse('blog.json', blogSchema, blogJson);
|
||||||
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
|
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
|
||||||
|
|
||||||
export type Site = z.infer<typeof siteSchema>;
|
export type Site = z.infer<typeof siteSchema>;
|
||||||
export type HomeSection = (typeof home.sections)[number];
|
export type HomeSection = (typeof home.sections)[number];
|
||||||
|
export type Category = (typeof categories)[number];
|
||||||
export type SocialLink = (typeof social)[number];
|
export type SocialLink = (typeof social)[number];
|
||||||
|
|
||||||
|
/** Fills `{key}` placeholders in UI strings from the JSON files. */
|
||||||
|
export function fmt(template: string, values: Record<string, string | number>): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (m, k: string) => (k in values ? String(values[k]) : m));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type DateKind = 'created' | 'updated';
|
||||||
|
export interface DateBadge {
|
||||||
|
kind: DateKind;
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TZ = 'Europe/Istanbul';
|
||||||
|
const dayKey = (d: Date) => new Intl.DateTimeFormat('en-CA', { timeZone: TZ }).format(d);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which date leads: the newer one is primary (filled badge), the other secondary (outline).
|
||||||
|
* No / same-day / older update → only the created date is shown.
|
||||||
|
*/
|
||||||
|
export function dateBadges(
|
||||||
|
pub: Date,
|
||||||
|
upd?: Date,
|
||||||
|
): { primary: DateBadge; secondary: DateBadge | null } {
|
||||||
|
if (!upd || dayKey(upd) === dayKey(pub) || upd < pub) {
|
||||||
|
return { primary: { kind: 'created', date: pub }, secondary: null };
|
||||||
|
}
|
||||||
|
return { primary: { kind: 'updated', date: upd }, secondary: { kind: 'created', date: pub } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const longFormat = new Intl.DateTimeFormat('tr-TR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: TZ,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatDate = (d: Date) => longFormat.format(d);
|
||||||
|
/** Value for `<time datetime>` (calendar day in Istanbul). */
|
||||||
|
export const isoDay = (d: Date) => dayKey(d);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Sätteri (Astro 7 default Markdown processor) HAST plugins.
|
||||||
|
// Plain JS because astro.config.mjs imports it directly.
|
||||||
|
|
||||||
|
const SITE_HOST = 'ta1sph.info';
|
||||||
|
|
||||||
|
const isWhitespace = (n) => n.type === 'text' && !n.value.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `` alone in a paragraph → `<figure><img><figcaption>Caption</figcaption></figure>`.
|
||||||
|
* Images without a title stay as they are.
|
||||||
|
*/
|
||||||
|
export const figurePlugin = {
|
||||||
|
name: 'ta1sph-figure',
|
||||||
|
element: {
|
||||||
|
filter: ['p'],
|
||||||
|
visit(node, ctx) {
|
||||||
|
const content = node.children.filter((c) => !isWhitespace(c));
|
||||||
|
if (content.length !== 1) return;
|
||||||
|
const img = content[0];
|
||||||
|
if (img.type !== 'element' || img.tagName !== 'img') return;
|
||||||
|
const caption = img.properties?.title;
|
||||||
|
if (typeof caption !== 'string' || !caption.trim()) return;
|
||||||
|
const { title: _title, ...props } = img.properties;
|
||||||
|
ctx.replaceNode(node, {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'figure',
|
||||||
|
properties: {},
|
||||||
|
children: [
|
||||||
|
{ ...img, properties: props },
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'figcaption',
|
||||||
|
properties: {},
|
||||||
|
children: [{ type: 'text', value: caption }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Absolute links to other sites open in a new tab with a safe rel. */
|
||||||
|
export const externalLinksPlugin = {
|
||||||
|
name: 'ta1sph-external-links',
|
||||||
|
element: {
|
||||||
|
filter: ['a'],
|
||||||
|
visit(node, ctx) {
|
||||||
|
const href = node.properties?.href;
|
||||||
|
if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return;
|
||||||
|
try {
|
||||||
|
const host = new URL(href).hostname.replace(/^www\./, '');
|
||||||
|
if (host === SITE_HOST) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.setProperty(node, 'target', '_blank');
|
||||||
|
ctx.setProperty(node, 'rel', ['noopener', 'noreferrer']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
|
import { categories, type Category } from './data';
|
||||||
|
import { slugify } from './slug';
|
||||||
|
|
||||||
|
export type Post = CollectionEntry<'blog'>;
|
||||||
|
|
||||||
|
/** Published posts (drafts only in dev), newest `pubDate` first. Unknown categories fail the build. */
|
||||||
|
export async function getPosts(): Promise<Post[]> {
|
||||||
|
const posts = await getCollection('blog', ({ data }) => import.meta.env.DEV || !data.draft);
|
||||||
|
for (const post of posts) {
|
||||||
|
if (!categories.some((c) => c.slug === post.data.category)) {
|
||||||
|
throw new Error(
|
||||||
|
`Post "${post.id}" uses unknown category "${post.data.category}" (see src/data/categories.json)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postUrl = (post: Post) => `/blog/${post.id}/`;
|
||||||
|
export const categoryUrl = (slug: string) => `/blog/kategori/${slug}/`;
|
||||||
|
export const tagUrl = (tag: string) => `/blog/etiket/${slugify(tag)}/`;
|
||||||
|
export const tagSlug = (tag: string) => slugify(tag);
|
||||||
|
|
||||||
|
export function categoryOf(post: Post): Category {
|
||||||
|
return categories.find((c) => c.slug === post.data.category)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ~200 words per minute on the raw Markdown body (code blocks included). */
|
||||||
|
export function readingMinutes(post: Post): number {
|
||||||
|
const words = (post.body ?? '').split(/\s+/).filter(Boolean).length;
|
||||||
|
return Math.max(1, Math.round(words / 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryCounts(posts: Post[]) {
|
||||||
|
return categories
|
||||||
|
.map((category) => ({
|
||||||
|
category,
|
||||||
|
count: posts.filter((p) => p.data.category === category.slug).length,
|
||||||
|
}))
|
||||||
|
.filter((c) => c.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagCount {
|
||||||
|
tag: string;
|
||||||
|
slug: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags grouped by slug (first spelling wins), most used first. */
|
||||||
|
export function tagCounts(posts: Post[]): TagCount[] {
|
||||||
|
const map = new Map<string, TagCount>();
|
||||||
|
for (const tag of posts.flatMap((p) => p.data.tags)) {
|
||||||
|
const slug = slugify(tag);
|
||||||
|
const entry = map.get(slug) ?? { tag, slug, count: 0 };
|
||||||
|
entry.count++;
|
||||||
|
map.set(slug, entry);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag, 'tr'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback "featured" list: frontmatter `featured`, else the latest posts. */
|
||||||
|
export function featuredPosts(posts: Post[], limit = 3): Post[] {
|
||||||
|
const featured = posts.filter((p) => p.data.featured);
|
||||||
|
return (featured.length ? featured : posts).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** prev = older post, next = newer post. */
|
||||||
|
export function adjacentPosts(posts: Post[], post: Post) {
|
||||||
|
const i = posts.findIndex((p) => p.id === post.id);
|
||||||
|
return { prev: posts[i + 1], next: i > 0 ? posts[i - 1] : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunks a list into pages; always at least one (possibly empty) page. */
|
||||||
|
export function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const pages: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size));
|
||||||
|
return pages.length ? pages : [[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page 1 lives at the base URL, others at `<base>sayfa/<n>/`. */
|
||||||
|
export const pageUrl = (base: string, n: number) => (n <= 1 ? base : `${base}sayfa/${n}/`);
|
||||||
|
|
||||||
|
/** `[...page]` param value for page n (undefined = page 1). */
|
||||||
|
export const pageParam = (n: number) => (n <= 1 ? undefined : `sayfa/${n}`);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
// /blog/ and /blog/sayfa/<n>/
|
||||||
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import BlogListing from '../../components/blog/BlogListing.astro';
|
||||||
|
import { blog, site } from '../../lib/data';
|
||||||
|
import { chunk, getPosts, pageParam } from '../../lib/posts';
|
||||||
|
|
||||||
|
export const getStaticPaths = (async () => {
|
||||||
|
const posts = await getPosts();
|
||||||
|
const pages = chunk(posts, site.postsPerPage);
|
||||||
|
return pages.map((items, i) => ({
|
||||||
|
params: { page: pageParam(i + 1) },
|
||||||
|
props: { items, allPosts: posts, current: i + 1, total: pages.length },
|
||||||
|
}));
|
||||||
|
}) satisfies GetStaticPaths;
|
||||||
|
|
||||||
|
const { items, allPosts, current, total } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogListing
|
||||||
|
eyebrow={blog.list.eyebrow}
|
||||||
|
title={blog.list.title}
|
||||||
|
description={blog.seo.description}
|
||||||
|
posts={items}
|
||||||
|
allPosts={allPosts}
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
base="/blog/"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import { getImage, Image } from 'astro:assets';
|
||||||
|
import { render } from 'astro:content';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import Badge from '../../components/Badge.astro';
|
||||||
|
import Breadcrumbs from '../../components/blog/Breadcrumbs.astro';
|
||||||
|
import DateBadges from '../../components/blog/DateBadges.astro';
|
||||||
|
import DiyNotice from '../../components/blog/DiyNotice.astro';
|
||||||
|
import ShareBar from '../../components/blog/ShareBar.astro';
|
||||||
|
import Toc from '../../components/blog/Toc.astro';
|
||||||
|
import Container from '../../components/Container.astro';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { blog, site } from '../../lib/data';
|
||||||
|
import {
|
||||||
|
adjacentPosts,
|
||||||
|
categoryOf,
|
||||||
|
categoryUrl,
|
||||||
|
getPosts,
|
||||||
|
postUrl,
|
||||||
|
readingMinutes,
|
||||||
|
tagUrl,
|
||||||
|
} from '../../lib/posts';
|
||||||
|
|
||||||
|
export const getStaticPaths = (async () => {
|
||||||
|
const posts = await getPosts();
|
||||||
|
return posts.map((post) => ({
|
||||||
|
params: { slug: post.id },
|
||||||
|
props: { post, ...adjacentPosts(posts, post) },
|
||||||
|
}));
|
||||||
|
}) satisfies GetStaticPaths;
|
||||||
|
|
||||||
|
const { post, prev, next } = Astro.props;
|
||||||
|
const { title, description, pubDate, updatedDate, cover, coverAlt, tags, diy } = post.data;
|
||||||
|
const { Content, headings } = await render(post);
|
||||||
|
const category = categoryOf(post);
|
||||||
|
const url = new URL(postUrl(post), site.url).href;
|
||||||
|
|
||||||
|
// 1200px OG/Twitter image from the cover (absolute URL in Seo).
|
||||||
|
const ogImage = cover
|
||||||
|
? (await getImage({ src: cover, width: 1200, format: 'jpeg' })).src
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const crumbs = [
|
||||||
|
{ label: site.ui.home, href: '/' },
|
||||||
|
{ label: blog.seo.title, href: '/blog/' },
|
||||||
|
{ label: category.name, href: categoryUrl(category.slug) },
|
||||||
|
{ label: title },
|
||||||
|
];
|
||||||
|
|
||||||
|
const jsonLd = [
|
||||||
|
{
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
'@id': `${url}#article`,
|
||||||
|
headline: title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
mainEntityOfPage: url,
|
||||||
|
inLanguage: site.lang,
|
||||||
|
datePublished: pubDate.toISOString(),
|
||||||
|
dateModified: (updatedDate ?? pubDate).toISOString(),
|
||||||
|
author: { '@id': `${site.url}/#person` },
|
||||||
|
publisher: { '@id': `${site.url}/#person` },
|
||||||
|
articleSection: category.name,
|
||||||
|
keywords: tags.join(', '),
|
||||||
|
...(ogImage && { image: new URL(ogImage, site.url).href }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
itemListElement: crumbs.map((c, i) => ({
|
||||||
|
'@type': 'ListItem',
|
||||||
|
position: i + 1,
|
||||||
|
name: c.label,
|
||||||
|
item: new URL(c.href ?? postUrl(post), site.url).href,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
type="article"
|
||||||
|
image={ogImage}
|
||||||
|
imageAlt={coverAlt}
|
||||||
|
publishedTime={pubDate}
|
||||||
|
modifiedTime={updatedDate}
|
||||||
|
jsonLd={jsonLd}
|
||||||
|
>
|
||||||
|
<Container class="max-w-[52rem] py-10 md:py-14">
|
||||||
|
<article data-pagefind-body class="flex flex-col gap-8">
|
||||||
|
<header class="flex flex-col gap-5">
|
||||||
|
<Breadcrumbs items={crumbs} />
|
||||||
|
<h1 class="headline text-[clamp(2rem,5vw,3.5rem)]" data-pagefind-meta="title">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p class="text-fg-muted text-lg">{description}</p>
|
||||||
|
<DateBadges pubDate={pubDate} updatedDate={updatedDate} minutes={readingMinutes(post)} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{cover && (
|
||||||
|
<Image
|
||||||
|
src={cover}
|
||||||
|
alt={coverAlt ?? ''}
|
||||||
|
widths={[640, 960, 1280]}
|
||||||
|
sizes="(min-width: 832px) 800px, 100vw"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
class="border-line w-full border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div data-pagefind-ignore>
|
||||||
|
<ShareBar url={url} title={title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{diy && <DiyNotice />}
|
||||||
|
<div data-pagefind-ignore>
|
||||||
|
<Toc headings={headings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="prose article-prose md:prose-lg max-w-[72ch]"
|
||||||
|
data-code-copy={blog.post.copyCode}
|
||||||
|
data-code-copied={blog.post.codeCopied}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer data-pagefind-ignore class="border-line flex flex-col gap-6 border-t pt-8">
|
||||||
|
<dl class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<dt class="font-display text-sm font-bold tracking-wide uppercase">
|
||||||
|
{blog.post.category}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="category" href={categoryUrl(category.slug)} icon={category.icon}>
|
||||||
|
{category.name}
|
||||||
|
</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="flex flex-wrap items-baseline gap-3">
|
||||||
|
<dt class="font-display text-sm font-bold tracking-wide uppercase">
|
||||||
|
{blog.post.tags}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<ul class="flex flex-wrap gap-2">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<li>
|
||||||
|
<Badge variant="outline" href={tagUrl(t)} icon="tabler:tag">
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
<p class="text-fg-muted flex items-start gap-2 text-sm">
|
||||||
|
<span class="flex shrink-0 gap-0.5" aria-hidden="true">
|
||||||
|
<Icon name="tabler:creative-commons" class="size-5" />
|
||||||
|
<Icon name="tabler:creative-commons-by" class="size-5" />
|
||||||
|
<Icon name="tabler:creative-commons-sa" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{blog.post.license.split('{license}')[0]}
|
||||||
|
<a
|
||||||
|
href={blog.post.licenseHref}
|
||||||
|
class="text-fg font-semibold underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{blog.post.licenseName}
|
||||||
|
</a>
|
||||||
|
{blog.post.license.split('{license}')[1]}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{(prev || next) && (
|
||||||
|
<nav aria-label={blog.post.adjacentLabel} class="mt-12 grid gap-4 sm:grid-cols-2">
|
||||||
|
{prev ? (
|
||||||
|
<a
|
||||||
|
href={postUrl(prev)}
|
||||||
|
class="border-line hover:border-fg flex flex-col gap-1 border p-5"
|
||||||
|
>
|
||||||
|
<span class="font-display text-accent-text inline-flex items-center gap-1 text-sm font-bold uppercase">
|
||||||
|
<Icon name="tabler:arrow-left" class="size-4" aria-hidden="true" />
|
||||||
|
{blog.post.prev}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">{prev.data.title}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{next && (
|
||||||
|
<a
|
||||||
|
href={postUrl(next)}
|
||||||
|
class="border-line hover:border-fg flex flex-col items-end gap-1 border p-5 text-right"
|
||||||
|
>
|
||||||
|
<span class="font-display text-accent-text inline-flex items-center gap-1 text-sm font-bold uppercase">
|
||||||
|
{blog.post.next}
|
||||||
|
<Icon name="tabler:arrow-right" class="size-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">{next.data.title}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Copy button on every code block.
|
||||||
|
const prose = document.querySelector<HTMLElement>('[data-code-copy]');
|
||||||
|
if (prose) {
|
||||||
|
const { codeCopy = '', codeCopied = '' } = prose.dataset;
|
||||||
|
for (const pre of prose.querySelectorAll('pre')) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'code-block';
|
||||||
|
pre.replaceWith(wrap);
|
||||||
|
wrap.append(pre);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'code-copy';
|
||||||
|
btn.textContent = codeCopy;
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
pre.querySelector('code')?.innerText ?? pre.innerText,
|
||||||
|
);
|
||||||
|
btn.textContent = codeCopied;
|
||||||
|
setTimeout(() => (btn.textContent = codeCopy), 2000);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
wrap.append(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
// /blog/etiket/<tag>/ and …/sayfa/<n>/
|
||||||
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import BlogListing from '../../../../components/blog/BlogListing.astro';
|
||||||
|
import { blog, fmt, site } from '../../../../lib/data';
|
||||||
|
import { chunk, getPosts, pageParam, tagCounts, tagSlug, tagUrl } from '../../../../lib/posts';
|
||||||
|
|
||||||
|
export const getStaticPaths = (async () => {
|
||||||
|
const posts = await getPosts();
|
||||||
|
return tagCounts(posts).flatMap((t) => {
|
||||||
|
const tagged = posts.filter((p) => p.data.tags.some((x) => tagSlug(x) === t.slug));
|
||||||
|
const pages = chunk(tagged, site.postsPerPage);
|
||||||
|
return pages.map((items, i) => ({
|
||||||
|
params: { tag: t.slug, page: pageParam(i + 1) },
|
||||||
|
props: {
|
||||||
|
tag: t.tag,
|
||||||
|
slug: t.slug,
|
||||||
|
items,
|
||||||
|
allPosts: posts,
|
||||||
|
current: i + 1,
|
||||||
|
total: pages.length,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}) satisfies GetStaticPaths;
|
||||||
|
|
||||||
|
const { tag, slug, items, allPosts, current, total } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogListing
|
||||||
|
eyebrow={blog.list.tagEyebrow}
|
||||||
|
title={fmt(blog.list.tagTitle, { tag })}
|
||||||
|
description={fmt(blog.list.tagDescription, { tag })}
|
||||||
|
posts={items}
|
||||||
|
allPosts={allPosts}
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
base={tagUrl(tag)}
|
||||||
|
activeTag={slug}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
// /blog/kategori/<category>/ and …/sayfa/<n>/
|
||||||
|
import type { GetStaticPaths } from 'astro';
|
||||||
|
import BlogListing from '../../../../components/blog/BlogListing.astro';
|
||||||
|
import { blog, categories, fmt, site } from '../../../../lib/data';
|
||||||
|
import { categoryUrl, chunk, getPosts, pageParam } from '../../../../lib/posts';
|
||||||
|
|
||||||
|
export const getStaticPaths = (async () => {
|
||||||
|
const posts = await getPosts();
|
||||||
|
return categories.flatMap((category) => {
|
||||||
|
const inCategory = posts.filter((p) => p.data.category === category.slug);
|
||||||
|
if (!inCategory.length) return [];
|
||||||
|
const pages = chunk(inCategory, site.postsPerPage);
|
||||||
|
return pages.map((items, i) => ({
|
||||||
|
params: { category: category.slug, page: pageParam(i + 1) },
|
||||||
|
props: { category, items, allPosts: posts, current: i + 1, total: pages.length },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}) satisfies GetStaticPaths;
|
||||||
|
|
||||||
|
const { category, items, allPosts, current, total } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BlogListing
|
||||||
|
eyebrow={blog.list.categoryEyebrow}
|
||||||
|
title={category.name}
|
||||||
|
description={category.description || fmt(blog.list.categoryDescription, { name: category.name })}
|
||||||
|
posts={items}
|
||||||
|
allPosts={allPosts}
|
||||||
|
current={current}
|
||||||
|
total={total}
|
||||||
|
base={categoryUrl(category.slug)}
|
||||||
|
activeCategory={category.slug}
|
||||||
|
/>
|
||||||
+18
-2
@@ -3,8 +3,12 @@ import CardGrid from '../components/home/CardGrid.astro';
|
|||||||
import FeatureSection from '../components/home/FeatureSection.astro';
|
import FeatureSection from '../components/home/FeatureSection.astro';
|
||||||
import Hero from '../components/home/Hero.astro';
|
import Hero from '../components/home/Hero.astro';
|
||||||
import LatestPosts from '../components/home/LatestPosts.astro';
|
import LatestPosts from '../components/home/LatestPosts.astro';
|
||||||
|
import PostCard from '../components/blog/PostCard.astro';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { home } from '../lib/data';
|
import { home } from '../lib/data';
|
||||||
|
import { getPosts } from '../lib/posts';
|
||||||
|
|
||||||
|
const posts = await getPosts();
|
||||||
|
|
||||||
// Hero is khaki, so sections alternate paper → khaki → … after it.
|
// Hero is khaki, so sections alternate paper → khaki → … after it.
|
||||||
let featureIndex = 0;
|
let featureIndex = 0;
|
||||||
@@ -23,8 +27,20 @@ const sections = home.sections.map((section, i) => ({
|
|||||||
return <FeatureSection section={section} index={index} alt={alt} />;
|
return <FeatureSection section={section} index={index} alt={alt} />;
|
||||||
case 'cards':
|
case 'cards':
|
||||||
return <CardGrid section={section} alt={alt} />;
|
return <CardGrid section={section} alt={alt} />;
|
||||||
case 'latestPosts':
|
case 'latestPosts': {
|
||||||
return <LatestPosts section={section} alt={alt} hasPosts={false} />;
|
const latest = posts.slice(0, section.count);
|
||||||
|
return (
|
||||||
|
<LatestPosts section={section} alt={alt} hasPosts={latest.length > 0}>
|
||||||
|
<ul class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{latest.map((post) => (
|
||||||
|
<li class="flex">
|
||||||
|
<PostCard post={post} layout="stack" headingLevel={3} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</LatestPosts>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import rss from '@astrojs/rss';
|
||||||
|
import type { APIContext } from 'astro';
|
||||||
|
import { site } from '../lib/data';
|
||||||
|
import { categoryOf, getPosts, postUrl } from '../lib/posts';
|
||||||
|
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const posts = await getPosts();
|
||||||
|
return rss({
|
||||||
|
title: site.title,
|
||||||
|
description: site.description,
|
||||||
|
site: context.site ?? site.url,
|
||||||
|
trailingSlash: true,
|
||||||
|
items: posts.map((post) => ({
|
||||||
|
title: post.data.title,
|
||||||
|
description: post.data.description,
|
||||||
|
pubDate: post.data.pubDate,
|
||||||
|
link: postUrl(post),
|
||||||
|
categories: [categoryOf(post).name, ...post.data.tags],
|
||||||
|
})),
|
||||||
|
customData: `<language>${site.lang}</language>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -120,3 +120,100 @@
|
|||||||
scroll-behavior: auto !important;
|
scroll-behavior: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Article typography (unlayered: overrides the typography plugin) ---------- */
|
||||||
|
.article-prose {
|
||||||
|
--tw-prose-body: var(--fg);
|
||||||
|
--tw-prose-headings: var(--fg);
|
||||||
|
--tw-prose-lead: var(--fg-muted);
|
||||||
|
--tw-prose-links: var(--accent-strong);
|
||||||
|
--tw-prose-bold: var(--fg);
|
||||||
|
--tw-prose-counters: var(--fg-muted);
|
||||||
|
--tw-prose-bullets: var(--accent);
|
||||||
|
--tw-prose-hr: var(--line);
|
||||||
|
--tw-prose-quotes: var(--fg);
|
||||||
|
--tw-prose-quote-borders: var(--accent);
|
||||||
|
--tw-prose-captions: var(--fg-muted);
|
||||||
|
--tw-prose-kbd: var(--fg);
|
||||||
|
--tw-prose-code: var(--fg);
|
||||||
|
--tw-prose-pre-code: var(--fg);
|
||||||
|
--tw-prose-pre-bg: var(--surface);
|
||||||
|
--tw-prose-th-borders: var(--fg);
|
||||||
|
--tw-prose-td-borders: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prose :where(h2, h3, h4) {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
scroll-margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prose :where(a) {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prose :where(:not(pre) > code) {
|
||||||
|
background: var(--bg-alt);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.article-prose :where(:not(pre) > code)::before,
|
||||||
|
.article-prose :where(:not(pre) > code)::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prose :where(blockquote) {
|
||||||
|
font-style: normal;
|
||||||
|
background: var(--surface);
|
||||||
|
padding-block: 0.25em;
|
||||||
|
}
|
||||||
|
.article-prose :where(blockquote p)::before,
|
||||||
|
.article-prose :where(blockquote p)::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-prose :where(figure) {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
.article-prose :where(figcaption) {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shiki dual theme (defaultColor: false) */
|
||||||
|
.astro-code {
|
||||||
|
color: var(--shiki-light);
|
||||||
|
background-color: var(--surface) !important;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.astro-code span {
|
||||||
|
color: var(--shiki-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .astro-code,
|
||||||
|
[data-theme='dark'] .astro-code span {
|
||||||
|
color: var(--shiki-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.code-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
font: 700 0.75rem/1.5 var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bg);
|
||||||
|
background: var(--fg);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.code-copy:hover {
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: var(--on-accent-fill);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user