@synap-core/sdk 1.1.0 → 1.2.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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Lead form — pure model. A form component only renders what these
3
+ * functions decide, so the decisions are testable without a DOM.
4
+ *
5
+ * Privacy rule: the pod answers every accepted submission with the same 202,
6
+ * so the site can only ever say "received". Never add copy that implies
7
+ * whether an email address is already known.
8
+ */
9
+ import type {
10
+ PublicForm,
11
+ PublicFormField,
12
+ PublicReadError,
13
+ SubmitResult,
14
+ } from "./client";
15
+
16
+ export type FieldControl = "input" | "textarea" | "select" | "checkbox";
17
+
18
+ export interface FieldModel {
19
+ key: string;
20
+ label: string;
21
+ help?: string;
22
+ required: boolean;
23
+ control: FieldControl;
24
+ /** HTML input type for `control: "input"`. */
25
+ inputType: "text" | "email" | "tel" | "url" | "number" | "date";
26
+ options: string[];
27
+ maxLength?: number;
28
+ min?: number;
29
+ max?: number;
30
+ }
31
+
32
+ export type FieldValue = string | boolean;
33
+ export type FormValues = Record<string, FieldValue>;
34
+
35
+ const INPUT_TYPE: Record<PublicFormField["type"], FieldModel["inputType"]> = {
36
+ text: "text",
37
+ richtext: "text",
38
+ email: "email",
39
+ phone: "tel",
40
+ url: "url",
41
+ number: "number",
42
+ boolean: "text",
43
+ date: "date",
44
+ enum: "text",
45
+ };
46
+
47
+ function controlFor(type: PublicFormField["type"]): FieldControl {
48
+ if (type === "richtext") return "textarea";
49
+ if (type === "enum") return "select";
50
+ if (type === "boolean") return "checkbox";
51
+ return "input";
52
+ }
53
+
54
+ export function toFieldModels(form: PublicForm): FieldModel[] {
55
+ return form.fields.map((f) => ({
56
+ key: f.key,
57
+ label: f.label,
58
+ help: f.help,
59
+ required: f.required === true,
60
+ control: controlFor(f.type),
61
+ inputType: INPUT_TYPE[f.type],
62
+ options: f.constraints?.enum ?? [],
63
+ maxLength: f.constraints?.maxLength,
64
+ min: f.constraints?.min,
65
+ max: f.constraints?.max,
66
+ }));
67
+ }
68
+
69
+ export function initialValues(fields: FieldModel[]): FormValues {
70
+ return Object.fromEntries(fields.map((f) => [f.key, f.control === "checkbox" ? false : ""]));
71
+ }
72
+
73
+ /** Client-side checks only; the pod re-validates everything. */
74
+ export function validate(fields: FieldModel[], values: FormValues): Record<string, string> {
75
+ const errors: Record<string, string> = {};
76
+ for (const f of fields) {
77
+ const v = values[f.key];
78
+ if (f.control === "checkbox") {
79
+ if (f.required && v !== true) errors[f.key] = "Required";
80
+ continue;
81
+ }
82
+ const s = typeof v === "string" ? v.trim() : "";
83
+ if (s === "") {
84
+ if (f.required) errors[f.key] = "Required";
85
+ continue;
86
+ }
87
+ if (f.maxLength !== undefined && s.length > f.maxLength) {
88
+ errors[f.key] = `At most ${f.maxLength} characters`;
89
+ } else if (f.control === "select" && f.options.length > 0 && !f.options.includes(s)) {
90
+ errors[f.key] = "Pick one of the options";
91
+ } else if (f.inputType === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) {
92
+ errors[f.key] = "Enter a valid email";
93
+ } else if (f.inputType === "number") {
94
+ const n = Number(s);
95
+ if (!Number.isFinite(n)) errors[f.key] = "Enter a number";
96
+ else if (f.min !== undefined && n < f.min) errors[f.key] = `At least ${f.min}`;
97
+ else if (f.max !== undefined && n > f.max) errors[f.key] = `At most ${f.max}`;
98
+ }
99
+ }
100
+ return errors;
101
+ }
102
+
103
+ /** The `fields` payload for `submitForm`: trimmed, empties dropped, numbers typed. */
104
+ export function toSubmission(fields: FieldModel[], values: FormValues): Record<string, unknown> {
105
+ const out: Record<string, unknown> = {};
106
+ for (const f of fields) {
107
+ const v = values[f.key];
108
+ if (f.control === "checkbox") {
109
+ out[f.key] = v === true;
110
+ continue;
111
+ }
112
+ const s = typeof v === "string" ? v.trim() : "";
113
+ if (s === "") continue;
114
+ out[f.key] = f.inputType === "number" ? Number(s) : s;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** Whether the submit button may fire (captcha passthrough). */
120
+ export function captchaSatisfied(form: PublicForm, captchaToken: string | null): boolean {
121
+ return !form.captcha.required || (captchaToken !== null && captchaToken !== "");
122
+ }
123
+
124
+ export function loadErrorMessage(error: PublicReadError): string {
125
+ switch (error) {
126
+ case "not_found":
127
+ return "This form isn't available.";
128
+ case "rate_limited":
129
+ return "Too many requests. Try again in a minute.";
130
+ case "doors_disabled":
131
+ return "This form isn't accepting responses right now.";
132
+ case "network":
133
+ return "We couldn't reach the server. Check your connection.";
134
+ case "server":
135
+ return "Something went wrong loading this form.";
136
+ }
137
+ }
138
+
139
+ /** A failed submit, as the form renders it. */
140
+ export interface SubmitFailure {
141
+ /** One plain sentence for the form; never server detail. */
142
+ message: string;
143
+ /** Sentences by field key, only for keys that are on this form. */
144
+ fieldErrors: Record<string, string>;
145
+ /** Offer "Try again": nothing was filed and resending is safe. */
146
+ retryable: boolean;
147
+ /** The ticket is stale: fetch the form again before the next send. */
148
+ refreshForm: boolean;
149
+ }
150
+
151
+ export type FailedSubmit = Extract<SubmitResult, { ok: false }>;
152
+
153
+ /**
154
+ * What to show for a submit that was NOT received. Success is only ever
155
+ * `{ ok: true, received: true }`; every branch here says "not sent".
156
+ */
157
+ export function submitFailure(res: FailedSubmit, fields: FieldModel[]): SubmitFailure {
158
+ const none = { fieldErrors: {}, retryable: false, refreshForm: false };
159
+ switch (res.error) {
160
+ case "invalid": {
161
+ const known = new Set(fields.map((f) => f.key));
162
+ const fieldErrors = Object.fromEntries(
163
+ res.fields.filter((k) => known.has(k)).map((k) => [k, "Check this answer."])
164
+ );
165
+ const n = Object.keys(fieldErrors).length;
166
+ return {
167
+ ...none,
168
+ fieldErrors,
169
+ message:
170
+ n > 0
171
+ ? "Some answers need another look. Your message was not sent."
172
+ : "We couldn't read that as a message. Reload the page and try again.",
173
+ };
174
+ }
175
+ case "retry":
176
+ return {
177
+ ...none,
178
+ retryable: true,
179
+ refreshForm: true,
180
+ message: "That was sent a little too fast. Your message was not sent; try again.",
181
+ };
182
+ case "unavailable":
183
+ return {
184
+ ...none,
185
+ retryable: true,
186
+ message: "We couldn't save your message just now. It was not sent; try again.",
187
+ };
188
+ case "rate_limited":
189
+ return { ...none, message: "Too many attempts. Try again in a minute." };
190
+ case "doors_disabled":
191
+ return {
192
+ ...none,
193
+ message: "This form isn't accepting responses right now. Your message was not sent.",
194
+ };
195
+ case "network":
196
+ return {
197
+ ...none,
198
+ retryable: true,
199
+ message: "We couldn't reach the server. Your message was not sent.",
200
+ };
201
+ case "server":
202
+ return { ...none, message: "Something went wrong. Your message was not sent." };
203
+ }
204
+ }
205
+
206
+ /** Milliseconds until the pod will accept a submit for a ticket minted at `loadedAt`. */
207
+ export function submitWaitRemaining(minSubmitMs: number, loadedAt: number, now: number): number {
208
+ const wait = Number.isFinite(minSubmitMs) && minSubmitMs > 0 ? minSubmitMs : 0;
209
+ return Math.max(0, loadedAt + wait - now);
210
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@synap-core/sdk/public` — everything a public site needs, framework-free:
3
+ * the credentialless door client and the pure view-models a UI renders.
4
+ *
5
+ * Invariant (pinned by src/__tests__/public.guard.test.ts): every file of this
6
+ * entry imports only its siblings, and the client imports nothing at all.
7
+ */
8
+ export * from "./client";
9
+ export * from "./form-model";
10
+ export * from "./share-model";
11
+ export * from "./safe-markdown";
12
+ export * from "./site-model";
@@ -0,0 +1,114 @@
1
+ /**
2
+ * A deliberately tiny, SAFE markdown reader for published bodies (shares,
3
+ * site records).
4
+ *
5
+ * It produces plain data (blocks of text runs) that React renders as text
6
+ * nodes. It never produces HTML, so there is nothing to sanitize: raw HTML in
7
+ * the source (`<script>`, `<img onerror>`) is shown verbatim as text.
8
+ *
9
+ * Supported: `#`–`######` headings, paragraphs, `-`/`*`/`1.` list items,
10
+ * fenced code blocks, `**bold**`, and `[text](url)` links whose URL is
11
+ * http, https or mailto. Any other link renders as its text only.
12
+ * Everything else is plain text. Extend here — never by rendering HTML.
13
+ */
14
+
15
+ export type Inline =
16
+ | { type: "text"; text: string }
17
+ | { type: "strong"; text: string }
18
+ | { type: "link"; text: string; href: string };
19
+
20
+ export type Block =
21
+ | { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; inlines: Inline[] }
22
+ | { type: "paragraph"; inlines: Inline[] }
23
+ | { type: "list"; ordered: boolean; items: Inline[][] }
24
+ | { type: "code"; text: string };
25
+
26
+ const SAFE_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
27
+
28
+ /** The href if it is an absolute http/https/mailto URL, else null. */
29
+ export function safeHref(raw: string): string | null {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(raw.trim());
33
+ } catch {
34
+ return null;
35
+ }
36
+ return SAFE_PROTOCOLS.has(url.protocol) ? url.href : null;
37
+ }
38
+
39
+ /** A link URL may hold one level of balanced parens (`/wiki/Foo_(bar)`). */
40
+ const INLINE = /\*\*([^*]+)\*\*|\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g;
41
+
42
+ export function parseInlines(text: string): Inline[] {
43
+ const out: Inline[] = [];
44
+ let last = 0;
45
+ for (const m of text.matchAll(INLINE)) {
46
+ const at = m.index ?? 0;
47
+ if (at > last) out.push({ type: "text", text: text.slice(last, at) });
48
+ if (m[1] !== undefined) {
49
+ out.push({ type: "strong", text: m[1] });
50
+ } else {
51
+ const href = safeHref(m[3]);
52
+ out.push(href ? { type: "link", text: m[2], href } : { type: "text", text: m[2] });
53
+ }
54
+ last = at + m[0].length;
55
+ }
56
+ if (last < text.length) out.push({ type: "text", text: text.slice(last) });
57
+ return out;
58
+ }
59
+
60
+ const HEADING = /^(#{1,6})\s+(.*)$/;
61
+ const BULLET = /^\s*[-*]\s+(.*)$/;
62
+ const ORDERED = /^\s*\d+[.)]\s+(.*)$/;
63
+ const FENCE = /^\s*(```|~~~)/;
64
+
65
+ export function parseSafeMarkdown(source: string): Block[] {
66
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
67
+ const blocks: Block[] = [];
68
+ let para: string[] = [];
69
+ let list: { ordered: boolean; items: Inline[][] } | null = null;
70
+
71
+ const flush = () => {
72
+ if (para.length) blocks.push({ type: "paragraph", inlines: parseInlines(para.join(" ")) });
73
+ para = [];
74
+ if (list) blocks.push({ type: "list", ...list });
75
+ list = null;
76
+ };
77
+
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i];
80
+ const fence = line.match(FENCE);
81
+ if (fence) {
82
+ flush();
83
+ const code: string[] = [];
84
+ i++;
85
+ while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++]);
86
+ blocks.push({ type: "code", text: code.join("\n") });
87
+ continue;
88
+ }
89
+ if (line.trim() === "") {
90
+ flush();
91
+ continue;
92
+ }
93
+ const heading = line.match(HEADING);
94
+ if (heading) {
95
+ flush();
96
+ const level = heading[1].length as 1 | 2 | 3 | 4 | 5 | 6;
97
+ blocks.push({ type: "heading", level, inlines: parseInlines(heading[2]) });
98
+ continue;
99
+ }
100
+ const bullet = line.match(BULLET);
101
+ const ordered = bullet ? null : line.match(ORDERED);
102
+ if (bullet || ordered) {
103
+ const isOrdered = ordered !== null;
104
+ if (para.length || (list && list.ordered !== isOrdered)) flush();
105
+ if (!list) list = { ordered: isOrdered, items: [] };
106
+ list.items.push(parseInlines((bullet ?? ordered)![1]));
107
+ continue;
108
+ }
109
+ if (list) flush();
110
+ para.push(line.trim());
111
+ }
112
+ flush();
113
+ return blocks;
114
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared-document page — pure model for a `/d/[token]` page.
3
+ *
4
+ * Every failure to read collapses into ONE "unavailable" state. `not_found`
5
+ * is already uniform at the pod (revoked, expired and unknown are
6
+ * indistinguishable by design); the page keeps that promise and does not add
7
+ * a distinction of its own. `retryable` only says whether "try again later"
8
+ * is honest (a network blip) — it never says anything about the token.
9
+ */
10
+ import type { PublicReadError, PublicResult, PublicShare } from "./client";
11
+ import { parseSafeMarkdown, type Block } from "./safe-markdown";
12
+ import { formatPublicValue, type FormatOptions } from "./site-model";
13
+
14
+ export const UNAVAILABLE_TITLE = "This link isn't available";
15
+
16
+ export type SharePageModel =
17
+ | { kind: "unavailable"; retryable: boolean }
18
+ | {
19
+ kind: "share";
20
+ title: string | null;
21
+ /** Property rows exactly as the publisher allowlisted them. */
22
+ properties: Array<{ key: string; value: string }>;
23
+ blocks: Block[];
24
+ publishedOn: string;
25
+ };
26
+
27
+ function isRetryable(error: PublicReadError): boolean {
28
+ return error === "network" || error === "rate_limited" || error === "server";
29
+ }
30
+
31
+ export function toSharePageModel(
32
+ result: PublicResult<PublicShare>,
33
+ opts: FormatOptions = {}
34
+ ): SharePageModel {
35
+ if (!result.ok) return { kind: "unavailable", retryable: isRetryable(result.error) };
36
+ const share = result.data;
37
+ return {
38
+ kind: "share",
39
+ title: share.title ?? null,
40
+ properties: Object.entries(share.properties ?? {}).map(([key, value]) => ({
41
+ key,
42
+ value: formatPublicValue(value, opts),
43
+ })),
44
+ blocks: share.body ? parseSafeMarkdown(share.body.content) : [],
45
+ publishedOn: share.publishedOn,
46
+ };
47
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Published site — pure models for collection and record pages: one value
3
+ * formatter, link-based pagination (`?page=n`, SEO-friendly) and the record
4
+ * view. A page component only renders what these decide.
5
+ *
6
+ * Formatting is deterministic: the locale and time zone default to `en` and
7
+ * `UTC`, never the runtime's, so a server render and a client render agree.
8
+ */
9
+ import {
10
+ SITE_MAX_LIMIT,
11
+ SITE_MAX_OFFSET,
12
+ type PublicCollectionPage,
13
+ type PublicRecord,
14
+ type PublicRecordSummary,
15
+ type PublicValue,
16
+ } from "./client";
17
+ import { parseSafeMarkdown, type Block } from "./safe-markdown";
18
+
19
+ export interface FormatOptions {
20
+ /** BCP 47 locale (default `en`). */
21
+ locale?: string;
22
+ /** IANA zone for date-times (default `UTC`). Plain dates are never shifted. */
23
+ timeZone?: string;
24
+ /** Words for booleans (default Yes / No). */
25
+ labels?: { yes: string; no: string };
26
+ }
27
+
28
+ /**
29
+ * Group digits only from 5 digits up, so a year stays `2026` and 12345 reads
30
+ * `12,345` (`useGrouping: "min2"`, ES2023 Intl; this package's lib is ES2022,
31
+ * hence the cast). An engine without it falls back to always grouping.
32
+ */
33
+ const GROUPING = { useGrouping: "min2" } as unknown as Intl.NumberFormatOptions;
34
+
35
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
36
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
37
+
38
+ /** A real calendar day (`2026-02-30` is not one). */
39
+ function isCalendarDay(s: string): boolean {
40
+ const m = ISO_DATE.exec(s);
41
+ if (!m) return false;
42
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
43
+ return d.toISOString().slice(0, 10) === s;
44
+ }
45
+
46
+ /**
47
+ * One published value as display text, the same on every site: booleans as
48
+ * words, numbers grouped for the locale, ISO dates and date-times as medium
49
+ * dates (date-times with a short time). Any other string is returned as is.
50
+ * Plain text only: render it as a text node.
51
+ */
52
+ export function formatPublicValue(value: PublicValue, opts: FormatOptions = {}): string {
53
+ const locale = opts.locale ?? "en";
54
+ if (typeof value === "boolean") return value ? (opts.labels?.yes ?? "Yes") : (opts.labels?.no ?? "No");
55
+ if (typeof value === "number") {
56
+ return Number.isFinite(value) ? new Intl.NumberFormat(locale, GROUPING).format(value) : String(value);
57
+ }
58
+ if (isCalendarDay(value)) {
59
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(
60
+ new Date(`${value}T00:00:00Z`)
61
+ );
62
+ }
63
+ if (ISO_DATE_TIME.test(value)) {
64
+ const at = new Date(value);
65
+ if (!Number.isNaN(at.getTime())) {
66
+ return new Intl.DateTimeFormat(locale, {
67
+ dateStyle: "medium",
68
+ timeStyle: "short",
69
+ timeZone: opts.timeZone ?? "UTC",
70
+ }).format(at);
71
+ }
72
+ }
73
+ return value;
74
+ }
75
+
76
+ // ── Pagination ──────────────────────────────────────────────────────────────
77
+
78
+ /** Highest page number `parsePageParam` accepts. */
79
+ const MAX_PAGE_PARAM = 1_000_000;
80
+
81
+ /**
82
+ * A `?page=` value → a page number. Missing or empty → 1; a positive integer
83
+ * → itself; anything else → null (render the not-found state).
84
+ */
85
+ export function parsePageParam(raw: string | string[] | null | undefined): number | null {
86
+ if (raw === undefined || raw === null || raw === "") return 1;
87
+ if (Array.isArray(raw)) return null;
88
+ if (!/^[1-9]\d*$/.test(raw)) return null;
89
+ const n = Number(raw);
90
+ return n <= MAX_PAGE_PARAM ? n : null;
91
+ }
92
+
93
+ /**
94
+ * The `listCollection` query for page `page` (1-based) of `pageSize` items.
95
+ * null when the page lies past the door's offset ceiling (10 000): render
96
+ * the not-found state, never a request that can only be refused.
97
+ */
98
+ export function pageQuery(page: number, pageSize: number): { offset: number; limit: number } | null {
99
+ if (!Number.isInteger(page) || page < 1) return null;
100
+ const limit = Math.min(SITE_MAX_LIMIT, Math.max(1, Math.floor(pageSize) || 1));
101
+ const offset = (page - 1) * limit;
102
+ return offset <= SITE_MAX_OFFSET ? { offset, limit } : null;
103
+ }
104
+
105
+ export interface PageLink {
106
+ page: number;
107
+ /** `basePath` for page 1 (the canonical URL), else `basePath?page=n`. */
108
+ href: string;
109
+ current: boolean;
110
+ }
111
+
112
+ export type PageItem = ({ kind: "page" } & PageLink) | { kind: "gap"; key: string };
113
+
114
+ export interface CollectionPageModel {
115
+ items: PublicRecordSummary[];
116
+ total: number;
117
+ /** Current page, 1-based. */
118
+ page: number;
119
+ /** Reachable pages (≥ 1); the door serves no offset past 10 000. */
120
+ pageCount: number;
121
+ pageSize: number;
122
+ /** 1-based positions of the first and last item shown; 0 and 0 when none. */
123
+ from: number;
124
+ to: number;
125
+ /** Nothing is published in this collection. */
126
+ empty: boolean;
127
+ /** Records exist but not on this page (a stale or hand-typed `?page=`). */
128
+ outOfRange: boolean;
129
+ prev: PageLink | null;
130
+ next: PageLink | null;
131
+ /** First, last, and `window` pages either side of the current one, with gaps. */
132
+ pages: PageItem[];
133
+ }
134
+
135
+ export interface PageModelOptions {
136
+ /** The collection page's path, e.g. `/papers` (may carry its own query). */
137
+ basePath: string;
138
+ /** Pages shown either side of the current one (default 1). */
139
+ window?: number;
140
+ /** Query parameter name (default `page`). */
141
+ param?: string;
142
+ }
143
+
144
+ export function toCollectionPageModel(
145
+ page: PublicCollectionPage,
146
+ opts: PageModelOptions
147
+ ): CollectionPageModel {
148
+ const pageSize = Math.max(1, page.limit);
149
+ const current = Math.floor(Math.max(0, page.offset) / pageSize) + 1;
150
+ const reachable = Math.floor(SITE_MAX_OFFSET / pageSize) + 1;
151
+ const pageCount = Math.max(1, Math.min(Math.ceil(page.total / pageSize), reachable));
152
+ const param = opts.param ?? "page";
153
+ const sep = opts.basePath.includes("?") ? "&" : "?";
154
+ const link = (n: number): PageLink => ({
155
+ page: n,
156
+ href: n === 1 ? opts.basePath : `${opts.basePath}${sep}${param}=${n}`,
157
+ current: n === current,
158
+ });
159
+
160
+ const window = Math.max(0, Math.floor(opts.window ?? 1));
161
+ const shown = new Set<number>([1, pageCount]);
162
+ for (let n = current - window; n <= current + window; n++) {
163
+ if (n >= 1 && n <= pageCount) shown.add(n);
164
+ }
165
+ const pages: PageItem[] = [];
166
+ let last = 0;
167
+ for (const n of [...shown].sort((a, b) => a - b)) {
168
+ if (n - last === 2) pages.push({ kind: "page", ...link(n - 1) });
169
+ else if (n - last > 2) pages.push({ kind: "gap", key: `gap-${last}` });
170
+ pages.push({ kind: "page", ...link(n) });
171
+ last = n;
172
+ }
173
+
174
+ const count = page.items.length;
175
+ const from = count > 0 ? page.offset + 1 : 0;
176
+ return {
177
+ items: page.items,
178
+ total: page.total,
179
+ page: current,
180
+ pageCount,
181
+ pageSize,
182
+ from,
183
+ to: count > 0 ? page.offset + count : 0,
184
+ empty: page.total === 0,
185
+ outOfRange: page.total > 0 && count === 0,
186
+ prev: current > 1 ? link(Math.min(current - 1, pageCount)) : null,
187
+ next: current < pageCount ? link(current + 1) : null,
188
+ pages,
189
+ };
190
+ }
191
+
192
+ // ── Record ──────────────────────────────────────────────────────────────────
193
+
194
+ export interface RecordViewModel {
195
+ title: string | null;
196
+ publicId: string;
197
+ slug: string | null;
198
+ /** Rows exactly as the publisher allowlisted them, formatted. */
199
+ properties: Array<{ key: string; value: string }>;
200
+ /** The body as safe blocks (never HTML). */
201
+ blocks: Block[];
202
+ publishedOn: string;
203
+ }
204
+
205
+ export function toRecordViewModel(record: PublicRecord, opts: FormatOptions = {}): RecordViewModel {
206
+ return {
207
+ title: record.title ?? null,
208
+ publicId: record.publicId,
209
+ slug: record.slug,
210
+ properties: Object.entries(record.properties ?? {}).map(([key, value]) => ({
211
+ key,
212
+ value: formatPublicValue(value, opts),
213
+ })),
214
+ blocks: record.body ? parseSafeMarkdown(record.body.content) : [],
215
+ publishedOn: record.publishedOn,
216
+ };
217
+ }