components/content/content.ts
286 lines and 5 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| queryHref | 291 to 300 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| intentForm | 302 to 310 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| outcomesOf | 314 to 322 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:315> | 315 to 321 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:320> | 320 to 320 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
Imports
It imports components/bulk-bar/bulk-bar.ts, components/media/media.ts, components/moderation-queue/moderation-queue.ts. Imported by components/content/content.react.tsx, components/media-library/examples.tsx, components/media-library/media-library.react.tsx, components/media-library/media-library.ts, components/mentions-list/examples.tsx, components/mentions-list/mentions-list.react.tsx, components/mentions-list/mentions-list.ts, components/posts-list/examples.tsx, components/posts-list/posts-list.react.tsx, components/posts-list/posts-list.ts.
Source
// capsomer/content: the view data the shared content components take, what their forms get back,
// and the two pure helpers every one of them uses to post an intent and read its outcomes.
//
// The design (docs/design/design-content-components.md, sections 2.2 to 2.4) puts the server half,
// the "content kit", in site-api (job "site-api: the content kit and localClient", S1). That kit
// had not shipped when these were written, so they are defined here from the design and are to be
// reconciled with S1's own types when it does. Where a name came from the site-api contract
// (v0.5.0, src/contract.ts) it is kept: ContentStatus, MediaUse, MentionStatus, MentionCounts.
import type { BulkOutcome } from "../bulk-bar/bulk-bar.ts";
import type { MediaRecord } from "../media/media.ts";
import type { Mention, ModState, ModView } from "../moderation-queue/moderation-queue.ts";
// ---------------------------------------------------------------------------------------
// Shared by every list.
// A post's state on its site. The contract's own words.
export type ContentStatus = "draft" | "scheduled" | "published";
// What this person may do here, decided by the host (Carrel's roles, or a site admin signed in
// through Access, who gets everything). The kit refuses an intent the person may not run, whatever
// the form says; the component only hides what is refused.
export interface ContentCan {
edit: boolean;
publish: boolean;
deleteContent: boolean;
deleteMedia: boolean;
decideMentions: boolean;
}
// A word under a row's title that only the host knows: "2 AI drafts waiting", "New draft",
// "Held by a flag". A tone draws it as a status pill; without one it is a quiet pill.
export interface RowNote {
text: string;
tone?: "info" | "ok" | "warn" | "crit";
}
// What a one-way action asks before it runs: the confirm dialog's title, lead and button, and the
// word to type for what cannot be recovered.
export interface ConfirmSpec {
title: string;
lead?: string;
action: string;
typeToConfirm?: string;
}
// A host's own toolbar action (dustinedwards.info's Ask index maintenance), posted as `intent`
// and run by the host's own handler. Rendered in a fixed place with fixed markup.
export interface ExtraAction {
intent: string;
label: string;
}
// A host's own column, already rendered to text by the kit: one cell per row id.
export interface ExtraColumn {
id: string;
label: string;
cells: Record<string, string>;
}
// One page of a list, by cursor. `total` arrives with site-api v0.6; until then the count says
// what is on the page.
export interface PageInfo {
nextCursor: string | null;
prevCursor?: string | null;
total?: number;
// True while the site cannot sort (site-api v0.5): the kit sorted this page only, and the
// list says so.
sortedOnPage?: boolean;
}
// One place a file is used, as the site's reference check found it (site-api MediaUse).
export interface MediaUse {
type: string;
id: string;
title: string;
detail: string;
}
// The fields a form posts, as the kit reads them back: `ids` repeats.
export type IntentFields = Record<string, string | string[]>;
// One item's outcome of an intent that acted on several. A refusal for one never stops the others.
export interface IntentOutcome {
id: string;
ok: boolean;
message?: string;
// On a refused media delete: every place the file is used.
usedBy?: MediaUse[];
}
// The kit asks before a one-way intent: the component shows this as the confirm dialog (with
// script) or the confirm page (without), and posting it again with `fields` performs it. The
// typed word, when asked, is posted as `confirm`.
export interface ConfirmRequest extends ConfirmSpec {
intent: string;
// Every item it will change, one line each. Never shortened.
items: string[];
fields: IntentFields;
}
// What a posted intent gives back, for the message region, the bulk bar's outcome list and Undo.
export interface IntentResult {
ok: boolean;
// One sentence for the message region: "Unpublished 2 posts."
message: string;
outcomes?: IntentOutcome[];
// The inverse, versions included: posting it undoes this result. Absent where nothing can
// undo it (a first decision on a waiting mention before site-api v0.6, a duplicate on a site
// with no delete).
undo?: { intent: string; fields: IntentFields };
// The item changed since it was read: what the site holds now.
conflict?: { id: string; currentVersion: string | null };
// Asked before a one-way intent runs. Nothing has changed yet.
confirm?: ConfirmRequest;
}
// The script path: a host passes a function that posts the form and resolves with the action's
// result (in React Router, a useFetcher wrapper). Without it the forms post and the page reloads
// with `result`.
export type SubmitIntent = (form: FormData) => Promise<IntentResult>;
// Words a host needs to change: the thing the list holds ("post", "article") and its plural.
export interface ContentLabels {
noun?: string;
plural?: string;
}
// ---------------------------------------------------------------------------------------
// Posts.
export type PostSort = "updated" | "published" | "title";
export interface PostsQuery {
q?: string;
status?: ContentStatus;
kind?: string;
tag?: string;
sort?: PostSort;
cursor?: string;
}
// What the site supports, read from its capabilities by the kit.
export interface PostOffers {
delete: boolean;
schedule: boolean;
tags: boolean;
duplicate: boolean;
}
export interface PostRow {
id: string;
title: string;
kind: string;
status: ContentStatus;
// The public path once published; null while never published.
path: string | null;
// Where the title links: Carrel's editor, or the site's own.
href: string;
// The live page, for "View on the site". Published posts only.
liveHref: string | null;
publishAt: string | null;
publishedAt: string | null;
updatedAt: string | null;
// Present from site-api v0.6; the kit reads each post before writing otherwise.
version?: string;
tags?: string[];
notes?: RowNote[];
}
export interface PostsData {
site: { name: string };
query: PostsQuery;
rows: PostRow[];
page: PageInfo;
// Across the whole site, not the page, for the status tabs.
counts?: Partial<Record<ContentStatus | "all", number>>;
kinds: string[];
offers: PostOffers;
can: ContentCan;
// "New post": the editor with a new id. Absent where the person may not write.
newHref?: string;
// Per-site additions, already rendered to data.
extra?: { columns?: ExtraColumn[]; toolbar?: ExtraAction[] };
}
// ---------------------------------------------------------------------------------------
// Media.
export interface MediaQuery {
q?: string;
tag?: string;
// The library, or the trash (with mediaTrash).
view?: "library" | "trash";
// Tiles or rows, kept in the address.
layout?: "grid" | "list";
// A host's lens (dustinedwards.info's unattached, duplicates): extensions until site-api v0.6.
lens?: string;
cursor?: string;
// The file the inspector is open on: the kit reads it with where it is used.
inspect?: string;
// Picker mode: the file chosen, waiting for its alt text.
pick?: string;
}
// What the site supports, read from its capabilities by the kit. `upload` is the site's own limits
// (capabilities.mediaUpload), checked before any byte is sent; null where it takes no uploads.
export interface MediaOffers {
upload: { maxBytes: number; types: string[] } | null;
alt: boolean;
tags: boolean;
trash: boolean;
delete: boolean;
}
export interface MediaData {
site: { name: string };
query: MediaQuery;
// One page of files, as the media component's records: `used` filled where the kit read it,
// `usedUnknown` where it did not (a list has no usedBy; the inspected file does).
rows: MediaRecord[];
page: PageInfo;
counts?: { library?: number; trash?: number };
// The tags in use, for the chip row, with how many files carry each where the kit knows.
tags: Array<{ tag: string; count?: number }>;
offers: MediaOffers;
can: ContentCan;
// The file in the inspector (?inspect=), read with every place it is used.
inspected?: MediaRecord | null;
// Per-site additions, already rendered to data: lenses (dustinedwards.info's unattached,
// duplicates, no alt), and toolbar actions.
extra?: { lenses?: Array<{ id: string; label: string; count?: number }>; toolbar?: ExtraAction[] };
}
// What the picker gives the host: the file and the alt text the person wrote for this use ("" when
// they said it is decorative).
export interface PickedMedia {
id: string;
url: string;
alt: string;
}
// ---------------------------------------------------------------------------------------
// Mentions.
// A mention's state, the contract's own words (site-api MentionStatus).
export type MentionStatus = ModState;
// How many mentions are in each state across the whole site, not the page (site-api
// MentionCounts), with their sum.
export type MentionCounts = Record<MentionStatus | "all", number>;
export interface MentionsQuery {
// The tab. With none, the list opens on Waiting, or on All when nothing waits.
view?: ModView;
cursor?: string;
// The mention opened in place (its whole excerpt, its source, the exact times).
open?: string;
}
// What the site supports, read from its capabilities by the kit. `reset` is site-api v0.6's
// decision "reset" (back to waiting): without it a decision on a waiting mention has no Undo.
export interface MentionOffers {
reset: boolean;
delete: boolean;
sweep: boolean;
}
export interface MentionsData {
site: { name: string };
query: MentionsQuery;
// One page of mentions, newest first, each as the moderation queue's Mention.
rows: Mention[];
page: PageInfo;
counts: MentionCounts;
// What the next sweep removes, by the site's own retention (site-api MentionList.expiring).
expiring: { failed: number; rejected: number };
// The site's retention in words, from its rules: "A mention whose source was not found is
// removed after 30 days, a rejected one after 90."
retention?: string;
offers: MentionOffers;
can: ContentCan;
}
// ---------------------------------------------------------------------------------------
// The script path, pure.
// The form an IntentResult's undo, or a confirm request, posts: the intent and its fields, a list
// value as one field per item.
// A list's address with a query, its keys in one fixed order and the empty ones left out, so the
// same view is always the same address. `base` is the route (any query on it is dropped).
export function queryHref<Q extends object>(base: string, q: Q, order: ReadonlyArray<keyof Q>): string {
const params = new URLSearchParams();
for (const k of order) {
const v = q[k];
if (typeof v === "string" && v) params.set(String(k), v);
}
const path = base.split("?")[0] || "";
const s = params.toString();
return s ? `${path}?${s}` : path || "?";
}
export function intentForm(intent: string, fields: IntentFields = {}): FormData {
const form = new FormData();
form.set("intent", intent);
for (const [k, v] of Object.entries(fields)) {
if (k === "intent") continue;
for (const one of typeof v === "string" ? [v] : v) form.append(k, one);
}
return form;
}
// A result's outcomes as the bulk bar lists them, each named as the person knows it. An item that
// is gone (deleted) keeps the name it had when the form was posted.
export function outcomesOf(result: Pick<IntentResult, "outcomes">, names: ReadonlyMap<string, string>): BulkOutcome[] {
return (result.outcomes ?? []).map((o) => ({
id: o.id,
label: names.get(o.id) ?? o.id,
ok: o.ok,
message: o.message,
usedBy: o.usedBy?.map((u) => (u.detail ? `${u.title} (${u.detail})` : u.title)),
}));
}