@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,55 @@
1
+ import type { RendererRegistry } from './types.js';
2
+ import type { MissingRenderer, ValidationResult } from './validation-types.js';
3
+
4
+ interface EntryTypeWithRendering {
5
+ slug: string;
6
+ name: string;
7
+ isRendered?: boolean;
8
+ rendering?: Record<string, unknown> | null;
9
+ }
10
+
11
+ function isRendererRegistry(value: unknown): value is RendererRegistry {
12
+ return (
13
+ typeof value === 'object' &&
14
+ value !== null &&
15
+ typeof (value as RendererRegistry).has === 'function'
16
+ );
17
+ }
18
+
19
+ export function validateRenderers(
20
+ entryTypes: EntryTypeWithRendering[],
21
+ registry: RendererRegistry | Record<string, unknown>,
22
+ options?: { ignore?: string[] }
23
+ ): ValidationResult {
24
+ const ignore = new Set(options?.ignore ?? []);
25
+
26
+ const withRenderer = entryTypes.filter(
27
+ (et) => et.isRendered && et.rendering?.renderer && !ignore.has(et.slug)
28
+ );
29
+
30
+ const hasRenderer = isRendererRegistry(registry)
31
+ ? (name: string) => registry.has(name)
32
+ : (name: string) => name in registry;
33
+
34
+ const missing: MissingRenderer[] = [];
35
+
36
+ for (const et of withRenderer) {
37
+ const rendering = et.rendering!;
38
+ const renderer = rendering.renderer as string;
39
+ if (!hasRenderer(renderer)) {
40
+ missing.push({
41
+ entryTypeSlug: et.slug,
42
+ entryTypeName: et.name,
43
+ renderer,
44
+ package: (rendering.package as string) ?? undefined,
45
+ version: (rendering.version as string) ?? undefined,
46
+ });
47
+ }
48
+ }
49
+
50
+ return {
51
+ total: withRenderer.length,
52
+ covered: withRenderer.length - missing.length,
53
+ missing,
54
+ };
55
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The repository factory.
3
+ *
4
+ * `getAll` / `getBySlug` / `getFeatured` is the same forty lines of try / fallback / memoize
5
+ * around every content type on every site. Only the transform differs. This holds the forty
6
+ * lines; the site writes the transform.
7
+ */
8
+
9
+ import { errorMessage } from './client.js';
10
+ import { createFieldAccessor, type FieldAccessor } from './fields.js';
11
+ import { isFullEntry, type MarvinFetcher } from './fetch.js';
12
+ import type { MarkdownRenderer } from './markdown.js';
13
+ import type { MarvinContentEntry } from './types.js';
14
+
15
+ export type RepositoryOptions<T> = {
16
+ /** Collection slug to load from. */
17
+ collection?: string;
18
+ /**
19
+ * Collection slugs tried in order; the first one that yields entries wins. Lets a site work
20
+ * against workspaces that named the same thing `bench-notes`, `journal`, or `blog`.
21
+ */
22
+ collections?: string[];
23
+ /**
24
+ * Fetch each list item as a full entry before transforming.
25
+ *
26
+ * The collection endpoint returns `PublishedEntryListItem`, which has no `assets[]` or
27
+ * `resources[]` (only their slugs) and, on older servers, no `data_json`; the single-entry
28
+ * endpoint returns `PublishedEntryRead`, which has all of it. So if the transform reads asset
29
+ * roles, resources, or (on an older server) any schema field, this must be `true`. It costs one
30
+ * request per entry. `bySlug` always works from full entries either way.
31
+ */
32
+ hydrate?: boolean;
33
+ /** Build the resolved item from an entry. May be async (e.g. to render markdown). */
34
+ transform: (entry: MarvinContentEntry, fields: FieldAccessor) => T | Promise<T>;
35
+ /** Static data to serve when Marvin is unreachable or the collection is empty. */
36
+ fallback?: () => T[] | Promise<T[]>;
37
+ /** Applied to Marvin and fallback results alike, so ordering doesn't depend on the source. */
38
+ sort?: (a: T, b: T) => number;
39
+ /** Applied to Marvin and fallback results alike. */
40
+ filter?: (item: T) => boolean;
41
+ /** The route an entry resolves to; exposed to the transform as `fields.href`. */
42
+ href?: (slug: string, entry: MarvinContentEntry) => string;
43
+ /** How to read an item's slug for `bySlug`. Defaults to `item.slug`. */
44
+ slugOf?: (item: T) => string | undefined;
45
+ /** How to tell whether an item is featured. Defaults to `item.featured`. */
46
+ isFeatured?: (item: T) => boolean;
47
+ };
48
+
49
+ export type Repository<T> = {
50
+ /** Every item, resolved once per process. */
51
+ all(): Promise<T[]>;
52
+ /**
53
+ * One item by slug. Served from `all()` once that has loaded; otherwise a direct entry
54
+ * fetch, falling back to a scan of `all()`.
55
+ */
56
+ bySlug(slug: string): Promise<T | undefined>;
57
+ /** The first featured item, or the first item when none is marked. */
58
+ featured(): Promise<T | undefined>;
59
+ /** Every featured item, in `all()` order. */
60
+ allFeatured(): Promise<T[]>;
61
+ /** Drop the memoized results. Next call re-fetches. */
62
+ reset(): void;
63
+ };
64
+
65
+ export type RepositoryContext = {
66
+ fetcher: MarvinFetcher;
67
+ renderMarkdown: MarkdownRenderer;
68
+ };
69
+
70
+ function defaultSlugOf<T>(item: T): string | undefined {
71
+ const slug = (item as { slug?: unknown }).slug;
72
+ return typeof slug === 'string' ? slug : undefined;
73
+ }
74
+
75
+ function defaultIsFeatured<T>(item: T): boolean {
76
+ return Boolean((item as { featured?: unknown }).featured);
77
+ }
78
+
79
+ export function createRepository<T>(
80
+ context: RepositoryContext,
81
+ options: RepositoryOptions<T>
82
+ ): Repository<T> {
83
+ const { fetcher, renderMarkdown } = context;
84
+ const collections = options.collections ?? (options.collection ? [options.collection] : []);
85
+ const slugOf = options.slugOf ?? defaultSlugOf;
86
+ const isFeatured = options.isFeatured ?? defaultIsFeatured;
87
+
88
+ /** The loaded list, plus the slugs whose items were built from full entries. */
89
+ type Loaded = { items: T[]; fromFullEntry: Set<string> };
90
+ let loadedPromise: Promise<Loaded> | null = null;
91
+ let allPromise: Promise<T[]> | null = null;
92
+ const bySlugCache = new Map<string, Promise<T | undefined>>();
93
+
94
+ function arrange(items: T[]): T[] {
95
+ const filtered = options.filter ? items.filter(options.filter) : items;
96
+ return options.sort ? [...filtered].sort(options.sort) : filtered;
97
+ }
98
+
99
+ function transform(entry: MarvinContentEntry, collection?: string): Promise<T> {
100
+ const slug = (entry as { slug?: string }).slug ?? '';
101
+ const fields = createFieldAccessor(entry, {
102
+ renderMarkdown,
103
+ href: options.href?.(slug, entry),
104
+ collection,
105
+ });
106
+ return Promise.resolve(options.transform(entry, fields));
107
+ }
108
+
109
+ async function useFallback(): Promise<Loaded> {
110
+ return { items: arrange((await options.fallback?.()) ?? []), fromFullEntry: new Set() };
111
+ }
112
+
113
+ async function load(): Promise<Loaded> {
114
+ if (collections.length > 0 && fetcher.backend.hasBackend()) {
115
+ try {
116
+ const entries = options.hydrate
117
+ ? await fetcher.hydratedCollectionEntriesFallback(collections)
118
+ : await fetcher.collectionEntriesFallback(collections);
119
+
120
+ if (entries.length > 0) {
121
+ const items = await Promise.all(entries.map((entry) => transform(entry)));
122
+ const fromFullEntry = new Set<string>();
123
+ items.forEach((item, index) => {
124
+ const slug = slugOf(item);
125
+ if (slug !== undefined && isFullEntry(entries[index])) fromFullEntry.add(slug);
126
+ });
127
+ return { items: arrange(items), fromFullEntry };
128
+ }
129
+ } catch (error) {
130
+ fetcher.backend.remember(error);
131
+ fetcher.backend.warn(
132
+ `Collection "${collections[0]}" failed, using fallback: ${errorMessage(error)}`
133
+ );
134
+ }
135
+ }
136
+
137
+ return useFallback();
138
+ }
139
+
140
+ function findInList(items: T[], slug: string): T | undefined {
141
+ return items.find((item) => slugOf(item) === slug);
142
+ }
143
+
144
+ async function loadBySlug(slug: string): Promise<T | undefined> {
145
+ // Once a hydrated list has loaded, every slug in it is already transformed from a full entry —
146
+ // a per-entry fetch would be a second request for the same content (~1.4s per detail page on
147
+ // a 280-entry site). An item built from a bare list item is NOT served: it has no assets[] or
148
+ // resources[], so a detail page would lose its role-picked images and resource links.
149
+ let listed: T | undefined;
150
+ if (loadedPromise) {
151
+ const loaded = await loadedPromise;
152
+ listed = findInList(loaded.items, slug);
153
+ if (listed && loaded.fromFullEntry.has(slug)) return listed;
154
+ }
155
+
156
+ if (fetcher.backend.hasBackend()) {
157
+ try {
158
+ const entry = await fetcher.entry(slug);
159
+ if (entry) return await transform(entry);
160
+ } catch (error) {
161
+ fetcher.backend.remember(error);
162
+ fetcher.backend.warn(`Entry "${slug}" failed, using fallback: ${errorMessage(error)}`);
163
+ }
164
+ }
165
+
166
+ // No backend, no such entry, or a transform that blew up: fall back to the resolved list,
167
+ // which is either Marvin's or the static data — the caller doesn't need to care which.
168
+ return listed ?? findInList(await all(), slug);
169
+ }
170
+
171
+ function all(): Promise<T[]> {
172
+ if (!allPromise) {
173
+ loadedPromise = load();
174
+ allPromise = loadedPromise.then((loaded) => loaded.items);
175
+ }
176
+ return allPromise;
177
+ }
178
+
179
+ return {
180
+ all,
181
+
182
+ bySlug(slug: string): Promise<T | undefined> {
183
+ let cached = bySlugCache.get(slug);
184
+ if (!cached) {
185
+ cached = loadBySlug(slug);
186
+ bySlugCache.set(slug, cached);
187
+ }
188
+ return cached;
189
+ },
190
+
191
+ async allFeatured(): Promise<T[]> {
192
+ return (await all()).filter(isFeatured);
193
+ },
194
+
195
+ async featured(): Promise<T | undefined> {
196
+ const items = await all();
197
+ return items.find(isFeatured) ?? items[0];
198
+ },
199
+
200
+ reset() {
201
+ allPromise = null;
202
+ loadedPromise = null;
203
+ bySlugCache.clear();
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,39 @@
1
+ import { entryEmbeds } from './embeds.js';
2
+ import type { MarvinFetcher } from './fetch.js';
3
+ import { createMarkdownRenderer, preserveSoftBreaks, type MarkdownRenderer } from './markdown.js';
4
+ import { asString, assetUrl, entryField, selectAssetByRole } from './normalize.js';
5
+ import type { SectionLanding } from './types.js';
6
+
7
+ export type SectionLandingOptions = {
8
+ /** Asset role to use as the hero image. Default `'hero'`. */
9
+ heroRole?: string;
10
+ /** Field holding the intro copy. Default `'body'`, falling back to the entry summary. */
11
+ bodyField?: string;
12
+ };
13
+
14
+ /**
15
+ * Resolve a section-landing header from a Marvin `page` entry with the given slug.
16
+ *
17
+ * The intro is rendered with authored line breaks preserved — landing copy is written as a few
18
+ * deliberate lines, not a soft-wrapped paragraph, and losing those breaks changes the layout.
19
+ */
20
+ export async function loadSectionLanding(
21
+ fetcher: MarvinFetcher,
22
+ slug: string,
23
+ renderMarkdown: MarkdownRenderer = createMarkdownRenderer(),
24
+ options: SectionLandingOptions = {}
25
+ ): Promise<SectionLanding> {
26
+ const entry = await fetcher.entry(slug);
27
+ if (!entry) return {};
28
+
29
+ const source =
30
+ asString(entryField(entry, options.bodyField ?? 'body')) ?? asString(entry.summary);
31
+
32
+ return {
33
+ title: asString(entry.title),
34
+ introHtml: source
35
+ ? await renderMarkdown(preserveSoftBreaks(source), { embeds: entryEmbeds(entry) })
36
+ : undefined,
37
+ hero: assetUrl(selectAssetByRole(entry, options.heroRole ?? 'hero')),
38
+ };
39
+ }
package/src/site.ts ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Site identity + SEO.
3
+ *
4
+ * Marvin's site configuration is optional almost everywhere; a template that had to guard every
5
+ * field would be unreadable. This resolves it once into an `ApiSite` where title, description,
6
+ * siteName, locale and timezone are always present, and where brand asset slugs have already
7
+ * become URLs.
8
+ */
9
+
10
+ import type { MarvinSite, SiteConfiguration } from '@inneropen/marvin-sdk';
11
+ import { siteEmbeds } from './embeds.js';
12
+ import type { MarvinFetcher } from './fetch.js';
13
+ import { asRecord, asString, assetUrl } from './normalize.js';
14
+ import type { ApiSeo, ApiSite } from './types.js';
15
+
16
+ export type SiteOptions = {
17
+ /**
18
+ * Static identity to fall back to, field by field, when Marvin is unreachable or leaves a
19
+ * value unset. Anything omitted here falls through to a neutral default.
20
+ */
21
+ fallback?: Partial<ApiSite> | (() => Partial<ApiSite>);
22
+ /** Default locale when neither Marvin nor the fallback sets one. Default `'en-US'`. */
23
+ defaultLocale?: string;
24
+ /** Default timezone when neither Marvin nor the fallback sets one. Default `'UTC'`. */
25
+ defaultTimezone?: string;
26
+ };
27
+
28
+ /**
29
+ * Build resolved SEO from Marvin's raw SiteSeo block, applying identity fallbacks so title,
30
+ * description and siteName are never empty. Absent → sensible defaults (robots `index,follow`;
31
+ * og type `website`; large-image Twitter card).
32
+ */
33
+ export function buildSeo(
34
+ raw: Record<string, unknown> | undefined,
35
+ identity: { title: string; description: string; canonicalUrl?: string; siteName: string }
36
+ ): ApiSeo {
37
+ const seo = raw ?? {};
38
+ const verification = asRecord(seo.verification);
39
+
40
+ return {
41
+ title: asString(seo.title) ?? identity.title,
42
+ titleTemplate: asString(seo.titleTemplate),
43
+ description: asString(seo.description) ?? identity.description,
44
+ keywords: Array.isArray(seo.keywords)
45
+ ? seo.keywords.filter((keyword): keyword is string => typeof keyword === 'string')
46
+ : [],
47
+ robots: asString(seo.robots) ?? 'index,follow',
48
+ image: asString(seo.image),
49
+ imageAlt: asString(seo.imageAlt),
50
+ ogType: asString(seo.ogType) ?? 'website',
51
+ siteName: asString(seo.siteName) ?? identity.siteName,
52
+ twitterCard: asString(seo.twitterCard) ?? 'summary_large_image',
53
+ twitterHandle: asString(seo.twitterHandle),
54
+ twitterCreator: asString(seo.twitterCreator),
55
+ fbAppId: asString(seo.fbAppId),
56
+ themeColor: asString(seo.themeColor),
57
+ publisher: asString(seo.publisher),
58
+ canonicalUrl: identity.canonicalUrl,
59
+ verification: {
60
+ google: asString(verification.google),
61
+ bing: asString(verification.bing),
62
+ pinterest: asString(verification.pinterest),
63
+ yandex: asString(verification.yandex),
64
+ },
65
+ };
66
+ }
67
+
68
+ function normalizeSocial(social: unknown): Record<string, string> {
69
+ return Object.fromEntries(
70
+ Object.entries(asRecord(social)).filter(
71
+ (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0
72
+ )
73
+ );
74
+ }
75
+
76
+ /** The offline site: the caller's fallback, with neutral defaults under it. */
77
+ export function buildFallbackSite(options: SiteOptions = {}): ApiSite {
78
+ const provided = typeof options.fallback === 'function' ? options.fallback() : options.fallback;
79
+ const fallback = provided ?? {};
80
+ const title = fallback.title ?? fallback.name ?? 'Untitled site';
81
+ const description = fallback.description ?? '';
82
+
83
+ return {
84
+ ...fallback,
85
+ name: fallback.name ?? title,
86
+ title,
87
+ description,
88
+ locale: fallback.locale ?? options.defaultLocale ?? 'en-US',
89
+ timezone: fallback.timezone ?? options.defaultTimezone ?? 'UTC',
90
+ social: fallback.social ?? {},
91
+ metadata: fallback.metadata ?? {},
92
+ seo:
93
+ fallback.seo ??
94
+ buildSeo(undefined, { title, description, siteName: fallback.name ?? title }),
95
+ };
96
+ }
97
+
98
+ function transformMarvinSite(marvinSite: MarvinSite, fallback: ApiSite): ApiSite {
99
+ const config: SiteConfiguration = marvinSite.site;
100
+ const metadata = asRecord(config.metadataJson);
101
+ const title =
102
+ asString(config.title) ?? asString(marvinSite.workspace?.name) ?? fallback.title;
103
+ const description = asString(config.description) ?? fallback.description;
104
+
105
+ return {
106
+ workspaceSlug: asString(marvinSite.workspace?.slug),
107
+ workspaceName: asString(marvinSite.workspace?.name),
108
+ name: title,
109
+ title,
110
+ tagline: asString(config.tagline) ?? fallback.tagline,
111
+ description,
112
+ canonicalUrl: asString(config.canonicalUrl) ?? fallback.canonicalUrl,
113
+ logo: asString(config.logo) ?? fallback.logo,
114
+ favicon: asString(config.favicon) ?? fallback.favicon,
115
+ locale: asString(config.locale) ?? fallback.locale,
116
+ timezone: asString(config.timezone) ?? fallback.timezone,
117
+ email: asString(config.contactEmail) ?? fallback.email,
118
+ imprint: asString(metadata.imprint) ?? fallback.imprint,
119
+ social: { ...fallback.social, ...normalizeSocial(config.social) },
120
+ // Typed `config.seo` when the SDK declares it, else the raw `metadata.seo` blob — older
121
+ // SDKs drop the typed field but pass the blob through untouched, and workspaces configured
122
+ // before the typed field existed still only have the blob.
123
+ seo: buildSeo(asRecord((config as { seo?: unknown }).seo ?? metadata.seo), {
124
+ title,
125
+ description,
126
+ canonicalUrl: asString(config.canonicalUrl),
127
+ siteName: title,
128
+ }),
129
+ // Older servers and SDKs don't send it; `siteEmbeds` returns undefined then.
130
+ embeds: siteEmbeds(
131
+ (config as { embeds?: unknown }).embeds ?? (marvinSite as { embeds?: unknown }).embeds
132
+ ),
133
+ metadata,
134
+ };
135
+ }
136
+
137
+ function looksLikeAssetSlug(value: string): boolean {
138
+ return !/^https?:\/\//.test(value) && !value.startsWith('/') && !value.startsWith('data:');
139
+ }
140
+
141
+ export type SiteLoader = {
142
+ get(): Promise<ApiSite>;
143
+ reset(): void;
144
+ };
145
+
146
+ export function createSiteLoader(fetcher: MarvinFetcher, options: SiteOptions = {}): SiteLoader {
147
+ const fallback = buildFallbackSite(options);
148
+ let promise: Promise<ApiSite> | null = null;
149
+
150
+ async function resolveAssetSlug(slug: string | undefined): Promise<string | undefined> {
151
+ if (!slug) return undefined;
152
+ return assetUrl(await fetcher.asset(slug));
153
+ }
154
+
155
+ // Resolve a value only when it looks like a bare asset slug; a URL/path/data URI (or a slug with
156
+ // no matching asset) is returned unchanged.
157
+ async function resolveMaybeSlug(value: string | undefined): Promise<string | undefined> {
158
+ if (!value || !looksLikeAssetSlug(value)) return value;
159
+ return (await resolveAssetSlug(value)) ?? value;
160
+ }
161
+
162
+ async function load(): Promise<ApiSite> {
163
+ const marvinSite = await fetcher.site();
164
+ if (!marvinSite) return fallback;
165
+
166
+ const site = transformMarvinSite(marvinSite, fallback);
167
+
168
+ // Brand-asset registry: resolve EVERY `brand.<name>` slug → URL in one pass. A site adds a
169
+ // shared asset with one config line (`brand.paperFold = "paper-fold"`) plus an upload;
170
+ // components then read `site.brand.paperFold`. No code change per asset.
171
+ const brandPairs = Object.entries(asRecord(site.metadata.brand)).filter(
172
+ (entry): entry is [string, string] =>
173
+ typeof entry[1] === 'string' && entry[1].trim().length > 0
174
+ );
175
+ const resolved = await Promise.all(
176
+ brandPairs.map(async ([name, slug]) => [name, await resolveAssetSlug(slug)] as const)
177
+ );
178
+ const brand: Record<string, string> = {};
179
+ const brandBySlug = new Map<string, string>();
180
+ for (const [index, [name, url]] of resolved.entries()) {
181
+ if (!url) continue;
182
+ brand[name] = url;
183
+ brandBySlug.set(brandPairs[index][1], url);
184
+ }
185
+
186
+ // The og:image may be given as a bare asset slug too. It is usually one of the brand slugs
187
+ // (a monogram, a logo), so check what was just resolved before fetching it again.
188
+ const rawImage = site.seo.image;
189
+ const ogImage =
190
+ rawImage && looksLikeAssetSlug(rawImage)
191
+ ? (brandBySlug.get(rawImage) ?? (await resolveAssetSlug(rawImage)))
192
+ : undefined;
193
+
194
+ // The top-level `site_logo` / `site_favicon` fields may be bare asset slugs too (like brand.*
195
+ // and og:image) — resolve them so they can reference an uploaded asset by slug. The brand-map
196
+ // aliases still win when present.
197
+ const logo = brand.logo ?? (await resolveMaybeSlug(site.logo));
198
+ const favicon = brand.favicon ?? (await resolveMaybeSlug(site.favicon));
199
+
200
+ return {
201
+ ...site,
202
+ brand,
203
+ // Convenience aliases for common callers; all of these also live in `site.brand`.
204
+ logo,
205
+ favicon,
206
+ seal: brand.seal ?? site.seal,
207
+ seo: { ...site.seo, image: ogImage ?? site.seo.image },
208
+ };
209
+ }
210
+
211
+ return {
212
+ get() {
213
+ // Memoized: components like a per-card brand stamp render many instances per page, so
214
+ // this keeps brand-slug resolution to once per process rather than once per render.
215
+ promise ??= load();
216
+ return promise;
217
+ },
218
+ reset() {
219
+ promise = null;
220
+ },
221
+ };
222
+ }