fedipod-bb 0.4.4 → 0.5.0
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/README.md +6 -0
- package/package.json +3 -2
- package/site/bb.js +223 -350
- package/site/draw.mjs +370 -0
- package/site/index.html +2 -2
- package/site/pod.mjs +17 -0
- package/site/read.mjs +61 -18
- package/src/forum-agent.mjs +62 -4
- package/src/gateway.mjs +19 -0
- package/src/render.mjs +135 -0
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,135 @@
|
|
|
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
|
+
const view = viewOfPath(url.pathname, place.root);
|
|
56
|
+
const read = reader({ fetch: f, at: AT });
|
|
57
|
+
const forum = await read.forum(place.base);
|
|
58
|
+
if (!forum) return null;
|
|
59
|
+
const cats = forum.categories;
|
|
60
|
+
const addr = addresses(place.root);
|
|
61
|
+
const frontHost = new URL(place.front).host;
|
|
62
|
+
const handleOf = (cat) => (cat.slug ? `@${cat.slug}@${frontHost}` : cat.id);
|
|
63
|
+
const d = { viewer: NOBODY, addr, handleOf, parse };
|
|
64
|
+
const cards = new Map();
|
|
65
|
+
const authorOf = (cbase, id) => {
|
|
66
|
+
const key = `${cbase}|${id}`;
|
|
67
|
+
if (!cards.has(key)) cards.set(key, read.author(cbase, id).catch(() => null));
|
|
68
|
+
return cards.get(key);
|
|
69
|
+
};
|
|
70
|
+
// The edge keeps the page under the forum's and every category's names,
|
|
71
|
+
// the same names their documents carry, so what drops those drops this.
|
|
72
|
+
const tags = [`u-${place.handle}`, ...cats.map(c => c.slug).filter(Boolean).map(s => `u-${s}`)];
|
|
73
|
+
const data = { v: 1, view, forum };
|
|
74
|
+
let drawn;
|
|
75
|
+
let status = 200;
|
|
76
|
+
let names;
|
|
77
|
+
if (view.kind === 'topic') {
|
|
78
|
+
const cat = cats.find(c => c.slug === view.slug || c.base.endsWith('/c/' + view.slug + '/'));
|
|
79
|
+
if (!cat) {
|
|
80
|
+
status = 404;
|
|
81
|
+
drawn = { main: '<p class="err">No such category.</p>', crumbs: { html: '', hidden: true }, title: forum.name || 'FediPod-BB', description: '', acts: '' };
|
|
82
|
+
} else {
|
|
83
|
+
const got = await readTopic(read, cat, view.tid, AT);
|
|
84
|
+
if (!got.t) status = 404;
|
|
85
|
+
drawn = { acts: '', ...drawTopic({ cat, slug: view.slug, ...got }, d) };
|
|
86
|
+
data.topic = got;
|
|
87
|
+
}
|
|
88
|
+
// A topic shows who runs the forum, as the page does when it opens there.
|
|
89
|
+
names = (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
|
|
90
|
+
} else {
|
|
91
|
+
const filter = view.kind === 'cat' ? view.slug : 'all';
|
|
92
|
+
const idx = await readIndex(read, place.base, cats, FRONT_POSTS, AT);
|
|
93
|
+
const rows = frontRows({ cats, idx, filter });
|
|
94
|
+
const facts = await rowFacts(rows.page, cats, authorOf);
|
|
95
|
+
let membersOnly = false;
|
|
96
|
+
if (!rows.topics.length && rows.here) membersOnly = !(await read.topics(rows.here.base)).total && !(await read.canRead(rows.here.base));
|
|
97
|
+
if (view.kind === 'cat' && !rows.here) status = 404;
|
|
98
|
+
drawn = drawFront({ forum, cats, filter, rows, facts, membersOnly, front: place.front }, d);
|
|
99
|
+
const mods = moderatorsOf(idx, rows.here);
|
|
100
|
+
names = mods.length ? await modNames(read, cats, mods, authorOf, AT) : (forum.admins || []).map(a => ({ id: a.id, handle: a.handle }));
|
|
101
|
+
data.index = idx;
|
|
102
|
+
data.facts = { authors: Object.fromEntries(await Promise.all([...cards].map(async ([k, p]) => [k, await p]))),
|
|
103
|
+
digests: Object.fromEntries(rows.page.map((p, i) => [p.id, facts[i].digest])) };
|
|
104
|
+
}
|
|
105
|
+
data.names = names;
|
|
106
|
+
const html = fill(shell, {
|
|
107
|
+
title: drawn.title, description: drawn.description, forumName: forum.name || 'Forum', forumHref: addr.front(),
|
|
108
|
+
modsLine: modsWho(names), acts: drawn.acts || '', trail: drawn.crumbs, main: drawn.main, data,
|
|
109
|
+
});
|
|
110
|
+
return html ? { status, html, tags } : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The page for `url` at a forum's own host (`https://bb.<gateway>/<forum>/…`),
|
|
115
|
+
* drawn from `shell` (the page as the site serves it), reading the forum
|
|
116
|
+
* through `fetch`. Returns { status, html, tags } or null: null means send
|
|
117
|
+
* the page undrawn. Never throws. A drawing that is not done in time goes on
|
|
118
|
+
* reading after the page has gone, when `keepGoing(promise)` lets it (the
|
|
119
|
+
* host's waitUntil): what it reads is then at hand for the next reader.
|
|
120
|
+
*/
|
|
121
|
+
export async function renderPage({ url, shell, fetch: f = globalThis.fetch.bind(globalThis), deadlineMs = DEADLINE_MS, keepGoing = null }) {
|
|
122
|
+
let u;
|
|
123
|
+
try { u = new URL(url); } catch { return null; }
|
|
124
|
+
if (u.search || !shell) return null;
|
|
125
|
+
const work = draw(u, shell, f).catch(() => null);
|
|
126
|
+
let timer;
|
|
127
|
+
const LATE = Symbol('late');
|
|
128
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(LATE), deadlineMs); });
|
|
129
|
+
try {
|
|
130
|
+
const out = await Promise.race([work, late]);
|
|
131
|
+
if (out !== LATE) return out;
|
|
132
|
+
try { keepGoing?.(work); } catch { /* the host would not wait */ }
|
|
133
|
+
return null;
|
|
134
|
+
} finally { clearTimeout(timer); }
|
|
135
|
+
}
|