Divan v2: Astro site with Divan's own design (proof of concept)

- web/: server-rendered Astro over the Node API: home (poets by Hijri century), poet
  (intro + sections), category (numbered contents), poem (couplets, prev/next, source),
  search (paged); Naskh default with Nastaliq toggle, light/dark, Urdu digits, RTL, mobile
- api: section counts include nested categories
- README: how to run v2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-08 20:26:54 +02:00
parent c450ec010a
commit 60f0791405
14 changed files with 4376 additions and 2 deletions

View File

@ -17,6 +17,23 @@ Divan is a standalone project built on a fork of [GanjoorService](https://github
- **Locale:** `ur-PK`. - **Locale:** `ur-PK`.
- **Search without full-text:** the SQL Server Linux image has no full-text search, so poem, similar-poem and comment search use `LIKE` patterns (`LanguageUtils.SearchLikePatterns`). The normaliser handles Urdu letter variants (Arabic ي/ك/ه → ی/ک/ہ, ۂ/ۓ), the Urdu full stop and Urdu diacritics. After changing normalisation rules, rebuild stored search text with `POST /api/divan/regenplaintext/0` (admin). - **Search without full-text:** the SQL Server Linux image has no full-text search, so poem, similar-poem and comment search use `LIKE` patterns (`LanguageUtils.SearchLikePatterns`). The normaliser handles Urdu letter variants (Arabic ي/ك/ه → ی/ک/ہ, ۂ/ۓ), the Urdu full stop and Urdu diacritics. After changing normalisation rules, rebuild stored search text with `POST /api/divan/regenplaintext/0` (admin).
## Divan v2 (Node.js + PostgreSQL + Astro), in progress
The next version replaces the .NET/SQL Server stack with a lighter one. It lives next to the current code until it reaches parity.
- `db/schema.sql`: PostgreSQL schema (poets, categories, poems, verses; trigram index for Urdu substring search)
- `api/`: Node.js API (Fastify + pg, TypeScript run natively by Node 24+, no build step)
- `web/`: the new site (Astro, server-rendered, Divan's own design; Naskh default, Nastaliq option, light/dark)
```sh
docker run -d --name divan-pg -e POSTGRES_USER=divan -e POSTGRES_PASSWORD=divan_local -e POSTGRES_DB=divan \
-p 5433:5432 -v divan-pg:/var/lib/postgresql/data postgres:17-alpine
cd api && npm install && npm run import -- ../../divan-data # or the CDN URL; ~40 s for 11k poems
npm start # API on :4100 (DATABASE_URL to override)
cd ../web && npm install && npm run build && npm start # site on :4200 (API_URL to override)
npm test --prefix ../api # Urdu normaliser tests
```
## Static reader (no server) ## Static reader (no server)
`reader/index.html` is a single-file reader: poets, intros, books and poems, with Nastaliq/Naskh switching. It reads the [divan-data](https://github.com/anas-rashid/divan-data) static API directly in the browser, so it needs no API, database or build step. `reader/index.html` is a single-file reader: poets, intros, books and poems, with Nastaliq/Naskh switching. It reads the [divan-data](https://github.com/anas-rashid/divan-data) static API directly in the browser, so it needs no API, database or build step.

View File

@ -59,9 +59,15 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
const [poet, crumbs, children, poems] = await Promise.all([ const [poet, crumbs, children, poems] = await Promise.all([
pool.query('SELECT * FROM poets WHERE id = $1', [cat.poet_id]), pool.query('SELECT * FROM poets WHERE id = $1', [cat.poet_id]),
ancestors(cat.id), ancestors(cat.id),
// child categories with the number of poems in them and all their sub-categories
pool.query( pool.query(
`SELECT c.url, c.title, (SELECT count(*) FROM poems p WHERE p.category_id = c.id)::int AS poems `WITH RECURSIVE tree AS (
FROM categories c WHERE c.parent_id = $1 ORDER BY c.position, c.id`, SELECT id AS root, id FROM categories WHERE parent_id = $1
UNION ALL
SELECT tree.root, c.id FROM categories c JOIN tree ON c.parent_id = tree.id)
SELECT c.url, c.title, count(p.id)::int AS poems
FROM categories c JOIN tree ON tree.root = c.id LEFT JOIN poems p ON p.category_id = tree.id
GROUP BY c.id ORDER BY c.position, c.id`,
[cat.id], [cat.id],
), ),
pool.query('SELECT url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]), pool.query('SELECT url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),

3
web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
.astro/

10
web/astro.config.mjs Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
// Server-rendered pages (content comes from the Divan API at request time)
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
server: { port: 4200 },
i18n: undefined,
});

3970
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
web/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "divan-web",
"private": true,
"type": "module",
"description": "Divan website (Astro, server-rendered) over the Divan API",
"scripts": {
"dev": "astro dev --port 4200",
"build": "astro build",
"start": "node dist/server/entry.mjs"
},
"dependencies": {
"@astrojs/node": "^11.1.7",
"astro": "^7.3.8"
}
}

1
web/public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#8b2e1f"/><text x="32" y="44" font-size="34" text-anchor="middle" fill="#f8f4ea" font-family="serif">د</text></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1,64 @@
---
import '../styles/global.css';
interface Props { title?: string; description?: string; q?: string }
const { title, description = 'اردو کی کلاسیکی شاعری اور نثر', q = '' } = Astro.props;
const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو کی کلاسیکی شاعری اور نثر';
---
<!doctype html>
<html lang="ur-PK" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Naskh+Arabic:wght@400..700&family=Noto+Nastaliq+Urdu:wght@400..700&display=swap" rel="stylesheet" />
<script is:inline>
// font + theme choice applied before paint (Naskh and system theme by default)
try {
if (localStorage.getItem('divan-font') === 'nastaliq') document.documentElement.classList.add('nastaliq');
const t = localStorage.getItem('divan-theme');
if (t) document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="wordmark" href="/">دیوان</a>
<form class="search-form" action="/search" method="get" role="search">
<input type="search" name="q" value={q} placeholder="شعر، لفظ یا عبارت تلاش کریں" aria-label="تلاش" />
</form>
<div class="toggles">
<button type="button" id="font-toggle" aria-label="خط بدلیں">نسخ</button>
<button type="button" id="theme-toggle" aria-label="روشن یا تاریک">◐</button>
</div>
</div>
</header>
<main class="wrap"><slot /></main>
<footer class="site-footer">
<div class="wrap">
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
</div>
</footer>
<script is:inline>
const root = document.documentElement;
const fontBtn = document.getElementById('font-toggle');
const label = () => (fontBtn.textContent = root.classList.contains('nastaliq') ? 'نستعلیق' : 'نسخ');
label();
fontBtn.onclick = () => {
root.classList.toggle('nastaliq');
try { localStorage.setItem('divan-font', root.classList.contains('nastaliq') ? 'nastaliq' : 'naskh'); } catch (e) {}
label();
};
document.getElementById('theme-toggle').onclick = () => {
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
try { localStorage.setItem('divan-theme', root.dataset.theme); } catch (e) {}
};
</script>
</body>
</html>

9
web/src/lib/api.ts Normal file
View File

@ -0,0 +1,9 @@
// Divan API client (server-side; API_URL defaults to the local API)
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
export async function api<T = any>(path: string): Promise<T | null> {
const res = await fetch(API + path);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`API ${res.status} for ${path}`);
return res.json() as Promise<T>;
}

12
web/src/lib/urdu.ts Normal file
View File

@ -0,0 +1,12 @@
// Urdu display helpers
const DIGITS = '۰۱۲۳۴۵۶۷۸۹';
export const ud = (n: number | string | null | undefined) =>
n == null ? '' : String(n).replace(/[0-9]/g, (d) => DIGITS[+d]);
const ORDINALS = ['', 'پہلی', 'دوسری', 'تیسری', 'چوتھی', 'پانچویں', 'چھٹی', 'ساتویں', 'آٹھویں', 'نویں', 'دسویں',
'گیارہویں', 'بارہویں', 'تیرہویں', 'چودہویں', 'پندرہویں'];
export const centuryOf = (yearAh: number | null) => (yearAh ? Math.floor(yearAh / 100) + 1 : null);
export const centuryName = (c: number | null) => (c ? `${ORDINALS[c] ?? ud(c)} صدی ہجری` : 'دیگر شعرا');
export const years = (birth: number | null, death: number | null) =>
birth || death ? `${birth ? ud(birth) : '؟'} – ${death ? ud(death) : '؟'} ھ` : '';

View File

@ -0,0 +1,73 @@
---
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
import Base from '../layouts/Base.astro';
import { api } from '../lib/api';
import { ud, years } from '../lib/urdu';
const url = '/' + (Astro.params.path ?? '');
const page = await api(`/api/page?url=${encodeURIComponent(url)}`);
if (!page) return new Response(null, { status: 404, statusText: 'Not found' });
// group verses into couplets / single lines / paragraphs for rendering
type Verse = { position: string; couplet: number; text: string };
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[] }[] = [];
if (page.type === 'poem') {
for (const v of page.verses as Verse[]) {
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text] });
else if (v.position === 'Left' && blocks.at(-1)?.kind === 'couplet' && blocks.at(-1)!.lines.length === 1) blocks.at(-1)!.lines.push(v.text);
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text] });
else blocks.push({ kind: 'single', lines: [v.text] });
}
}
const crumbs = page.breadcrumbs ?? [];
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
---
<Base title={page.type === 'poem' ? `${page.poem.title} · ${page.poet.nickname}` : title} description={page.type === 'poet' ? page.poet.description?.slice(0, 160) : undefined}>
{page.type !== 'poet' && (
<nav class="crumbs">
{crumbs.map((c: any, i: number) => (
i < crumbs.length - 1 || page.type === 'poem' ? <span><a href={c.url}>{c.title}</a></span> : <span>{c.title}</span>
))}
</nav>
)}
{page.type === 'poet' && (
<>
<h1>{page.poet.name}</h1>
<p class="muted">{years(page.poet.birth_year_ah, page.poet.death_year_ah)}</p>
{page.poet.description && <div class="intro">{page.poet.description}</div>}
</>
)}
{page.type === 'category' && <h1>{page.category.title}</h1>}
{page.type !== 'poem' && page.children.length > 0 && (
<div class="cards">
{page.children.map((c: any) => (
<a class="card" href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
))}
</div>
)}
{page.type !== 'poem' && page.poems.length > 0 && (
<ol class="toc">{page.poems.map((p: any) => <li><a href={p.url}>{p.title}</a></li>)}</ol>
)}
{page.type === 'poem' && (
<>
<h1>{page.poem.title}</h1>
<article class="poem">
{blocks.map((b) =>
b.kind === 'couplet' ? <div class="couplet">{b.lines.map((l) => <p>{l}</p>)}</div>
: b.kind === 'para' ? <p class="para">{b.lines[0]}</p>
: <p class="single">{b.lines[0]}</p>
)}
</article>
<nav class="poem-nav">
{page.next ? <a href={page.next}>اگلا ›</a> : <span />}
{page.prev ? <a href={page.prev}>‹ پچھلا</a> : <span />}
</nav>
{page.poem.source_url && (
<p class="source">ماخذ: <a href={page.poem.source_url}>ویکی ماخذ</a> · {page.poet && <a href={page.poet.url}>{page.poet.nickname}</a>}</p>
)}
</>
)}
</Base>

39
web/src/pages/index.astro Normal file
View File

@ -0,0 +1,39 @@
---
import Base from '../layouts/Base.astro';
import { api } from '../lib/api';
import { centuryOf, centuryName, years, ud } from '../lib/urdu';
type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null };
const poets = (await api<Poet[]>('/api/poets')) ?? [];
// group by Hijri century of birth (poets without a known year last)
const groups = new Map<number | null, Poet[]>();
for (const p of poets) {
const c = centuryOf(p.birth_year_ah);
if (!groups.has(c)) groups.set(c, []);
groups.get(c)!.push(p);
}
const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99));
---
<Base>
<section class="hero">
<h1>اردو کی کلاسیکی شاعری اور نثر</h1>
<p>{ud(poets.length)} شعرا و ادبا · ویکی ماخذ کے عوامی ملکیت متون</p>
</section>
{poets.length === 0 && <p class="muted">مواد تیار کیا جا رہا ہے۔ براہِ کرم کچھ دیر بعد دوبارہ تشریف لائیں۔</p>}
{ordered.map(([century, list]) => (
<section class="century">
<h2>{centuryName(century)}</h2>
<div class="poets">
{list.map((p) => (
<a class="poet-card" href={p.url}>
{p.nickname}
<small>{years(p.birth_year_ah, p.death_year_ah)}</small>
</a>
))}
</div>
</section>
))}
</Base>

View File

@ -0,0 +1,33 @@
---
import Base from '../layouts/Base.astro';
import { api } from '../lib/api';
import { ud } from '../lib/urdu';
const q = Astro.url.searchParams.get('q')?.trim() ?? '';
const pageNo = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}`) : null;
const pages = res ? Math.ceil(res.total / res.pageSize) : 0;
const link = (n: number) => `/search?q=${encodeURIComponent(q)}&page=${n}`;
---
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
{!q && <p class="muted">اوپر کے خانے میں شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>}
{res && (
<>
<h1>تلاش: {q}</h1>
<p class="muted">{res.total ? `${ud(res.total)} نتائج` : 'کوئی نتیجہ نہیں ملا۔ کم الفاظ یا کلیدی لفظ آزمائیں۔'}</p>
{res.results.map((r: any) => (
<div class="result">
<a href={r.url}>{r.title}</a> · <a class="muted" href={r.poet_url}>{r.poet}</a>
{r.first_line && <div class="first-line">{r.first_line}</div>}
</div>
))}
{pages > 1 && (
<nav class="pager">
{pageNo > 1 && <a href={link(pageNo - 1)}>‹ پچھلا</a>}
<span class="muted">صفحہ {ud(pageNo)} از {ud(pages)}</span>
{pageNo < pages && <a href={link(pageNo + 1)}>اگلا ›</a>}
</nav>
)}
</>
)}
</Base>

122
web/src/styles/global.css Normal file
View File

@ -0,0 +1,122 @@
/* Divan design system: reading-first, RTL, Naskh default (Nastaliq option), light/dark. */
:root {
--bg: #f8f4ea;
--surface: #fffdf7;
--ink: #2a241c;
--muted: #776b5a;
--line: #e7dfcd;
--accent: #8b2e1f; /* deep vermilion, like a manuscript rubric */
--accent-soft: #f3e3dc;
--gold: #b08a3e;
--font: 'Noto Naskh Arabic', 'Noto Nastaliq Urdu', serif;
--verse-lh: 2.1;
--radius: 14px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #15120e; --surface: #1d1913; --ink: #ece3d2; --muted: #a39680; --line: #332c21;
--accent: #e08a6d; --accent-soft: #2c1d17; --gold: #d2ad62; color-scheme: dark;
}
}
:root[data-theme='dark'] {
--bg: #15120e; --surface: #1d1913; --ink: #ece3d2; --muted: #a39680; --line: #332c21;
--accent: #e08a6d; --accent-soft: #2c1d17; --gold: #d2ad62; color-scheme: dark;
}
:root.nastaliq { --font: 'Noto Nastaliq Urdu', 'Noto Naskh Arabic', serif; --verse-lh: 2.7; }
* { box-sizing: border-box; }
html { font-size: 17px; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font-family: var(--font); line-height: 1.9;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 4px; }
.wrap { max-width: 980px; margin: 0 auto; padding: 0 16px; }
/* header */
.site-header { border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 5; }
.site-header .wrap { display: flex; align-items: center; gap: 16px; min-height: 64px; flex-wrap: wrap; }
.wordmark { font-family: 'Noto Nastaliq Urdu', serif; font-size: 1.9rem; line-height: 1.4; color: var(--ink); font-weight: 700; }
.wordmark:hover { text-decoration: none; color: var(--accent); }
.search-form { flex: 1; min-width: 200px; display: flex; }
.search-form input {
flex: 1; font: inherit; padding: 6px 16px; border-radius: 999px; border: 1px solid var(--line);
background: var(--bg); color: var(--ink);
}
.search-form input:focus { outline: 2px solid var(--gold); outline-offset: 1px; }
.toggles { display: flex; gap: 8px; }
.toggles button {
font: inherit; font-size: .85rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
border-radius: 999px; padding: 2px 14px; cursor: pointer;
}
.toggles button:hover { border-color: var(--gold); }
main { padding: 24px 0 64px; }
h1, h2, h3 { line-height: 1.6; margin: .2em 0 .4em; font-weight: 700; }
h1 { font-size: 1.9rem; }
.muted { color: var(--muted); }
.crumbs { font-size: .9rem; color: var(--muted); margin-bottom: 8px; }
.crumbs a { color: var(--muted); }
.crumbs span + span::before { content: '‹'; margin: 0 8px; color: var(--line); }
/* home */
.hero { text-align: center; padding: 24px 0 8px; }
.hero p { color: var(--muted); margin: 0; }
.century { margin-top: 32px; }
.century h2 {
font-size: 1.15rem; color: var(--gold); display: flex; align-items: center; gap: 12px;
}
.century h2::after { content: ''; flex: 1; height: 1px; background: var(--line); }
.poets { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; }
.poet-card {
display: block; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 8px 14px; color: var(--ink);
}
.poet-card:hover { border-color: var(--accent); text-decoration: none; }
.poet-card small { display: block; color: var(--muted); font-size: .8rem; }
/* poet / category */
.intro {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 12px 20px; white-space: pre-line; text-align: justify;
}
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin: 16px 0; }
.card {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
background: var(--accent-soft); border-radius: var(--radius); padding: 10px 16px; color: var(--ink);
}
.card:hover { text-decoration: none; outline: 1px solid var(--accent); }
.card small { color: var(--muted); }
.toc { list-style: none; padding: 0; margin: 16px 0; counter-reset: poem; }
.toc li { counter-increment: poem; border-bottom: 1px dashed var(--line); }
.toc li a { display: flex; gap: 12px; padding: 6px 4px; color: var(--ink); }
.toc li a::before { content: counter(poem, arabic-indic); color: var(--gold); min-width: 2.5em; }
.toc li a:hover { color: var(--accent); text-decoration: none; }
/* poem */
.poem {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 24px clamp(16px, 4vw, 48px); margin: 16px 0;
}
.couplet { display: grid; grid-template-columns: 1fr 1fr; gap: 0 48px; margin: 0 0 14px; line-height: var(--verse-lh); font-size: 1.12rem; }
.couplet p { margin: 0; }
.couplet p:last-child { text-align: left; }
.single { text-align: center; margin: 0 0 6px; line-height: var(--verse-lh); font-size: 1.12rem; }
.para { text-align: justify; margin: 0 0 14px; line-height: var(--verse-lh); }
@media (max-width: 640px) {
.couplet { grid-template-columns: 1fr; }
.couplet p:last-child { text-align: right; padding-right: 2em; }
}
.poem-nav { display: flex; justify-content: space-between; gap: 8px; }
.poem-nav a { border: 1px solid var(--line); border-radius: 999px; padding: 2px 16px; }
.source { font-size: .85rem; color: var(--muted); }
/* search */
.result { border-bottom: 1px solid var(--line); padding: 10px 0; }
.result .first-line { color: var(--muted); }
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
.site-footer { border-top: 1px solid var(--line); padding: 20px 0; font-size: .85rem; color: var(--muted); text-align: center; }