Repository navigation
Releases: Invalid8/better-content
Release list
better-content 0.10.0
The subtraction release before 1.0. Four things were reachable that should not have been. Breaking changes are free now and permanent after 1.0.
Removed
StorageAdapter, a third spelling of the storage seam, exported fromcoreandserver, referenced nowhere in the implementation and in no doc. UseClientStorageAdapter(upload) andServerStorageAdapter(sign).dirtyKeyfrombetter-content/core. It returned"collection:id", the engine's internal dirty-set key, so exporting it froze that format.setPathandgetPathare unaffected.PageContextfrombetter-content/react. The raw context object pinned the two-context design as API. UseusePageContext,useCmsItemoruseCmsEngine.PageContextValuestays as a type andCmsAuthContextstays deliberately.resetGoogleKeyCachefrombetter-content/auth/google, a test hook whose own docstring said so. The JWKS cache is internal now and still honours Google'scache-control.
Changed
engines.nodeis now>=20. Node 18 left maintenance in April 2025.- The root entry still re-exports
core, and the docs now say so rather than leaving a second spelling undocumented.
Migration
| Was | Now |
|---|---|
StorageAdapter |
ClientStorageAdapter or ServerStorageAdapter |
dirtyKey(collection, id) |
your own template string, if you need it |
useContext(PageContext) |
usePageContext() |
resetGoogleKeyCache() |
nothing; the cache expires on max-age |
No behaviour changed, no signatures changed, nothing was added. Requires create-better-content@0.9.0 or newer to scaffold.
better-content 0.9.1
Packaging only. No JavaScript changed.
CommonJS consumers were handed ESM type declarations. The build has always emitted a .d.cts beside each .d.ts and shipped both, but the exports map named one types file per entry and served it to require as well. TypeScript on moduleResolution: node16 or nodenext resolved an ESM declaration file for a CommonJS build and reported the import as an error. Each entry now carries its own types under import and under require.
Subpath exports resolved to no types under classic node resolution. moduleResolution: node does not read the exports map and there was no typesVersions fallback, so better-content/core, /react, /server and every other subpath came back untyped. Only the root entry worked.
Also fixes peerDependenciesMeta.vue.optional, a string where every sibling is a boolean.
Bundler and ESM consumers were unaffected, which is why this reached 0.9.0 unnoticed. @arethetypeswrong/cli now reports no problems across all sixteen entries in node10, node16 from CJS, node16 from ESM and bundler, verified against the published tarball.
create-better-content 0.9.0
Pins better-content ^0.10.0, which a caret below 1.0 does not reach on its own, so this release is required to scaffold against 0.10.0.
Also raises the CLI's own engines.node to >=20, matching the library, so a generated app cannot fail later on a runtime the scaffolder accepted.
better-content 0.9.0
Both adapters now read back the same shape
The seam exists so behaviour does not change when the backend does. Write contracts were pinned per adapter; read shapes were not, and two divergences had survived there in opposite directions:
- The Postgres adapter added a
collectionfield to every record it read. Firestore never did,Itemdoes not declare it, andtoRowalready stripped it going in — so it was synthesized on read rather than stored. It is gone. - The Postgres adapter stripped
createdAtandupdatedAt, which Firestore returned. Those are real stored columns written by the adapter itself, so they are no longer hidden.
The rule both now follow: a read returns the record's stored fields plus id. An adapter neither invents fields nor hides them.
{ id: "hero", title: "Hello", createdAt: "2026-09-06T…", updatedAt: "…" }Timestamps are ISO strings on both backends
Matching field names is not parity if one backend yields a Date and the other a string — and these records are JSON-serialized across createContentHandler and server-rendered payloads, where a Date becomes a string in transit regardless.
Writes accept either form, because an adapter that cannot be handed back what it just returned is not round-trippable, and copying one environment into another does exactly that. That was found by a test rather than by reasoning: the first cut serialized reads and broke seedItemMap.
seedItemMap strips the timestamps too
Alongside id and collection. All four address the record or belong to the adapter rather than being content, and a snapshot read from a backend carries all four. Passing the timestamps through would also have broken the documented promise that createdAt is reset — Firestore's createWithId overrides what it is handed, while Postgres would have inserted it verbatim. Divergence again, one layer up.
⚠️ Behavior change for Postgres consumers
Reading item.collection now returns undefined, and reads carry createdAt/updatedAt that were not there before. Nothing in the library consumed collection from a fetched record, and it was undocumented.
Added
A cross-adapter parity suite running the real Postgres adapter against PGlite and the Firestore adapter against a stub, pinning that the two agree on field names, on how a timestamp is represented, and that each accepts back what it returned. Its first run is what found the timestamp divergence.
Verified against the real Postgres consumer: portfolio-2026 typechecks and builds green, its SSG pages still generating from live Postgres rows.
Docs: adapters
Requires create-better-content@0.8.0 for newly generated apps to pick this up.
better-content 0.8.1
Fixed: ContentEditSpan duplicated content after an edit
With a renderValue that turns the stored string into markup, editing a field and clicking away showed the raw source text and the rendered output one after the other, and every further edit round-tripped through the doubled text.
stored: A **bold** claim
after blur: A **bolder** claimA bolder claim ← both
While the field is focused its text is written imperatively, so React holds no record of those nodes — it renders false for children and a layout effect puts the raw string in the DOM behind its back. On blur React goes from no children to the rendered value and appends, leaving the imperative text node in front of it. The element is now cleared before React renders into it.
Only reachable with a renderValue. The default identity renderer duplicated too, but of identical text, so it read as a stutter rather than as markup — which is likely why no test caught it. Two tests now cover a renderValue across focus/blur, including a second round trip.
Reproduced and verified under a real browser, not only jsdom, since this is a DOM reconciliation bug and jsdom simulates contentEditable.
No API change. ^0.8.0 already resolves this, so no create-better-content release is needed.
Thanks to the report from portfolio-2025.
better-content 0.8.0
seedItemMap, the write mirror of loadItemMap
import { seedItemMap } from "better-content/server";
await seedItemMap(data, {
portfolio: sections, // byId, the default
projects: { items: projects, mode: "replace" },
});"byId"replaces the named records and leaves everything else in the collection alone."replace"makes the seeded array the collection: records in the backend but absent from the seed are deleted.
The mode is per collection because a real seed needs both in one run — singletons written by id, lists replaced wholesale. A bare Item[] is accepted, so an ItemMap is already a valid argument and seedItemMap(target, await loadItemMap(source, …)) copies an environment.
Why it exists
Not for the loop, which is a dozen lines. For the things a hand-rolled seed has to know:
Each record is written as delete then createWithId, because neither verb expresses a portable replace on its own. createWithId rejects an existing id, and upsert merges rather than replaces and does not stamp createdAt — which on Firestore leaves the record invisible to a default read that orders by that field. A seed written the obvious way, as a loop of upsert calls, looks fine on Postgres and silently writes unreadable data on Firestore.
Stated rather than left to be discovered
- Two round trips per record. Seeding is an offline one-shot.
createdAtis reset for a record that already existed. Correct for a replace.- Not atomic. The seam has no batch or transaction. Writes run sequentially in a deterministic order and the first failure throws, naming the collection, the id and how many writes landed — and saying explicitly when the record was deleted before the write failed, since it is then gone rather than merely unwritten.
There is deliberately no "merge" mode (a migration concern, and the one shape carrying the createdAt trap), and ordering is untouched with no order field invented.
Fixed
id and collection are treated as a record's address rather than its fields, so neither is written into the document. This matters across backends: the Postgres adapter adds a collection field to every row it reads and the Firestore adapter does not, so copying a Postgres-sourced ItemMap into Firestore would otherwise have stored collection as a real field in every document. The divergence between the two adapters' read shapes is not changed here.
Docs: server API
Requires create-better-content@0.7.0 for newly generated apps to pick this up.
better-content 0.7.0
Say what your content is
Item takes an optional type argument: Item<T> = T & { id: string }. Item on its own is unchanged, so nothing breaks.
const banner = engine.getItem<Banner>("portfolio", "banner");
banner?.skills; // Skill[] | undefined, previously unknownThis is less a new concept than a name for one: DataAdapter has returned T & { id: string } from fetchCollection, fetchById, create and createWithId since v1, and those four are now stated as Item<T>.
The parameter is on the addressed read in every binding — getItem<T> in core, useCmsItem<T> in React and Vue, itemStore<T> in Svelte — and on the write path:
await engine.createItem<Project>("projects", { title: "Portfolio", order: 0 });
await engine.updateItem<Project>("projects", id, { order: 3 });Read and write are not the same promise
On a read, T is an assertion: nothing verifies the stored record matches, exactly as fetchCollection<T> has never verified it. On a write, T is checked, because you hand over the object and TypeScript compares it to the shape.
The write constraint is T extends object, deliberately not Record<string, unknown> — an interface has no implicit index signature and would have been rejected. This also fixes an older papercut: createItem's previous parameter rejected interface-typed objects outright, so passing your own interface Project needed a cast or a type-alias workaround. It no longer does.
editField stays unknown. A single field value cannot be typed without a collection-keyed content schema, which is deliberately not shipped.
ItemMap stays non-generic, and that was measured
Against the real consumer that motivated this release: deleting its entire compatibility shim left six type errors, and every one was a getItem read. None came from reading items, because list reads there narrow with a runtime type guard, which checks for real. A collection-keyed schema would have returned a union for a collection of mixed singletons — so it would not have fixed the case that prompted this — while encouraging consumers to drop the guard protecting them.
Docs: core API
Requires create-better-content@0.6.0 for newly generated apps to pick this up.
create-better-content 0.8.1
Fixed: an absolute target directory produced a half-built project
npm create better-content@latest /home/me/projects/my-appcreate-vite resolves an absolute path as if it were relative, stripping the leading slash. So the delegated CLI wrote its half under the current directory while our layer was written to the real path, and the run ended on:
Error: ENOENT: no such file or directory, open '…/my-app/vite.config.ts'
…which reads like a broken template and is not one.
Delegated CLIs are now run from the target's parent and handed only the directory name, so their relative resolution lands where it should. Our own writes already used the resolved absolute path and are unaffected.
The project name now comes from the resolved target rather than from splitting the raw argument, so a trailing slash or a . cannot produce an empty name.
No pin change: still better-content ^0.9.0, which a caret already covers for patches.
Found while re-verifying the scaffolder docs, by using an absolute path.
create-better-content 0.8.0
Pin move only, to better-content ^0.9.0.
Below 1.0 a caret does not allow minor bumps, so ^0.8.0 would not have picked up 0.9.0.
Nothing else in the scaffolder changed.
npm create better-content@latest my-appcreate-better-content 0.7.0
Pin move only, to better-content ^0.8.0.
Below 1.0 a caret does not allow minor bumps: ^0.7.0 resolves to >=0.7.0 <0.8.0, so without this release every newly generated app would have quietly stayed on 0.7.0 and missed seedItemMap.
Nothing else in the scaffolder changed.
npm create better-content@latest my-app