2026-09-20 14:16:53 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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
|
2026-09-24 00:22:40 +02:00
|
|
|
A seed entry is always a pair - the Arabic document and its English
|
2026-09-20 14:16:53 +02:00
|
|
|
translation - because every localised content type on this site ships in
|
2026-09-24 00:22:40 +02:00
|
|
|
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
|
|
|
|
|
`upsert` writes the Arabic document first, because that is the one
|
|
|
|
|
carrying the identity (the slug), then writes the English locale onto the
|
|
|
|
|
SAME document, which is how Strapi 5 models a translation: one
|
|
|
|
|
`documentId`, one row per locale.
|
2026-09-20 14:16:53 +02:00
|
|
|
|
|
|
|
|
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 } : {}),
|
2026-09-24 00:22:40 +02:00
|
|
|
locale: 'ar',
|
2026-09-20 14:16:53 +02:00
|
|
|
...draftFilter(strapi, uid),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let documentId: string;
|
|
|
|
|
let created = false;
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
documentId = existing.documentId;
|
2026-09-24 00:22:40 +02:00
|
|
|
await service.update({ documentId, locale: 'ar', data: data.ar });
|
2026-09-20 14:16:53 +02:00
|
|
|
} else {
|
2026-09-24 00:22:40 +02:00
|
|
|
const document = await service.create({ locale: 'ar', data: data.ar });
|
2026-09-20 14:16:53 +02:00
|
|
|
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. */
|
2026-09-24 00:22:40 +02:00
|
|
|
await service.update({ documentId, locale: 'en', data: data.en });
|
2026-09-20 14:16:53 +02:00
|
|
|
|
|
|
|
|
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
|
2026-09-24 00:22:40 +02:00
|
|
|
* base half has already gone is invisible to a search in that locale.
|
2026-09-20 14:16:53 +02:00
|
|
|
* 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 } : {}),
|
2026-09-24 00:22:40 +02:00
|
|
|
locale: 'ar',
|
2026-09-20 14:16:53 +02:00
|
|
|
...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;
|
|
|
|
|
}
|