/** * What a Radial link says about itself — the tab title, and the card an unfurler draws. * * Two consumers, one set of rules. The browser calls this to keep `document.title` honest as the * reader moves around, and the Cloudflare Pages worker (`src/edge/`) calls the same functions to * write `` and the `og:*` tags into the shell before a crawler ever sees it. A crawler does * not run the app, so the second consumer is the only reason a pasted link says anything at all; * having it share this module is what keeps the two from disagreeing about the same URL. * * Everything here is pure and text-only. Bodies are member- and agent-authored, so nothing is * assembled as markup: a title is a string, a description is a string, and the one place either * becomes HTML (`edge/head.ts`) escapes it there. Same posture as `prose.ts` — no `{@html}` near an * authored body, ever. */ import type { ArtifactRecord, ArtifactRequestRecord, GoalRecord } from '@radial/core' import { plain, prose } from './prose.js' export const SITE_NAME = 'Radial' /** * The one sentence every Radial URL falls back to. It is repeated verbatim in `src/app.html`, which * is the copy a crawler reads on any route the worker does not touch and on any host that is not * Pages; `test/og-meta.test.mjs` holds the two to each other. */ export const SITE_DESCRIPTION = 'Coordinate humans and coding agents on software goals. Every unit of agent work is requested by a human and lands as a signed, reviewable artifact in its author’s own atproto repo — no central server.' /** Hard caps. Unfurlers truncate anyway; these bound what a hostile record can make us emit. */ export const TITLE_MAX = 300 export const DESCRIPTION_MAX = 300 /** Where a body excerpt is cut. Past roughly this, no unfurler shows the rest. */ export const EXCERPT_MAX = 200 /** Collapse a run of authored text to one line: no newlines, no runs of spaces, trimmed. */ export const oneLine = (text: string): string => text.replace(/\s+/gu, ' ').trim() /** * Cut `text` to `max` characters, on a word boundary when there is one near the end, with an * ellipsis to say it was cut. Counted in code points rather than UTF-16 units so a truncation never * lands inside a surrogate pair and leaves half a character behind. */ export function clamp(text: string, max: number): string { const points = [...text] if (points.length <= max) return text const head = points.slice(0, max - 1).join('') // The cut already fell between words when the character it displaced was a space; otherwise back // up to the last one — unless that throws away most of the line, in which case a word cut in half // reads better than three characters and an ellipsis. const boundary = /\s/u.test(points[max - 1] ?? '') ? head.length : head.lastIndexOf(' ') const kept = boundary > head.length * 0.6 ? head.slice(0, boundary) : head return `${kept.replace(/[\s,;:.—-]+$/u, '')}…` } /** * A markdown body, flattened to the plain sentence a preview can show. * * Blocks are flattened one at a time and rejoined with a space: `plain()` concatenates what it is * given, which is right for the runs inside a paragraph and wrong across them — a heading would * otherwise run straight into the sentence beneath it. */ export const excerpt = (body: string, max = EXCERPT_MAX): string => clamp(oneLine(prose(body).map((block) => plain([block])).join(' ')), max) /** The tab title. The site name trails it so a row of tabs stays identifiable when they narrow. */ export const pageTitle = (label?: string): string => label && label.trim() ? `${clamp(oneLine(label), TITLE_MAX)} · ${SITE_NAME}` : SITE_NAME export interface Preview { /** `og:title` — and, with the site name appended, `<title>`. */ title: string description: string /** `article` once a preview is about one record rather than about the product. */ type: 'website' | 'article' } /** The defaults: what the shell already says, for a URL that names no record. */ export const sitePreview = (): Preview => ({ title: SITE_NAME, description: SITE_DESCRIPTION, type: 'website', }) export const goalPreview = (goal: GoalRecord): Preview => ({ title: clamp(oneLine(goal.title), TITLE_MAX), description: excerpt(goal.body), type: 'article', }) /** * One unit of work on a goal — the drawer a `?unit=` link opens. * * A unit is keyed by the root artifact's URI once something has landed, and by the driving * request's URI until then (§3.7), so both are reachable and both are named here: what landed, or * what was asked for. An artifact that carries no title falls back to naming its type against the * goal, which is what the row it came from reads as too. */ export function unitPreview( goal: GoalRecord, unit: ArtifactRecord | ArtifactRequestRecord, ): Preview { const goalTitle = oneLine(goal.title) if (unit.$type === 'com.disnetdev.radial.artifact') { const title = unit.title ? oneLine(unit.title) : `${unit.type} — ${goalTitle}` return { title: clamp(title, TITLE_MAX), description: excerpt(unit.body), type: 'article' } } return { title: clamp(`${unit.type} requested — ${goalTitle}`, TITLE_MAX), // A request need not carry a brief; the goal it was asked against is the next best thing to // say, and is never empty. description: excerpt(unit.brief ?? goal.body), type: 'article', } } /** * A project route carries a name and nothing else — no DID, so there is no record to read without * already knowing the space. The name is all a preview can honestly claim. */ export const projectPreview = (name: string): Preview => ({ title: clamp(oneLine(name), TITLE_MAX), description: clamp( `Goals, requests and signed artifacts for ${oneLine(name)} on ${SITE_NAME}.`, DESCRIPTION_MAX, ), type: 'website', })