From 809f89af086c61e57125cd5214a2cd95861fe2a8 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 16 Oct 2026 01:06:07 +0300 Subject: [PATCH] feat: add blog with archives, pagination, search, sharing and RSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Content collection (one folder per post) with zod schema - /blog, category and tag archives with /sayfa// 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 --- PROJECT_MEMORY.md | 65 +++-- astro.config.mjs | 11 + src/components/blog/BlogListing.astro | 71 ++++++ src/components/blog/Breadcrumbs.astro | 35 +++ src/components/blog/DateBadges.astro | 41 +++ src/components/blog/DiyNotice.astro | 24 ++ src/components/blog/Pager.astro | 101 ++++++++ src/components/blog/PostCard.astro | 65 +++++ src/components/blog/SearchBox.astro | 101 ++++++++ src/components/blog/ShareBar.astro | 82 ++++++ src/components/blog/Sidebar.astro | 117 +++++++++ src/components/blog/Toc.astro | 29 +++ src/content.config.ts | 33 +++ src/content/blog/dmr-temel-kavramlar/index.md | 41 +++ .../blog/roleler-ve-ctcss-tonu/index.md | 37 +++ .../uhf-ground-plane-anten-mantigi/index.md | 36 +++ src/lib/data.ts | 77 ++++++ src/lib/dates.ts | 33 +++ src/lib/markdown-plugins.mjs | 60 +++++ src/lib/posts.ts | 85 +++++++ src/pages/blog/[...page].astro | 29 +++ src/pages/blog/[slug].astro | 240 ++++++++++++++++++ src/pages/blog/etiket/[tag]/[...page].astro | 40 +++ .../blog/kategori/[category]/[...page].astro | 34 +++ src/pages/index.astro | 20 +- src/pages/rss.xml.ts | 22 ++ src/styles/global.css | 97 +++++++ 27 files changed, 1599 insertions(+), 27 deletions(-) create mode 100644 src/components/blog/BlogListing.astro create mode 100644 src/components/blog/Breadcrumbs.astro create mode 100644 src/components/blog/DateBadges.astro create mode 100644 src/components/blog/DiyNotice.astro create mode 100644 src/components/blog/Pager.astro create mode 100644 src/components/blog/PostCard.astro create mode 100644 src/components/blog/SearchBox.astro create mode 100644 src/components/blog/ShareBar.astro create mode 100644 src/components/blog/Sidebar.astro create mode 100644 src/components/blog/Toc.astro create mode 100644 src/content.config.ts create mode 100644 src/content/blog/dmr-temel-kavramlar/index.md create mode 100644 src/content/blog/roleler-ve-ctcss-tonu/index.md create mode 100644 src/content/blog/uhf-ground-plane-anten-mantigi/index.md create mode 100644 src/lib/dates.ts create mode 100644 src/lib/markdown-plugins.mjs create mode 100644 src/lib/posts.ts create mode 100644 src/pages/blog/[...page].astro create mode 100644 src/pages/blog/[slug].astro create mode 100644 src/pages/blog/etiket/[tag]/[...page].astro create mode 100644 src/pages/blog/kategori/[category]/[...page].astro create mode 100644 src/pages/rss.xml.ts diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index e2e1a09..6b99cd4 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -1,14 +1,14 @@ # PROJECT_MEMORY — ta1sph.info > Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur. -> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 3) +> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 4) ## 1. Durum -- **Aşama:** 3 tamamlandı (ana sayfa). Sıradaki: **Aşama 4 — Blog altyapısı**. -- **Tamamlanan:** Aşama 0–3. 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. -- **Bilinen sorunlar:** — +- **Aşama:** 4 tamamlandı (blog altyapısı). Sıradaki: **Aşama 5 — Görüntülenme sayacı**. +- **Tamamlanan:** Aşama 0–4. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`. +- **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:** 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 @@ -29,6 +29,13 @@ - `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ışı) - `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/lib/slug.ts` — Türkçe karakter dönüştürücü `slugify` - `scripts/new-post.mjs` — `npm run new:post -- "Başlık"` → `src/content/blog//index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder @@ -38,32 +45,40 @@ ## 4. Kararlar (ADR) -| 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 | 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 | 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 | 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 | 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 | 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 | +| 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 | 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 | 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 | 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 | 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 | Astro 7 Markdown = Sätteri; eklentiler `markdown.processor: satteri({ hastPlugins })` | remark/rehype eklentileri bu işlemcide çalışmaz | +| 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 | Sayfalama `[...page]` + `sayfa/` (Astro `paginate()` değil) | Spec URL'leri `/blog/sayfa/n/`; tek dosyada 1. sayfa + diğerleri | +| 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 | Shiki `github-*-high-contrast`, zemin `--surface` | Kod renkleri AA kontrastı | +| 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 -| 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) +- 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 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` diff --git a/astro.config.mjs b/astro.config.mjs index d349a09..c8c116e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,6 +6,8 @@ import mdx from '@astrojs/mdx'; import sitemap from '@astrojs/sitemap'; import node from '@astrojs/node'; 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. @@ -29,6 +31,15 @@ export default defineConfig({ output: 'static', adapter: node({ mode: 'standalone' }), 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: { plugins: [tailwindcss()], }, diff --git a/src/components/blog/BlogListing.astro b/src/components/blog/BlogListing.astro new file mode 100644 index 0000000..fb31993 --- /dev/null +++ b/src/components/blog/BlogListing.astro @@ -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[]; +} + +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; +--- + + +
+ + + +

{description}

+
+
+ + +
+ {posts.length ? ( +
    + {posts.map((post, i) => ( +
  • + +
  • + ))} +
+ ) : ( +

{blog.list.empty}

+ )} + +
+ +
+
diff --git a/src/components/blog/Breadcrumbs.astro b/src/components/blog/Breadcrumbs.astro new file mode 100644 index 0000000..fa05af3 --- /dev/null +++ b/src/components/blog/Breadcrumbs.astro @@ -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; +--- + + diff --git a/src/components/blog/DateBadges.astro b/src/components/blog/DateBadges.astro new file mode 100644 index 0000000..8a5b4b2 --- /dev/null +++ b/src/components/blog/DateBadges.astro @@ -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); +--- + +
+ + {p.label} + + + {secondary && s && ( + + {s.label} + + + )} + {minutes && ( + + {fmt(blog.card.readingTime, { n: minutes })} + + )} +
diff --git a/src/components/blog/DiyNotice.astro b/src/components/blog/DiyNotice.astro new file mode 100644 index 0000000..e952749 --- /dev/null +++ b/src/components/blog/DiyNotice.astro @@ -0,0 +1,24 @@ +--- +import { Icon } from 'astro-icon/components'; +import { blog } from '../../lib/data'; +--- + + diff --git a/src/components/blog/Pager.astro b/src/components/blog/Pager.astro new file mode 100644 index 0000000..a23cd7e --- /dev/null +++ b/src/components/blog/Pager.astro @@ -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 && ( + +)} diff --git a/src/components/blog/PostCard.astro b/src/components/blog/PostCard.astro new file mode 100644 index 0000000..0e63598 --- /dev/null +++ b/src/components/blog/PostCard.astro @@ -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'; +--- + + +
+ {cover ? ( + {coverAlt + ) : ( + + )} +
+
+ + {category.name} + + + + {title} + + +

{description}

+ +
+
diff --git a/src/components/blog/SearchBox.astro b/src/components/blog/SearchBox.astro new file mode 100644 index 0000000..a44558a --- /dev/null +++ b/src/components/blog/SearchBox.astro @@ -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'; +--- + +
+
+ + diff --git a/src/components/blog/ShareBar.astro b/src/components/blog/ShareBar.astro new file mode 100644 index 0000000..857f64e --- /dev/null +++ b/src/components/blog/ShareBar.astro @@ -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'; +--- + +
+

+ {blog.post.shareLabel} +

+ +

+
+ + diff --git a/src/components/blog/Sidebar.astro b/src/components/blog/Sidebar.astro new file mode 100644 index 0000000..0593728 --- /dev/null +++ b/src/components/blog/Sidebar.astro @@ -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'; +--- + + diff --git a/src/components/blog/Toc.astro b/src/components/blog/Toc.astro new file mode 100644 index 0000000..6ba05f3 --- /dev/null +++ b/src/components/blog/Toc.astro @@ -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 && ( +
+ + {blog.post.toc} + + +
+)} diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..5ed6f9a --- /dev/null +++ b/src/content.config.ts @@ -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//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 }; diff --git a/src/content/blog/dmr-temel-kavramlar/index.md b/src/content/blog/dmr-temel-kavramlar/index.md new file mode 100644 index 0000000..e2c49f7 --- /dev/null +++ b/src/content/blog/dmr-temel-kavramlar/index.md @@ -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 diff --git a/src/content/blog/roleler-ve-ctcss-tonu/index.md b/src/content/blog/roleler-ve-ctcss-tonu/index.md new file mode 100644 index 0000000..8379529 --- /dev/null +++ b/src/content/blog/roleler-ve-ctcss-tonu/index.md @@ -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 diff --git a/src/content/blog/uhf-ground-plane-anten-mantigi/index.md b/src/content/blog/uhf-ground-plane-anten-mantigi/index.md new file mode 100644 index 0000000..a26f0d2 --- /dev/null +++ b/src/content/blog/uhf-ground-plane-anten-mantigi/index.md @@ -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 diff --git a/src/lib/data.ts b/src/lib/data.ts index 0edae9a..08ba954 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -6,6 +6,8 @@ import { z } from 'astro/zod'; import siteJson from '../data/site.json'; import socialJson from '../data/social.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 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 = (keys: readonly K[]) => + z.object(Object.fromEntries(keys.map((k) => [k, z.string()])) as Record); + +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(name: string, schema: z.ZodType, data: unknown): T { const result = schema.safeParse(data); if (!result.success) { @@ -121,8 +190,16 @@ function parse(name: string, schema: z.ZodType, data: unknown): T { export const site = parse('site.json', siteSchema, siteJson); 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 type Site = z.infer; export type HomeSection = (typeof home.sections)[number]; +export type Category = (typeof categories)[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 { + return template.replace(/\{(\w+)\}/g, (m, k: string) => (k in values ? String(values[k]) : m)); +} diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..a74b367 --- /dev/null +++ b/src/lib/dates.ts @@ -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 `