@inneropen/marvin-astro 1.1.0 → 1.2.0

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 +175 -9
  2. package/dist/embeds-BTNryQA6.d.ts +79 -0
  3. package/dist/index.d.ts +42 -3
  4. package/dist/index.js +143 -9
  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 +198 -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 +190 -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,190 @@
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 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 omits `data_json`; the
27
+ * single-entry endpoint returns `PublishedEntryRead`, which includes it. So if the transform
28
+ * reads ANY schema-defined field — anything not title/slug/summary/metadata — this must be
29
+ * `true` or those fields come back `undefined`. It costs one request per entry.
30
+ */
31
+ hydrate?: boolean;
32
+ /** Build the resolved item from an entry. May be async (e.g. to render markdown). */
33
+ transform: (entry: MarvinContentEntry, fields: FieldAccessor) => T | Promise<T>;
34
+ /** Static data to serve when Marvin is unreachable or the collection is empty. */
35
+ fallback?: () => T[] | Promise<T[]>;
36
+ /** Applied to Marvin and fallback results alike, so ordering doesn't depend on the source. */
37
+ sort?: (a: T, b: T) => number;
38
+ /** Applied to Marvin and fallback results alike. */
39
+ filter?: (item: T) => boolean;
40
+ /** The route an entry resolves to; exposed to the transform as `fields.href`. */
41
+ href?: (slug: string, entry: MarvinContentEntry) => string;
42
+ /** How to read an item's slug for `bySlug`. Defaults to `item.slug`. */
43
+ slugOf?: (item: T) => string | undefined;
44
+ /** How to tell whether an item is featured. Defaults to `item.featured`. */
45
+ isFeatured?: (item: T) => boolean;
46
+ };
47
+
48
+ export type Repository<T> = {
49
+ /** Every item, resolved once per process. */
50
+ all(): Promise<T[]>;
51
+ /**
52
+ * One item by slug. Served from `all()` once that has loaded; otherwise a direct entry
53
+ * fetch, falling back to a scan of `all()`.
54
+ */
55
+ bySlug(slug: string): Promise<T | undefined>;
56
+ /** The first featured item, or the first item when none is marked. */
57
+ featured(): Promise<T | undefined>;
58
+ /** Every featured item, in `all()` order. */
59
+ allFeatured(): Promise<T[]>;
60
+ /** Drop the memoized results. Next call re-fetches. */
61
+ reset(): void;
62
+ };
63
+
64
+ export type RepositoryContext = {
65
+ fetcher: MarvinFetcher;
66
+ renderMarkdown: MarkdownRenderer;
67
+ };
68
+
69
+ function defaultSlugOf<T>(item: T): string | undefined {
70
+ const slug = (item as { slug?: unknown }).slug;
71
+ return typeof slug === 'string' ? slug : undefined;
72
+ }
73
+
74
+ function defaultIsFeatured<T>(item: T): boolean {
75
+ return Boolean((item as { featured?: unknown }).featured);
76
+ }
77
+
78
+ export function createRepository<T>(
79
+ context: RepositoryContext,
80
+ options: RepositoryOptions<T>
81
+ ): Repository<T> {
82
+ const { fetcher, renderMarkdown } = context;
83
+ const collections = options.collections ?? (options.collection ? [options.collection] : []);
84
+ const slugOf = options.slugOf ?? defaultSlugOf;
85
+ const isFeatured = options.isFeatured ?? defaultIsFeatured;
86
+
87
+ let allPromise: Promise<T[]> | null = null;
88
+ const bySlugCache = new Map<string, Promise<T | undefined>>();
89
+
90
+ function arrange(items: T[]): T[] {
91
+ const filtered = options.filter ? items.filter(options.filter) : items;
92
+ return options.sort ? [...filtered].sort(options.sort) : filtered;
93
+ }
94
+
95
+ function transform(entry: MarvinContentEntry, collection?: string): Promise<T> {
96
+ const slug = (entry as { slug?: string }).slug ?? '';
97
+ const fields = createFieldAccessor(entry, {
98
+ renderMarkdown,
99
+ href: options.href?.(slug, entry),
100
+ collection,
101
+ });
102
+ return Promise.resolve(options.transform(entry, fields));
103
+ }
104
+
105
+ async function useFallback(): Promise<T[]> {
106
+ return arrange((await options.fallback?.()) ?? []);
107
+ }
108
+
109
+ async function load(): Promise<T[]> {
110
+ if (collections.length > 0 && fetcher.backend.hasBackend()) {
111
+ try {
112
+ const entries = options.hydrate
113
+ ? await fetcher.hydratedCollectionEntriesFallback(collections)
114
+ : await fetcher.collectionEntriesFallback(collections);
115
+
116
+ if (entries.length > 0) {
117
+ const items = await Promise.all(entries.map((entry) => transform(entry)));
118
+ return arrange(items);
119
+ }
120
+ } catch (error) {
121
+ fetcher.backend.remember(error);
122
+ fetcher.backend.warn(
123
+ `Collection "${collections[0]}" failed, using fallback: ${errorMessage(error)}`
124
+ );
125
+ }
126
+ }
127
+
128
+ return useFallback();
129
+ }
130
+
131
+ function findInList(items: T[], slug: string): T | undefined {
132
+ return items.find((item) => slugOf(item) === slug);
133
+ }
134
+
135
+ async function loadBySlug(slug: string): Promise<T | undefined> {
136
+ // Once the list has loaded, every slug in it is already transformed — a per-entry fetch
137
+ // would be a second request (and a second hydration) for the same content. On a 280-entry
138
+ // site that was ~1.4s per detail page.
139
+ if (allPromise) {
140
+ const listed = findInList(await allPromise, slug);
141
+ if (listed) return listed;
142
+ }
143
+
144
+ if (fetcher.backend.hasBackend()) {
145
+ try {
146
+ const entry = await fetcher.entry(slug);
147
+ if (entry) return await transform(entry);
148
+ } catch (error) {
149
+ fetcher.backend.remember(error);
150
+ fetcher.backend.warn(`Entry "${slug}" failed, using fallback: ${errorMessage(error)}`);
151
+ }
152
+ }
153
+
154
+ // No backend, no such entry, or a transform that blew up: fall back to the resolved list,
155
+ // which is either Marvin's or the static data — the caller doesn't need to care which.
156
+ return findInList(await all(), slug);
157
+ }
158
+
159
+ function all(): Promise<T[]> {
160
+ allPromise ??= load();
161
+ return allPromise;
162
+ }
163
+
164
+ return {
165
+ all,
166
+
167
+ bySlug(slug: string): Promise<T | undefined> {
168
+ let cached = bySlugCache.get(slug);
169
+ if (!cached) {
170
+ cached = loadBySlug(slug);
171
+ bySlugCache.set(slug, cached);
172
+ }
173
+ return cached;
174
+ },
175
+
176
+ async allFeatured(): Promise<T[]> {
177
+ return (await all()).filter(isFeatured);
178
+ },
179
+
180
+ async featured(): Promise<T | undefined> {
181
+ const items = await all();
182
+ return items.find(isFeatured) ?? items[0];
183
+ },
184
+
185
+ reset() {
186
+ allPromise = null;
187
+ bySlugCache.clear();
188
+ },
189
+ };
190
+ }
@@ -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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Site-integration types.
3
+ *
4
+ * These are the *resolved* shapes a site renders from — Marvin's raw payloads normalized,
5
+ * defaulted, and made non-optional where a template would otherwise need a guard on every
6
+ * use. Nothing here is specific to a particular site's design system.
7
+ */
8
+
9
+ import type { MarvinSiteEmbeds } from './embeds.js';
10
+ import type {
11
+ CollectionEntry,
12
+ Entry,
13
+ MarvinEntry,
14
+ MarvinEntryListItem,
15
+ } from '@inneropen/marvin-sdk';
16
+
17
+ /**
18
+ * Any entry shape the published API can hand back. `MarvinEntry`/`Entry` carry `data_json`;
19
+ * `CollectionEntry`/`MarvinEntryListItem` (list items) do not — see the `hydrate` repository
20
+ * option.
21
+ */
22
+ export type MarvinContentEntry = MarvinEntry | Entry | CollectionEntry | MarvinEntryListItem;
23
+
24
+ /** Criteria for picking one asset off an entry's `assets[]`. */
25
+ export type AssetSelectOptions = {
26
+ role?: string;
27
+ roles?: string[];
28
+ usage?: string;
29
+ usages?: string[];
30
+ type?: string;
31
+ types?: string[];
32
+ mimeType?: string;
33
+ /** Also accept assets carrying neither a role nor a usage. */
34
+ allowUnroled?: boolean;
35
+ };
36
+
37
+ /** A resolved image: a URL plus the presentation hints stored alongside the asset. */
38
+ export type ApiImage = {
39
+ src: string;
40
+ alt: string;
41
+ /** CSS `object-position` value, e.g. `"40% 65%"`. */
42
+ focalPoint?: string;
43
+ };
44
+
45
+ /** A resolved link to an attached resource (material, construction detail, document…). */
46
+ export type ApiResourceLink = {
47
+ name: string;
48
+ /** The resource type, e.g. `'material'`, `'construction'`. */
49
+ type: string;
50
+ /** The relationship role, e.g. `'primary-material'`. */
51
+ role?: string;
52
+ slug?: string;
53
+ href?: string;
54
+ };
55
+
56
+ /**
57
+ * Resolved SEO / social-sharing metadata for the site. Mirrors Marvin's SiteSeo, with identity
58
+ * fallbacks already applied (title/description/siteName never empty). The rendering layer
59
+ * (SeoHead) turns this into the actual `<meta>`/Open Graph/Twitter tags.
60
+ */
61
+ export type ApiSeo = {
62
+ title: string;
63
+ titleTemplate?: string;
64
+ description: string;
65
+ keywords: string[];
66
+ robots: string;
67
+ image?: string;
68
+ imageAlt?: string;
69
+ ogType: string;
70
+ siteName: string;
71
+ twitterCard: string;
72
+ twitterHandle?: string;
73
+ twitterCreator?: string;
74
+ fbAppId?: string;
75
+ themeColor?: string;
76
+ publisher?: string;
77
+ canonicalUrl?: string;
78
+ verification: {
79
+ google?: string;
80
+ bing?: string;
81
+ pinterest?: string;
82
+ yandex?: string;
83
+ };
84
+ };
85
+
86
+ export type ApiSite = {
87
+ workspaceSlug?: string;
88
+ workspaceName?: string;
89
+ name: string;
90
+ title: string;
91
+ tagline?: string;
92
+ description: string;
93
+ canonicalUrl?: string;
94
+ logo?: string;
95
+ favicon?: string;
96
+ /** Round maker's stamp / seal — resolved from `site_metadata_json.brand.seal` (asset slug). */
97
+ seal?: string;
98
+ /**
99
+ * Brand-asset registry: every `site_metadata_json.brand.<name>` slug resolved to its URL.
100
+ * Add a shared asset with one config line; components read `site.brand.<name>`.
101
+ */
102
+ brand?: Record<string, string>;
103
+ locale: string;
104
+ timezone: string;
105
+ email?: string;
106
+ imprint?: string;
107
+ social: Record<string, string>;
108
+ seo: ApiSeo;
109
+ /**
110
+ * Embed privacy mode, consent text and the frame sources a `frame-src` CSP must allow.
111
+ * Absent when the server predates media embeds.
112
+ */
113
+ embeds?: MarvinSiteEmbeds;
114
+ metadata: Record<string, unknown>;
115
+ };
116
+
117
+ export type ApiNavigationLink = {
118
+ label: string;
119
+ href: string;
120
+ description?: string;
121
+ external: boolean;
122
+ /** Membership role from the nav collection (e.g. `'primary'`, `'legal'`). Drives grouping. */
123
+ role?: string;
124
+ };
125
+
126
+ export type ApiSocialLink = ApiNavigationLink & {
127
+ icon: string;
128
+ };
129
+
130
+ export type ApiSiteChrome = {
131
+ site: ApiSite;
132
+ mainNavigation: ApiNavigationLink[];
133
+ footerNavigation: ApiNavigationLink[][];
134
+ /** Legal links (`role: 'legal'` in the footer nav collection), e.g. Terms/Privacy. */
135
+ legalLinks: ApiNavigationLink[];
136
+ socialLinks: ApiSocialLink[];
137
+ inquiry?: ApiNavigationLink;
138
+ };
139
+
140
+ export type ApiActionVariant = 'primary' | 'secondary';
141
+
142
+ export type ApiPageAction = ApiNavigationLink & {
143
+ variant: ApiActionVariant;
144
+ };
145
+
146
+ export type ApiHeroAction = ApiPageAction;
147
+
148
+ export type ApiHero = {
149
+ kicker: string;
150
+ headline: string;
151
+ body: string;
152
+ actions: ApiHeroAction[];
153
+ layout: 'split' | 'centered';
154
+ image?: {
155
+ src?: string;
156
+ alt: string;
157
+ focalPoint?: string;
158
+ dominantColor?: string;
159
+ fit?: 'cover' | 'contain';
160
+ caption?: string;
161
+ credit?: string;
162
+ orientation?: string;
163
+ };
164
+ };
165
+
166
+ /**
167
+ * A section-landing header driven by a Marvin `page` entry (e.g. `bench-notes`, `projects`).
168
+ * The entry is linkable in nav (collection membership) AND supplies the landing's title, intro,
169
+ * and hero — so the top of an index page stops being static. Absent fields resolve to
170
+ * `undefined` so callers fall back to their existing copy/illustration.
171
+ */
172
+ export type SectionLanding = {
173
+ title?: string;
174
+ /** The `body` field (or summary) rendered from markdown to HTML — render with `set:html`. */
175
+ introHtml?: string;
176
+ hero?: string;
177
+ };
178
+
179
+ export type {
180
+ MarvinEmbed,
181
+ MarvinEmbedIframe,
182
+ MarvinEmbedKind,
183
+ MarvinEmbedLink,
184
+ MarvinEmbedMode,
185
+ MarvinEmbeds,
186
+ MarvinEmbedStatus,
187
+ MarvinSiteEmbeds,
188
+ } from './embeds.js';