Files
sharper.ly/apps/cms/src/seed/upsert.ts
T
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00

238 lines
8.7 KiB
TypeScript

/* ============================================================================
SEED - idempotent document helpers
----------------------------------------------------------------------------
Every write in the seed goes through here, so the whole seed can be run
again over a database it has already written to and change nothing but the
fields it owns. Nothing is ever deleted except demo entries, and only on an
explicit `--demo --clean`.
THE SHAPE OF A WRITE
A seed entry is always a pair - the English document and its Arabic
translation - because every localised content type on this site ships in
both languages (CLAUDE.md rule 7). `upsert` writes the English document
first, because that is the one carrying the identity (the slug), then
writes the Arabic locale onto the SAME document, which is how Strapi 5
models a translation: one `documentId`, one row per locale.
DRAFT AND PUBLISH
Content is written as a draft and then published explicitly. Publishing
is a separate call rather than `status: 'published'` on the write, so a
half-built entry is never live for the moment between the two locales.
Two content types - `topic` and `region` - are pure taxonomy and have
draft & publish switched off, and a document service for such a type has
no `publish` method and no `status` to filter on at all. `usesDraft`
below reads that off the schema rather than keeping a list, so a type that
gains or loses the setting needs no change here.
TYPES
`strapi.documents(uid)` is generated per content type, so a helper that
takes the uid as a parameter cannot be typed against one schema. The
single cast below is the seam: past it, the seed's own data is checked by
the content files' own types, and Strapi validates the payload at runtime
against the real schema - a wrong field name fails the seed loudly.
========================================================================= */
import type { Core } from '@strapi/strapi';
/** The two locales this site ships. */
export const LOCALES = ['en', 'ar'] as const;
export type SeedLocale = (typeof LOCALES)[number];
/** One piece of content, written once per locale. */
export type Localised<T> = Readonly<Record<SeedLocale, T>>;
/** A field payload - checked by Strapi against the real schema at runtime. */
export type SeedData = Record<string, unknown>;
/** Any content type uid `strapi.documents()` accepts. */
export type SeedUid = Parameters<Core.Strapi['documents']>[0];
interface SeedDocument {
documentId: string;
[field: string]: unknown;
}
/**
* The document service, minus the per-uid generics.
*
* See the TYPES note above: this is the one place the seed steps outside
* Strapi's generated types, and it is deliberate.
*/
interface LooseDocumentService {
findFirst(params: SeedData): Promise<SeedDocument | null>;
findMany(params: SeedData): Promise<SeedDocument[]>;
create(params: SeedData): Promise<SeedDocument>;
update(params: SeedData): Promise<SeedDocument | null>;
delete(params: SeedData): Promise<unknown>;
publish(params: SeedData): Promise<unknown>;
}
const documents = (strapi: Core.Strapi, uid: SeedUid): LooseDocumentService =>
strapi.documents(uid) as unknown as LooseDocumentService;
/** Whether this content type has draft & publish, and so a `publish` call. */
const usesDraft = (strapi: Core.Strapi, uid: SeedUid): boolean =>
Boolean(strapi.contentType(uid).options?.draftAndPublish);
/** `status: 'draft'`, but only where a status exists to ask about. */
const draftFilter = (strapi: Core.Strapi, uid: SeedUid): SeedData =>
usesDraft(strapi, uid) ? { status: 'draft' } : {};
/** What a seed step did, for the run summary. */
export interface SeedResult {
documentId: string;
created: boolean;
}
/**
* Write both locales of a document and publish it.
*
* `find` identifies the entry across runs - a slug for a collection type,
* nothing at all for a single type, which has only ever one document. When it
* matches nothing the document is created; when it matches, the same fields
* are written over the existing one, so an editor's OTHER changes survive a
* re-run and the fields the seed owns go back to the seeded value.
*/
export async function upsert(
strapi: Core.Strapi,
uid: SeedUid,
data: Localised<SeedData>,
find?: SeedData
): Promise<SeedResult> {
const service = documents(strapi, uid);
/* Looked up as a draft: a document that exists but has never been published
is still the same document, and creating a second one beside it is how a
seed ends up with duplicates. */
const existing = await service.findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
...draftFilter(strapi, uid),
});
let documentId: string;
let created = false;
if (existing) {
documentId = existing.documentId;
await service.update({ documentId, locale: 'en', data: data.en });
} else {
const document = await service.create({ locale: 'en', data: data.en });
documentId = document.documentId;
created = true;
}
/* An update against a locale the document does not have yet creates that
translation - there is no separate "add a locale" call. */
await service.update({ documentId, locale: 'ar', data: data.ar });
if (usesDraft(strapi, uid)) {
await service.publish({ documentId, locale: '*' });
}
return { documentId, created };
}
/**
* Delete the documents a filter matches, in every locale.
*
* TWO THINGS THE DOCUMENT SERVICE DOES NOT DO BY ITSELF, both of which leave
* Arabic rows behind that the next run then collides with on the unique slug:
*
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
* only, not the document. It takes `locale: '*'` to remove the lot.
* 2. `findMany({ locale })` answers for one locale, so a document whose
* English half has already gone is invisible to a search in English.
* Both locales are searched and the documentIds pooled, which also
* sweeps up orphans an earlier, less careful run left.
*/
async function deleteMatching(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
const service = documents(strapi, uid);
const documentIds = new Set<string>();
for (const locale of LOCALES) {
const matches = await service.findMany({
filters,
locale,
...draftFilter(strapi, uid),
pagination: { limit: -1 },
});
for (const match of matches) documentIds.add(match.documentId);
}
for (const documentId of documentIds) {
await service.delete({ documentId, locale: '*' });
}
return documentIds.size;
}
/**
* Delete every document of a type whose slug starts with the given prefix.
*
* This is how `--demo --clean` finds what it planted: the prefix is on the
* slug itself, so nothing has to be remembered between runs and an entry an
* editor has since renamed out of the prefix is deliberately left alone.
*/
export async function deleteBySlugPrefix(
strapi: Core.Strapi,
uid: SeedUid,
prefix: string
): Promise<number> {
return deleteMatching(strapi, uid, { slug: { $startsWith: prefix } });
}
/**
* Delete documents matched by an arbitrary filter.
*
* The FAQ content type has no slug to prefix, so its demo entries are found by
* their exact questions instead - in both languages, because the filter is
* matched per locale. Anything the seed did not write is left untouched,
* because the filter is the seeded list itself.
*/
export async function deleteWhere(
strapi: Core.Strapi,
uid: SeedUid,
filters: SeedData
): Promise<number> {
return deleteMatching(strapi, uid, filters);
}
/**
* Read one field off a single type before overwriting it.
*
* Used for the media an editor has already uploaded: the seed owns the words
* on a page, not the pictures, so it reads the current hero image back and
* writes it out again with the new copy rather than dropping it. A component
* is replaced wholesale on write, so anything inside it that is not in the
* payload is gone.
*/
export async function readExisting(
strapi: Core.Strapi,
uid: SeedUid,
populate: string[],
find?: SeedData
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
...draftFilter(strapi, uid),
populate,
});
}
/** The id of a media entry nested in a populated component, if there is one. */
export function mediaId(component: unknown, field: string): number | undefined {
if (!component || typeof component !== 'object') return undefined;
const media = (component as Record<string, unknown>)[field];
if (!media || typeof media !== 'object') return undefined;
const id = (media as Record<string, unknown>).id;
return typeof id === 'number' ? id : undefined;
}