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:
2026-10-16 01:06:07 +03:00
parent f2d943c31b
commit 809f89af08
27 changed files with 1599 additions and 27 deletions
+40 -25
View File
@@ -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` (`![alt](img "Altyazı")` → 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`
+11
View File
@@ -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()],
}, },
+71
View File
@@ -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>
+35
View File
@@ -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>
+41
View File
@@ -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>
+24
View File
@@ -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>
+101
View File
@@ -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>
)}
+65
View File
@@ -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>
+101
View File
@@ -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>
+82
View File
@@ -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>
+117
View File
@@ -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>
+29
View File
@@ -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>
)}
+33
View File
@@ -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
+77
View File
@@ -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));
}
+33
View File
@@ -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);
+60
View File
@@ -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();
/**
* `![alt](./img.jpg "Caption")` 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']);
},
},
};
+85
View File
@@ -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}`);
+29
View File
@@ -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/"
/>
+240
View File
@@ -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
View File
@@ -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>
+22
View File
@@ -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>`,
});
}
+97
View File
@@ -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);
}