feat: add anonymous view counter with most-read sidebar
- node:sqlite storage in $DATA_DIR/views.db (no native deps) - POST /api/views/<slug>/ counts once per visitor per 24h via daily-salted hash; IPs are never stored, bots are skipped - GET /api/views/top/ feeds the sidebar's featured list, falling back to frontmatter-featured and latest posts when unavailable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+32
-26
@@ -1,13 +1,13 @@
|
||||
# PROJECT_MEMORY — ta1sph.info
|
||||
|
||||
> Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur.
|
||||
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 4)
|
||||
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — Aşama 5)
|
||||
|
||||
## 1. Durum
|
||||
|
||||
- **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.
|
||||
- **Aşama:** 5 tamamlandı (görüntülenme sayacı). Sıradaki: **Aşama 6 — Hakkımda, İletişim, Yasal, 404**.
|
||||
- **Tamamlanan:** Aşama 0–5. Stil önizleme yalnızca dev'de: `http://localhost:4321/styleguide/`.
|
||||
- **Açık işler:** Aşama 5–8. 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
|
||||
@@ -36,6 +36,9 @@
|
||||
- `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/lib/views.ts` — `node:sqlite` sayaç (`$DATA_DIR/views.db`, varsayılan `./data`; Docker'da `/app/data` volume). Tablolar: `views(slug,count)`, `hits(hash,at)` 24 sa, `salts(day,salt)` günlük tuz. IP saklanmaz; `clientIp` önce `X-Forwarded-For`; `isBot` UA filtresi (headless dahil)
|
||||
- `src/pages/api/views/[slug].ts` (POST, 204; bilinmeyen slug 404) · `src/pages/api/views/top.ts` (GET `?limit=1..10`, JSON `{slug,title,url,count}`, 5 dk cache) — ikisi `prerender = false`
|
||||
- Yazı sayfası `sendBeacon('/api/views/<slug>/')`; Sidebar script'i `/api/views/top/` ile `ol[data-featured]`'ı değiştirir, eksikleri build-time yedekle tamamlar
|
||||
- `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/<slug>/index.md` (draft: true); `slug.ts`'yi Node type-stripping ile import eder
|
||||
@@ -45,28 +48,30 @@
|
||||
|
||||
## 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 | 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/<n>` (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 |
|
||||
| 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, volume `/app/data` (sürücü: aşağıdaki 2026-10-16 kararı) | 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 | SQLite sürücüsü `node:sqlite` (better-sqlite3 değil) — kullanıcı onayı | Native derleme yok, ek paket yok; Node 26'da uyarısız. Docker imajı Node ≥24 olmalı (A8'de kesinleşecek) |
|
||||
| 2026-10-16 | Astro `checkOrigin` açık kalıyor | Tarayıcı `Sec-Fetch-Site: same-origin` gönderir; başka sitelerden sayaç şişirme engellenir. curl testlerinde bu başlık eklenmeli |
|
||||
| 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/<n>` (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
|
||||
|
||||
@@ -78,6 +83,7 @@
|
||||
|
||||
## 6. Değişiklik Günlüğü (en yeni üstte)
|
||||
|
||||
- 2026-10-16 | Siteyi Kur A5 | Görüntülenme sayacı (node:sqlite, anonim, 24 sa tekil, bot filtresi), top API, sidebar öne çıkanlar API + yedek; uçtan uca test | `src/lib/views.ts`, `src/pages/api/views/`, `src/components/blog/Sidebar.astro`, `src/pages/blog/[slug].astro`, `src/lib/posts.ts`
|
||||
- 2026-10-16 | Siteyi Kur A4 | Blog: koleksiyon, liste/kategori/etiket arşivleri + sayfalama, detay (paylaş, TOC, DIY, kod kopyala, lisans satırı), sidebar (Pagefind arama), RSS, ana sayfa son yazılar, 3 örnek yazı; axe temiz | `src/content*`, `src/pages/blog/`, `src/pages/rss.xml.ts`, `src/components/blog/`, `src/lib/{posts,dates,markdown-plugins}`, `src/data/{blog,categories}.json`, `astro.config.mjs`, `global.css`
|
||||
- 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`
|
||||
|
||||
@@ -71,9 +71,9 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
|
||||
{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">
|
||||
<ol data-featured data-limit="3" class="flex flex-col gap-4">
|
||||
{featured.map((post, i) => (
|
||||
<li class="flex gap-3">
|
||||
<li class="flex gap-3" data-slug={post.id}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="font-display text-accent-text text-2xl leading-none font-black"
|
||||
@@ -115,3 +115,37 @@ const title = 'font-display mb-4 flex items-center gap-3 text-sm font-bold track
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
// Replace the build-time fallback with the most viewed posts when the API is reachable.
|
||||
const list = document.querySelector<HTMLOListElement>('ol[data-featured]');
|
||||
if (list) {
|
||||
const limit = Number(list.dataset.limit) || 3;
|
||||
fetch(`/api/views/top/?limit=${limit}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((top: { slug: string; title: string; url: string }[]) => {
|
||||
if (!top.length) return;
|
||||
const template = list.querySelector('li');
|
||||
if (!template) return;
|
||||
const fallback = [...list.querySelectorAll<HTMLLIElement>('li')];
|
||||
const items = top.map((t) => {
|
||||
const li = template.cloneNode(true) as HTMLLIElement;
|
||||
li.dataset.slug = t.slug;
|
||||
const a = li.querySelector('a')!;
|
||||
a.href = t.url;
|
||||
a.textContent = t.title;
|
||||
return li;
|
||||
});
|
||||
// Top up with fallback posts not already listed.
|
||||
for (const li of fallback) {
|
||||
if (items.length >= limit) break;
|
||||
if (!items.some((x) => x.dataset.slug === li.dataset.slug)) items.push(li);
|
||||
}
|
||||
items.forEach((li, i) => {
|
||||
li.querySelector('span')!.textContent = String(i + 1).padStart(2, '0');
|
||||
});
|
||||
list.replaceChildren(...items);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
+2
-2
@@ -59,10 +59,10 @@ export function tagCounts(posts: Post[]): TagCount[] {
|
||||
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. */
|
||||
/** Fallback "featured" list: frontmatter `featured` first, topped up with 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);
|
||||
return [...featured, ...posts.filter((p) => !p.data.featured)].slice(0, limit);
|
||||
}
|
||||
|
||||
/** prev = older post, next = newer post. */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Anonymous view counter on node:sqlite (server-only; used by /api/views/*).
|
||||
* Privacy: the IP is never stored. Each hit stores sha256(daySalt + ip + slug) for 24 h only,
|
||||
* the salt is random per UTC day and old salts are deleted, so hashes cannot be linked later.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
let db: DatabaseSync | undefined;
|
||||
|
||||
function open(): DatabaseSync {
|
||||
if (db) return db;
|
||||
const dir = process.env.DATA_DIR ?? './data';
|
||||
mkdirSync(dir, { recursive: true });
|
||||
db = new DatabaseSync(join(dir, 'views.db'));
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS views (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS hits (hash TEXT PRIMARY KEY, at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function daySalt(d: DatabaseSync, now: number): string {
|
||||
const day = new Date(now).toISOString().slice(0, 10);
|
||||
const row = d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as
|
||||
{ salt: string } | undefined;
|
||||
if (row) return row.salt;
|
||||
const salt = randomBytes(32).toString('hex');
|
||||
d.prepare('INSERT OR IGNORE INTO salts (day, salt) VALUES (?, ?)').run(day, salt);
|
||||
d.prepare('DELETE FROM salts WHERE day <> ?').run(day);
|
||||
return (d.prepare('SELECT salt FROM salts WHERE day = ?').get(day) as { salt: string }).salt;
|
||||
}
|
||||
|
||||
/** Counts a view unless the same visitor viewed this slug in the last 24 h. Returns true if counted. */
|
||||
export function recordView(slug: string, ip: string, now = Date.now()): boolean {
|
||||
const d = open();
|
||||
const hash = createHash('sha256')
|
||||
.update(`${daySalt(d, now)}|${ip}|${slug}`)
|
||||
.digest('hex');
|
||||
d.prepare('DELETE FROM hits WHERE at < ?').run(now - DAY_MS);
|
||||
const fresh = d.prepare('INSERT OR IGNORE INTO hits (hash, at) VALUES (?, ?)').run(hash, now);
|
||||
if (fresh.changes === 0) return false;
|
||||
d.prepare(
|
||||
'INSERT INTO views (slug, count) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET count = count + 1',
|
||||
).run(slug);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function topViews(limit: number): { slug: string; count: number }[] {
|
||||
return open()
|
||||
.prepare('SELECT slug, count FROM views WHERE count > 0 ORDER BY count DESC, slug LIMIT ?')
|
||||
.all(limit) as { slug: string; count: number }[];
|
||||
}
|
||||
|
||||
/** First X-Forwarded-For hop (Traefik/Dokploy), else the socket address. */
|
||||
export function clientIp(request: Request, fallback: string): string {
|
||||
return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || fallback;
|
||||
}
|
||||
|
||||
export const isBot = (ua: string | null) =>
|
||||
!ua || /bot|crawl|spider|slurp|preview|facebookexternalhit|headless|lighthouse/i.test(ua);
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getPosts } from '../../../lib/posts';
|
||||
import { clientIp, isBot, recordView } from '../../../lib/views';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// POST /api/views/<slug>/ — counts one view (sent with navigator.sendBeacon).
|
||||
export const POST: APIRoute = async ({ params, request, clientAddress }) => {
|
||||
const slug = params.slug ?? '';
|
||||
const posts = await getPosts();
|
||||
if (!posts.some((p) => p.id === slug)) return new Response(null, { status: 404 });
|
||||
if (!isBot(request.headers.get('user-agent'))) {
|
||||
recordView(slug, clientIp(request, clientAddress));
|
||||
}
|
||||
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getPosts, postUrl } from '../../../lib/posts';
|
||||
import { topViews } from '../../../lib/views';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// GET /api/views/top/?limit=3 — most viewed published posts.
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
const limit = Math.min(10, Math.max(1, Number(url.searchParams.get('limit')) || 3));
|
||||
const posts = new Map((await getPosts()).map((p) => [p.id, p]));
|
||||
const items = topViews(limit * 2) // headroom for deleted/draft slugs
|
||||
.filter((v) => posts.has(v.slug))
|
||||
.slice(0, limit)
|
||||
.map((v) => {
|
||||
const post = posts.get(v.slug)!;
|
||||
return { slug: v.slug, title: post.data.title, url: postUrl(post), count: v.count };
|
||||
});
|
||||
return Response.json(items, { headers: { 'Cache-Control': 'public, max-age=300' } });
|
||||
};
|
||||
@@ -121,6 +121,7 @@ const jsonLd = [
|
||||
|
||||
<div
|
||||
class="prose article-prose md:prose-lg max-w-[72ch]"
|
||||
data-view-slug={post.id}
|
||||
data-code-copy={blog.post.copyCode}
|
||||
data-code-copied={blog.post.codeCopied}
|
||||
>
|
||||
@@ -212,6 +213,12 @@ const jsonLd = [
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// Count one view (anonymous, server dedupes per visitor for 24 h).
|
||||
const viewed = document.querySelector<HTMLElement>('[data-view-slug]')?.dataset.viewSlug;
|
||||
if (viewed && 'sendBeacon' in navigator) {
|
||||
navigator.sendBeacon(`/api/views/${encodeURIComponent(viewed)}/`);
|
||||
}
|
||||
|
||||
// Copy button on every code block.
|
||||
const prose = document.querySelector<HTMLElement>('[data-code-copy]');
|
||||
if (prose) {
|
||||
|
||||
Reference in New Issue
Block a user