@inneropen/marvin-astro 1.1.1 → 1.2.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 (47) hide show
  1. package/README.md +173 -14
  2. package/dist/embeds-BTNryQA6.d.ts +79 -0
  3. package/dist/index.d.ts +43 -6
  4. package/dist/index.js +116 -14
  5. package/dist/index.js.map +1 -1
  6. package/dist/renderers/index.d.ts +119 -0
  7. package/dist/renderers/index.js +251 -0
  8. package/dist/renderers/index.js.map +1 -0
  9. package/dist/types.d.ts +8 -1
  10. package/package.json +16 -6
  11. package/src/chrome.ts +242 -0
  12. package/src/client.ts +119 -0
  13. package/src/components/Embed.astro +119 -0
  14. package/src/components/EmbedLoader.astro +24 -0
  15. package/src/components/EntryRenderer.astro +40 -0
  16. package/src/components/FormRenderer.astro +188 -0
  17. package/src/components/LinkCard.astro +42 -0
  18. package/src/components/embed-frame.ts +168 -0
  19. package/src/components/embeds.css +137 -0
  20. package/src/components/index.ts +29 -0
  21. package/src/components/integration.ts +62 -0
  22. package/src/components/markdown.ts +4 -0
  23. package/src/components/registry.ts +34 -0
  24. package/src/components/renderers/ArticleRenderer.astro +56 -0
  25. package/src/components/renderers/FaqRenderer.astro +26 -0
  26. package/src/components/renderers/NavigationRenderer.astro +27 -0
  27. package/src/components/renderers/PageRenderer.astro +51 -0
  28. package/src/config.ts +149 -0
  29. package/src/embeds.ts +137 -0
  30. package/src/env.d.ts +5 -0
  31. package/src/fetch.ts +208 -0
  32. package/src/fields.ts +330 -0
  33. package/src/format.ts +63 -0
  34. package/src/index.ts +169 -0
  35. package/src/markdown.ts +84 -0
  36. package/src/normalize.ts +284 -0
  37. package/src/renderers/debug.ts +6 -0
  38. package/src/renderers/index.ts +48 -0
  39. package/src/renderers/registry.ts +133 -0
  40. package/src/renderers/resolve.ts +134 -0
  41. package/src/renderers/types.ts +71 -0
  42. package/src/renderers/validation-types.ts +23 -0
  43. package/src/renderers/validation.ts +55 -0
  44. package/src/repository.ts +206 -0
  45. package/src/sections.ts +39 -0
  46. package/src/site.ts +222 -0
  47. package/src/types.ts +188 -0
@@ -0,0 +1,56 @@
1
+ ---
2
+ import type { RendererEntry } from '../../renderers/types.js';
3
+ import { extractBody, getFeaturedAsset } from '../../renderers/resolve.js';
4
+ import { entryEmbeds } from '../../embeds.js';
5
+ import type { MarkdownRenderer } from '../../markdown.js';
6
+ import { defaultRenderMarkdown } from '../markdown.js';
7
+
8
+ interface Props {
9
+ entry: RendererEntry;
10
+ config?: Record<string, unknown>;
11
+ class?: string;
12
+ /** The site's renderer (`marvin.renderMarkdown`) so its markdown options apply. */
13
+ renderMarkdown?: MarkdownRenderer;
14
+ }
15
+
16
+ const { entry, config, class: className, renderMarkdown = defaultRenderMarkdown } = Astro.props;
17
+
18
+ // The body is markdown: render it (with the entry's media embeds) rather than inject it raw.
19
+ const source = extractBody(entry);
20
+ const body = source ? await renderMarkdown(source, { embeds: entryEmbeds(entry) }) : undefined;
21
+ const featuredAsset = getFeaturedAsset(entry);
22
+ const typeSlug = entry.entryTypeInfo?.slug ?? 'article';
23
+ ---
24
+
25
+ <article
26
+ data-renderer="article"
27
+ data-entry-type={typeSlug}
28
+ class={className}
29
+ >
30
+ {featuredAsset?.publicUrl && (
31
+ <figure data-role="featured-asset">
32
+ <img
33
+ src={featuredAsset.publicUrl}
34
+ alt={featuredAsset.altText ?? entry.title}
35
+ width={featuredAsset.width}
36
+ height={featuredAsset.height}
37
+ />
38
+ </figure>
39
+ )}
40
+
41
+ <header data-role="header">
42
+ <h1 data-role="title">{entry.title}</h1>
43
+ {entry.summary && <p data-role="summary">{entry.summary}</p>}
44
+ {entry.publishedAt && (
45
+ <time data-role="published-at" datetime={entry.publishedAt}>
46
+ {new Date(entry.publishedAt).toLocaleDateString()}
47
+ </time>
48
+ )}
49
+ </header>
50
+
51
+ {body && (
52
+ <div data-role="body" set:html={body} />
53
+ )}
54
+
55
+ <slot />
56
+ </article>
@@ -0,0 +1,26 @@
1
+ ---
2
+ import type { RendererEntry } from '../../renderers/types.js';
3
+
4
+ interface Props {
5
+ entry: RendererEntry;
6
+ config?: Record<string, unknown>;
7
+ class?: string;
8
+ }
9
+
10
+ const { entry, config, class: className } = Astro.props;
11
+
12
+ const typeSlug = entry.entryTypeInfo?.slug ?? 'faq';
13
+ ---
14
+
15
+ <section
16
+ data-renderer="faq"
17
+ data-entry-type={typeSlug}
18
+ class={className}
19
+ >
20
+ <h2 data-role="title">{entry.title}</h2>
21
+ {entry.summary && (
22
+ <p data-role="summary">{entry.summary}</p>
23
+ )}
24
+
25
+ <slot />
26
+ </section>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import type { RendererEntry } from '../../renderers/types.js';
3
+ import { extractField } from '../../renderers/resolve.js';
4
+
5
+ interface Props {
6
+ entry: RendererEntry;
7
+ config?: Record<string, unknown>;
8
+ class?: string;
9
+ }
10
+
11
+ const { entry, config, class: className } = Astro.props;
12
+
13
+ const href = extractField<string>(entry, 'href') ?? '#';
14
+ const openInNewTab = extractField<boolean>(entry, 'openInNewTab') ?? false;
15
+ const typeSlug = entry.entryTypeInfo?.slug ?? 'navigation-item';
16
+ ---
17
+
18
+ <a
19
+ data-renderer="navigation"
20
+ data-entry-type={typeSlug}
21
+ class={className}
22
+ href={href}
23
+ {...openInNewTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}}
24
+ >
25
+ {entry.title}
26
+ <slot />
27
+ </a>
@@ -0,0 +1,51 @@
1
+ ---
2
+ import type { RendererEntry } from '../../renderers/types.js';
3
+ import { extractBody, getFeaturedAsset } from '../../renderers/resolve.js';
4
+ import { entryEmbeds } from '../../embeds.js';
5
+ import type { MarkdownRenderer } from '../../markdown.js';
6
+ import { defaultRenderMarkdown } from '../markdown.js';
7
+
8
+ interface Props {
9
+ entry: RendererEntry;
10
+ config?: Record<string, unknown>;
11
+ class?: string;
12
+ /** The site's renderer (`marvin.renderMarkdown`) so its markdown options apply. */
13
+ renderMarkdown?: MarkdownRenderer;
14
+ }
15
+
16
+ const { entry, config, class: className, renderMarkdown = defaultRenderMarkdown } = Astro.props;
17
+
18
+ // The body is markdown: render it (with the entry's media embeds) rather than inject it raw.
19
+ const source = extractBody(entry);
20
+ const body = source ? await renderMarkdown(source, { embeds: entryEmbeds(entry) }) : undefined;
21
+ const featuredAsset = getFeaturedAsset(entry);
22
+ const typeSlug = entry.entryTypeInfo?.slug ?? 'page';
23
+ ---
24
+
25
+ <article
26
+ data-renderer="page"
27
+ data-entry-type={typeSlug}
28
+ class={className}
29
+ >
30
+ {featuredAsset?.publicUrl && (
31
+ <figure data-role="featured-asset">
32
+ <img
33
+ src={featuredAsset.publicUrl}
34
+ alt={featuredAsset.altText ?? entry.title}
35
+ width={featuredAsset.width}
36
+ height={featuredAsset.height}
37
+ />
38
+ </figure>
39
+ )}
40
+
41
+ <header data-role="header">
42
+ <h1 data-role="title">{entry.title}</h1>
43
+ {entry.summary && <p data-role="summary">{entry.summary}</p>}
44
+ </header>
45
+
46
+ {body && (
47
+ <div data-role="body" set:html={body} />
48
+ )}
49
+
50
+ <slot />
51
+ </article>
package/src/config.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Configuration + environment resolution.
3
+ *
4
+ * Precedence for every value: explicit option → `import.meta.env` → `process.env`.
5
+ * Astro exposes server-side env through `import.meta.env`; a plain Node script
6
+ * (a smoke test, a build hook) only has `process.env`. Both work, unchanged.
7
+ */
8
+
9
+ import type { MarkdownOptions } from './markdown.js';
10
+
11
+ export type MarvinLogger = {
12
+ log: (message: string, ...args: unknown[]) => void;
13
+ warn: (message: string, ...args: unknown[]) => void;
14
+ error: (message: string, ...args: unknown[]) => void;
15
+ };
16
+
17
+ export const ENV_KEYS = {
18
+ apiUrl: 'MARVIN_API_URL',
19
+ siteClientToken: 'MARVIN_SITE_CLIENT_TOKEN',
20
+ workspaceSlug: 'MARVIN_WORKSPACE_SLUG',
21
+ debug: 'MARVIN_DEBUG',
22
+ } as const;
23
+
24
+ /** Dev default for the network-failure latch: retry a downed backend after 30s. */
25
+ export const DEFAULT_DEV_RETRY_MS = 30_000;
26
+
27
+ /** How many entry reads `hydrate` keeps in flight at once. */
28
+ export const DEFAULT_HYDRATE_CONCURRENCY = 6;
29
+
30
+ export type MarvinAstroConfig = {
31
+ apiUrl?: string;
32
+ siteClientToken?: string;
33
+ workspaceSlug?: string;
34
+ /** Log the resolved env once at startup, and enable SDK request logging. */
35
+ debug?: boolean;
36
+ /**
37
+ * How long the network-failure latch stays closed before the backend is retried.
38
+ * Defaults to 30s in dev and `Infinity` in production — a build should fail fast and
39
+ * consistently rather than half-succeed with some pages live and some pages static.
40
+ */
41
+ retryAfterMs?: number;
42
+ /**
43
+ * Maximum entry reads in flight at once while hydrating a collection. Firing every read at
44
+ * once makes a large collection time out on itself; the default of 6 keeps a 280-entry
45
+ * hydration well inside the SDK's per-request timeout.
46
+ */
47
+ hydrateConcurrency?: number;
48
+ logger?: MarvinLogger;
49
+ /** Markdown rendering options; defaults to `{ gfm: true, breaks: false }`. */
50
+ markdown?: MarkdownOptions;
51
+ /** Explicit env source. Highest priority after direct options — mainly a test seam. */
52
+ env?: Record<string, string | undefined>;
53
+ /** Injectable clock, so latch expiry is testable without waiting. */
54
+ now?: () => number;
55
+ /** Injectable SDK client factory — a test seam; defaults to the SDK's `createMarvinClient`. */
56
+ createClient?: (config: MarvinClientOptions) => unknown;
57
+ };
58
+
59
+ export type MarvinClientOptions = {
60
+ apiUrl: string;
61
+ siteClientToken: string;
62
+ workspaceSlug: string;
63
+ debug: boolean;
64
+ };
65
+
66
+ export type ResolvedConfig = {
67
+ apiUrl: string;
68
+ siteClientToken: string;
69
+ workspaceSlug: string;
70
+ debug: boolean;
71
+ retryAfterMs: number;
72
+ hydrateConcurrency: number;
73
+ logger: MarvinLogger;
74
+ markdown?: MarkdownOptions;
75
+ now: () => number;
76
+ createClient?: (config: MarvinClientOptions) => unknown;
77
+ /** True when all three connection values are present. */
78
+ configured: boolean;
79
+ };
80
+
81
+ function metaEnv(): Record<string, unknown> | undefined {
82
+ try {
83
+ return (import.meta as unknown as { env?: Record<string, unknown> }).env;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ function processEnv(): Record<string, string | undefined> | undefined {
90
+ try {
91
+ return typeof process !== 'undefined' ? process.env : undefined;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+
97
+ function nonEmpty(value: unknown): string | undefined {
98
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
99
+ }
100
+
101
+ /** Read one env var, honouring the documented precedence. */
102
+ export function readEnv(
103
+ key: string,
104
+ override?: Record<string, string | undefined>
105
+ ): string | undefined {
106
+ return nonEmpty(override?.[key]) ?? nonEmpty(metaEnv()?.[key]) ?? nonEmpty(processEnv()?.[key]);
107
+ }
108
+
109
+ function isProduction(override?: Record<string, string | undefined>): boolean {
110
+ const prodFlag = metaEnv()?.PROD;
111
+ if (typeof prodFlag === 'boolean') return prodFlag;
112
+ const mode = readEnv('NODE_ENV', override) ?? readEnv('MODE', override);
113
+ return mode === 'production';
114
+ }
115
+
116
+ export function resolveConfig(options: MarvinAstroConfig = {}): ResolvedConfig {
117
+ const env = options.env;
118
+ const apiUrl = options.apiUrl ?? readEnv(ENV_KEYS.apiUrl, env) ?? '';
119
+ const siteClientToken = options.siteClientToken ?? readEnv(ENV_KEYS.siteClientToken, env) ?? '';
120
+ const workspaceSlug = options.workspaceSlug ?? readEnv(ENV_KEYS.workspaceSlug, env) ?? '';
121
+ const debug = options.debug ?? readEnv(ENV_KEYS.debug, env) === 'true';
122
+
123
+ return {
124
+ apiUrl,
125
+ siteClientToken,
126
+ workspaceSlug,
127
+ debug,
128
+ retryAfterMs:
129
+ options.retryAfterMs ??
130
+ (isProduction(env) ? Number.POSITIVE_INFINITY : DEFAULT_DEV_RETRY_MS),
131
+ hydrateConcurrency: Math.max(1, Math.floor(options.hydrateConcurrency ?? DEFAULT_HYDRATE_CONCURRENCY)),
132
+ logger: options.logger ?? console,
133
+ markdown: options.markdown,
134
+ now: options.now ?? (() => Date.now()),
135
+ createClient: options.createClient,
136
+ configured: Boolean(apiUrl && siteClientToken && workspaceSlug),
137
+ };
138
+ }
139
+
140
+ /** One-line summary of the resolved env, with the token masked. For `debug` logging. */
141
+ export function describeConfig(config: ResolvedConfig): Record<string, unknown> {
142
+ const token = config.siteClientToken;
143
+ return {
144
+ [ENV_KEYS.apiUrl]: config.apiUrl || 'MISSING',
145
+ [ENV_KEYS.siteClientToken]: token ? `${token.slice(0, 12)}${'*'.repeat(20)}` : 'MISSING',
146
+ [ENV_KEYS.workspaceSlug]: config.workspaceSlug || 'MISSING',
147
+ hasAll: config.configured,
148
+ };
149
+ }
package/src/embeds.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Media embeds: the published shapes Marvin hands a site, read tolerantly.
3
+ *
4
+ * Marvin resolves a pasted provider link (YouTube, Vimeo, Spotify, …) on the server and publishes
5
+ * the result next to the entry as `entry.embeds`, keyed by the URL exactly as the author wrote it.
6
+ * Stored markdown is never rewritten: a site that knows about embeds swaps the link for the
7
+ * player; an older site just shows the link.
8
+ *
9
+ * These types are declared here rather than imported from `@inneropen/marvin-sdk` so the package
10
+ * works with SDKs that predate embeds. Kind and status are open-ended so a newer server value
11
+ * doesn't break an older site's types.
12
+ */
13
+
14
+ import { asRecord, asString } from './normalize.js';
15
+
16
+ export type MarvinEmbedKind = 'video' | 'audio' | 'podcast' | 'playlist';
17
+ export type MarvinEmbedStatus = 'ok' | 'link' | 'unavailable';
18
+ export type MarvinEmbedMode = 'direct' | 'click_to_load';
19
+
20
+ /**
21
+ * A Marvin-built iframe. Every value comes from Marvin's provider registry. Exactly one of
22
+ * `aspectRatio` / `height` is set; the other is `null`.
23
+ */
24
+ export type MarvinEmbedIframe = {
25
+ src: string;
26
+ title: string;
27
+ allow?: string | null;
28
+ sandbox?: string | null;
29
+ referrerpolicy?: string | null;
30
+ /** CSS aspect ratio for video players, e.g. `"16/9"` (`"9/16"` for Shorts); `null` otherwise. */
31
+ aspectRatio?: string | null;
32
+ /** Fixed pixel height for audio / podcast / playlist players; `null` for video. */
33
+ height?: number | null;
34
+ };
35
+
36
+ /** The plain link every embed falls back to. */
37
+ export type MarvinEmbedLink = {
38
+ href: string;
39
+ title: string;
40
+ providerName: string;
41
+ };
42
+
43
+ /** Optional fields arrive as `null` rather than omitted; every reader here treats both alike. */
44
+ export type MarvinEmbed = {
45
+ /** The URL exactly as written in the content. */
46
+ url: string;
47
+ canonicalUrl?: string | null;
48
+ /** Provider key, e.g. `youtube`. */
49
+ provider: string;
50
+ /** Display name, e.g. `YouTube`. */
51
+ providerName: string;
52
+ kind: MarvinEmbedKind | (string & {});
53
+ /** `ok` has an iframe; `link` and `unavailable` render as a link card. */
54
+ status: MarvinEmbedStatus | (string & {});
55
+ title?: string | null;
56
+ authorName?: string | null;
57
+ thumbnailUrl?: string | null;
58
+ iframe?: MarvinEmbedIframe | null;
59
+ link: MarvinEmbedLink;
60
+ /** Marvin-built HTML for the site's embed mode (an iframe, a click-to-load facade, or a link). */
61
+ html: string;
62
+ };
63
+
64
+ /** `entry.embeds`: every resolved embed on the entry, keyed by the URL as written. */
65
+ export type MarvinEmbeds = Record<string, MarvinEmbed>;
66
+
67
+ /**
68
+ * `site.embeds` (next to `site.seo`): the site's embed privacy mode, consent text (`{provider}`
69
+ * already substituted per embed in the published `html`) and the frame sources a CSP must allow.
70
+ */
71
+ export type MarvinSiteEmbeds = {
72
+ mode: MarvinEmbedMode;
73
+ consentText: string;
74
+ /** Origins/hosts the site's embeds may frame — feed these into a `frame-src` directive. */
75
+ frameSources: string[];
76
+ };
77
+
78
+ const EMBED_MODES = new Set<string>(['direct', 'click_to_load']);
79
+
80
+ function readEmbedsField(entry: unknown): unknown {
81
+ if (!entry || typeof entry !== 'object') return undefined;
82
+ const direct = (entry as { embeds?: unknown }).embeds;
83
+ if (direct !== undefined) return direct;
84
+ // The SDK's `Entry` class keeps the payload private; its `toJSON()` hands it back.
85
+ const toJSON = (entry as { toJSON?: unknown }).toJSON;
86
+ if (typeof toJSON === 'function') {
87
+ return asRecord(toJSON.call(entry)).embeds;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * The entry's `embeds` map, or `undefined` when it has none (an older server, or no embeds).
94
+ * Values that aren't objects carrying a URL are dropped.
95
+ */
96
+ export function entryEmbeds(entry: unknown): MarvinEmbeds | undefined {
97
+ const raw = readEmbedsField(entry);
98
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
99
+
100
+ const embeds: MarvinEmbeds = {};
101
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
102
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
103
+ embeds[key] = value as MarvinEmbed;
104
+ }
105
+ }
106
+ return Object.keys(embeds).length > 0 ? embeds : undefined;
107
+ }
108
+
109
+ /** The embed for `url` as written, tolerating surrounding whitespace. */
110
+ export function findEmbed(
111
+ embeds: MarvinEmbeds | null | undefined,
112
+ url: string | null | undefined
113
+ ): MarvinEmbed | undefined {
114
+ if (!embeds || !url) return undefined;
115
+ const key = url.trim();
116
+ if (!key) return undefined;
117
+ return Object.prototype.hasOwnProperty.call(embeds, key) ? embeds[key] : undefined;
118
+ }
119
+
120
+ /** Normalize `site.embeds`; `undefined` when the server doesn't send it. */
121
+ export function siteEmbeds(raw: unknown): MarvinSiteEmbeds | undefined {
122
+ if (!raw || typeof raw !== 'object') return undefined;
123
+ const value = asRecord(raw);
124
+ const mode = asString(value.mode);
125
+ const frameSources = Array.isArray(value.frameSources)
126
+ ? value.frameSources.filter(
127
+ (source): source is string => typeof source === 'string' && source.trim().length > 0
128
+ )
129
+ : [];
130
+
131
+ return {
132
+ // Unknown or missing mode → the privacy-preserving default.
133
+ mode: mode && EMBED_MODES.has(mode) ? (mode as MarvinEmbedMode) : 'click_to_load',
134
+ consentText: asString(value.consentText) ?? '',
135
+ frameSources,
136
+ };
137
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module '*.astro' {
2
+ type AstroComponentFactory = import('astro/runtime/server/index.js').AstroComponentFactory;
3
+ const component: AstroComponentFactory;
4
+ export default component;
5
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Every call into Marvin, wrapped the same way: skip when there's no backend, catch, remember
3
+ * network failures for the latch, warn once, and return an empty value the caller can fall
4
+ * back from. Nothing here throws.
5
+ */
6
+
7
+ import type {
8
+ Collection,
9
+ CollectionEntry,
10
+ Entry,
11
+ MarvinAsset,
12
+ MarvinEntry,
13
+ MarvinResource,
14
+ MarvinSite,
15
+ PublishedCollectionSummary,
16
+ Workspace,
17
+ } from '@inneropen/marvin-sdk';
18
+ import { errorMessage, type MarvinBackend } from './client.js';
19
+ import type { MarvinContentEntry } from './types.js';
20
+
21
+ /** An entry that carries `data_json` — i.e. schema fields are readable. */
22
+ export type HydratedEntry = MarvinEntry | Entry;
23
+
24
+ export type MarvinFetcher = ReturnType<typeof createFetcher>;
25
+
26
+ /**
27
+ * True for a full entry (`PublishedEntryRead`), false for a list item (`PublishedEntryListItem`).
28
+ *
29
+ * `data` doesn't tell them apart: list items carry it too now. What differs is attachments. A full
30
+ * read has `assets[]` and `resources[]` (placement objects, always present, possibly empty); a list
31
+ * item has only `assetSlugs` / `resourceSlugs` and a `featuredAsset`. The SDK's `Entry` wrapper
32
+ * only ever comes from the single-entry read.
33
+ */
34
+ export function isFullEntry(item: unknown): boolean {
35
+ if (!item || typeof item !== 'object') return false;
36
+ if (typeof (item as { field?: unknown }).field === 'function') return true;
37
+ const raw = item as Record<string, unknown>;
38
+ if ('assetSlugs' in raw || 'resourceSlugs' in raw) return false;
39
+ return Array.isArray(raw.assets) || Array.isArray(raw.resources);
40
+ }
41
+
42
+ /** A failed hydration read is retried this many times in total, backing off from this base. */
43
+ export const HYDRATE_ATTEMPTS = 3;
44
+ export const HYDRATE_BACKOFF_MS = 500;
45
+
46
+ function sleep(ms: number): Promise<void> {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ /** `Promise.all(items.map(run))` with at most `limit` calls in flight. Order is preserved. */
51
+ async function mapWithConcurrency<T, R>(
52
+ items: T[],
53
+ limit: number,
54
+ run: (item: T) => Promise<R>
55
+ ): Promise<R[]> {
56
+ const results = new Array<R>(items.length);
57
+ let next = 0;
58
+
59
+ async function worker(): Promise<void> {
60
+ while (next < items.length) {
61
+ const index = next++;
62
+ results[index] = await run(items[index]);
63
+ }
64
+ }
65
+
66
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
67
+ await Promise.all(workers);
68
+ return results;
69
+ }
70
+
71
+ export function createFetcher(backend: MarvinBackend) {
72
+ async function guarded<T>(label: string, empty: T, run: () => Promise<T>): Promise<T> {
73
+ if (!backend.hasBackend()) return empty;
74
+ try {
75
+ return await run();
76
+ } catch (error) {
77
+ backend.remember(error);
78
+ backend.warn(`${label} unavailable: ${errorMessage(error)}`);
79
+ return empty;
80
+ }
81
+ }
82
+
83
+ async function entry(slug: string): Promise<Entry | null> {
84
+ return guarded(`Entry "${slug}"`, null, () => backend.client().entry(slug));
85
+ }
86
+
87
+ /**
88
+ * One entry read that survives a transient failure.
89
+ *
90
+ * `entry()` can't be used here: `guarded` swallows the error and trips the latch, and a
91
+ * tripped latch turns every remaining item into a silent null. So retry with backoff,
92
+ * re-opening the latch first in case a sibling read tripped it, and only remember the
93
+ * failure — and warn — once the attempts are spent.
94
+ */
95
+ async function entryForHydration(slug: string): Promise<Entry | null> {
96
+ if (!backend.hasBackend()) return null;
97
+
98
+ let failure: unknown;
99
+ for (let attempt = 1; attempt <= HYDRATE_ATTEMPTS; attempt++) {
100
+ if (attempt > 1) {
101
+ await sleep(HYDRATE_BACKOFF_MS * 2 ** (attempt - 2));
102
+ if (backend.isLatched()) backend.clearLatch();
103
+ }
104
+ try {
105
+ return await backend.client().entry(slug);
106
+ } catch (error) {
107
+ failure = error;
108
+ }
109
+ }
110
+
111
+ backend.remember(failure);
112
+ backend.warn(
113
+ `Entry "${slug}" dropped after ${HYDRATE_ATTEMPTS} attempts: ${errorMessage(failure)}`
114
+ );
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Turn list items into full entries.
120
+ *
121
+ * The collection endpoint returns `PublishedEntryListItem`: core fields, `metadata_json` and
122
+ * (on current servers) `data`, but only asset/resource *slugs* — no `assets[]` placements (so
123
+ * no roles: no `hero-grade` image) and no `resources[]`. `PublishedEntryRead` (the single-entry
124
+ * endpoint) has all of it. Only items that are already full entries ({@link isFullEntry}) are
125
+ * passed through; everything else is re-read, even when it carries `data`.
126
+ *
127
+ * Reads run at most `config.hydrateConcurrency` at a time. Unbounded, a 280-entry collection
128
+ * fired 280 requests at once, most of which timed out and were dropped — and the first
129
+ * timeout latched the backend off for the rest of the build.
130
+ */
131
+ async function hydrate(entries: MarvinContentEntry[]): Promise<HydratedEntry[]> {
132
+ const hydrated = await mapWithConcurrency(
133
+ entries,
134
+ backend.config.hydrateConcurrency,
135
+ async (item) => {
136
+ if (isFullEntry(item)) return item as HydratedEntry;
137
+ return entryForHydration(item.slug ?? '');
138
+ }
139
+ );
140
+
141
+ return hydrated.filter((item): item is HydratedEntry => Boolean(item));
142
+ }
143
+
144
+ return {
145
+ backend,
146
+
147
+ collections(): Promise<PublishedCollectionSummary[]> {
148
+ return guarded('Collections', [], () => backend.client().collections.list());
149
+ },
150
+
151
+ async collection(slug: string): Promise<Collection | null> {
152
+ return guarded(`Collection "${slug}"`, null, async () => {
153
+ const found = await backend.client().collections.get(slug);
154
+ return Array.isArray(found) ? null : found;
155
+ });
156
+ },
157
+
158
+ collectionEntries(slug: string): Promise<CollectionEntry[]> {
159
+ return guarded(`Collection entries "${slug}"`, [], () =>
160
+ backend.client().collections.entries(slug)
161
+ );
162
+ },
163
+
164
+ /** Try each collection slug in order; return the first that yields entries. */
165
+ async collectionEntriesFallback(slugs: string[]): Promise<CollectionEntry[]> {
166
+ for (const slug of slugs) {
167
+ const entries = await this.collectionEntries(slug);
168
+ if (entries.length > 0) return entries;
169
+ }
170
+ return [];
171
+ },
172
+
173
+ async hydratedCollectionEntries(slug: string): Promise<HydratedEntry[]> {
174
+ return hydrate(await this.collectionEntries(slug));
175
+ },
176
+
177
+ async hydratedCollectionEntriesFallback(slugs: string[]): Promise<HydratedEntry[]> {
178
+ for (const slug of slugs) {
179
+ const entries = await this.hydratedCollectionEntries(slug);
180
+ if (entries.length > 0) return entries;
181
+ }
182
+ return [];
183
+ },
184
+
185
+ hydrate,
186
+ entry,
187
+
188
+ site(): Promise<MarvinSite | null> {
189
+ return guarded('Site', null, () => backend.client().getSite());
190
+ },
191
+
192
+ workspace(): Promise<Workspace | null> {
193
+ return guarded('Workspace', null, () => backend.client().getWorkspace());
194
+ },
195
+
196
+ assets(type?: string): Promise<MarvinAsset[]> {
197
+ return guarded('Assets', [], () => backend.client().assets.list({ type }));
198
+ },
199
+
200
+ asset(slugOrId: string): Promise<MarvinAsset | null> {
201
+ return guarded(`Asset "${slugOrId}"`, null, () => backend.client().assets.get(slugOrId));
202
+ },
203
+
204
+ resources(resourceType?: string): Promise<MarvinResource[]> {
205
+ return guarded('Resources', [], () => backend.client().resources.list({ resourceType }));
206
+ },
207
+ };
208
+ }