Word sidebar: select a word for its meanings and pronunciation from Wiktionary
Urdu, Persian and Arabic Wiktionaries in that order, each labelled; English meanings, IPA, transliteration and audio from en.wiktionary; Urdu equivalents pivoted through English when Urdu Wiktionary has no entry. Cached in PostgreSQL for 30 days. Left panel on wide screens, bottom sheet on phones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
efb7aacde3
commit
ef618094d5
@ -51,6 +51,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
|
||||
| `GET /api/poets` | all poets |
|
||||
| `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) |
|
||||
| `GET /api/search?q=&poet=&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`) |
|
||||
| `GET /api/word?w=` | one word's meanings and pronunciation from Wiktionary (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none), cached 30 days |
|
||||
| `GET /health` | database check |
|
||||
|
||||
Search normalises both stored text and queries: Arabic ي/ك/ه → Urdu ی/ک/ہ, ۂ/ۓ, diacritics and the Urdu full stop removed; do-chashmi ھ stays distinct.
|
||||
|
||||
34
api/src/dictionary.test.ts
Normal file
34
api/src/dictionary.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { forms, urduEntry, pronunciations } from './dictionary.ts';
|
||||
|
||||
test('spelling forms: diacritics dropped, final noon ghunna tried as noon', () => {
|
||||
assert.deepEqual(forms('ناداں'), ['ناداں', 'نادان']);
|
||||
assert.deepEqual(forms('وِصال'), ['وِصال', 'وصال']);
|
||||
});
|
||||
|
||||
test('ur.wiktionary entry: meanings and origin', () => {
|
||||
const wt = "وِصال {وِصال} ([[عربی]])\n\nاسم [[نکرہ]]\n\n==معانی==\n\n1. بھیٹ، ملاقات، [[عاشق و معشوق]] کی محبت۔\n\n==مرکبات==\n\nوِصال کا دِن";
|
||||
assert.deepEqual(urduEntry(wt), { meanings: ['بھیٹ، ملاقات، عاشق و معشوق کی محبت۔'], origin: 'عربی' });
|
||||
});
|
||||
|
||||
test('en.wiktionary HTML: IPA, transliteration and audio per language', () => {
|
||||
const html = '<h2 data-mw-wikitext="" id="Persian">Persian</h2><span class="IPA nowrap">/qis.ˈmat/</span><span class="IPA">-at</span>' +
|
||||
'<span lang="fa-Latn" class="headword-tr manual-tr tr Latn" dir="ltr">qismat</span><audio><source src="//upload.wikimedia.org/a/b.ogg"></audio>' +
|
||||
'<h2 id="Urdu">Urdu</h2><span class="IPA nowrap">/qɪs.mət̪/</span><span lang="ur-Latn" class="headword-tr tr Latn">qismat</span>';
|
||||
assert.deepEqual(pronunciations(html), {
|
||||
fa: { ipa: ['/qis.ˈmat/'], tr: 'qismat', audio: 'https://upload.wikimedia.org/a/b.ogg' },
|
||||
ur: { ipa: ['/qɪs.mət̪/'], tr: 'qismat', audio: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('fa./ar.wiktionary definitions: skip etymology, follow Arabic pointers', async () => {
|
||||
const { definitions, formsIn } = await import('./dictionary.ts');
|
||||
const fa = '==فارسی==\n===ریشهشناسی===\n* [[عربی]]\n# قسمة\n===اسم===\n#بهره، نصیب.\n#:example\n#سرنوشت، تقدیر.\n====برگردانها====\n# x y z';
|
||||
assert.deepEqual(definitions(fa, 'fa').defs, ['بهره، نصیب.', 'سرنوشت، تقدیر.']);
|
||||
const ar = '== {{اللغة|عربية}} ==\nهل تقصد:\n* [[عِشْق]]\n* [[عَشَقَ]]\n----\n== {{اللغة|أردية}} ==\n# [[x]]';
|
||||
assert.deepEqual(definitions(ar, 'ar'), { defs: [], links: ['عِشْق', 'عَشَقَ'] });
|
||||
assert.deepEqual(definitions('# {{مصدر|عَشِقَ}}.\n# فرط الحب.', 'ar').defs, ['فرط الحب.']);
|
||||
assert.deepEqual(formsIn('fa', 'نگاہ'), ['نگاه']);
|
||||
assert.deepEqual(formsIn('ar', 'معنی'), ['معني', 'معنى']);
|
||||
});
|
||||
156
api/src/dictionary.ts
Normal file
156
api/src/dictionary.ts
Normal file
@ -0,0 +1,156 @@
|
||||
// Word lookup for the reading sidebar, from Wiktionary (CC BY-SA). For Urdu, Persian and Arabic, in that order:
|
||||
// - meanings in the language itself from its own Wiktionary (ur., fa., ar.wiktionary)
|
||||
// - English meanings and pronunciation (IPA, transliteration, audio) from en.wiktionary
|
||||
// With no Urdu meanings, Urdu equivalents are pivoted through English (the English glosses' translation tables).
|
||||
// Found words are cached in PostgreSQL (dictionary table) for 30 days.
|
||||
import { pool } from './db.ts';
|
||||
|
||||
const UA = 'Divan/2 (https://github.com/anas-rashid/divan)';
|
||||
const LANGS = { ur: 'Urdu', fa: 'Persian', ar: 'Arabic' } as const;
|
||||
const DIAC = /[ً-ْٰٔٗ٘]/g;
|
||||
const plain = (html: string) =>
|
||||
html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/&#\d+;/g, '').replace(/\s+/g, ' ').trim();
|
||||
const unlink = (wt: string) => wt.replace(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/g, '$1').replace(/'''?/g, '').trim();
|
||||
|
||||
async function get(url: string, json = true): Promise<any> {
|
||||
const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(8000) }).catch(() => null);
|
||||
if (!res?.ok) return null;
|
||||
return json ? res.json() : res.text();
|
||||
}
|
||||
const rest = (path: string, title: string) => `https://en.wiktionary.org/api/rest_v1/page/${path}/${encodeURIComponent(title)}`;
|
||||
const wikitext = async (host: string, title: string): Promise<string> => {
|
||||
const d = await get(`https://${host}/w/api.php?action=parse&page=${encodeURIComponent(title)}&prop=wikitext&format=json&formatversion=2&redirects=1`);
|
||||
return d?.parse?.wikitext ?? '';
|
||||
};
|
||||
|
||||
// spelling forms to try: as written, without diacritics, final noon ghunna as noon (ناداں -> نادان)
|
||||
export const forms = (w: string) => {
|
||||
const bare = w.replace(DIAC, '').replace(/ۂ/g, 'ہ');
|
||||
return [...new Set([w, bare, bare.replace(/ں$/, 'ن')])];
|
||||
};
|
||||
|
||||
// the word in Persian / Arabic spelling (ہ -> ه, ی -> ي, ک -> ك …); Arabic also tries final alef maqsura
|
||||
const SPELL: Record<string, [RegExp, string][]> = {
|
||||
fa: [[/[\u06C1\u06C2\u06BE]/g, '\u0647'], [/[\u06D2\u064A]/g, '\u06CC'], [/\u0643/g, '\u06A9']],
|
||||
ar: [[/[\u06C1\u06BE]/g, '\u0647'], [/\u06C2/g, '\u0629'], [/[\u06CC\u06D2]/g, '\u064A'], [/\u06A9/g, '\u0643']],
|
||||
};
|
||||
export function formsIn(code: string, w: string) {
|
||||
const base = forms(w);
|
||||
if (!SPELL[code]) return base;
|
||||
const conv = base.map((f) => SPELL[code].reduce((s, [re, to]) => s.replace(re, to), f));
|
||||
return [...new Set([...conv, ...(code === 'ar' ? conv.map((f) => f.replace(/\u064A$/, '\u0649')) : [])])];
|
||||
}
|
||||
|
||||
// fa./ar.wiktionary: "# definition" lines outside etymology/translation sections; on ar.wiktionary only
|
||||
// the Arabic section, and undiacritised pages that just point to entries ("* [[عِشْق]]") give those links
|
||||
export function definitions(wt: string, code: string) {
|
||||
if (code === 'ar' && wt.includes('{{اللغة|')) wt = wt.split(/==\s*\{\{اللغة\|/).find((x) => x.startsWith('عربية')) ?? '';
|
||||
const defs: string[] = [], links: string[] = [];
|
||||
let skip = false;
|
||||
for (const line of wt.split('\n')) {
|
||||
const h = line.match(/^=+\s*(.*?)\s*=+\s*$/);
|
||||
if (h) { skip = /ریشه|ترجم|برگردان|تصريف|تصریف|منابع|مشتق|نفس الجذر/.test(h[1]); continue; }
|
||||
if (skip) continue;
|
||||
const only = line.match(/^[#*]\s*'*\[\[([^\]|]+)\]\]'*\s*(?:\([^)]*\))?\.?\s*$/);
|
||||
if (only) { links.push(only[1]); continue; }
|
||||
const m = line.match(/^#(?![:*])\s*(.+)/);
|
||||
if (!m) continue;
|
||||
let t = m[1];
|
||||
while (/\{\{[^{}]*\}\}/.test(t)) t = t.replace(/\{\{[^{}]*\}\}/g, '');
|
||||
t = unlink(t).replace(/^[\s.،:-]+|[\s]+$/g, '');
|
||||
if (t.length >= 3 && !/^-+$/.test(t)) defs.push(t);
|
||||
}
|
||||
return { defs: defs.slice(0, 5), links };
|
||||
}
|
||||
|
||||
// meanings from the language's own Wiktionary: the first spelling with an entry (following one pointer hop)
|
||||
async function native(code: string, word: string) {
|
||||
const host = `${code}.wiktionary.org`;
|
||||
for (const f of formsIn(code, word)) {
|
||||
const wt = await wikitext(host, f);
|
||||
if (!wt) continue;
|
||||
if (code === 'ur') {
|
||||
const e = urduEntry(wt);
|
||||
if (e.meanings.length) return { defs: e.meanings, origin: e.origin, url: `https://${host}/wiki/${encodeURIComponent(f)}` };
|
||||
continue;
|
||||
}
|
||||
let { defs, links } = definitions(wt, code), title = f;
|
||||
if (!defs.length && links[0]) ({ defs } = definitions(await wikitext(host, (title = links[0])), code));
|
||||
if (defs.length) return { defs, origin: null, url: `https://${host}/wiki/${encodeURIComponent(title)}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ur.wiktionary: numbered lines under ==معانی==, origin from "(عربی)" on the first line
|
||||
export function urduEntry(wt: string) {
|
||||
const m = wt.split(/==\s*معانی\s*==/)[1]?.split(/\n==/)[0] ?? '';
|
||||
const meanings = m.split('\n').map((l) => l.match(/^\s*(?:\d+[.)-]|#)\s*(.+)/)?.[1]).filter(Boolean).map((l) => unlink(l!)).slice(0, 6);
|
||||
const origin = unlink(wt.match(/\((\[\[[^\]]+\]\])\)/)?.[1] ?? '') || null;
|
||||
return { meanings, origin };
|
||||
}
|
||||
|
||||
// en.wiktionary page HTML: per language, IPA, headword transliteration, audio
|
||||
export function pronunciations(html: string) {
|
||||
const out: Record<string, { ipa: string[]; tr: string | null; audio: string | null }> = {};
|
||||
const parts = html.split(/<h2[^>]*id="([^"]+)"/);
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const code = Object.entries(LANGS).find(([, n]) => n === parts[i])?.[0];
|
||||
if (!code) continue;
|
||||
const body = parts[i + 1];
|
||||
const ipa = [...new Set([...body.matchAll(/class="IPA[^"]*"[^>]*>([^<]+)/g)].map((m) => m[1]).filter((x) => /^[/[]/.test(x)))].slice(0, 2);
|
||||
const tr = body.match(new RegExp(`lang="${code}-Latn" class="headword-tr[^"]*"[^>]*>([^<]+)`))?.[1] ?? null;
|
||||
const a = body.match(/"(?:https:)?(\/\/upload\.wikimedia\.org\/[^"]+\.(?:ogg|oga|mp3|wav))"/)?.[1];
|
||||
out[code] = { ipa, tr: tr && plain(tr), audio: a ? 'https:' + a : null };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Urdu equivalents of single-word English glosses, from their translation tables
|
||||
async function pivot(word: string, glosses: string[]) {
|
||||
const seen = new Set([word.replace(DIAC, '')]); // the word itself, and duplicates differing only in diacritics
|
||||
const words = [...new Set(glosses.flatMap((g) => g.split(/[,;]/)).map((s) => s.trim()).filter((s) => /^[a-z]+$/.test(s)))].slice(0, 3); // lowercase: no proper nouns
|
||||
const found = await Promise.all(words.map(async (gloss) => {
|
||||
const wt = await wikitext('en.wiktionary.org', gloss);
|
||||
const lines = [...wt.matchAll(/^\*:? Urdu: (.*)$/gm)].map((m) => m[1]).join(' ');
|
||||
const urdu = [...lines.matchAll(/\{\{t\+?\|ur\|([^|}]+)/g)].map((m) => m[1].trim())
|
||||
.filter((u) => !seen.has(u.replace(DIAC, '')) && seen.add(u.replace(DIAC, ''))).slice(0, 8);
|
||||
return { gloss, urdu };
|
||||
}));
|
||||
return found.filter((f) => f.urdu.length);
|
||||
}
|
||||
|
||||
async function fetchWord(word: string) {
|
||||
let title: string | null = null, defs: any = null;
|
||||
for (const f of forms(word)) if ((defs = await get(rest('definition', f)))) { title = f; break; }
|
||||
const [pron, ...own] = await Promise.all([
|
||||
title ? get(rest('html', title), false).then((h) => pronunciations(h ?? '')) : {},
|
||||
...Object.keys(LANGS).map((c) => native(c, word)),
|
||||
]) as [Record<string, any>, ...(Awaited<ReturnType<typeof native>>)[]];
|
||||
const langs = Object.keys(LANGS).map((code, i) => ({
|
||||
code, ...(pron[code] ?? { ipa: [], tr: null, audio: null }),
|
||||
meanings: own[i]?.defs ?? [], origin: own[i]?.origin ?? null, source: own[i]?.url ?? null,
|
||||
senses: (defs?.[code] ?? []).map((e: any) => ({
|
||||
pos: e.partOfSpeech, defs: e.definitions.map((d: any) => plain(d.definition)).filter(Boolean).slice(0, 4),
|
||||
})).filter((s: any) => s.defs.length).slice(0, 3),
|
||||
})).filter((l) => l.meanings.length || l.senses.length || l.ipa.length || l.tr);
|
||||
const glosses = langs.find((l) => l.senses.length)?.senses[0].defs.slice(0, 2) ?? []; // the primary meaning only
|
||||
const hasUrdu = langs.some((l) => l.code === 'ur' && l.meanings.length);
|
||||
return {
|
||||
word, langs,
|
||||
equivalents: hasUrdu ? [] : await pivot(word, glosses),
|
||||
sources: { en: title && `https://en.wiktionary.org/wiki/${encodeURIComponent(title)}` },
|
||||
found: langs.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function lookup(word: string) {
|
||||
const hit = await pool.query(`SELECT data FROM dictionary WHERE word = $1 AND fetched_at > now() - interval '30 days'`, [word]);
|
||||
if (hit.rows[0]) return hit.rows[0].data;
|
||||
const data = await fetchWord(word);
|
||||
if (!data.found) return data; // not found (or Wiktionary unreachable): don't cache
|
||||
await pool.query(
|
||||
`INSERT INTO dictionary (word, data) VALUES ($1, $2) ON CONFLICT (word) DO UPDATE SET data = $2, fetched_at = now()`,
|
||||
[word, data],
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@ -2,10 +2,12 @@
|
||||
// GET /api/poets all poets
|
||||
// GET /api/page?url=/p238/... poet, category or poem at that URL
|
||||
// GET /api/search?q=&poet=&page=
|
||||
// GET /api/word?w= Wiktionary meanings and pronunciation (sidebar)
|
||||
// GET /health
|
||||
import Fastify from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
import { likePatterns, normalise, terms } from './urdu.ts';
|
||||
import { lookup } from './dictionary.ts';
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||
const PAGE_SIZE = 20;
|
||||
@ -124,5 +126,12 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
|
||||
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results };
|
||||
});
|
||||
|
||||
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader
|
||||
app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => {
|
||||
const w = (req.query.w ?? '').trim();
|
||||
if (!/^[\p{Script=Arabic}\p{M}\u200C]{1,40}$/u.test(w)) return reply.code(400).send({ error: 'one Urdu, Persian or Arabic word' });
|
||||
return lookup(w);
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||
|
||||
@ -57,3 +57,10 @@ CREATE TABLE IF NOT EXISTS verses (
|
||||
text text NOT NULL,
|
||||
PRIMARY KEY (poem_id, vorder)
|
||||
);
|
||||
|
||||
-- Wiktionary lookups for the reading sidebar (api/src/dictionary.ts), refreshed after 30 days
|
||||
CREATE TABLE IF NOT EXISTS dictionary (
|
||||
word text PRIMARY KEY,
|
||||
data jsonb NOT NULL,
|
||||
fetched_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@ -48,6 +48,10 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
<div id="kb" class="wrap kb" hidden></div>
|
||||
</header>
|
||||
<main class="wrap"><slot /></main>
|
||||
<aside id="dict" class="dict" hidden aria-live="polite">
|
||||
<button type="button" class="dict-close" aria-label="بند کریں">✕</button>
|
||||
<div class="dict-body"></div>
|
||||
</aside>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
|
||||
@ -112,6 +116,87 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
if (ch) { ev.preventDefault(); insert(ch); }
|
||||
});
|
||||
|
||||
// word sidebar: select one word in the text -> Wiktionary meanings and pronunciation (data shown as text only)
|
||||
const dict = document.getElementById('dict'), dictBody = dict.querySelector('.dict-body'), main = document.querySelector('main');
|
||||
const LANG = { ur: 'اردو', fa: 'فارسی', ar: 'عربی' };
|
||||
const POS = { Noun: 'اسم', 'Proper noun': 'اسم معرفہ', Verb: 'فعل', Adjective: 'صفت', Adverb: 'متعلق فعل', Pronoun: 'ضمیر',
|
||||
Postposition: 'حرف جار', Preposition: 'حرف جار', Conjunction: 'حرف عطف', Interjection: 'حرف ندا', Particle: 'حرف' };
|
||||
const el = (tag, cls, text, attrs) => {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
for (const k in attrs ?? {}) e.setAttribute(k, attrs[k]);
|
||||
return e;
|
||||
};
|
||||
let current = '';
|
||||
async function showWord(w) {
|
||||
if (w === current && !dict.hidden) return;
|
||||
current = w; dict.hidden = false; document.body.classList.add('dict-open');
|
||||
dictBody.replaceChildren(el('h2', null, w), el('p', 'muted', 'تلاش جاری ہے…'));
|
||||
let d = null;
|
||||
try { const r = await fetch('/api/word?w=' + encodeURIComponent(w)); if (r.ok) d = await r.json(); } catch (e) {}
|
||||
if (w !== current) return; // a newer selection won
|
||||
const out = [el('h2', null, w)];
|
||||
if (!d || !d.found) {
|
||||
dictBody.replaceChildren(...out, el('p', 'muted', 'ویکی لغت میں یہ لفظ نہیں ملا۔'));
|
||||
return;
|
||||
}
|
||||
const list = (items, cls, attrs) => { const ol = el('ol', cls, null, attrs); items.forEach((x) => ol.append(el('li', null, x))); return ol; };
|
||||
const equivalents = () => {
|
||||
const sec = el('section', 'dict-lang');
|
||||
sec.append(el('h3', null, 'ہم معنی اردو الفاظ'), el('p', 'muted note', 'انگریزی کے واسطے سے'));
|
||||
for (const e of d.equivalents) {
|
||||
const p = el('p');
|
||||
p.append(el('bdi', 'en', e.gloss), ': ' + e.urdu.join('، '));
|
||||
sec.append(p);
|
||||
}
|
||||
return sec;
|
||||
};
|
||||
// one section per language, in order اردو، فارسی، عربی; each says where its meanings come from
|
||||
for (const l of d.langs) {
|
||||
const sec = el('section', 'dict-lang');
|
||||
sec.append(el('h3', null, LANG[l.code]));
|
||||
const pr = el('p', 'pron');
|
||||
if (l.tr) pr.append(el('bdi', 'en', l.tr));
|
||||
l.ipa.forEach((i) => pr.append(' ', el('bdi', 'ipa', i)));
|
||||
if (l.audio && l.audio.startsWith('https://upload.wikimedia.org/')) {
|
||||
const b = el('button', 'play', '🔊', { type: 'button', 'aria-label': 'تلفظ سنیں' });
|
||||
b.onclick = () => new Audio(l.audio).play();
|
||||
pr.append(' ', b);
|
||||
}
|
||||
if (pr.childNodes.length) sec.append(pr);
|
||||
if (l.meanings.length) {
|
||||
const label = el('p', 'pos');
|
||||
label.append(`معانی: ${LANG[l.code]} ویکی لغت` + (l.origin ? ` · اصل: ${l.origin}` : '') + ' · ');
|
||||
if (l.source) label.append(el('a', null, 'ماخذ', { href: l.source, target: '_blank', rel: 'noopener' }));
|
||||
sec.append(label, list(l.meanings, null, { lang: l.code }));
|
||||
}
|
||||
for (const s of l.senses) sec.append(el('p', 'pos', `انگریزی میں · ${POS[s.pos] ?? s.pos}`), list(s.defs, 'en', { dir: 'ltr', lang: 'en' }));
|
||||
out.push(sec);
|
||||
if (l.code === 'ur' && d.equivalents.length) out.push(equivalents());
|
||||
}
|
||||
if (d.equivalents.length && !d.langs.some((l) => l.code === 'ur')) out.splice(1, 0, equivalents());
|
||||
const src = el('p', 'src muted');
|
||||
src.append('ویکی لغت · ');
|
||||
if (d.sources.en) src.append(el('a', null, 'انگریزی ویکی لغت', { href: d.sources.en, target: '_blank', rel: 'noopener' }), ' · ');
|
||||
src.append('CC BY-SA');
|
||||
dictBody.replaceChildren(...out, src);
|
||||
}
|
||||
const TRIM = /^[\s"'«»()\[\]،۔؟؛!:.,]+|[\s"'«»()\[\]،۔؟؛!:.,]+$/g;
|
||||
let selTimer;
|
||||
document.addEventListener('selectionchange', () => {
|
||||
clearTimeout(selTimer);
|
||||
selTimer = setTimeout(() => {
|
||||
const sel = getSelection();
|
||||
if (!sel || sel.isCollapsed || !main.contains(sel.anchorNode)) return;
|
||||
const w = sel.toString().replace(TRIM, '').replace(/\u0614/g, ''); // drop the takhallus sign
|
||||
if (/^[\p{Script=Arabic}\p{M}\u200C]{1,40}$/u.test(w)) showWord(w);
|
||||
}, 350);
|
||||
});
|
||||
const closeDict = () => { dict.hidden = true; current = ''; document.body.classList.remove('dict-open'); };
|
||||
dict.querySelector('.dict-close').onclick = closeDict;
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !dict.hidden) closeDict(); });
|
||||
|
||||
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';
|
||||
|
||||
@ -87,6 +87,7 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
||||
: <p class="single"><Seg s={show(b.lines[0])} /></p>
|
||||
)}
|
||||
</article>
|
||||
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>
|
||||
<nav class="poem-nav">
|
||||
{page.next ? <a href={page.next}>اگلا ›</a> : <span />}
|
||||
{page.prev ? <a href={page.prev}>‹ پچھلا</a> : <span />}
|
||||
|
||||
12
web/src/pages/api/word.ts
Normal file
12
web/src/pages/api/word.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Browser -> API proxy for the word sidebar (the API itself is not public)
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
|
||||
|
||||
export const GET: APIRoute = async ({ url }) => {
|
||||
const res = await fetch(`${API}/api/word?w=${encodeURIComponent(url.searchParams.get('w') ?? '')}`);
|
||||
return new Response(await res.text(), {
|
||||
status: res.status,
|
||||
headers: { 'content-type': 'application/json', 'cache-control': res.ok ? 'public, max-age=86400' : 'no-store' },
|
||||
});
|
||||
};
|
||||
@ -109,6 +109,7 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
|
||||
.result .snippet p { margin: 0; }
|
||||
mark { background: var(--gold-subtle); color: inherit; border-radius: 3px; padding: 0 2px; box-shadow: inset 0 -2px var(--gold); }
|
||||
.hl-note { text-align: center; font-size: .85rem; }
|
||||
.hint { text-align: center; font-size: .8rem; margin: 0; }
|
||||
.pager { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }
|
||||
|
||||
.site-footer { border-top: 1.5px solid var(--border); padding: 16px 0; font-size: .85rem; color: var(--muted); text-align: center; }
|
||||
@ -130,3 +131,26 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.kb .wide { min-width: 12em; }
|
||||
.kb .fn { min-width: 3.4em; background: var(--inner); }
|
||||
.kb .fn.on { border-color: var(--brand); color: var(--brand); }
|
||||
|
||||
/* word sidebar (Wiktionary): left side on wide screens, bottom sheet on phones */
|
||||
.dict { position: fixed; top: 0; left: 0; bottom: 0; width: min(340px, 92vw); z-index: 20; overflow-y: auto; padding: 14px 18px 24px;
|
||||
background: var(--paper); border-right: 1.5px solid var(--border); box-shadow: 4px 0 18px rgb(0 0 0 / .18); }
|
||||
.dict h2 { font-size: 1.6rem; margin: 0 0 4px; color: var(--brand); text-align: start; }
|
||||
.dict h2::after { display: none; }
|
||||
.dict h3 { font-size: 1rem; margin: 14px 0 4px; padding-bottom: 2px; border-bottom: 1px dashed var(--gold-light); }
|
||||
.dict ol { margin: 4px 0; padding-inline-start: 22px; }
|
||||
.dict .en { font-family: system-ui, sans-serif; font-size: .88rem; line-height: 1.5; }
|
||||
.dict ol.en { text-align: left; }
|
||||
.dict .ipa { font-family: system-ui, sans-serif; font-size: .88rem; color: var(--muted); }
|
||||
.dict .pron { margin: 2px 0; }
|
||||
.dict .pos { font-size: .8rem; color: var(--muted); margin: 8px 0 0; }
|
||||
.dict .note { font-size: .8rem; margin: 0; }
|
||||
.dict .src { font-size: .78rem; margin-top: 18px; }
|
||||
.dict-close { position: sticky; top: 0; float: left; border: 0; background: none; color: var(--muted); font-size: 1rem; cursor: pointer; }
|
||||
.dict .play { border: 1px solid var(--border); background: var(--inner); border-radius: 8px; cursor: pointer; padding: 0 6px; }
|
||||
/* wide screens: the page moves over instead of being covered */
|
||||
@media (min-width: 1100px) { body.dict-open { padding-left: min(340px, 92vw); } }
|
||||
.dict ol:not(.en) { list-style-type: urdu; }
|
||||
@media (max-width: 700px) {
|
||||
.dict { top: auto; right: 0; width: auto; max-height: 60vh; border-right: 0; border-top: 1.5px solid var(--border); border-radius: 14px 14px 0 0; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user