← bütün yazılar

Nº02 // YAZILAR

Bu saytı necə qurdum

İstədiyim sayt çap olunmuş bir şey kimi hiss olunsun deyə idi — redaksiya posteri, SaaS açılış səhifəsi yox — və onu tam idarə etdiyim düz HTML kimi yayımlamaq istəyirdim. Beləliklə itahir.com statik bir Astro build-idir, ikidilli (ingiliscə /-də, azərbaycanca /az/ altında), zine/redaksiya kimliyi ilə: isti kağız fonu, mürəkkəb-qara xətlər, tək bir paslı vurğu rəngi, başlıqlar üçün Space Grotesk və kiçik texniki mətn üçün JetBrains Mono. Bu yazı Astro turu deyil — onu qurarkən verdiyim öz qərarlarım və məni dişləyən kiçik şeylər haqqındadır.

Statik nəticə, qəsdən

Bütün sayt statik build-dir. Sorğu anında işləyən server yoxdur, verilənlər bazası yoxdur, canlı saxlanılası heç nə yoxdur. astro.config.mjs-im qəsdən balacadır:

export default defineConfig({
  site: 'https://itahir.com',
  build: { format: 'directory' }, // /az/ → /az/index.html
  compressHTML: true,
});

İkidilli sayt üçün vacib olan tək sətir format: 'directory'-dir. O, /az.html yerinə /az/index.html yaradır, beləcə azərbaycanca ana səhifə təmiz bir /az/ URL-də yaşayır və bütün nisbi yollar oxucunun gözlədiyi kimi həll olunur. compressHTML nəticədəki artıq boşluqları kəsir, site isə kanonik və hreflang linkləri üçün Astro-ya lazım olan mütləq bazanı verir.

Deploy də eyni dərəcədə darıxdırıcıdır, məsələ də elə budur: astro build bir dist/ qovluğu yaradır, mən isə həmin qovluğu öz serverimə (panel.itahir.com-un arxasındakı eyni maşına) rsync edirəm. CI yox, platforma yox, build dəqiqələri yox. Statik qovluq bildiyim ən davamlı artefaktdır və bu o deməkdir ki, saytın gecə ikidə sınası heç bir hərəkətli hissəsi yoxdur.

Blog bir content collection-dur — və slug bir tələdir

Blog Astro-nun content collection-larında, glob loader ilə işləyir. Hər yazı bir tərcümə açarını paylaşan iki Markdown faylı kimi mövcuddur — src/content/blog/en/<açar>.mdsrc/content/blog/az/<açar>.md — və sxem frontmatter-i Zod ilə yoxlayır:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    lang: z.enum(['en', 'az']),
    tkey: z.string(),
    summary: z.string().optional(),
    draft: z.boolean().default(false),
  }),
});

İndi səni qurtarmaq istədiyim tələ. İlk instinktim həmin tərcümə sahəsini slug adlandırmaq oldu — bir yazının ingiliscə və azərbaycanca versiyaları açıq-aşkar eyni slug-u paylaşır və hər iki dildə istədiyim URL /writing/<slug>/-dir. Amma glob loader frontmatter-dəki slug-u entry id kimi qəbul edir. Id collection daxilində unikal olmalıdır, ona görə eyni slug-u elan edən iki fayl toqquşur — biri səssizcə qalib gəlir, o biri yox olur. Səbəbini başa düşənə qədər sadəcə orada olmayan bir yazıya bir neçə dəqiqə itirdim.

Həll tək bir sözü dəyişmək oldu: sahəni slug yerinə tkey (translation key) çağırıram və loader-in id-ləri fayl yolundan (en/foo, az/foo) öz istədiyi kimi çıxarmasına icazə verirəm. Sonra getStaticPaths URL-i loader-in id-sindən deyil, öz sahəmdən qurur:

const posts = await getCollection('blog', ({ data }) =>
  data.lang === 'en' && !data.draft);
return posts.map((entry) => ({
  params: { slug: entry.data.tkey },
  props: { entry },
}));

Beləcə tkey həm iki dil faylı arasındakı birləşmə açarı, həm də ictimai slug-dur, Astro-nun id-si isə heç vaxt mübarizə aparmadığım daxili bir təfərrüat olaraq qalır. İki dil paralel [slug].astro route-ları alır — biri /writing/ altında, biri /az/writing/ altında — hər biri collection-u lang-a görə süzgəcdən keçirir. Qaralamalar (draft) eyni şərtlə süzülür, ona görə bitməmiş bir yazı heç nəyi silmədən prod-da görünmür.

Dilə görə data, tərcümə kitabxanası yox

Nəsr olmayan çərçivə üçün — hero, iş indeksi, naviqasiya etiketləri, kolofon — i18n kitabxanasına əl atmadım. Hər dil sadə, tipli bir TypeScript modulu-dur (src/data/en.ts, src/data/az.ts) və hər ikisi tək bir SiteContent interfeysini ödəyir. İngiliscə səhifə en-i, azərbaycanca səhifə az-ı import edir, eyni komponentlər isə hər ikisini render edir. İnterfeys müqavilədir: ingiliscə bir hero faktı əlavə edib azərbaycanca unutsam, build bitməzdən əvvəl TypeScript mənə deyir. Bu ən az ağıllı seçimdir və yenə seçərdim — işləmə anında axtarış yox, çatışmayan açar üçün ehtiyat məntiqi yox, sadəcə eyni formada iki obyekt.

Dil keçidi səni olduğun yerdə saxlayır

Masthead-dəki EN/AZ keçidi qarşı dilin URL-inə yönələn adi bir <a>-dır — heç bir JavaScript olmadan işləyir. Amma uzun ana səhifədə, əgər Writing bölməsini oxuyub dili dəyişsən, yenidən ən yuxarıya düşmək əsəbiləşdiricidir. Ona görə kiçik bir progressive-enhancement skripti cari səhifədaxili hash-i keçidin href-inə kopyalayır və hashchange-də sinxron saxlayır:

const base = toggle.getAttribute('href')?.split('#')[0] ?? '';
toggle.setAttribute('href', base + window.location.hash);

#writing-də olarkən azərbaycancaya keç və /az/#writing-ə düşürsən — eyni bölmə, başqa dil. JS söndürülübsə yenə də düzgün səhifəni alırsan, sadəcə yuxarıda. Hər yerdə tutmağa çalışdığım qayda budur: baza işləyir, skript isə onu sadəcə daha rahat edir.

Tarixlər: ICU-suz, sürüşməsiz

Tarix formatlaması da eyni ruhdadır. Build-i işlədən maşında Node-un ICU lokal datasının olmasına güvənmək yerinə, ay adlarını hər iki dil üçün sabit yazdığım balaca bir formatlayıcı qurdum və o, tarixin UTC hissələrini oxuyur, beləcə gecə-yarısı-UTC frontmatter tarixi mənfi saat qurşağında heç vaxt əvvəlki günə sürüşmür:

const AZ_MONTHS = ['Yanvar', 'Fevral', 'Mart', /* … */];
const day = d.getUTCDate();
return `${day} ${AZ_MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;

Kiçikdir, amma səhv olana qədər görünməyən şeylərin tam nümunəsidir.

Platformaya hörmət edən bir akkordeon

İş indeksi yerli <details>/<summary> sətirlərindən istifadə edir. Yerli <details> animasiyasız, anında açılır, ona görə Web Animations API üzərində kiçik bir animator yazdım. Hiylə ondadır ki, yerli open atributu yeganə həqiqət mənbəyi olaraq qalır: summary klikini ələ keçirirəm, anında açılmanı preventDefault edirəm və elementin öz hündürlüyünü yığılmış (yalnız summary) ilə açılmış (summary + panel) ölçü arasında animasiya edirəm, open-ı isə yalnız ən başda və ən sonda çevirirəm. Klaviatura, səhifədə-axtarış və JS-siz hal — hamısı işləməyə davam edir, çünki yerli mexanizmi heç vaxt əvəz etmirəm, sadəcə onun ətrafında animasiya edirəm.

İki təfərrüat mənim üçün vacibdir. Birincisi, müddət qət edilən məsafəyə görə miqyaslanır (məhdudlaşdırılmış halda), beləcə hündür panel sürünmür, qısa panel isə kəsilmir:

private duration(from: string, to: string): number {
  const delta = Math.abs(parseFloat(from) - parseFloat(to));
  return Math.min(420, Math.max(180, Math.round(delta * 0.9)));
}

İkincisi, prefers-reduced-motion. Oxucu az hərəkət istəyibsə, handler-i heç qoşmuram da — brauzerin anında yerli açılması tamamilə toxunulmaz qalır, bu da sıfır-müddətli saxta animasiyadan yaxşıdır. CSS də panelin görünməsi üçün eyni seçimə hörmət edir. Burada əlçatanlıq bir qutucuq deyil; brauzerin onsuz da yaxşı etdiyi işə söykənmək və onun yolundan çəkilməkdir.

Nəyi qurmadım

Analitika yox, cookie banneri yox, şərh sistemi yox, heç nəyi hidrasiya edən JS framework yox. Yeganə müştəri tərəfi kod hash daşıyan keçid, akkordeon və loqodakı yanıb-sönən nöqtədir (o da reduced-motion mühafizəli). Statik redaksiya saytının sakit zövqlərindən biri nə qədər çox şeyi qurmamağa haqqın olmasıdır. Çətin hissələr darıxdırıcı səslənənlər oldu — slug adlı bir sahə, bir gün sürüşən tarix, harada qaldığını unudan bir keçid — və yazmağa dəyən hissələr də elə bunlardır.