/**
* 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: 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',
})