75 lines
5.4 KiB
JavaScript
75 lines
5.4 KiB
JavaScript
// renderReply(body): how a stored reply looks (design 7.4), shared by the Console chat (C2-10b) and the
|
|
// Discord relay (C2-18). It replaces C2-01's items renderer. Pure: no git, no clock, no DOM. The caller
|
|
// passes what only the bus knows: the stored check result (C2-06) and whether a cited file changed.
|
|
// The guard checks a reply before it is stored. This only decides how a body reads; it trusts nothing
|
|
// in the body for a scope or the agent-written label.
|
|
export const SCOPES = Object.freeze(['canon', 'playground', 'supporting', 'web']);
|
|
export const NOTICE_OPS = Object.freeze({ write: 'wrote', delete: 'deleted' });
|
|
const SHA = /^[0-9a-f]{40}$/;
|
|
|
|
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
const line = n => Number.isSafeInteger(n) && n > 0;
|
|
const only = (value, keys) => Object.keys(value).every(k => keys.includes(k));
|
|
const quoted = text => JSON.stringify(text);
|
|
// webby-0041 N5: only an https url is ever a link. Anything else, javascript: and http: included, is text.
|
|
function https(url) {
|
|
try { return new URL(url).protocol === 'https:' ? url : null; } catch { return null; }
|
|
}
|
|
|
|
// The design 5.2 cite shapes. null when the value isn't one, and then the body is not a cited reply.
|
|
function cite(value) {
|
|
if (!plain(value) || typeof value.quote !== 'string') return null;
|
|
if (Object.hasOwn(value, 'url')) return typeof value.url === 'string' && only(value, ['url', 'quote']) ? { url: value.url, quote: value.quote } : null;
|
|
const { path, commit, lines } = value;
|
|
if (!only(value, ['path', 'commit', 'lines', 'quote', 'as']) || typeof path !== 'string' || !SHA.test(commit ?? '')
|
|
|| !Array.isArray(lines) || lines.length !== 2 || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]
|
|
|| (Object.hasOwn(value, 'as') && value.as !== 'canon')) return null;
|
|
return { path, commit, lines: [...lines], quote: value.quote, canon: value.as === 'canon' };
|
|
}
|
|
|
|
// check: the stored entry for this cite, { scope, agent }, or undefined. A path cite takes its badge only
|
|
// from it; a url cite is web by its shape. agent is the Mosaic-Agent trailer value, never the body's.
|
|
function shown(c, check, changed, i) {
|
|
if (c.url !== undefined) return { badge: 'web', url: c.url, link: https(c.url), ref: c.url, quote: c.quote, agent: null, changed: false };
|
|
const scope = plain(check) && SCOPES.includes(check.scope) && check.scope !== 'web' ? check.scope : null;
|
|
const agent = plain(check) && typeof check.agent === 'string' && check.agent !== '' ? check.agent : null;
|
|
const ref = `${c.path}:${c.lines[0] === c.lines[1] ? c.lines[0] : `${c.lines[0]}-${c.lines[1]}`} @${c.commit.slice(0, 7)}`;
|
|
return { badge: scope, path: c.path, commit: c.commit, lines: c.lines, ref, quote: c.quote, canon: c.canon, agent,
|
|
changed: changed?.({ path: c.path, commit: c.commit }, i) === true };
|
|
}
|
|
|
|
// The broker's write notice (design, writes): {"notice": "library-write", op, path, commit}, no agent text.
|
|
function notice(value, name) {
|
|
if (!plain(value) || value.notice !== 'library-write' || !only(value, ['notice', 'op', 'path', 'commit'])
|
|
|| !Object.hasOwn(NOTICE_OPS, value.op) || typeof value.path !== 'string' || !SHA.test(value.commit ?? '')) return null;
|
|
const who = typeof name === 'string' && name !== '' ? name : 'An agent';
|
|
return { kind: 'notice', op: value.op, path: value.path, commit: value.commit,
|
|
text: `${who} ${NOTICE_OPS[value.op]} ${value.path} @${value.commit.slice(0, 7)}` };
|
|
}
|
|
|
|
// body: the stored message body, a string (or an already parsed object).
|
|
// options.check: the stored check result, { citations: [{ scope, agent }] } in body order, or null.
|
|
// options.changed({path, commit}, index) -> true when the file's last commit is newer than the cited one.
|
|
// options.name: the sender's name for a notice line.
|
|
// Returns { kind: 'text', text, plain } | { kind: 'reply', text, citations, plain } | { kind: 'notice', op, path, commit, text, plain }.
|
|
// plain is the whole rendering as text, for a channel that shows no markup.
|
|
export function renderReply(body, options = {}) {
|
|
const raw = typeof body === 'string' ? body : JSON.stringify(body) ?? String(body);
|
|
let value = body;
|
|
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { value = undefined; } }
|
|
// Guard-checked agent messages cannot impersonate a broker-inserted write notice.
|
|
// Broker notices bypass the guard; pre-217 unchecked history remains a documented residual.
|
|
const n = options.check == null ? notice(value, options.name) : null;
|
|
if (n) return { ...n, plain: n.text };
|
|
// Design 5.2: a JSON object must be {text, citations} and nothing else. Any other body is a reply whose
|
|
// text is the whole body and which carries no citation.
|
|
const free = { kind: 'text', text: raw, plain: raw };
|
|
if (!plain(value) || typeof value.text !== 'string' || !Array.isArray(value.citations) || !only(value, ['text', 'citations'])) return free;
|
|
const cites = value.citations.map(cite);
|
|
if (cites.some(c => c === null)) return free;
|
|
const checks = plain(options.check) && Array.isArray(options.check.citations) ? options.check.citations : [];
|
|
const citations = cites.map((c, i) => shown(c, checks[i], options.changed, i));
|
|
const lines = citations.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.agent ? ` (agent-written: ${c.agent})` : ''}${c.changed ? ' (changed since)' : ''}`);
|
|
return { kind: 'reply', text: value.text, citations, plain: [value.text, ...lines].join('\n') };
|
|
}
|