@inneropen/marvin-astro 1.1.1 → 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.
- package/README.md +164 -9
- package/dist/embeds-BTNryQA6.d.ts +79 -0
- package/dist/index.d.ts +29 -2
- package/dist/index.js +88 -4
- package/dist/index.js.map +1 -1
- package/dist/renderers/index.d.ts +119 -0
- package/dist/renderers/index.js +251 -0
- package/dist/renderers/index.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/package.json +16 -6
- package/src/chrome.ts +242 -0
- package/src/client.ts +119 -0
- package/src/components/Embed.astro +119 -0
- package/src/components/EmbedLoader.astro +24 -0
- package/src/components/EntryRenderer.astro +40 -0
- package/src/components/FormRenderer.astro +188 -0
- package/src/components/LinkCard.astro +42 -0
- package/src/components/embed-frame.ts +168 -0
- package/src/components/embeds.css +137 -0
- package/src/components/index.ts +29 -0
- package/src/components/integration.ts +62 -0
- package/src/components/markdown.ts +4 -0
- package/src/components/registry.ts +34 -0
- package/src/components/renderers/ArticleRenderer.astro +56 -0
- package/src/components/renderers/FaqRenderer.astro +26 -0
- package/src/components/renderers/NavigationRenderer.astro +27 -0
- package/src/components/renderers/PageRenderer.astro +51 -0
- package/src/config.ts +149 -0
- package/src/embeds.ts +137 -0
- package/src/env.d.ts +5 -0
- package/src/fetch.ts +198 -0
- package/src/fields.ts +330 -0
- package/src/format.ts +63 -0
- package/src/index.ts +169 -0
- package/src/markdown.ts +84 -0
- package/src/normalize.ts +284 -0
- package/src/renderers/debug.ts +6 -0
- package/src/renderers/index.ts +48 -0
- package/src/renderers/registry.ts +133 -0
- package/src/renderers/resolve.ts +134 -0
- package/src/renderers/types.ts +71 -0
- package/src/renderers/validation-types.ts +23 -0
- package/src/renderers/validation.ts +55 -0
- package/src/repository.ts +190 -0
- package/src/sections.ts +39 -0
- package/src/site.ts +222 -0
- 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
|
+
}
|
package/src/sections.ts
ADDED
|
@@ -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';
|