fedipod-bb 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gateway.mjs CHANGED
@@ -17,6 +17,9 @@ import { keeperSession } from 'fedipod/gateway/keeper-session.mjs';
17
17
  import { ForumAgent } from './forum-agent.mjs';
18
18
  import * as publish from './publish.mjs';
19
19
 
20
+ // The forum's page at its own host, drawn for the gateway to send.
21
+ export { renderPage } from './render.mjs';
22
+
20
23
  // How soon a run comes back for an ask that could not be applied yet.
21
24
  const ASK_RETRY_MS = 60_000;
22
25
 
@@ -195,7 +198,23 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
195
198
  prepare?.(forum); // a test's origins, in place of the network
196
199
  if (!await forum.lease.acquire()) return { skipped: 'another host has the forum', retry: 'soon' };
197
200
  try {
201
+ // A forum on a pod it has not published to — moved there, its rows
202
+ // naming the new pod — is published there whole, and what that pod
203
+ // lacks is read again from where its authors keep it. Recorded once
204
+ // done, so it is done once.
205
+ const moved = forum.store.read('home.json', {}).home !== f.home;
206
+ if (moved) {
207
+ forum.store.setConfig({ ...forum.store.getConfig(), republish: true });
208
+ forum.config = forum.store.getConfig();
209
+ }
198
210
  await forum.publishAll();
211
+ if (moved) {
212
+ const n = await forum.refillCopies().catch((e) => { say(`copying again: ${e.message}`); return null; });
213
+ if (n !== null) {
214
+ forum.store.write('home.json', { home: f.home, at: new Date().toISOString() });
215
+ say(`published whole at ${f.home}${n ? `; ${n} post(s) copied again from their authors` : ''}`);
216
+ }
217
+ }
199
218
  await forum.intake.drain();
200
219
  await forum.applyVerifiedAsks().catch((e) => say(`asks: ${e.message}`));
201
220
  await forum.publishModQueue().catch((e) => say(`queue: ${e.message}`));
package/src/render.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // render.mjs — a forum's page at its own host, drawn on the server: the
2
+ // front page, a category or a topic, read as a stranger reads it and drawn
3
+ // with the page's own drawing, with what it was drawn from in the page so the
4
+ // browser reads none of it again. Anything that does not come out whole is
5
+ // nothing, and the caller sends the page undrawn for the browser to build.
6
+
7
+ import { parseDocument } from 'htmlparser2';
8
+ import { reader, placeOf, viewOfPath } from '../site/read.mjs';
9
+ import { NOBODY, addresses, esc, modNames, modsWho, readIndex, frontRows, rowFacts, moderatorsOf,
10
+ drawFront, readTopic, drawTopic } from '../site/draw.mjs';
11
+
12
+ // The page cleans a post with the browser's parser; here htmlparser2's tree
13
+ // stands in, read through the same few DOM properties the cleaner uses.
14
+ const node = (n) => (n.type === 'text' ? { nodeType: 3, nodeValue: n.data }
15
+ : ['tag', 'script', 'style'].includes(n.type)
16
+ ? { nodeType: 1, nodeName: n.name.toUpperCase(),
17
+ attributes: Object.entries(n.attribs).map(([name, value]) => ({ name, value })),
18
+ get childNodes() { return n.children.map(node); } }
19
+ : { nodeType: 8 });
20
+ export const parse = (html) => ({ childNodes: parseDocument(html).children.map(node) });
21
+
22
+ // How many documents are asked for at once, and how long a reader waits for
23
+ // the drawing before the page goes out undrawn instead.
24
+ const AT = 8;
25
+ const DEADLINE_MS = 2000;
26
+ // Enough posts to fill a page of topics, as the page asks for.
27
+ const FRONT_POSTS = 150;
28
+
29
+ // The page with the drawing in place of its empty parts. Every part it fills
30
+ // must be where it was, or the page is not drawn at all.
31
+ function fill(shell, { title, description, forumName, forumHref, modsLine, acts, trail, main, data }) {
32
+ const swaps = [
33
+ [/<title>[^<]*<\/title>/u, `<title>${esc(title)}</title>${description ? `\n<meta name="description" content="${esc(description)}">` : ''}`],
34
+ [/<a id="forum-link" href="[^"]*">[^<]*<\/a>/u, `<a id="forum-link" href="${esc(forumHref)}">${esc(forumName)}</a>`],
35
+ [/<span id="mods-who"><\/span>/u, `<span id="mods-who">${modsLine}</span>`],
36
+ [/<span id="mods-acts"><\/span>/u, `<span id="mods-acts">${acts}</span>`],
37
+ [/<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"><\/nav>/u,
38
+ `<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"${trail.hidden ? ' hidden' : ''}>${trail.html}</nav>`],
39
+ [/<main id="main" tabindex="-1">[\s\S]*?<\/main>/u, `<main id="main" tabindex="-1">\n${main}\n</main>`],
40
+ // What it was drawn from, for the page: JSON, with nothing in it that can
41
+ // end the element it sits in.
42
+ [/<script type="module"/u, `<script type="application/json" id="bb-data">${JSON.stringify(data).replace(/</gu, '\\u003c')}</script>\n<script type="module"`],
43
+ ];
44
+ let out = shell;
45
+ for (const [at, to] of swaps) {
46
+ if (!at.test(out)) return null;
47
+ out = out.replace(at, () => to);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ async function draw(url, shell, f) {
53
+ const place = placeOf({ origin: url.origin, pathname: url.pathname, search: '' });
54
+ if (!place.own || !place.root || !place.base) return null;
55
+ // Drawn: the front page, a category, a topic. Any other address under the
56
+ // forum is the page's own to build.
57
+ if (!/^(?:c\/[^/]+|t\/[^/]+\/[^/]+)?\/?$/u.test(url.pathname.slice(place.root.length))) return null;
58
+ const view = viewOfPath(url.pathname, place.root);
59
+ const read = reader({ fetch: f, at: AT });
60
+ const forum = await read.forum(place.base);
61
+ if (!forum) return null;
62
+ const cats = forum.categories;
63
+ const addr = addresses(place.root);
64
+ const frontHost = new URL(place.front).host;
65
+ const handleOf = (cat) => (cat.slug ? `@${cat.slug}@${frontHost}` : cat.id);
66
+ const d = { viewer: NOBODY, addr, handleOf, parse };
67
+ const cards = new Map();
68
+ const authorOf = (cbase, id) => {
69
+ const key = `${cbase}|${id}`;
70
+ if (!cards.has(key)) cards.set(key, read.author(cbase, id).catch(() => null));
71
+ return cards.get(key);
72
+ };
73
+ // The edge keeps the page under the forum's and every category's names,
74
+ // the same names their documents carry, so what drops those drops this.
75
+ const tags = [`u-${place.handle}`, ...cats.map(c => c.slug).filter(Boolean).map(s => `u-${s}`)];
76
+ const data = { v: 1, view, forum };
77
+ let drawn;
78
+ let status = 200;
79
+ let names;
80
+ if (view.kind === 'topic') {
81
+ const cat = cats.find(c => c.slug === view.slug || c.base.endsWith('/c/' + view.slug + '/'));
82
+ if (!cat) {
83
+ status = 404;
84
+ drawn = { main: '<p class="err">No such category.</p>', crumbs: { html: '', hidden: true }, title: forum.name || 'FediPod-BB', description: '', acts: '' };
85
+ } else {
86
+ const got = await readTopic(read, cat, view.tid, AT);
87
+ if (!got.t) status = 404;
88
+ drawn = { acts: '', ...drawTopic({ cat, slug: view.slug, ...got }, d) };
89
+ data.topic = got;
90
+ }
91
+ // A topic shows who runs the forum, as the page does when it opens there.
92
+ names = (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
93
+ } else {
94
+ const filter = view.kind === 'cat' ? view.slug : 'all';
95
+ const idx = await readIndex(read, place.base, cats, FRONT_POSTS, AT);
96
+ const rows = frontRows({ cats, idx, filter });
97
+ const facts = await rowFacts(rows.page, cats, authorOf);
98
+ let membersOnly = false;
99
+ if (!rows.topics.length && rows.here) membersOnly = !(await read.topics(rows.here.base)).total && !(await read.canRead(rows.here.base));
100
+ if (view.kind === 'cat' && !rows.here) status = 404;
101
+ drawn = drawFront({ forum, cats, filter, rows, facts, membersOnly, front: place.front }, d);
102
+ const mods = moderatorsOf(idx, rows.here);
103
+ names = mods.length ? await modNames(read, cats, mods, authorOf, AT) : (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
104
+ data.index = idx;
105
+ data.facts = { authors: Object.fromEntries(await Promise.all([...cards].map(async ([k, p]) => [k, await p]))),
106
+ digests: Object.fromEntries(rows.page.map((p, i) => [p.id, facts[i].digest])) };
107
+ }
108
+ data.names = names;
109
+ const html = fill(shell, {
110
+ title: drawn.title, description: drawn.description, forumName: forum.name || 'Forum', forumHref: addr.front(),
111
+ modsLine: modsWho(names), acts: drawn.acts || '', trail: drawn.crumbs, main: drawn.main, data,
112
+ });
113
+ return html ? { status, html, tags } : null;
114
+ }
115
+
116
+ /**
117
+ * The page for `url` at a forum's own host (`https://bb.<gateway>/<forum>/…`),
118
+ * drawn from `shell` (the page as the site serves it), reading the forum
119
+ * through `fetch`. Returns { status, html, tags } or null: null means send
120
+ * the page undrawn. Never throws. A drawing that is not done in time goes on
121
+ * reading after the page has gone, when `keepGoing(promise)` lets it (the
122
+ * host's waitUntil): what it reads is then at hand for the next reader.
123
+ */
124
+ export async function renderPage({ url, shell, fetch: f = globalThis.fetch.bind(globalThis), deadlineMs = DEADLINE_MS, keepGoing = null }) {
125
+ let u;
126
+ try { u = new URL(url); } catch { return null; }
127
+ if (u.search || !shell) return null;
128
+ const work = draw(u, shell, f).catch(() => null);
129
+ let timer;
130
+ const LATE = Symbol('late');
131
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(LATE), deadlineMs); });
132
+ try {
133
+ const out = await Promise.race([work, late]);
134
+ if (out !== LATE) return out;
135
+ try { keepGoing?.(work); } catch { /* the host would not wait */ }
136
+ return null;
137
+ } finally { clearTimeout(timer); }
138
+ }