@janmarkuslanger/astro-strapi-platform 0.1.0 → 0.1.1

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.
Files changed (38) hide show
  1. package/dist/core/blocks.d.ts +34 -0
  2. package/dist/core/blocks.js +7 -0
  3. package/dist/core/index.d.ts +6 -0
  4. package/dist/core/index.js +6 -0
  5. package/dist/core/llms.d.ts +3 -0
  6. package/dist/core/llms.js +42 -0
  7. package/dist/core/paths.d.ts +9 -0
  8. package/dist/core/paths.js +13 -0
  9. package/dist/core/repository.d.ts +18 -0
  10. package/dist/core/repository.js +1 -0
  11. package/dist/core/seo.d.ts +13 -0
  12. package/dist/core/seo.js +34 -0
  13. package/dist/core/types.d.ts +49 -0
  14. package/dist/core/types.js +9 -0
  15. package/dist/strapi/adapter.d.ts +91 -0
  16. package/dist/strapi/adapter.js +129 -0
  17. package/dist/strapi/index.d.ts +11 -0
  18. package/dist/strapi/index.js +10 -0
  19. package/dist/strapi/mapper.d.ts +74 -0
  20. package/dist/strapi/mapper.js +100 -0
  21. package/dist/strapi-kit/config.d.ts +102 -0
  22. package/dist/strapi-kit/config.js +95 -0
  23. package/dist/strapi-kit/index.d.ts +5 -0
  24. package/dist/strapi-kit/index.js +18 -0
  25. package/dist/strapi-kit/package.json +1 -0
  26. package/dist/strapi-kit/permissions.d.ts +15 -0
  27. package/dist/strapi-kit/permissions.js +33 -0
  28. package/dist/strapi-kit/platform.d.ts +8 -0
  29. package/dist/strapi-kit/platform.js +21 -0
  30. package/dist/strapi-kit/rebuild.d.ts +47 -0
  31. package/dist/strapi-kit/rebuild.js +123 -0
  32. package/dist/strapi-kit/schemas-cli.d.ts +5 -0
  33. package/dist/strapi-kit/schemas-cli.js +104 -0
  34. package/dist/strapi-kit/schemas.d.ts +56 -0
  35. package/dist/strapi-kit/schemas.js +180 -0
  36. package/dist/strapi-kit/seed.d.ts +52 -0
  37. package/dist/strapi-kit/seed.js +113 -0
  38. package/package.json +56 -13
@@ -0,0 +1,34 @@
1
+ import type { BaseBlock } from "./types.js";
2
+ export type JsonLd = Record<string, unknown>;
3
+ /**
4
+ * The block contract: everything the platform needs to know about one block
5
+ * type, in one object. Platform code (adapter, renderer, llms.txt, JSON-LD)
6
+ * iterates over registered definitions and never names a block type itself.
7
+ *
8
+ * `C` is the CMS side, owned by the adapter package (e.g. a Strapi mapping);
9
+ * core never looks inside it. Every field except `jsonLd` is required, so a
10
+ * half-registered block cannot compile.
11
+ */
12
+ export interface BlockDefinition<B extends BaseBlock, C = unknown> {
13
+ /** CMS side: read only by the adapter. */
14
+ cms: C;
15
+ /** Renders the block. Receives it as the `block` prop. */
16
+ component: (props: {
17
+ block: B;
18
+ }) => unknown;
19
+ /** Facts for llms.txt. `[]` is a valid answer — but it must be a decision. */
20
+ llms: (block: B) => string[];
21
+ /** JSON-LD nodes derived from the block, e.g. FAQPage for an FAQ block. */
22
+ jsonLd?: (block: B) => JsonLd[];
23
+ }
24
+ /**
25
+ * One definition per member of the site's block union. Typing a registry with
26
+ * this makes a missing block a compile error.
27
+ */
28
+ export type BlockRegistry<B extends BaseBlock> = {
29
+ [T in B["type"]]: BlockDefinition<Extract<B, {
30
+ type: T;
31
+ }>>;
32
+ };
33
+ /** Definition for a block, or undefined for a type the registry does not know. */
34
+ export declare function definitionFor<B extends BaseBlock>(registry: BlockRegistry<B>, block: B): BlockDefinition<B> | undefined;
@@ -0,0 +1,7 @@
1
+ /** Definition for a block, or undefined for a type the registry does not know. */
2
+ export function definitionFor(registry, block) {
3
+ // Sound: each key holds the definition for exactly that type. TypeScript
4
+ // cannot reduce Extract<B, { type: B["type"] }> back to B, so say it once.
5
+ // `undefined` covers a block whose type the registry does not know.
6
+ return registry[block.type];
7
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./repository.js";
3
+ export * from "./blocks.js";
4
+ export * from "./paths.js";
5
+ export { buildLlmsTxt } from "./llms.js";
6
+ export { organizationSchema, pageSchemas } from "./seo.js";
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./repository.js";
3
+ export * from "./blocks.js";
4
+ export * from "./paths.js";
5
+ export { buildLlmsTxt } from "./llms.js";
6
+ export { organizationSchema, pageSchemas } from "./seo.js";
@@ -0,0 +1,3 @@
1
+ import { type BlockRegistry } from "./blocks.js";
2
+ import type { BaseBlock, Page, SiteGlobal } from "./types.js";
3
+ export declare function buildLlmsTxt<B extends BaseBlock>(global: SiteGlobal, pages: Page<B>[], siteUrl: string, blocks: BlockRegistry<B>): string;
@@ -0,0 +1,42 @@
1
+ import { definitionFor } from "./blocks.js";
2
+ import { pageHref, slugOfHref } from "./paths.js";
3
+ /**
4
+ * Builds `llms.txt` (llmstxt.org) — a compact site summary for generative
5
+ * search engines. Generated from the domain model at build time, never
6
+ * hand-maintained, so it cannot drift away from the site.
7
+ *
8
+ * What each block contributes is its definition's `llms` — a required field,
9
+ * so a new block type fails the build until someone decides. Often the answer
10
+ * is `[]`, but it has to be a decision rather than an oversight; that is what
11
+ * keeps this file accurate as a project grows.
12
+ */
13
+ function truncate(value, max = 300) {
14
+ return value.length <= max ? value : `${value.slice(0, max - 1).trimEnd()}…`;
15
+ }
16
+ export function buildLlmsTxt(global, pages, siteUrl, blocks) {
17
+ const lines = [];
18
+ lines.push(`# ${global.siteName}`);
19
+ if (global.claim)
20
+ lines.push(`\n> ${global.claim}`);
21
+ lines.push(`\nContact: ${global.email}`);
22
+ // Navigation order is the site's own idea of importance — reuse it instead
23
+ // of inventing a ranking.
24
+ const rank = (slug) => {
25
+ const i = global.navigation.findIndex((n) => slugOfHref(n.href) === slug);
26
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
27
+ };
28
+ const ordered = [...pages].sort((a, b) => rank(a.slug) - rank(b.slug));
29
+ lines.push("\n## Pages\n");
30
+ for (const page of ordered) {
31
+ lines.push(`### ${page.title}`);
32
+ lines.push(`${siteUrl}${pageHref(page.slug)}`);
33
+ if (page.seo.description)
34
+ lines.push(page.seo.description);
35
+ const facts = page.blocks.flatMap((block) => definitionFor(blocks, block)?.llms(block) ?? []);
36
+ for (const fact of facts.filter(Boolean)) {
37
+ lines.push(`- ${truncate(fact)}`);
38
+ }
39
+ lines.push("");
40
+ }
41
+ return `${lines.join("\n").trim()}\n`;
42
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The page with this slug renders at "/", so editors create the home page
3
+ * like any other page — no special content type, no special case.
4
+ */
5
+ export declare const HOME_SLUG = "home";
6
+ /** Site-relative URL of a page: "/" for the home page, "/<slug>/" otherwise. */
7
+ export declare function pageHref(slug: string): string;
8
+ /** Inverse of {@link pageHref} for internal links, e.g. navigation entries. */
9
+ export declare function slugOfHref(href: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The page with this slug renders at "/", so editors create the home page
3
+ * like any other page — no special content type, no special case.
4
+ */
5
+ export const HOME_SLUG = "home";
6
+ /** Site-relative URL of a page: "/" for the home page, "/<slug>/" otherwise. */
7
+ export function pageHref(slug) {
8
+ return slug === HOME_SLUG ? "/" : `/${slug}/`;
9
+ }
10
+ /** Inverse of {@link pageHref} for internal links, e.g. navigation entries. */
11
+ export function slugOfHref(href) {
12
+ return href.replace(/^\/|\/$/g, "") || HOME_SLUG;
13
+ }
@@ -0,0 +1,18 @@
1
+ import type { BaseBlock, Page, SiteGlobal } from "./types.js";
2
+ /**
3
+ * The contract every CMS adapter must fulfill. Pages and components only ever
4
+ * talk to an object shaped like this — they never learn whether the data came
5
+ * from Strapi, a JSON file, or anything else.
6
+ *
7
+ * `P` and `G` are the site's own page and global fields (see `Page`). A site
8
+ * that reads its own content types declares a repository extending this one.
9
+ *
10
+ * Keep query objects (rather than long parameter lists) for anything a
11
+ * listing block can configure — that is what lets a block carry a *selection*
12
+ * while the component fetches the data.
13
+ */
14
+ export interface CmsRepository<B extends BaseBlock = BaseBlock, P extends object = {}, G extends object = {}> {
15
+ getGlobal(): Promise<SiteGlobal<G>>;
16
+ listPages(): Promise<Page<B, P>[]>;
17
+ getPage(slug: string): Promise<Page<B, P> | null>;
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { type BlockRegistry, type JsonLd } from "./blocks.js";
2
+ import type { BaseBlock, Page, SiteGlobal } from "./types.js";
3
+ /**
4
+ * Structured data (JSON-LD).
5
+ *
6
+ * The pattern worth keeping: DERIVE schemas from the content blocks rather
7
+ * than maintaining them by hand. A block definition's optional `jsonLd` turns
8
+ * its block into nodes, so an editor who adds an FAQ block to a page gets
9
+ * `FAQPage` markup with nobody touching code — and a new page is correct by
10
+ * default instead of forgotten.
11
+ */
12
+ export declare function organizationSchema(global: SiteGlobal, siteUrl: string): JsonLd;
13
+ export declare function pageSchemas<B extends BaseBlock>(page: Page<B>, global: SiteGlobal, siteUrl: string, blocks: BlockRegistry<B>): JsonLd[];
@@ -0,0 +1,34 @@
1
+ import { definitionFor } from "./blocks.js";
2
+ import { HOME_SLUG } from "./paths.js";
3
+ /**
4
+ * Structured data (JSON-LD).
5
+ *
6
+ * The pattern worth keeping: DERIVE schemas from the content blocks rather
7
+ * than maintaining them by hand. A block definition's optional `jsonLd` turns
8
+ * its block into nodes, so an editor who adds an FAQ block to a page gets
9
+ * `FAQPage` markup with nobody touching code — and a new page is correct by
10
+ * default instead of forgotten.
11
+ */
12
+ export function organizationSchema(global, siteUrl) {
13
+ return {
14
+ "@context": "https://schema.org",
15
+ "@type": "Organization",
16
+ name: global.siteName,
17
+ url: siteUrl,
18
+ email: global.email,
19
+ ...(global.claim ? { description: global.claim } : {}),
20
+ };
21
+ }
22
+ export function pageSchemas(page, global, siteUrl, blocks) {
23
+ const schemas = [];
24
+ // Only the homepage carries the organization node — repeating it on every
25
+ // page adds noise without adding information.
26
+ if (page.slug === HOME_SLUG)
27
+ schemas.push(organizationSchema(global, siteUrl));
28
+ // Derived from block type, never from page slug: a slug-based rule breaks
29
+ // the moment an editor renames a page or reuses the block somewhere else.
30
+ for (const block of page.blocks) {
31
+ schemas.push(...(definitionFor(blocks, block)?.jsonLd?.(block) ?? []));
32
+ }
33
+ return schemas;
34
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Domain model shared by every site. This is the ONLY shape pages and
3
+ * components may depend on; whatever CMS sits behind it is translated into
4
+ * these types by its adapter.
5
+ *
6
+ * Blocks are not defined here: the content model is where sites differ. A
7
+ * site declares its own `Block` union and passes it in as `B`.
8
+ */
9
+ export interface Seo {
10
+ title: string;
11
+ description: string;
12
+ }
13
+ export interface ImageAsset {
14
+ src: string;
15
+ alt: string;
16
+ width?: number;
17
+ height?: number;
18
+ }
19
+ export interface Link {
20
+ label: string;
21
+ href: string;
22
+ }
23
+ /** Every block has a `type` discriminator; the rest is the block's own. */
24
+ export interface BaseBlock {
25
+ type: string;
26
+ }
27
+ /** The fields every site's pages have; the platform owns them. */
28
+ export interface PageFields<B extends BaseBlock = BaseBlock> {
29
+ slug: string;
30
+ title: string;
31
+ seo: Seo;
32
+ blocks: B[];
33
+ }
34
+ /**
35
+ * A page as a site sees it: the platform fields plus `X`, the fields the site
36
+ * added to its page schema (`schema-extensions/page.json`). `X` cannot
37
+ * replace a platform field — the adapter writes those last.
38
+ */
39
+ export type Page<B extends BaseBlock = BaseBlock, X extends object = {}> = PageFields<B> & X;
40
+ /** The fields every site's global settings have; the platform owns them. */
41
+ export interface SiteGlobalFields {
42
+ siteName: string;
43
+ claim?: string;
44
+ email: string;
45
+ navigation: Link[];
46
+ footerLinks: Link[];
47
+ }
48
+ /** Global settings plus the site's own fields, as for {@link Page}. */
49
+ export type SiteGlobal<X extends object = {}> = SiteGlobalFields & X;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Domain model shared by every site. This is the ONLY shape pages and
3
+ * components may depend on; whatever CMS sits behind it is translated into
4
+ * these types by its adapter.
5
+ *
6
+ * Blocks are not defined here: the content model is where sites differ. A
7
+ * site declares its own `Block` union and passes it in as `B`.
8
+ */
9
+ export {};
@@ -0,0 +1,91 @@
1
+ import type { BaseBlock, CmsRepository, Page, SiteGlobal } from "../core/index.js";
2
+ import { type MapContext, type StrapiExtension, type StrapiBlockRegistry } from "./mapper.js";
3
+ interface BaseOptions<B extends BaseBlock> {
4
+ /**
5
+ * Where THIS process fetches from at build time. May be 127.0.0.1 when
6
+ * building on the CMS host — never localhost (IPv6, see CLAUDE.md §4).
7
+ */
8
+ url: string;
9
+ /** Optional read-only API token. */
10
+ token?: string;
11
+ /**
12
+ * What ends up in the HTML — image sources, form endpoints — loaded later by
13
+ * the VISITOR's browser, where 127.0.0.1 means their own machine. Defaults
14
+ * to `url`, which is only right when that URL is public.
15
+ */
16
+ mediaUrl?: string;
17
+ /** The site's block registry; the adapter reads each definition's `cms`. */
18
+ blocks: StrapiBlockRegistry<B>;
19
+ }
20
+ /**
21
+ * Required as soon as the site declares fields `X`, optional ones included —
22
+ * a page type promising a field nobody maps would compile and then render
23
+ * `undefined`.
24
+ */
25
+ type ExtensionOption<K extends string, X extends object> = [keyof X] extends [never] ? {
26
+ [key in K]?: StrapiExtension<X>;
27
+ } : {
28
+ [key in K]: StrapiExtension<X>;
29
+ };
30
+ export type StrapiCmsAdapterOptions<B extends BaseBlock, P extends object = {}, G extends object = {}> = BaseOptions<B> & ExtensionOption<"page", P> & ExtensionOption<"global", G>;
31
+ export declare class StrapiCmsAdapter<B extends BaseBlock = BaseBlock, P extends object = {}, G extends object = {}> implements CmsRepository<B, P, G> {
32
+ private readonly url;
33
+ private readonly token;
34
+ private readonly mediaUrl;
35
+ private readonly pageExtension;
36
+ private readonly globalExtension;
37
+ private readonly globalPopulate;
38
+ /** Strapi side of every registered block, keyed by domain type. */
39
+ private readonly blocks;
40
+ /** Cache so a page with five references costs one extra request, not five. */
41
+ private sharedBlocks;
42
+ /**
43
+ * Populate query so Strapi ships every component and media file of the
44
+ * dynamic zone. Derived from the registered blocks; a block whose nested
45
+ * fields arrive empty needs its own `populate` (see StrapiBlockMapping).
46
+ */
47
+ private readonly populate;
48
+ /**
49
+ * Pages additionally need their `seo` component. Strapi 5 populates no
50
+ * component unless asked, so without this every page silently loses its
51
+ * title and meta description.
52
+ */
53
+ private readonly pagePopulate;
54
+ /**
55
+ * `image` and `markdown` bound to the public media URL, for a site mapping
56
+ * its own content types (see `get`).
57
+ */
58
+ readonly context: MapContext;
59
+ constructor(options: StrapiCmsAdapterOptions<B, P, G>);
60
+ /**
61
+ * Retries connection failures and gateway statuses with a short backoff.
62
+ *
63
+ * A build often starts right after the CMS restarts. A connect timeout or a
64
+ * 502 in those first seconds is a blip, not an outage, and should not fail a
65
+ * whole site build. Real errors (404, 401) fail on the first try — no wasted
66
+ * backoff on a genuine mistake.
67
+ */
68
+ private fetchWithRetry;
69
+ /**
70
+ * GET against the Strapi REST API, with the token and the retry above. For
71
+ * a site's own content types: the site maps the response itself, using
72
+ * `context` for media and Markdown.
73
+ *
74
+ * const json = await strapi.get("/api/posts?populate[cover]=true");
75
+ * json.data.map((e) => ({ title: e.title, cover: strapi.context.image(e.cover) }));
76
+ */
77
+ get(path: string): Promise<any>;
78
+ /**
79
+ * Resolves `blocks.reference` into the blocks of the shared block it points
80
+ * at. Done here so the domain model never sees a reference.
81
+ *
82
+ * Shared blocks may not contain references themselves, so there is nothing
83
+ * to recurse into and cycles are impossible by construction — no runtime
84
+ * cycle detection needed.
85
+ */
86
+ private blockResolver;
87
+ getGlobal(): Promise<SiteGlobal<G>>;
88
+ listPages(): Promise<Page<B, P>[]>;
89
+ getPage(slug: string): Promise<Page<B, P> | null>;
90
+ }
91
+ export {};
@@ -0,0 +1,129 @@
1
+ import { blockPopulate, mapBlocks, mapContext, mapGlobal, mapPage, } from "./mapper.js";
2
+ export class StrapiCmsAdapter {
3
+ url;
4
+ token;
5
+ mediaUrl;
6
+ pageExtension;
7
+ globalExtension;
8
+ globalPopulate;
9
+ /** Strapi side of every registered block, keyed by domain type. */
10
+ blocks;
11
+ /** Cache so a page with five references costs one extra request, not five. */
12
+ sharedBlocks = null;
13
+ /**
14
+ * Populate query so Strapi ships every component and media file of the
15
+ * dynamic zone. Derived from the registered blocks; a block whose nested
16
+ * fields arrive empty needs its own `populate` (see StrapiBlockMapping).
17
+ */
18
+ populate;
19
+ /**
20
+ * Pages additionally need their `seo` component. Strapi 5 populates no
21
+ * component unless asked, so without this every page silently loses its
22
+ * title and meta description.
23
+ */
24
+ pagePopulate;
25
+ /**
26
+ * `image` and `markdown` bound to the public media URL, for a site mapping
27
+ * its own content types (see `get`).
28
+ */
29
+ context;
30
+ constructor(options) {
31
+ this.url = options.url;
32
+ this.token = options.token;
33
+ this.mediaUrl = options.mediaUrl || options.url;
34
+ this.context = mapContext(this.mediaUrl);
35
+ const extensions = options;
36
+ this.pageExtension = extensions.page;
37
+ this.globalExtension = extensions.global;
38
+ // Every value of a StrapiBlockRegistry has a Strapi `cms`; TypeScript
39
+ // cannot see through a mapped type over a generic union, so say it once.
40
+ const definitions = Object.entries(options.blocks);
41
+ this.blocks = Object.fromEntries(definitions.map(([type, definition]) => [type, definition.cms]));
42
+ this.populate = blockPopulate(this.blocks);
43
+ const extra = (extension) => extension?.populate ? `&${extension.populate}` : "";
44
+ this.pagePopulate = `populate[seo]=true&${this.populate}${extra(this.pageExtension)}`;
45
+ this.globalPopulate = `populate[navigation]=true&populate[footerLinks]=true${extra(this.globalExtension)}`;
46
+ }
47
+ /**
48
+ * Retries connection failures and gateway statuses with a short backoff.
49
+ *
50
+ * A build often starts right after the CMS restarts. A connect timeout or a
51
+ * 502 in those first seconds is a blip, not an outage, and should not fail a
52
+ * whole site build. Real errors (404, 401) fail on the first try — no wasted
53
+ * backoff on a genuine mistake.
54
+ */
55
+ async fetchWithRetry(url, init, attempt = 1) {
56
+ const maxAttempts = 4;
57
+ const retryable = (s) => s === 502 || s === 503 || s === 504;
58
+ let res;
59
+ try {
60
+ res = await fetch(url, init);
61
+ }
62
+ catch (error) {
63
+ if (attempt >= maxAttempts)
64
+ throw error;
65
+ await new Promise((r) => setTimeout(r, attempt * 2000));
66
+ return this.fetchWithRetry(url, init, attempt + 1);
67
+ }
68
+ if (!res.ok && retryable(res.status) && attempt < maxAttempts) {
69
+ await new Promise((r) => setTimeout(r, attempt * 2000));
70
+ return this.fetchWithRetry(url, init, attempt + 1);
71
+ }
72
+ return res;
73
+ }
74
+ /**
75
+ * GET against the Strapi REST API, with the token and the retry above. For
76
+ * a site's own content types: the site maps the response itself, using
77
+ * `context` for media and Markdown.
78
+ *
79
+ * const json = await strapi.get("/api/posts?populate[cover]=true");
80
+ * json.data.map((e) => ({ title: e.title, cover: strapi.context.image(e.cover) }));
81
+ */
82
+ async get(path) {
83
+ const headers = {};
84
+ if (this.token)
85
+ headers.Authorization = `Bearer ${this.token}`;
86
+ const res = await this.fetchWithRetry(`${this.url}${path}`, { headers });
87
+ if (!res.ok) {
88
+ throw new Error(`Strapi request failed: ${res.status} ${res.statusText} (${path})`);
89
+ }
90
+ return res.json();
91
+ }
92
+ /**
93
+ * Resolves `blocks.reference` into the blocks of the shared block it points
94
+ * at. Done here so the domain model never sees a reference.
95
+ *
96
+ * Shared blocks may not contain references themselves, so there is nothing
97
+ * to recurse into and cycles are impossible by construction — no runtime
98
+ * cycle detection needed.
99
+ */
100
+ async blockResolver() {
101
+ if (!this.sharedBlocks) {
102
+ const json = await this.get(`/api/shared-blocks?${this.populate}`);
103
+ this.sharedBlocks = new Map((json.data ?? []).map((entry) => [
104
+ String(entry.documentId ?? entry.id),
105
+ mapBlocks(entry.blocks ?? [], this.mediaUrl, this.blocks),
106
+ ]));
107
+ }
108
+ const cache = this.sharedBlocks;
109
+ return (id) => cache.get(id) ?? [];
110
+ }
111
+ async getGlobal() {
112
+ const json = await this.get(`/api/global?${this.globalPopulate}`);
113
+ return mapGlobal(json.data ?? {}, this.mediaUrl, this.globalExtension);
114
+ }
115
+ // Mapped blocks are B by construction: only registered types survive the
116
+ // mapper. The cast states that; it cannot be checked across the CMS boundary.
117
+ async listPages() {
118
+ const json = await this.get(`/api/pages?${this.pagePopulate}`);
119
+ const resolve = await this.blockResolver();
120
+ return (json.data ?? []).map((e) => mapPage(e, this.mediaUrl, this.blocks, resolve, this.pageExtension));
121
+ }
122
+ async getPage(slug) {
123
+ const json = await this.get(`/api/pages?filters[slug][$eq]=${encodeURIComponent(slug)}&${this.pagePopulate}`);
124
+ const entry = (json.data ?? [])[0];
125
+ if (!entry)
126
+ return null;
127
+ return mapPage(entry, this.mediaUrl, this.blocks, await this.blockResolver(), this.pageExtension);
128
+ }
129
+ }
@@ -0,0 +1,11 @@
1
+ import type { BaseBlock, BlockDefinition } from "../core/index.js";
2
+ import type { StrapiBlockMapping } from "./mapper.js";
3
+ export { StrapiCmsAdapter, type StrapiCmsAdapterOptions } from "./adapter.js";
4
+ export type { MapContext, StrapiBlockMapping, StrapiBlockRegistry, StrapiExtension, } from "./mapper.js";
5
+ /**
6
+ * Declares a block whose CMS side is Strapi. Identity at runtime; exists so a
7
+ * block module gets its types checked where it is written:
8
+ *
9
+ * export default defineBlock<HeroBlock>({ cms: { component, map }, component, llms });
10
+ */
11
+ export declare function defineBlock<B extends BaseBlock>(definition: BlockDefinition<B, StrapiBlockMapping<B>>): BlockDefinition<B, StrapiBlockMapping<B>>;
@@ -0,0 +1,10 @@
1
+ export { StrapiCmsAdapter } from "./adapter.js";
2
+ /**
3
+ * Declares a block whose CMS side is Strapi. Identity at runtime; exists so a
4
+ * block module gets its types checked where it is written:
5
+ *
6
+ * export default defineBlock<HeroBlock>({ cms: { component, map }, component, llms });
7
+ */
8
+ export function defineBlock(definition) {
9
+ return definition;
10
+ }
@@ -0,0 +1,74 @@
1
+ import type { BaseBlock, BlockDefinition, ImageAsset, Page, SiteGlobal } from "../core/index.js";
2
+ /** Resolves a shared-block id into its blocks. Supplied by the adapter. */
3
+ export type BlockResolver = (id: string) => BaseBlock[];
4
+ /**
5
+ * Component id of the reference block. Adapter-internal: references are
6
+ * resolved here and never reach the domain model.
7
+ */
8
+ export declare const REFERENCE_COMPONENT = "blocks.reference";
9
+ /** Helpers a block's `map` gets, so no block re-implements URL or Markdown handling. */
10
+ export interface MapContext {
11
+ image(raw: any): ImageAsset | undefined;
12
+ markdown(value: unknown): string;
13
+ }
14
+ /**
15
+ * Site fields on a platform type (`page`, `global`), added in Strapi through
16
+ * `schema-extensions/`. The adapter fetches them with `populate` and merges
17
+ * `map`'s result under the platform fields.
18
+ */
19
+ export interface StrapiExtension<X extends object> {
20
+ /**
21
+ * Extra populate parameters, e.g. `populate[hero]=true`. Plain fields
22
+ * (strings, numbers, booleans) need none; media, components and relations
23
+ * arrive empty without it.
24
+ */
25
+ populate?: string;
26
+ /** Raw Strapi entry → the site's fields. */
27
+ map(raw: any, ctx: MapContext): X;
28
+ }
29
+ /** Strapi side of a block definition — the `cms` field of `defineBlock`. */
30
+ export interface StrapiBlockMapping<B extends BaseBlock> {
31
+ /** Strapi component id, e.g. "blocks.hero". */
32
+ component: string;
33
+ /**
34
+ * Populate fragment after `populate[blocks][on][<component>]`. Defaults to
35
+ * `[populate]=*` (one level). Blocks with deeper nesting (items with images,
36
+ * links in a list) must spell it out, or those fields arrive empty.
37
+ */
38
+ populate?: string;
39
+ /** Strapi component → domain block, without `type` (added by the mapper). */
40
+ map(raw: any, ctx: MapContext): Omit<B, "type">;
41
+ }
42
+ /** A block registry whose CMS side is Strapi: one definition per member of `B`. */
43
+ export type StrapiBlockRegistry<B extends BaseBlock> = {
44
+ [T in B["type"]]: BlockDefinition<Extract<B, {
45
+ type: T;
46
+ }>, StrapiBlockMapping<Extract<B, {
47
+ type: T;
48
+ }>>>;
49
+ };
50
+ /** Strapi mappings keyed by domain block type. */
51
+ export type StrapiBlockMappings = Record<string, StrapiBlockMapping<BaseBlock>>;
52
+ /** Populate query for a dynamic zone holding these blocks plus references. */
53
+ export declare function blockPopulate(mappings: StrapiBlockMappings): string;
54
+ /**
55
+ * Media URLs arrive relative from Strapi ("/uploads/x.jpg"). They must become
56
+ * absolute against the PUBLIC base, not the build-time base — the visitor's
57
+ * browser loads them, not the build. See CLAUDE.md §4.
58
+ *
59
+ * Offered to every block's `map` as `ctx.image`, so no block can get this
60
+ * wrong — it is the classic production-only bug.
61
+ */
62
+ export declare function mapImage(raw: any, mediaUrl: string): ImageAsset | undefined;
63
+ /** The helpers handed to every `map`, bound to the public media URL. */
64
+ export declare function mapContext(mediaUrl: string): MapContext;
65
+ /**
66
+ * Turns Strapi's dynamic zone into domain blocks, using the registered
67
+ * mappings.
68
+ *
69
+ * Unknown component ids are SKIPPED, not thrown on: a block added in the CMS
70
+ * before the frontend knows it must never take the site down.
71
+ */
72
+ export declare function mapBlocks(raw: any[], mediaUrl: string, mappings: StrapiBlockMappings, resolve?: BlockResolver): BaseBlock[];
73
+ export declare function mapPage<X extends object = {}>(entry: any, mediaUrl: string, mappings: StrapiBlockMappings, resolve?: BlockResolver, extension?: StrapiExtension<X>): Page<BaseBlock, X>;
74
+ export declare function mapGlobal<X extends object = {}>(entry: any, mediaUrl: string, extension?: StrapiExtension<X>): SiteGlobal<X>;