@dalgoridim/headless-cms 0.1.0 → 0.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,126 @@
1
+ # Architecture & design notes
2
+
3
+ Core/internal documentation for `@dalgoridim/headless-cms`. For consumer usage,
4
+ see the [README](./README.md). For the history of the headless redesign, see
5
+ [REDESIGN.md](./REDESIGN.md).
6
+
7
+ ## Philosophy: harness, not framework
8
+
9
+ The package wires **behavior** and imposes nothing else. Concretely:
10
+
11
+ - **Zero runtime dependencies.** No CSS framework, icon set, toast library, or
12
+ markdown renderer is bundled. Anything visual or opinionated is the consumer's.
13
+ - **The UI primitives are headless.** They manage `contentEditable`, uploads,
14
+ saves, and edit state, and expose that state (via `data-*` attributes,
15
+ render-props, and hooks) so the consumer owns 100% of the markup.
16
+ - **The store is schemaless.** The engine only ever adds an `id` and `collection`
17
+ for addressing; your content type `T` is never forced to declare them
18
+ (`Editable<T> = T & { id; collection }`).
19
+
20
+ This is a deliberate reaction to the original extraction, where the components
21
+ shipped a finished, portfolio-specific look (Tailwind + a dark palette + a
22
+ `primary` token + a bespoke markup syntax). Adopting the package then meant
23
+ inheriting that design. The redesign split **behavior (package)** from
24
+ **appearance (consumer)**.
25
+
26
+ ## Two layers
27
+
28
+ | Layer | Responsibility | Freedom |
29
+ |---|---|---|
30
+ | **Data / server** | CRUD, the neutral `Query`, auth gating, storage signing | Schemaless; adapters are generic over `T` |
31
+ | **UI / client** | inline-edit behavior, save buffering, upload flow | Headless; consumer supplies all styling/markup |
32
+
33
+ ## Subpath exports & the client/server bundle boundary
34
+
35
+ Server-only SDKs must never reach the client bundle. Two mechanisms enforce this:
36
+
37
+ 1. **Split entries per concern.** Each storage adapter is two modules:
38
+ - `storage/<x>` — the **client** half (`upload`), pure `fetch`, safe in client
39
+ components.
40
+ - `storage/<x>/server` — the **server** half (`sign`), pulls in the SDK.
41
+
42
+ This split exists because a single module that *also* contained a lazy
43
+ `import("cloudinary")` still got traced into the client bundle by
44
+ Next/Turbopack → `Can't resolve 'fs'`. Splitting the entry removes the trace
45
+ entirely.
46
+
47
+ 2. **Auth/data adapters live behind `/server`, `/adapters/*`, `/auth/*`** so a
48
+ client import can never transitively pull a Node-only SDK.
49
+
50
+ ## One React context across entries
51
+
52
+ The built-in auth providers (e.g. `auth/firebase/client`) import
53
+ `CmsAuthProvider` through the package's **public** `@dalgoridim/headless-cms/client`
54
+ specifier — not a relative path — so the provider and the consumer's primitives
55
+ share the **same** context instance at runtime. This is enforced with a tsup
56
+ `external` regex (`/^@dalgoridim\/headless-cms(\/.*)?$/`) plus `tsconfig` paths.
57
+ Using a relative import here would create a second context instance and silently
58
+ break `useCmsAuth`.
59
+
60
+ ## The neutral Query & backend parity
61
+
62
+ `Query` is intentionally backend-agnostic so no native query type (Firestore's
63
+ `Query`, raw SQL) leaks through the engine. Not every backend can honor every
64
+ operator; rather than return wrong results, an adapter **throws** on what it
65
+ cannot do.
66
+
67
+ | Capability | Postgres | Firestore |
68
+ |---|:--:|:--:|
69
+ | `eq` `ne` `lt` `lte` `gt` `gte` | ✅ | ✅ |
70
+ | `in` / `nin` | ✅ | ✅ |
71
+ | `contains` (case-insensitive substring) | ✅ (`ILIKE`) | ❌ throws (no native substring) |
72
+ | `{ or: [...] }` groups | ✅ | ❌ throws |
73
+ | `offset` pagination | ✅ | ✅ |
74
+
75
+ ## Hybrid Postgres adapter
76
+
77
+ Two storage strategies under one adapter:
78
+
79
+ - **Unregistered collections** → a shared JSONB `documents` table, keyed by a
80
+ composite `PRIMARY KEY (collection, id)` (global `id` PKs collided across
81
+ collections — e.g. `project/1`, `skill/1`). Fully schemaless, zero config.
82
+ - **Registered collections** → flat fields mapped onto typed columns, **plus** a
83
+ JSONB `extra` column so unmapped fields are never dropped.
84
+
85
+ Notable details:
86
+
87
+ - `ON CONFLICT DO UPDATE` for typed-table upsert qualifies `extra` as
88
+ `<table>.extra` — a bare `extra` is ambiguous between the target table and the
89
+ `excluded` pseudo-relation.
90
+ - **Caveat:** JSONB fields are extracted as **text** (`data->>'field'`), so
91
+ numeric ordering/comparison in the schemaless table is lexicographic
92
+ (`"10" < "5"`). Register the collection with a typed `int`/`float` column for
93
+ true numeric behavior.
94
+
95
+ ## Relations
96
+
97
+ References are plain field values — a self-describing `{ collection, id }`, a bare
98
+ id string, or an array of either. `resolveRelations(adapter, docs, opts)` runs
99
+ **engine-side** (never in an adapter) so it works identically over Postgres,
100
+ Firestore, or any custom `DataAdapter`. Loads are deduplicated (one `fetchById`
101
+ per unique `(collection, id)`) and parallelized; unresolved refs are left
102
+ untouched.
103
+
104
+ ## Auth model
105
+
106
+ `AuthIdentity` is minimal and open: only `isAdmin` is meaningful to the default
107
+ gate; carry any other claims (roles, scopes, tenant) as extra keys. The gate
108
+ authorizes via an injectable `AuthorizeFn` (`(identity, req) => boolean`),
109
+ defaulting to `identity.isAdmin === true`. This lets a consumer gate on anything
110
+ without the package prescribing an identity shape.
111
+
112
+ ## Headless UI internals
113
+
114
+ - **`ContentEditSpan`** — wires `contentEditable`, persists on blur, and emits
115
+ `data-cms-editable` / `data-cms-editing` / `data-cms-focused`. Rich-text
116
+ rendering is injected via `renderValue(raw) => ReactNode` (default: plain text).
117
+ - **`EditableImage`** — owns the file input, object-URL previews, external-URL
118
+ validation, and the pending-upload queue; hands all state to a render-prop.
119
+ - **`useMarkdownEditor`** — value state + a selection-aware `insert` command +
120
+ save/reset, with no UI. (The reference app skins it with `@uiw/react-md-editor`.)
121
+
122
+ ## Build
123
+
124
+ `tsup` produces ESM + CJS + `.d.ts` per subpath entry, with every backend SDK and
125
+ the package's own specifier marked `external`. `files: ["dist"]` (plus the
126
+ markdown docs) controls what ships to npm.
package/README.md CHANGED
@@ -1,24 +1,30 @@
1
1
  # @dalgoridim/headless-cms
2
2
 
3
- Database-agnostic, inline-edit headless CMS engine for React / Next.js apps.
3
+ Database-agnostic, **inline-edit** headless CMS engine for React / Next.js apps.
4
4
 
5
- Content lives in your database and is editable **inline on the live site** by an
6
- authenticated admin. The persistence, auth, and storage layers are fully
7
- pluggable — swap Firestore for Postgres, Cloudinary for S3, Firebase auth for
8
- NextAuth, without touching the editing UI.
5
+ Content lives in *your* database and is editable **inline on the live site** by an
6
+ authenticated admin. Persistence, auth, and storage are fully pluggable — swap
7
+ Firestore for Postgres, Cloudinary for S3, Firebase auth for NextAuth, without
8
+ touching your UI.
9
+
10
+ **Harness, not framework.** Zero runtime dependencies; the editing components are
11
+ headless (you own all markup and styling); your content shape stays unconstrained.
12
+ You build a thin skin over the primitives (see [Styling](#styling-bring-your-own-look)) —
13
+ the package never pushes a look onto your site. The design rationale and internals
14
+ live in [ARCHITECTURE.md](./ARCHITECTURE.md).
9
15
 
10
16
  ## Install
11
17
 
12
18
  ```bash
13
19
  npm install @dalgoridim/headless-cms
14
- # plus the adapters you actually use, e.g.:
20
+ # plus only the adapters you actually use:
15
21
  npm install firebase firebase-admin cloudinary # Firestore + Cloudinary + Firebase
16
22
  npm install pg # Postgres
17
23
  npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner # S3
18
24
  ```
19
25
 
20
26
  `react` / `react-dom` are peer deps; every backend SDK is an **optional** peer —
21
- you only install the ones your chosen adapters need.
27
+ install only what your chosen adapters need.
22
28
 
23
29
  ## Subpath exports
24
30
 
@@ -27,31 +33,32 @@ Server-only code never leaks into the client bundle. Import from the right entry
27
33
  | Entry | Contents |
28
34
  |---|---|
29
35
  | `@dalgoridim/headless-cms` | Shared types only (safe anywhere) |
30
- | `.../client` | `PageProvider`, `usePageContext`, `ContentEditSpan`, `EditableImage`, `MarkdownEditor`, `CmsAuthProvider`, `useCmsAuth` |
31
- | `.../server` | `createCmsHandlers`, `createAdminGate` |
36
+ | `.../client` | `PageProvider`, `usePageContext`, `ContentEditSpan`, `EditableImage`, `useMarkdownEditor`, `CmsAuthProvider`, `useCmsAuth` |
37
+ | `.../server` | `createCmsHandlers`, `createAdminGate`, `resolveRelations` |
32
38
  | `.../adapters/firestore` | `FirestoreDataAdapter` |
33
39
  | `.../adapters/postgres` | `PostgresDataAdapter` (hybrid JSONB + typed tables) |
34
- | `.../storage/cloudinary` \| `.../storage/s3` \| `.../storage/local` | **Client** upload adapters (`cloudinaryStorage`, `s3Storage`, `localStorage`) — pure `fetch`, safe in client components |
35
- | `.../storage/cloudinary/server` \| `.../storage/s3/server` \| `.../storage/local/server` | **Server** signers (`cloudinarySign`, `s3Sign`, `localSign`) — pull in SDKs, server-only |
40
+ | `.../storage/{cloudinary,s3,local}` | **Client** upload adapters — pure `fetch`, safe in client components |
41
+ | `.../storage/{cloudinary,s3,local}/server` | **Server** signers — pull in SDKs, server-only |
36
42
  | `.../auth/firebase` | `firebaseAuth` (server gate) |
37
43
  | `.../auth/firebase/client` | `FirebaseAuthProvider`, `useFirebaseAuth` |
38
44
  | `.../auth/nextauth` | `nextAuthAuth`, `customAuth` |
39
45
 
40
- ## The three core interfaces
46
+ ## Core interfaces
41
47
 
42
48
  ```ts
43
49
  import type { DataAdapter, AuthAdapter, StorageAdapter } from "@dalgoridim/headless-cms";
44
50
  ```
45
51
 
46
- - **`DataAdapter`** — backend CRUD over a neutral `Query`.
47
- - **`AuthAdapter`** — `verifyRequest(req)` gates every admin route, server-side.
48
- - **Storage** is split into two halves so server SDKs never reach the client
49
- bundle: `ClientStorageAdapter` (`upload`, from `.../storage/<x>`) and
52
+ - **`DataAdapter`** — backend CRUD over a neutral [`Query`](#the-query-language).
53
+ - **`AuthAdapter`** — `verifyRequest(req)` resolves an identity; the gate decides
54
+ ([auth](#auth-the-gate-is-yours)).
55
+ - **Storage** is split in two so server SDKs never reach the client bundle:
56
+ `ClientStorageAdapter` (`upload`, from `.../storage/<x>`) and
50
57
  `ServerStorageAdapter` (`sign`, from `.../storage/<x>/server`).
51
58
 
52
- ## Wiring (Next.js App Router)
59
+ ## Quick start (Next.js App Router)
53
60
 
54
- **1. The admin CRUD route** — replaces a hand-written handler:
61
+ ### 1. Admin CRUD route
55
62
 
56
63
  ```ts
57
64
  // app/api/admin/[collection]/[id]/route.ts
@@ -71,7 +78,7 @@ export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({
71
78
  });
72
79
  ```
73
80
 
74
- **2. The storage sign route** — uses the **server** signer:
81
+ ### 2. Storage sign route (server signer)
75
82
 
76
83
  ```ts
77
84
  // app/api/admin/sign/route.ts
@@ -90,16 +97,15 @@ export const POST = createCmsHandlers({
90
97
  }).sign;
91
98
  ```
92
99
 
93
- **3. Providers + editable content** (client):
100
+ ### 3. Providers (client)
94
101
 
95
102
  ```tsx
96
103
  "use client";
97
- import { PageProvider, ContentEditSpan, EditableImage } from "@dalgoridim/headless-cms/client";
104
+ import { PageProvider } from "@dalgoridim/headless-cms/client";
98
105
  import { FirebaseAuthProvider } from "@dalgoridim/headless-cms/auth/firebase/client";
99
106
  import { cloudinaryStorage } from "@dalgoridim/headless-cms/storage/cloudinary";
100
107
  import { auth, googleProvider } from "@/lib/firebase/config";
101
108
 
102
- // Client upload adapter — posts to the sign route, then to Cloudinary.
103
109
  const storage = cloudinaryStorage({ folder: "uploads", signEndpoint: "/api/admin/sign" });
104
110
 
105
111
  export function Providers({ initialSections, children }) {
@@ -111,18 +117,156 @@ export function Providers({ initialSections, children }) {
111
117
  </FirebaseAuthProvider>
112
118
  );
113
119
  }
120
+ ```
121
+
122
+ `PageProvider` saves `PATCH ${apiBasePath}/{collection}/{id}` (default `apiBasePath`
123
+ = `/api/admin`). Edit mode is driven by `useCmsAuth().isEditing`, which the auth
124
+ providers feed. Pass `notify` to surface your own toasts — the default logs to the
125
+ console so the package carries no toast dependency.
126
+
127
+ ## The editing primitives (headless)
128
+
129
+ ### `ContentEditSpan` — inline-editable text
114
130
 
115
- // Anywhere inside:
116
- <ContentEditSpan collection="portfolio" sectionKey="hero" fieldKey="title" as="h1">
131
+ Wires `contentEditable`, reads/writes the field, and exposes edit state via
132
+ `data-*` attributes. It ships **no styling and no markup language** — supply your
133
+ own via `className` and `renderValue`.
134
+
135
+ ```tsx
136
+ import { ContentEditSpan } from "@dalgoridim/headless-cms/client";
137
+
138
+ <ContentEditSpan
139
+ collection="pages"
140
+ sectionKey="hero"
141
+ fieldKey="title"
142
+ as="h1" // any element/tag; defaults to "span"
143
+ renderValue={(raw) => raw} // turn the stored string into nodes (default: plain text)
144
+ className="my-heading"
145
+ >
117
146
  Default title
118
147
  </ContentEditSpan>
119
- <EditableImage collection="portfolio" sectionKey="hero" fieldKey="image"
120
- docId="hero" src={section.image} />
121
148
  ```
122
149
 
123
- `PageProvider` saves `PATCH ${apiBasePath}/{collection}/{id}` (default
124
- `apiBasePath` = `/api/admin`). Edit mode is driven by `useCmsAuth().isEditing`;
125
- the built-in auth providers feed that context.
150
+ State is exposed as attributes so you can style with plain CSS (or Tailwind
151
+ `data-[...]` variants):
152
+
153
+ | Attribute | Present when |
154
+ |---|---|
155
+ | `data-cms-editable` | always (the primitive is mounted) |
156
+ | `data-cms-editing` | the current user is in edit mode |
157
+ | `data-cms-focused` | the field is actively being edited |
158
+
159
+ ```css
160
+ [data-cms-focused] { outline: 2px solid var(--accent); border-radius: 4px; }
161
+ [data-cms-editing]:hover { outline: 1px dashed var(--accent); cursor: text; }
162
+ ```
163
+
164
+ Want rich text? Pass a `renderValue` that parses your own syntax (markdown, a
165
+ custom mini-language, etc.) into React nodes — the package no longer dictates one.
166
+
167
+ ### `EditableImage` — upload / external-URL via render-prop
168
+
169
+ Manages file picking, object URLs, external-URL validation, and the pending-upload
170
+ queue. You render the chrome:
171
+
172
+ ```tsx
173
+ import { EditableImage } from "@dalgoridim/headless-cms/client";
174
+
175
+ <EditableImage collection="pages" sectionKey="hero" fieldKey="image"
176
+ docId="hero" src={section.image}>
177
+ {({ isEditing, saving, hasError, openFilePicker, setExternalUrl, imgProps }) => (
178
+ <div className="relative">
179
+ <img {...imgProps} alt="" />
180
+ {isEditing && (
181
+ <button onClick={openFilePicker} disabled={saving}>Replace</button>
182
+ )}
183
+ </div>
184
+ )}
185
+ </EditableImage>
186
+ ```
187
+
188
+ Omit the render-prop child and it falls back to a bare unstyled `<img>`.
189
+
190
+ ### `useMarkdownEditor` — headless markdown editing
191
+
192
+ State + a selection-aware `insert` command. Bring your own modal, toolbar, icons,
193
+ and preview (e.g. `react-markdown`):
194
+
195
+ ```tsx
196
+ import { useMarkdownEditor } from "@dalgoridim/headless-cms/client";
197
+
198
+ function Editor({ initialValue, onSave }) {
199
+ const md = useMarkdownEditor({ initialValue, onSave });
200
+ return (
201
+ <>
202
+ <button onClick={() => md.insert("**", "**", "bold")}>Bold</button>
203
+ <textarea ref={md.textareaRef} value={md.value}
204
+ onChange={(e) => md.setValue(e.target.value)} />
205
+ <span>{md.charCount} chars</span>
206
+ <button onClick={md.save}>Save</button>
207
+ </>
208
+ );
209
+ }
210
+ ```
211
+
212
+ ## The Query language
213
+
214
+ A neutral, backend-agnostic query passed to `DataAdapter.fetchCollection`:
215
+
216
+ ```ts
217
+ type Query = {
218
+ filters?: (QueryFilter | { or: QueryFilter[] })[]; // top level AND; groups OR
219
+ orderBy?: { field: string; direction: "asc" | "desc" }[];
220
+ limit?: number;
221
+ offset?: number;
222
+ populate?: string[]; // reference fields to inline-resolve (see Relations)
223
+ };
224
+ ```
225
+
226
+ **Operators** (`QueryFilter = { field, op, value }`):
227
+
228
+ | op | meaning | Postgres | Firestore |
229
+ |---|---|:--:|:--:|
230
+ | `eq` / `ne` | equals / not equals | ✅ | ✅ |
231
+ | `lt` `lte` `gt` `gte` | comparisons | ✅ | ✅ |
232
+ | `in` / `nin` | value in / not in array | ✅ | ✅ |
233
+ | `contains` | case-insensitive substring | ✅ | ❌ throws |
234
+ | `{ or: [...] }` | disjunction | ✅ | ❌ throws |
235
+
236
+ Where a backend can't honor an op, it throws a clear error rather than returning
237
+ wrong results.
238
+
239
+ ```ts
240
+ await data.fetchCollection("posts", {
241
+ filters: [
242
+ { field: "title", op: "contains", value: "hello" },
243
+ { or: [{ field: "tag", op: "eq", value: "react" },
244
+ { field: "tag", op: "eq", value: "sql" }] },
245
+ ],
246
+ orderBy: [{ field: "createdAt", direction: "desc" }],
247
+ limit: 20,
248
+ offset: 40,
249
+ });
250
+ ```
251
+
252
+ ## Relations
253
+
254
+ References are just fields — a self-describing `{ collection, id }`, a bare id
255
+ string, or an array of either. Resolve them after a fetch with `resolveRelations`
256
+ (engine-side, works over any adapter):
257
+
258
+ ```ts
259
+ import { resolveRelations } from "@dalgoridim/headless-cms/server";
260
+
261
+ const posts = await data.fetchCollection("posts");
262
+ await resolveRelations(data, posts, {
263
+ populate: ["authorId"],
264
+ relations: { authorId: { collection: "authors" } }, // needed for bare-id refs
265
+ });
266
+ // each post.authorId is now the resolved author document
267
+ ```
268
+
269
+ Loads are deduplicated and parallelized; unresolved refs are left untouched.
126
270
 
127
271
  ## Postgres (hybrid)
128
272
 
@@ -136,35 +280,81 @@ import { PostgresDataAdapter } from "@dalgoridim/headless-cms/adapters/postgres"
136
280
  const data = new PostgresDataAdapter({
137
281
  connectionString: process.env.DATABASE_URL!,
138
282
  collections: {
139
- projects: { table: "projects", columns: { title: "text", date: "date" } },
283
+ projects: { table: "projects", columns: { title: "text", views: "int", date: "date" } },
140
284
  },
141
285
  });
142
286
  await data.migrate(); // creates documents + registered tables if missing
143
287
  ```
144
288
 
145
- ## Content markup
289
+ > **Caveat:** fields in the schemaless JSONB `documents` table are extracted as
290
+ > **text**, so numeric ordering/comparison is lexicographic (`"10" < "5"`).
291
+ > Register the collection with a typed `int`/`float` column for true numeric
292
+ > behavior.
146
293
 
147
- `ContentEditSpan` supports inline marks: `**bold**`, `*italic*`, `~~strike~~`,
148
- `^^primary color^^`, `__underline__`, `~~br~~`, and `[label](https://url)`.
149
- `MarkdownEditor` provides full GitHub-flavored markdown editing.
294
+ ## Auth: the gate is yours
150
295
 
151
- ## Styling
296
+ `AuthIdentity` is intentionally minimal and open — only `isAdmin` is meaningful to
297
+ the default gate; carry any other claims and authorize on them yourself.
152
298
 
153
- The client components ship **Tailwind utility classes** (and use a `--color-primary`
154
- custom property / `text-primary` utility for accents). The consuming app is
155
- responsible for Tailwind. With Tailwind v4, point it at the package so those
156
- classes are generated, and define a primary color:
299
+ ```ts
300
+ interface AuthIdentity {
301
+ isAdmin: boolean;
302
+ userId?: string;
303
+ email?: string;
304
+ [claim: string]: unknown; // roles, scopes, tenant, …
305
+ }
306
+ ```
157
307
 
158
- ```css
159
- /* globals.css */
160
- @import "tailwindcss";
161
- @source "../node_modules/@dalgoridim/headless-cms/dist";
308
+ Pass an `authorize` predicate to gate on whatever you like (defaults to
309
+ `identity.isAdmin === true`):
162
310
 
163
- @theme inline {
164
- --color-primary: var(--primary);
165
- }
311
+ ```ts
312
+ createCmsHandlers({
313
+ data,
314
+ auth,
315
+ authorize: (identity) => identity.role === "editor" || identity.isAdmin,
316
+ });
317
+ ```
318
+
319
+ ## Your content types stay unconstrained
320
+
321
+ The engine only adds addressing fields; your domain type `T` is never forced to
322
+ declare them:
323
+
324
+ ```ts
325
+ import type { Editable } from "@dalgoridim/headless-cms";
326
+
327
+ interface Post { title: string; body: string } // your shape — clean
328
+ type StoredPost = Editable<Post>; // Post & { id: string; collection: string }
166
329
  ```
167
330
 
331
+ ## Styling: bring your own look
332
+
333
+ The package ships **no styles**. Skin the primitives in your app — for example, a
334
+ thin wrapper that applies your design system:
335
+
336
+ ```tsx
337
+ // components/EditableTitle.tsx
338
+ import { ContentEditSpan } from "@dalgoridim/headless-cms/client";
339
+
340
+ export const EditableTitle = (props) => (
341
+ <ContentEditSpan
342
+ {...props}
343
+ className="text-4xl font-bold data-[cms-focused]:ring-2 data-[cms-focused]:ring-blue-500"
344
+ />
345
+ );
346
+ ```
347
+
348
+ This keeps your design isolated from the engine and lets the package upgrade
349
+ without ever changing how your site looks.
350
+
351
+ ## Documentation
352
+
353
+ - **[README](./README.md)** — this guide: install and usage.
354
+ - **[ARCHITECTURE.md](./ARCHITECTURE.md)** — design rationale and internals
355
+ (bundle boundaries, backend parity, the hybrid Postgres model, headless UI).
356
+ - **[REDESIGN.md](./REDESIGN.md)** — the "harness, not framework" redesign spec.
357
+
168
358
  ## Build
169
359
 
170
360
  ```bash
@@ -174,5 +364,5 @@ npm run typecheck # tsc --noEmit
174
364
 
175
365
  ## Stability
176
366
 
177
- Pre-`1.0`: the API may change between minor versions as the engine evolves.
178
- Pin an exact version if you depend on it in production.
367
+ Pre-`1.0`: the API may change between minor versions as the engine evolves. Pin an
368
+ exact version if you depend on it in production.