Compare commits
3 Commits
415459d55f
...
9f61da990d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f61da990d | |||
|
|
0aa3d5979e | ||
|
|
13eaf9fe90 |
33
api/src/feeds.test.ts
Normal file
33
api/src/feeds.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { feedRoutes } from './feeds.ts';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
after(() => pool.end());
|
||||||
|
|
||||||
|
test('sitemap and feed: every public page; a published version appears with its date, summary, author and lines', async () => {
|
||||||
|
const app = Fastify(); feedRoutes(app);
|
||||||
|
const work = (await pool.query(`SELECT id, url FROM poems WHERE url LIKE '/p266/ghazal/%' ORDER BY id LIMIT 1`)).rows[0];
|
||||||
|
const when = new Date(Date.now() + 60_000).toISOString(); // newer than anything else, so it comes first
|
||||||
|
const rev = (await pool.query(
|
||||||
|
`INSERT INTO revisions (entity, entity_id, version, base_version, content, status, author_email, summary, published_at, credits)
|
||||||
|
VALUES ('work', $1, 99, 98, '', 'published', 'feed-test@divan.test', 'آزمائشی ترمیم', $2, $3) RETURNING id`,
|
||||||
|
[work.id, when, { by: 'آزمائشی موڈریٹر', publishedBy: 'ایڈمن' }])).rows[0].id;
|
||||||
|
try {
|
||||||
|
const s = (await app.inject('/api/sitemap')).json();
|
||||||
|
assert.ok(s.poets.length > 300 && s.categories.length > 500 && s.works.length > 10000, 'all the public pages');
|
||||||
|
assert.ok(s.poets.includes('/p266'));
|
||||||
|
assert.equal(new Date(s.works.find((w: any) => w.url === work.url).updated).toISOString(), when, "a work's date is its latest version");
|
||||||
|
|
||||||
|
const feed = (await app.inject('/api/feed?limit=5')).json();
|
||||||
|
assert.ok(feed.length >= 1 && feed.length <= 5);
|
||||||
|
const first = feed[0];
|
||||||
|
assert.deepEqual([first.kind, first.url, first.version, first.summary, first.by], ['work', work.url, 99, 'آزمائشی ترمیم', 'آزمائشی موڈریٹر']);
|
||||||
|
assert.ok(first.lines.length >= 1, 'the opening lines');
|
||||||
|
assert.equal((await app.inject('/api/feed?limit=999')).json().length <= 100, true, 'at most 100');
|
||||||
|
} finally {
|
||||||
|
await pool.query('DELETE FROM revisions WHERE id = $1', [rev]);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
60
api/src/feeds.ts
Normal file
60
api/src/feeds.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// What the site's sitemap.xml and rss.xml are made from (#83, #87).
|
||||||
|
// GET /api/sitemap every public page: poets, books/chapters, works (with the date of their latest Divan
|
||||||
|
// version), tags, e-books
|
||||||
|
// GET /api/feed?limit= the latest published changes, newest first: new versions of works (version, summary, the
|
||||||
|
// opening lines), e-books, arrangements and tags; who made each (public names)
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
export function feedRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/sitemap', async (_req, reply) => {
|
||||||
|
const [poets, cats, works, tags, ebooks] = await Promise.all([
|
||||||
|
pool.query('SELECT url FROM poets ORDER BY id'),
|
||||||
|
pool.query('SELECT url FROM categories WHERE parent_id IS NOT NULL ORDER BY id'),
|
||||||
|
pool.query(`SELECT p.url, max(r.published_at) AS updated FROM poems p
|
||||||
|
LEFT JOIN revisions r ON r.entity = 'work' AND r.entity_id = p.id AND r.status = 'published'
|
||||||
|
GROUP BY p.id ORDER BY p.id`),
|
||||||
|
pool.query('SELECT DISTINCT t.type, t.name FROM tags t JOIN entity_tags e ON e.tag_id = t.id ORDER BY t.type, t.name'),
|
||||||
|
pool.query('SELECT b.id, t.url AS poet_url FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.published ORDER BY b.id'),
|
||||||
|
]);
|
||||||
|
reply.header('cache-control', 'public, max-age=3600');
|
||||||
|
return {
|
||||||
|
poets: poets.rows.map((r) => r.url),
|
||||||
|
categories: cats.rows.map((r) => r.url),
|
||||||
|
works: works.rows.map((r) => ({ url: r.url, updated: r.updated })),
|
||||||
|
tags: tags.rows,
|
||||||
|
ebooks: ebooks.rows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { limit?: string } }>('/api/feed', async (req, reply) => {
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 100);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT r.id, r.entity, r.entity_id, r.version, r.summary, r.published_at, r.credits,
|
||||||
|
p.title AS work_title, p.url AS work_url, pp.nickname AS work_poet,
|
||||||
|
c.title AS cat_title, c.url AS cat_url, cp.nickname AS cat_poet,
|
||||||
|
b.title AS ebook_title, bp.nickname AS ebook_poet
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id LEFT JOIN poets pp ON pp.id = p.poet_id
|
||||||
|
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id LEFT JOIN poets cp ON cp.id = c.poet_id
|
||||||
|
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id LEFT JOIN poets bp ON bp.id = b.poet_id
|
||||||
|
WHERE r.status = 'published' AND r.published_at IS NOT NULL
|
||||||
|
ORDER BY r.published_at DESC, r.id DESC LIMIT $1`, [limit]);
|
||||||
|
// the opening lines of each new version of a work, for the feed's description
|
||||||
|
const workIds = rows.filter((r) => r.entity === 'work').map((r) => r.entity_id);
|
||||||
|
const lines = workIds.length ? (await pool.query(
|
||||||
|
`SELECT poem_id, array_agg(text ORDER BY vorder) AS lines FROM verses WHERE poem_id = ANY($1) AND vorder <= 2 GROUP BY poem_id`,
|
||||||
|
[workIds])).rows : [];
|
||||||
|
reply.header('cache-control', 'public, max-age=300');
|
||||||
|
return rows.map((r) => {
|
||||||
|
const kind = r.entity === 'work' ? 'work' : r.entity === 'ebook' ? 'ebook' : r.entity === 'order' ? 'order' : 'tags';
|
||||||
|
const title = r.work_title ?? r.cat_title ?? r.ebook_title ?? '';
|
||||||
|
const url = r.work_url ?? r.cat_url ?? (r.entity === 'ebook' ? `/ebook/${r.entity_id}` : '/');
|
||||||
|
return {
|
||||||
|
id: Number(r.id), kind, version: r.version, title, url, poet: r.work_poet ?? r.cat_poet ?? r.ebook_poet ?? null,
|
||||||
|
summary: r.summary ?? null, published: r.published_at, by: r.credits?.by ?? null,
|
||||||
|
lines: lines.find((l) => l.poem_id === r.entity_id)?.lines ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { moderationRoutes } from './moderation.ts';
|
|||||||
import { siteRoutes } from './site.ts';
|
import { siteRoutes } from './site.ts';
|
||||||
import { tagRoutes, pageTags } from './tags.ts';
|
import { tagRoutes, pageTags } from './tags.ts';
|
||||||
import { ebookRoutes } from './ebooks.ts';
|
import { ebookRoutes } from './ebooks.ts';
|
||||||
|
import { feedRoutes } from './feeds.ts';
|
||||||
|
|
||||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||||
|
|
||||||
@ -218,6 +219,7 @@ moderationRoutes(app);
|
|||||||
siteRoutes(app);
|
siteRoutes(app);
|
||||||
tagRoutes(app);
|
tagRoutes(app);
|
||||||
ebookRoutes(app);
|
ebookRoutes(app);
|
||||||
|
feedRoutes(app);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 4100);
|
const port = Number(process.env.PORT ?? 4100);
|
||||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||||
|
|||||||
@ -55,6 +55,7 @@ const PATHS = {
|
|||||||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.9-4.9"/>',
|
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m20 20-4.9-4.9"/>',
|
||||||
chevron: '<path d="m15 18-6-6 6-6"/>',
|
chevron: '<path d="m15 18-6-6 6-6"/>',
|
||||||
// footer
|
// footer
|
||||||
|
rss: '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/>',
|
||||||
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
home: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
lock: '<rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
scale: '<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>',
|
scale: '<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/>',
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { brand } from '../lib/brand';
|
|||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title={`${brand.name}: نئی تبدیلیاں`} href="/rss.xml" />
|
||||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
@ -134,6 +135,7 @@ import { brand } from '../lib/brand';
|
|||||||
<div><h2>{brand.name}</h2><ul>
|
<div><h2>{brand.name}</h2><ul>
|
||||||
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
|
<li><a href="/about"><Icon name="info" size={18} /><span>{brand.name} کے بارے میں<small>مقصد اور طریقۂ کار</small></span></a></li>
|
||||||
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
|
<li><a href="/tags"><Icon name="tag" size={18} /><span>ٹیگ<small>موضوعات سے کلام</small></span></a></li>
|
||||||
|
<li><a href="/rss.xml"><Icon name="rss" size={18} /><span>نئی تبدیلیاں<small>RSS: نئے ورژن اور ای بکس</small></span></a></li>
|
||||||
<li><a href="/privacy"><Icon name="lock" size={18} /><span>رازداری<small>ہم کیا محفوظ رکھتے ہیں</small></span></a></li>
|
<li><a href="/privacy"><Icon name="lock" size={18} /><span>رازداری<small>ہم کیا محفوظ رکھتے ہیں</small></span></a></li>
|
||||||
<li><a href="/terms"><Icon name="scale" size={18} /><span>استعمال کی شرائط<small>لائسنس اور ذمہ داریاں</small></span></a></li>
|
<li><a href="/terms"><Icon name="scale" size={18} /><span>استعمال کی شرائط<small>لائسنس اور ذمہ داریاں</small></span></a></li>
|
||||||
</ul></div>
|
</ul></div>
|
||||||
|
|||||||
4
web/src/lib/site-url.ts
Normal file
4
web/src/lib/site-url.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// The site's public address for links in feeds and the sitemap: SITE_URL if set (e.g. https://divan.pk), else the
|
||||||
|
// address the request came to (behind Caddy that is the public one).
|
||||||
|
export const siteUrl = (url: URL) => (process.env.SITE_URL ?? url.origin).replace(/\/$/, '');
|
||||||
|
export const xml = (s: unknown) => String(s ?? '').replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]!);
|
||||||
15
web/src/pages/robots.txt.ts
Normal file
15
web/src/pages/robots.txt.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// robots.txt: search engines may read the reading pages, not accounts, moderation or search results (endless and
|
||||||
|
// private). DIVAN_NOINDEX=1 (the private preview) keeps everything out.
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { siteUrl } from '../lib/site-url';
|
||||||
|
|
||||||
|
export const GET: APIRoute = ({ url }) => {
|
||||||
|
const closed = process.env.DIVAN_NOINDEX === '1';
|
||||||
|
const body = closed ? 'User-agent: *\nDisallow: /\n' : [
|
||||||
|
'User-agent: *',
|
||||||
|
...['/admin', '/mod', '/account', '/library', '/writers', '/words', '/notes', '/signin', '/signup', '/search', '/api/', '/ebooks/file/', '/health']
|
||||||
|
.map((p) => `Disallow: ${p}`),
|
||||||
|
'', `Sitemap: ${siteUrl(url)}/sitemap.xml`, '',
|
||||||
|
].join('\n');
|
||||||
|
return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
|
||||||
|
};
|
||||||
33
web/src/pages/rss.xml.ts
Normal file
33
web/src/pages/rss.xml.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// rss.xml (RSS 2.0): the latest published changes, to follow or share: new versions of works, new e-books,
|
||||||
|
// arrangements and tags, each with who made it (public names) and, for a work, its opening lines.
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { brand } from '../lib/brand';
|
||||||
|
import { siteUrl, xml } from '../lib/site-url';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { work: 'نیا ورژن', ebook: 'نئی ای بک', order: 'نئی ترتیب', tags: 'ٹیگ' };
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const base = siteUrl(url), items = (await api<any[]>('/api/feed?limit=50')) ?? [];
|
||||||
|
const item = (i: any) => {
|
||||||
|
const title = `${KIND[i.kind]}: ${i.title}${i.poet ? ` (${i.poet})` : ''}${i.kind === 'work' && i.version ? ` · ورژن ${ud(i.version)}` : ''}`;
|
||||||
|
const text = [i.lines.join(' / '), i.summary, i.by && `از ${i.by}`].filter(Boolean).join(' — ');
|
||||||
|
return `<item><title>${xml(title)}</title><link>${xml(base + encodeURI(i.url))}</link>` +
|
||||||
|
`<guid isPermaLink="false">divan-${i.id}</guid><pubDate>${new Date(i.published).toUTCString()}</pubDate>` +
|
||||||
|
`${i.by ? `<dc:creator>${xml(i.by)}</dc:creator>` : ''}<category>${xml(KIND[i.kind])}</category><description>${xml(text)}</description></item>`;
|
||||||
|
};
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<channel>
|
||||||
|
<title>${xml(`${brand.name}: نئی تبدیلیاں`)}</title>
|
||||||
|
<link>${xml(base)}/</link>
|
||||||
|
<description>${xml(`${brand.name} (${brand.tagline}) میں شائع ہونے والے نئے ورژن، ای بکس اور تبدیلیاں`)}</description>
|
||||||
|
<language>ur-PK</language>
|
||||||
|
<atom:link href="${xml(base)}/rss.xml" rel="self" type="application/rss+xml" />
|
||||||
|
${items.length ? `<lastBuildDate>${new Date(items[0].published).toUTCString()}</lastBuildDate>\n` : ''}${items.map(item).join('\n')}
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
`;
|
||||||
|
return new Response(body, { headers: { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=300' } });
|
||||||
|
};
|
||||||
22
web/src/pages/sitemap.xml.ts
Normal file
22
web/src/pages/sitemap.xml.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// sitemap.xml: every public page (poets, books/chapters, works, tags, e-books, and the fixed pages), for search
|
||||||
|
// engines. A work's date is when its latest Divan version was published. About 13,000 addresses (one file holds 50,000).
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { siteUrl, xml } from '../lib/site-url';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
const base = siteUrl(url), s = await api<any>('/api/sitemap');
|
||||||
|
const loc = (path: string, updated?: string | null) =>
|
||||||
|
`<url><loc>${xml(base + encodeURI(path))}</loc>${updated ? `<lastmod>${new Date(updated).toISOString().slice(0, 10)}</lastmod>` : ''}</url>`;
|
||||||
|
const urls = [
|
||||||
|
...['/', '/about', '/tags', '/privacy', '/terms'].map((p) => loc(p)),
|
||||||
|
...s.poets.map((p: string) => loc(p)),
|
||||||
|
...s.poets.map((p: string) => loc(`${p}/ebooks`)),
|
||||||
|
...s.categories.map((c: string) => loc(c)),
|
||||||
|
...s.works.map((w: any) => loc(w.url, w.updated)),
|
||||||
|
...s.tags.map((t: any) => loc(`/tag/${t.type}/${t.name}`)),
|
||||||
|
...s.ebooks.map((b: any) => loc(`/ebook/${b.id}`)),
|
||||||
|
];
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
|
||||||
|
return new Response(body, { headers: { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=3600' } });
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user