@inneropen/marvin-astro 1.0.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 +209 -0
- package/dist/index.d.ts +576 -0
- package/dist/index.js +1055 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -0
- package/src/astro/SeoHead.astro +73 -0
- package/src/astro/index.ts +8 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { createMarvinClient } from "@inneropen/marvin-sdk";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
var ENV_KEYS = {
|
|
6
|
+
apiUrl: "MARVIN_API_URL",
|
|
7
|
+
siteClientToken: "MARVIN_SITE_CLIENT_TOKEN",
|
|
8
|
+
workspaceSlug: "MARVIN_WORKSPACE_SLUG",
|
|
9
|
+
debug: "MARVIN_DEBUG"
|
|
10
|
+
};
|
|
11
|
+
var DEFAULT_DEV_RETRY_MS = 3e4;
|
|
12
|
+
function metaEnv() {
|
|
13
|
+
try {
|
|
14
|
+
return import.meta.env;
|
|
15
|
+
} catch {
|
|
16
|
+
return void 0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function processEnv() {
|
|
20
|
+
try {
|
|
21
|
+
return typeof process !== "undefined" ? process.env : void 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return void 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function nonEmpty(value) {
|
|
27
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
28
|
+
}
|
|
29
|
+
function readEnv(key, override) {
|
|
30
|
+
return nonEmpty(override?.[key]) ?? nonEmpty(metaEnv()?.[key]) ?? nonEmpty(processEnv()?.[key]);
|
|
31
|
+
}
|
|
32
|
+
function isProduction(override) {
|
|
33
|
+
const prodFlag = metaEnv()?.PROD;
|
|
34
|
+
if (typeof prodFlag === "boolean") return prodFlag;
|
|
35
|
+
const mode = readEnv("NODE_ENV", override) ?? readEnv("MODE", override);
|
|
36
|
+
return mode === "production";
|
|
37
|
+
}
|
|
38
|
+
function resolveConfig(options = {}) {
|
|
39
|
+
const env = options.env;
|
|
40
|
+
const apiUrl = options.apiUrl ?? readEnv(ENV_KEYS.apiUrl, env) ?? "";
|
|
41
|
+
const siteClientToken = options.siteClientToken ?? readEnv(ENV_KEYS.siteClientToken, env) ?? "";
|
|
42
|
+
const workspaceSlug = options.workspaceSlug ?? readEnv(ENV_KEYS.workspaceSlug, env) ?? "";
|
|
43
|
+
const debug = options.debug ?? readEnv(ENV_KEYS.debug, env) === "true";
|
|
44
|
+
return {
|
|
45
|
+
apiUrl,
|
|
46
|
+
siteClientToken,
|
|
47
|
+
workspaceSlug,
|
|
48
|
+
debug,
|
|
49
|
+
retryAfterMs: options.retryAfterMs ?? (isProduction(env) ? Number.POSITIVE_INFINITY : DEFAULT_DEV_RETRY_MS),
|
|
50
|
+
logger: options.logger ?? console,
|
|
51
|
+
markdown: options.markdown,
|
|
52
|
+
now: options.now ?? (() => Date.now()),
|
|
53
|
+
createClient: options.createClient,
|
|
54
|
+
configured: Boolean(apiUrl && siteClientToken && workspaceSlug)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function describeConfig(config) {
|
|
58
|
+
const token = config.siteClientToken;
|
|
59
|
+
return {
|
|
60
|
+
[ENV_KEYS.apiUrl]: config.apiUrl || "MISSING",
|
|
61
|
+
[ENV_KEYS.siteClientToken]: token ? `${token.slice(0, 12)}${"*".repeat(20)}` : "MISSING",
|
|
62
|
+
[ENV_KEYS.workspaceSlug]: config.workspaceSlug || "MISSING",
|
|
63
|
+
hasAll: config.configured
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/client.ts
|
|
68
|
+
function errorMessage(error) {
|
|
69
|
+
if (error instanceof Error) return error.message;
|
|
70
|
+
return String(error);
|
|
71
|
+
}
|
|
72
|
+
function isNetworkFailure(error) {
|
|
73
|
+
const message = errorMessage(error).toLowerCase();
|
|
74
|
+
return message.includes("network error") || message.includes("fetch failed") || message.includes("econnrefused") || message.includes("enotfound") || message.includes("etimedout");
|
|
75
|
+
}
|
|
76
|
+
function createBackend(options = {}) {
|
|
77
|
+
const config = resolveConfig(options);
|
|
78
|
+
let client = null;
|
|
79
|
+
let latchedAt = null;
|
|
80
|
+
let envLogged = false;
|
|
81
|
+
function isLatched() {
|
|
82
|
+
if (latchedAt === null) return false;
|
|
83
|
+
if (config.retryAfterMs === Number.POSITIVE_INFINITY) return true;
|
|
84
|
+
if (config.now() - latchedAt < config.retryAfterMs) return true;
|
|
85
|
+
latchedAt = null;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
function hasBackend() {
|
|
89
|
+
if (config.debug && !envLogged) {
|
|
90
|
+
envLogged = true;
|
|
91
|
+
config.logger.log("[marvin-astro] env check:", describeConfig(config));
|
|
92
|
+
}
|
|
93
|
+
if (!config.configured) return false;
|
|
94
|
+
return !isLatched();
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
config,
|
|
98
|
+
hasBackend,
|
|
99
|
+
isLatched,
|
|
100
|
+
clearLatch() {
|
|
101
|
+
latchedAt = null;
|
|
102
|
+
},
|
|
103
|
+
remember(error) {
|
|
104
|
+
if (isNetworkFailure(error)) latchedAt = config.now();
|
|
105
|
+
},
|
|
106
|
+
warn(message) {
|
|
107
|
+
config.logger.warn(`[marvin-astro] ${message}`);
|
|
108
|
+
},
|
|
109
|
+
client() {
|
|
110
|
+
if (!config.configured) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"[marvin-astro] Marvin is not configured. Set MARVIN_API_URL, MARVIN_SITE_CLIENT_TOKEN and MARVIN_WORKSPACE_SLUG, or pass them to createMarvinContent()."
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
client ??= config.createClient ? config.createClient({
|
|
116
|
+
apiUrl: config.apiUrl,
|
|
117
|
+
siteClientToken: config.siteClientToken,
|
|
118
|
+
workspaceSlug: config.workspaceSlug,
|
|
119
|
+
debug: config.debug
|
|
120
|
+
}) : createMarvinClient({
|
|
121
|
+
apiUrl: config.apiUrl,
|
|
122
|
+
siteClientToken: config.siteClientToken,
|
|
123
|
+
workspaceSlug: config.workspaceSlug,
|
|
124
|
+
debug: config.debug
|
|
125
|
+
});
|
|
126
|
+
return client;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/normalize.ts
|
|
132
|
+
function asString(value) {
|
|
133
|
+
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
|
134
|
+
}
|
|
135
|
+
function asRecord(value) {
|
|
136
|
+
return value && typeof value === "object" ? value : {};
|
|
137
|
+
}
|
|
138
|
+
function asNumber(value) {
|
|
139
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
140
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
141
|
+
const parsed = Number(value);
|
|
142
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
143
|
+
}
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
function asStringArray(value) {
|
|
147
|
+
if (!Array.isArray(value)) return void 0;
|
|
148
|
+
const items = value.filter(
|
|
149
|
+
(item) => typeof item === "string" && item.trim().length > 0
|
|
150
|
+
);
|
|
151
|
+
return items.length > 0 ? items : void 0;
|
|
152
|
+
}
|
|
153
|
+
function entryMetadata(entry) {
|
|
154
|
+
const value = entry;
|
|
155
|
+
return asRecord(value.metadata ?? value.metadataJson);
|
|
156
|
+
}
|
|
157
|
+
function entryData(entry) {
|
|
158
|
+
const value = entry;
|
|
159
|
+
return asRecord(value.data ?? value.dataJson);
|
|
160
|
+
}
|
|
161
|
+
function entryField(entry, key) {
|
|
162
|
+
const accessor = entry.field;
|
|
163
|
+
if (typeof accessor === "function") {
|
|
164
|
+
return accessor.call(entry, key);
|
|
165
|
+
}
|
|
166
|
+
return entryData(entry)[key];
|
|
167
|
+
}
|
|
168
|
+
function field(entry, key) {
|
|
169
|
+
const value = entryField(entry, key);
|
|
170
|
+
if (value !== void 0 && value !== null && value !== "") return value;
|
|
171
|
+
const fallback = entryMetadata(entry)[key];
|
|
172
|
+
return fallback === "" ? void 0 : fallback;
|
|
173
|
+
}
|
|
174
|
+
function assetField(asset, key) {
|
|
175
|
+
if (!asset || typeof asset !== "object") return void 0;
|
|
176
|
+
const value = asset;
|
|
177
|
+
const nested = asRecord(value.asset);
|
|
178
|
+
return value[key] ?? asRecord(value.entryMetadata)[key] ?? asRecord(value.placementMetadata)[key] ?? asRecord(value.metadataJson)[key] ?? asRecord(value.metadata_)[key] ?? asRecord(value.metadata)[key] ?? nested[key] ?? asRecord(nested.metadataJson)[key] ?? asRecord(nested.metadata_)[key] ?? asRecord(nested.metadata)[key];
|
|
179
|
+
}
|
|
180
|
+
function resourceField(resource, key) {
|
|
181
|
+
if (!resource || typeof resource !== "object") return void 0;
|
|
182
|
+
const value = resource;
|
|
183
|
+
const nested = asRecord(value.resource);
|
|
184
|
+
return value[key] ?? asRecord(value.entryMetadata)[key] ?? asRecord(value.metadataJson)[key] ?? asRecord(value.metadata_)[key] ?? asRecord(value.metadata)[key] ?? nested[key] ?? asRecord(nested.metadataJson)[key] ?? asRecord(nested.metadata_)[key] ?? asRecord(nested.metadata)[key];
|
|
185
|
+
}
|
|
186
|
+
function collectionSlugs(entry) {
|
|
187
|
+
const direct = entry.collectionSlugs;
|
|
188
|
+
if (Array.isArray(direct)) {
|
|
189
|
+
return direct.filter((slug) => typeof slug === "string");
|
|
190
|
+
}
|
|
191
|
+
const raw = entry.collections;
|
|
192
|
+
if (!Array.isArray(raw)) return [];
|
|
193
|
+
return raw.map(
|
|
194
|
+
(item) => typeof item === "string" ? item : asRecord(asRecord(item).collection).slug ?? asRecord(item).slug
|
|
195
|
+
).filter((slug) => typeof slug === "string");
|
|
196
|
+
}
|
|
197
|
+
function collectionRole(entry, collectionSlug) {
|
|
198
|
+
const raw = entry.collections;
|
|
199
|
+
if (!Array.isArray(raw)) return void 0;
|
|
200
|
+
for (const item of raw) {
|
|
201
|
+
if (typeof item === "string") continue;
|
|
202
|
+
const record = asRecord(item);
|
|
203
|
+
const slug = asString(record.slug) ?? asString(asRecord(record.collection).slug);
|
|
204
|
+
if (slug === collectionSlug) {
|
|
205
|
+
return asString(asRecord(record.entryMetadata).role) ?? asString(record.role);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return void 0;
|
|
209
|
+
}
|
|
210
|
+
function focalPoint(value) {
|
|
211
|
+
const direct = asString(value);
|
|
212
|
+
if (direct) return direct;
|
|
213
|
+
const point = asRecord(value);
|
|
214
|
+
const x = typeof point.x === "number" ? point.x : void 0;
|
|
215
|
+
const y = typeof point.y === "number" ? point.y : void 0;
|
|
216
|
+
if (x == null || y == null) return void 0;
|
|
217
|
+
return `${x > 1 ? x : x * 100}% ${y > 1 ? y : y * 100}%`;
|
|
218
|
+
}
|
|
219
|
+
function entryAssets(entry) {
|
|
220
|
+
const assets = entry.assets;
|
|
221
|
+
return Array.isArray(assets) ? assets : [];
|
|
222
|
+
}
|
|
223
|
+
function entryResources(entry) {
|
|
224
|
+
const resources = entry.resources;
|
|
225
|
+
return Array.isArray(resources) ? resources : [];
|
|
226
|
+
}
|
|
227
|
+
function includesPreferred(actual, exact, any) {
|
|
228
|
+
if (!exact && !any?.length) return true;
|
|
229
|
+
if (!actual) return false;
|
|
230
|
+
return actual === exact || Boolean(any?.includes(actual));
|
|
231
|
+
}
|
|
232
|
+
function assetMatches(asset, options) {
|
|
233
|
+
const role = asString(assetField(asset, "role"));
|
|
234
|
+
const usage = asString(assetField(asset, "usage"));
|
|
235
|
+
const type = asString(assetField(asset, "assetType"));
|
|
236
|
+
const mimeType = asString(assetField(asset, "mimeType"));
|
|
237
|
+
const hasRoleCriteria = Boolean(options.role || options.roles?.length);
|
|
238
|
+
const hasUsageCriteria = Boolean(options.usage || options.usages?.length);
|
|
239
|
+
const roleMatches = includesPreferred(role, options.role, options.roles);
|
|
240
|
+
const usageMatches = includesPreferred(usage, options.usage, options.usages);
|
|
241
|
+
const typeMatches = includesPreferred(type, options.type, options.types);
|
|
242
|
+
const mimeMatches = options.mimeType ? mimeType === options.mimeType : true;
|
|
243
|
+
const unroledAllowed = Boolean(options.allowUnroled) && !role && !usage;
|
|
244
|
+
const relationshipMatches = hasRoleCriteria || hasUsageCriteria ? roleMatches || usageMatches || unroledAllowed : true;
|
|
245
|
+
return relationshipMatches && typeMatches && mimeMatches;
|
|
246
|
+
}
|
|
247
|
+
function selectEntryAsset(entry, options = {}) {
|
|
248
|
+
return entryAssets(entry).find((asset) => assetMatches(asset, options));
|
|
249
|
+
}
|
|
250
|
+
function selectAssetByRole(entry, ...roles) {
|
|
251
|
+
for (const role of roles) {
|
|
252
|
+
const found = entryAssets(entry).find((asset) => asString(assetField(asset, "role")) === role);
|
|
253
|
+
if (found) return found;
|
|
254
|
+
}
|
|
255
|
+
return void 0;
|
|
256
|
+
}
|
|
257
|
+
function selectFeaturedAsset(entry) {
|
|
258
|
+
const featured = asRecord(entry.featuredAsset);
|
|
259
|
+
return Object.keys(featured).length > 0 ? featured : void 0;
|
|
260
|
+
}
|
|
261
|
+
function selectImageAsset(entry, options = {}) {
|
|
262
|
+
return selectEntryAsset(entry, { ...options, type: "image" }) ?? selectEntryAsset(entry, { ...options, types: ["image"], allowUnroled: true });
|
|
263
|
+
}
|
|
264
|
+
function selectIconAsset(entry) {
|
|
265
|
+
return selectEntryAsset(entry, {
|
|
266
|
+
roles: ["icon"],
|
|
267
|
+
types: ["svg", "image"],
|
|
268
|
+
mimeType: "image/svg+xml",
|
|
269
|
+
allowUnroled: true
|
|
270
|
+
}) ?? selectEntryAsset(entry, {
|
|
271
|
+
roles: ["icon"],
|
|
272
|
+
types: ["svg", "image"],
|
|
273
|
+
allowUnroled: true
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function assetUrl(asset) {
|
|
277
|
+
return asString(assetField(asset, "publicUrl"));
|
|
278
|
+
}
|
|
279
|
+
function assetAlt(asset, fallback) {
|
|
280
|
+
return asString(assetField(asset, "altText")) ?? fallback;
|
|
281
|
+
}
|
|
282
|
+
function resourceRole(resource) {
|
|
283
|
+
return asString(resourceField(resource, "role"));
|
|
284
|
+
}
|
|
285
|
+
function isExternalHref(href) {
|
|
286
|
+
return /^(https?:|mailto:|tel:)/.test(href);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/chrome.ts
|
|
290
|
+
function toNavigationLink(link) {
|
|
291
|
+
return {
|
|
292
|
+
label: link.label,
|
|
293
|
+
href: link.href,
|
|
294
|
+
description: link.description,
|
|
295
|
+
external: isExternalHref(link.href),
|
|
296
|
+
role: link.role
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
var defaultResolveHref = (_entry, context) => {
|
|
300
|
+
const owning = context.collections.find((slug) => !context.navCollections.has(slug));
|
|
301
|
+
return owning ? `/${owning}/${context.slug}` : `/${context.slug}`;
|
|
302
|
+
};
|
|
303
|
+
function metadataLink(value) {
|
|
304
|
+
const link = asRecord(value);
|
|
305
|
+
const label = asString(link.label);
|
|
306
|
+
const href = asString(link.href);
|
|
307
|
+
if (!label || !href) return void 0;
|
|
308
|
+
return {
|
|
309
|
+
label,
|
|
310
|
+
href,
|
|
311
|
+
description: asString(link.description) ?? asString(link.subject),
|
|
312
|
+
external: isExternalHref(href)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function socialLabel(key) {
|
|
316
|
+
return key.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
317
|
+
}
|
|
318
|
+
function socialLinkFromKey(key, href) {
|
|
319
|
+
return {
|
|
320
|
+
label: socialLabel(key),
|
|
321
|
+
href,
|
|
322
|
+
icon: key,
|
|
323
|
+
external: isExternalHref(href)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function createChromeLoader(fetcher, siteLoader, options = {}) {
|
|
327
|
+
const mainCollection = options.mainCollection ?? "main-navigation";
|
|
328
|
+
const footerCollection = options.footerCollection ?? "footer-navigation";
|
|
329
|
+
const legalRole = options.legalRole ?? "legal";
|
|
330
|
+
const footerColumnThreshold = options.footerColumnThreshold ?? 4;
|
|
331
|
+
const resolveHref = options.resolveHref ?? defaultResolveHref;
|
|
332
|
+
const navCollections = /* @__PURE__ */ new Set([mainCollection, footerCollection]);
|
|
333
|
+
const fallback = options.fallback ?? {};
|
|
334
|
+
let promise = null;
|
|
335
|
+
function entryToLink(entry, collectionSlug, context) {
|
|
336
|
+
const slug = entry.slug ?? "";
|
|
337
|
+
const href = asString(entryField(entry, "href")) ?? asString(entryField(entry, "url")) ?? asString(entryField(entry, "path")) ?? resolveHref(entry, {
|
|
338
|
+
context,
|
|
339
|
+
collectionSlug,
|
|
340
|
+
slug,
|
|
341
|
+
collections: collectionSlugs(entry),
|
|
342
|
+
navCollections
|
|
343
|
+
});
|
|
344
|
+
const label = asString(entryField(entry, "label")) ?? asString(asRecord(entry.metadataJson).label) ?? asString(entry.title) ?? href;
|
|
345
|
+
return {
|
|
346
|
+
label,
|
|
347
|
+
href,
|
|
348
|
+
description: asString(entry.summary) ?? asString(entry.description),
|
|
349
|
+
external: isExternalHref(href),
|
|
350
|
+
role: collectionRole(entry, collectionSlug)
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
async function navigationCollection(slug, context, staticLinks) {
|
|
354
|
+
if (!fetcher.backend.hasBackend()) return { links: staticLinks, fromBackend: false };
|
|
355
|
+
const entries = await fetcher.hydratedCollectionEntries(slug);
|
|
356
|
+
if (entries.length === 0) return { links: staticLinks, fromBackend: false };
|
|
357
|
+
return { links: entries.map((entry) => entryToLink(entry, slug, context)), fromBackend: true };
|
|
358
|
+
}
|
|
359
|
+
function groupFooterLinks(links) {
|
|
360
|
+
if (links.length <= footerColumnThreshold) return [links];
|
|
361
|
+
const midpoint = Math.ceil(links.length / 2);
|
|
362
|
+
return [links.slice(0, midpoint), links.slice(midpoint)];
|
|
363
|
+
}
|
|
364
|
+
async function load() {
|
|
365
|
+
const site = await siteLoader.get();
|
|
366
|
+
const staticMain = (fallback.mainNavigation ?? []).map(toNavigationLink);
|
|
367
|
+
const staticFooter = (fallback.footerNavigation ?? []).map(
|
|
368
|
+
(group) => group.map(toNavigationLink)
|
|
369
|
+
);
|
|
370
|
+
const main = await navigationCollection(mainCollection, "main", staticMain);
|
|
371
|
+
const footer = await navigationCollection(footerCollection, "footer", staticFooter.flat());
|
|
372
|
+
const socialLinks = Object.entries(site.social).map(
|
|
373
|
+
([key, href]) => socialLinkFromKey(key, href)
|
|
374
|
+
);
|
|
375
|
+
if (site.email && !socialLinks.some((link) => link.icon === "email")) {
|
|
376
|
+
socialLinks.push(socialLinkFromKey("email", `mailto:${site.email}`));
|
|
377
|
+
}
|
|
378
|
+
const legalLinks = footer.fromBackend ? footer.links.filter((link) => link.role === legalRole) : [];
|
|
379
|
+
const columnLinks = footer.links.filter((link) => link.role !== legalRole);
|
|
380
|
+
const staticLegal = (fallback.legalLinks ?? []).map(toNavigationLink);
|
|
381
|
+
return {
|
|
382
|
+
site,
|
|
383
|
+
mainNavigation: main.links,
|
|
384
|
+
footerNavigation: footer.fromBackend ? groupFooterLinks(columnLinks) : staticFooter,
|
|
385
|
+
legalLinks: legalLinks.length > 0 ? legalLinks : staticLegal,
|
|
386
|
+
socialLinks: socialLinks.length > 0 ? socialLinks : (fallback.socialLinks ?? []).map((link) => ({
|
|
387
|
+
...toNavigationLink(link),
|
|
388
|
+
icon: link.icon
|
|
389
|
+
})),
|
|
390
|
+
inquiry: metadataLink(site.metadata.inquiry) ?? (fallback.inquiry ? toNavigationLink(fallback.inquiry) : void 0)
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
get() {
|
|
395
|
+
promise ??= load();
|
|
396
|
+
return promise;
|
|
397
|
+
},
|
|
398
|
+
reset() {
|
|
399
|
+
promise = null;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/fetch.ts
|
|
405
|
+
function createFetcher(backend) {
|
|
406
|
+
async function guarded(label, empty, run) {
|
|
407
|
+
if (!backend.hasBackend()) return empty;
|
|
408
|
+
try {
|
|
409
|
+
return await run();
|
|
410
|
+
} catch (error) {
|
|
411
|
+
backend.remember(error);
|
|
412
|
+
backend.warn(`${label} unavailable: ${errorMessage(error)}`);
|
|
413
|
+
return empty;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function entry(slug) {
|
|
417
|
+
return guarded(`Entry "${slug}"`, null, () => backend.client().entry(slug));
|
|
418
|
+
}
|
|
419
|
+
async function hydrate(entries) {
|
|
420
|
+
const hydrated = await Promise.all(
|
|
421
|
+
entries.map(async (item) => {
|
|
422
|
+
if (typeof item.field === "function" || "data" in item || "dataJson" in item) {
|
|
423
|
+
return item;
|
|
424
|
+
}
|
|
425
|
+
return entry(item.slug ?? "");
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
return hydrated.filter((item) => Boolean(item));
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
backend,
|
|
432
|
+
collections() {
|
|
433
|
+
return guarded("Collections", [], () => backend.client().collections.list());
|
|
434
|
+
},
|
|
435
|
+
async collection(slug) {
|
|
436
|
+
return guarded(`Collection "${slug}"`, null, async () => {
|
|
437
|
+
const found = await backend.client().collections.get(slug);
|
|
438
|
+
return Array.isArray(found) ? null : found;
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
collectionEntries(slug) {
|
|
442
|
+
return guarded(
|
|
443
|
+
`Collection entries "${slug}"`,
|
|
444
|
+
[],
|
|
445
|
+
() => backend.client().collections.entries(slug)
|
|
446
|
+
);
|
|
447
|
+
},
|
|
448
|
+
/** Try each collection slug in order; return the first that yields entries. */
|
|
449
|
+
async collectionEntriesFallback(slugs) {
|
|
450
|
+
for (const slug of slugs) {
|
|
451
|
+
const entries = await this.collectionEntries(slug);
|
|
452
|
+
if (entries.length > 0) return entries;
|
|
453
|
+
}
|
|
454
|
+
return [];
|
|
455
|
+
},
|
|
456
|
+
async hydratedCollectionEntries(slug) {
|
|
457
|
+
return hydrate(await this.collectionEntries(slug));
|
|
458
|
+
},
|
|
459
|
+
async hydratedCollectionEntriesFallback(slugs) {
|
|
460
|
+
for (const slug of slugs) {
|
|
461
|
+
const entries = await this.hydratedCollectionEntries(slug);
|
|
462
|
+
if (entries.length > 0) return entries;
|
|
463
|
+
}
|
|
464
|
+
return [];
|
|
465
|
+
},
|
|
466
|
+
hydrate,
|
|
467
|
+
entry,
|
|
468
|
+
site() {
|
|
469
|
+
return guarded("Site", null, () => backend.client().getSite());
|
|
470
|
+
},
|
|
471
|
+
workspace() {
|
|
472
|
+
return guarded("Workspace", null, () => backend.client().getWorkspace());
|
|
473
|
+
},
|
|
474
|
+
assets(type) {
|
|
475
|
+
return guarded("Assets", [], () => backend.client().assets.list({ type }));
|
|
476
|
+
},
|
|
477
|
+
asset(slugOrId) {
|
|
478
|
+
return guarded(`Asset "${slugOrId}"`, null, () => backend.client().assets.get(slugOrId));
|
|
479
|
+
},
|
|
480
|
+
resources(resourceType) {
|
|
481
|
+
return guarded("Resources", [], () => backend.client().resources.list({ resourceType }));
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/markdown.ts
|
|
487
|
+
import { Marked } from "marked";
|
|
488
|
+
function createMarkdownRenderer(options = {}) {
|
|
489
|
+
const marked = new Marked();
|
|
490
|
+
marked.setOptions({ gfm: true, breaks: false, ...options });
|
|
491
|
+
return async (markdown) => {
|
|
492
|
+
const source = Array.isArray(markdown) ? markdown.join("\n\n") : markdown ?? "";
|
|
493
|
+
if (!source) return "";
|
|
494
|
+
return await marked.parse(source);
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function preserveSoftBreaks(source) {
|
|
498
|
+
return source.replace(/(?<!\n)\n(?!\n)/g, " \n");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/format.ts
|
|
502
|
+
var MONTHS = [
|
|
503
|
+
"Jan",
|
|
504
|
+
"Feb",
|
|
505
|
+
"Mar",
|
|
506
|
+
"Apr",
|
|
507
|
+
"May",
|
|
508
|
+
"Jun",
|
|
509
|
+
"Jul",
|
|
510
|
+
"Aug",
|
|
511
|
+
"Sep",
|
|
512
|
+
"Oct",
|
|
513
|
+
"Nov",
|
|
514
|
+
"Dec"
|
|
515
|
+
];
|
|
516
|
+
function formatDisplayDate(value) {
|
|
517
|
+
if (!value) return void 0;
|
|
518
|
+
const trimmed = value.trim();
|
|
519
|
+
if (!trimmed) return void 0;
|
|
520
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
|
521
|
+
if (iso) {
|
|
522
|
+
const [, year, month, day] = iso;
|
|
523
|
+
const name = MONTHS[Number(month) - 1];
|
|
524
|
+
if (name) return `${name} ${day}, ${year}`;
|
|
525
|
+
}
|
|
526
|
+
return trimmed;
|
|
527
|
+
}
|
|
528
|
+
function selectValuesForPage(items, pageSlug, count = 4) {
|
|
529
|
+
const seeded = [...items];
|
|
530
|
+
let seed = 0;
|
|
531
|
+
for (const character of pageSlug) {
|
|
532
|
+
seed = seed * 31 + character.charCodeAt(0) >>> 0;
|
|
533
|
+
}
|
|
534
|
+
for (let index = seeded.length - 1; index > 0; index -= 1) {
|
|
535
|
+
seed = seed * 1664525 + 1013904223 >>> 0;
|
|
536
|
+
const target = seed % (index + 1);
|
|
537
|
+
[seeded[index], seeded[target]] = [seeded[target], seeded[index]];
|
|
538
|
+
}
|
|
539
|
+
return seeded.slice(0, count);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/fields.ts
|
|
543
|
+
var DEFAULT_IMAGE_ROLES = ["hero", "featured", "card"];
|
|
544
|
+
function createFieldAccessor(entry, context) {
|
|
545
|
+
const title = asString(entry.title);
|
|
546
|
+
const accessor = {
|
|
547
|
+
/** The entry itself, for anything the accessor doesn't cover. */
|
|
548
|
+
entry,
|
|
549
|
+
/** The route this entry resolves to (from the repository's `href` option). */
|
|
550
|
+
href: context.href,
|
|
551
|
+
collection: context.collection,
|
|
552
|
+
/** `data_json` → `metadata_json`, untyped. */
|
|
553
|
+
raw(key) {
|
|
554
|
+
return field(entry, key);
|
|
555
|
+
},
|
|
556
|
+
string(key) {
|
|
557
|
+
return asString(field(entry, key));
|
|
558
|
+
},
|
|
559
|
+
number(key) {
|
|
560
|
+
return asNumber(field(entry, key));
|
|
561
|
+
},
|
|
562
|
+
/** Booleans authored as strings (`"true"`, `"1"`, `"yes"`) read as booleans. */
|
|
563
|
+
bool(key) {
|
|
564
|
+
const value = field(entry, key);
|
|
565
|
+
if (typeof value === "boolean") return value;
|
|
566
|
+
if (typeof value === "string") {
|
|
567
|
+
return ["true", "1", "yes", "on"].includes(value.trim().toLowerCase());
|
|
568
|
+
}
|
|
569
|
+
if (typeof value === "number") return value !== 0;
|
|
570
|
+
return Boolean(value);
|
|
571
|
+
},
|
|
572
|
+
/** A string array, tolerating a single string authored in place of a list. */
|
|
573
|
+
list(key) {
|
|
574
|
+
const value = field(entry, key);
|
|
575
|
+
const array = asStringArray(value);
|
|
576
|
+
if (array) return array;
|
|
577
|
+
const single = asString(value);
|
|
578
|
+
return single ? [single] : void 0;
|
|
579
|
+
},
|
|
580
|
+
/**
|
|
581
|
+
* An enum-guarded read: the value if it's in `allowed`, else `fallback`. Replaces the
|
|
582
|
+
* per-field `normalizeStatus`/`normalizeCategory`/`normalizeTone` guards that every site
|
|
583
|
+
* ends up writing.
|
|
584
|
+
*/
|
|
585
|
+
oneOf(key, allowed, fallback) {
|
|
586
|
+
const value = asString(field(entry, key));
|
|
587
|
+
const set = allowed instanceof Set ? allowed : new Set(allowed);
|
|
588
|
+
return value && set.has(value) ? value : fallback;
|
|
589
|
+
},
|
|
590
|
+
/** A field read as a display date ("Mon DD, YYYY"); already-formatted values pass through. */
|
|
591
|
+
date(key) {
|
|
592
|
+
return formatDisplayDate(asString(field(entry, key)));
|
|
593
|
+
},
|
|
594
|
+
/** The raw ISO publish timestamp, if any. */
|
|
595
|
+
publishedAt() {
|
|
596
|
+
const value = entry.publishedAt;
|
|
597
|
+
return value != null ? String(value) : void 0;
|
|
598
|
+
},
|
|
599
|
+
/**
|
|
600
|
+
* Render a markdown field to HTML. Falls back to the entry's `contentMarkdown` when the
|
|
601
|
+
* named field is absent. Returns `undefined` when there is nothing to render, so the
|
|
602
|
+
* caller can omit the property rather than emit an empty string.
|
|
603
|
+
*/
|
|
604
|
+
async markdown(key = "body", options = {}) {
|
|
605
|
+
const raw = field(entry, key) ?? entry.contentMarkdown;
|
|
606
|
+
const source = Array.isArray(raw) ? raw.join("\n\n") : asString(raw);
|
|
607
|
+
if (!source) return void 0;
|
|
608
|
+
return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source);
|
|
609
|
+
},
|
|
610
|
+
/**
|
|
611
|
+
* The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
|
|
612
|
+
* the exact `preferRoles`, a role/usage match over the entry's image assets, and finally the
|
|
613
|
+
* list item's `featuredAsset`.
|
|
614
|
+
*/
|
|
615
|
+
image(options = {}) {
|
|
616
|
+
const {
|
|
617
|
+
preferRoles,
|
|
618
|
+
metadataKey = "featuredImage",
|
|
619
|
+
alt: altFallback,
|
|
620
|
+
fallbackToFeatured = true,
|
|
621
|
+
roles = DEFAULT_IMAGE_ROLES,
|
|
622
|
+
usages = DEFAULT_IMAGE_ROLES,
|
|
623
|
+
allowUnroled = true,
|
|
624
|
+
...rest
|
|
625
|
+
} = options;
|
|
626
|
+
const fallbackAlt = altFallback ?? title ?? "";
|
|
627
|
+
if (metadataKey) {
|
|
628
|
+
const authored = asRecord(entryMetadata(entry)[metadataKey]);
|
|
629
|
+
const src2 = asString(authored.src);
|
|
630
|
+
if (src2) {
|
|
631
|
+
return {
|
|
632
|
+
src: src2,
|
|
633
|
+
alt: asString(authored.alt) ?? fallbackAlt,
|
|
634
|
+
focalPoint: focalPoint(authored.focalPoint)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const asset = (preferRoles?.length ? selectAssetByRole(entry, ...preferRoles) : void 0) ?? selectImageAsset(entry, { ...rest, roles, usages, allowUnroled }) ?? (fallbackToFeatured ? selectFeaturedAsset(entry) : void 0);
|
|
639
|
+
const src = assetUrl(asset);
|
|
640
|
+
if (!src) return void 0;
|
|
641
|
+
return {
|
|
642
|
+
src,
|
|
643
|
+
alt: assetAlt(asset, fallbackAlt) ?? fallbackAlt,
|
|
644
|
+
focalPoint: focalPoint(assetField(asset, "focalPoint"))
|
|
645
|
+
};
|
|
646
|
+
},
|
|
647
|
+
/** URL of the entry's icon asset (`role: icon`, SVG preferred). */
|
|
648
|
+
icon(options = {}) {
|
|
649
|
+
const { roles = ["icon"], fallbackToFeatured = false } = options;
|
|
650
|
+
const asset = selectAssetByRole(entry, ...roles) ?? selectIconAsset(entry);
|
|
651
|
+
return assetUrl(asset) ?? (fallbackToFeatured ? assetUrl(selectFeaturedAsset(entry)) : void 0);
|
|
652
|
+
},
|
|
653
|
+
/** The raw asset placement matching `options` — when you need more than src/alt/focal. */
|
|
654
|
+
asset(options = {}) {
|
|
655
|
+
return selectImageAsset(entry, options) ?? selectFeaturedAsset(entry);
|
|
656
|
+
},
|
|
657
|
+
/** The asset whose role is EXACTLY one of `roles`, in preference order. */
|
|
658
|
+
assetByRole(...roles) {
|
|
659
|
+
return selectAssetByRole(entry, ...roles);
|
|
660
|
+
},
|
|
661
|
+
/** All asset placements on the entry. */
|
|
662
|
+
assets() {
|
|
663
|
+
return entryAssets(entry);
|
|
664
|
+
},
|
|
665
|
+
/** Every image asset resolved, filtered by role/usage — support/detail galleries. */
|
|
666
|
+
images(options = {}) {
|
|
667
|
+
const wanted = /* @__PURE__ */ new Set([...options.roles ?? [], ...options.usages ?? []]);
|
|
668
|
+
const fallbackAlt = options.alt ?? title ?? "";
|
|
669
|
+
const images = [];
|
|
670
|
+
for (const asset of entryAssets(entry)) {
|
|
671
|
+
const usage = asString(assetField(asset, "usage")) ?? asString(assetField(asset, "role"));
|
|
672
|
+
const src = assetUrl(asset);
|
|
673
|
+
if (!src) continue;
|
|
674
|
+
if (wanted.size > 0 && (!usage || !wanted.has(usage))) continue;
|
|
675
|
+
images.push({
|
|
676
|
+
src,
|
|
677
|
+
alt: assetAlt(asset, fallbackAlt) ?? fallbackAlt,
|
|
678
|
+
focalPoint: focalPoint(assetField(asset, "focalPoint")),
|
|
679
|
+
usage
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return images;
|
|
683
|
+
},
|
|
684
|
+
/** Attached resources, normalized to name/type/role/href. */
|
|
685
|
+
resources(options = {}) {
|
|
686
|
+
const types = options.types?.length ? new Set(options.types) : void 0;
|
|
687
|
+
return entryResources(entry).map((relationship) => {
|
|
688
|
+
const nested = asRecord(relationship.resource);
|
|
689
|
+
const source = Object.keys(nested).length > 0 ? nested : relationship;
|
|
690
|
+
const slug = asString(resourceField(source, "slug"));
|
|
691
|
+
return {
|
|
692
|
+
name: asString(resourceField(source, "name")) ?? "",
|
|
693
|
+
type: asString(resourceField(source, "resourceType")) ?? "",
|
|
694
|
+
role: asString(assetField(relationship, "role")),
|
|
695
|
+
slug,
|
|
696
|
+
href: slug && options.href ? options.href(slug) : void 0
|
|
697
|
+
};
|
|
698
|
+
}).filter((resource) => Boolean(resource.name)).filter((resource) => !types || types.has(resource.type)).filter((resource) => !options.role || resource.role === options.role);
|
|
699
|
+
},
|
|
700
|
+
/** The first attached resource matching `options`. */
|
|
701
|
+
resource(options = {}) {
|
|
702
|
+
return accessor.resources(options)[0];
|
|
703
|
+
},
|
|
704
|
+
/** The `metadata_json` blob. */
|
|
705
|
+
metadata() {
|
|
706
|
+
return entryMetadata(entry);
|
|
707
|
+
},
|
|
708
|
+
/** The `data_json` blob. Empty on a list item that was not hydrated. */
|
|
709
|
+
data() {
|
|
710
|
+
return entryData(entry);
|
|
711
|
+
},
|
|
712
|
+
/** Slugs of every collection the entry belongs to. */
|
|
713
|
+
collections() {
|
|
714
|
+
return collectionSlugs(entry);
|
|
715
|
+
},
|
|
716
|
+
/** The entry's membership role within `collectionSlug`. */
|
|
717
|
+
role(collectionSlug) {
|
|
718
|
+
return collectionRole(entry, collectionSlug);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
return accessor;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/repository.ts
|
|
725
|
+
function defaultSlugOf(item) {
|
|
726
|
+
const slug = item.slug;
|
|
727
|
+
return typeof slug === "string" ? slug : void 0;
|
|
728
|
+
}
|
|
729
|
+
function defaultIsFeatured(item) {
|
|
730
|
+
return Boolean(item.featured);
|
|
731
|
+
}
|
|
732
|
+
function createRepository(context, options) {
|
|
733
|
+
const { fetcher, renderMarkdown } = context;
|
|
734
|
+
const collections = options.collections ?? (options.collection ? [options.collection] : []);
|
|
735
|
+
const slugOf = options.slugOf ?? defaultSlugOf;
|
|
736
|
+
const isFeatured = options.isFeatured ?? defaultIsFeatured;
|
|
737
|
+
let allPromise = null;
|
|
738
|
+
const bySlugCache = /* @__PURE__ */ new Map();
|
|
739
|
+
function arrange(items) {
|
|
740
|
+
const filtered = options.filter ? items.filter(options.filter) : items;
|
|
741
|
+
return options.sort ? [...filtered].sort(options.sort) : filtered;
|
|
742
|
+
}
|
|
743
|
+
function transform(entry, collection) {
|
|
744
|
+
const slug = entry.slug ?? "";
|
|
745
|
+
const fields = createFieldAccessor(entry, {
|
|
746
|
+
renderMarkdown,
|
|
747
|
+
href: options.href?.(slug, entry),
|
|
748
|
+
collection
|
|
749
|
+
});
|
|
750
|
+
return Promise.resolve(options.transform(entry, fields));
|
|
751
|
+
}
|
|
752
|
+
async function useFallback() {
|
|
753
|
+
return arrange(await options.fallback?.() ?? []);
|
|
754
|
+
}
|
|
755
|
+
async function load() {
|
|
756
|
+
if (collections.length > 0 && fetcher.backend.hasBackend()) {
|
|
757
|
+
try {
|
|
758
|
+
const entries = options.hydrate ? await fetcher.hydratedCollectionEntriesFallback(collections) : await fetcher.collectionEntriesFallback(collections);
|
|
759
|
+
if (entries.length > 0) {
|
|
760
|
+
const items = await Promise.all(entries.map((entry) => transform(entry)));
|
|
761
|
+
return arrange(items);
|
|
762
|
+
}
|
|
763
|
+
} catch (error) {
|
|
764
|
+
fetcher.backend.remember(error);
|
|
765
|
+
fetcher.backend.warn(
|
|
766
|
+
`Collection "${collections[0]}" failed, using fallback: ${errorMessage(error)}`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return useFallback();
|
|
771
|
+
}
|
|
772
|
+
async function loadBySlug(slug) {
|
|
773
|
+
if (fetcher.backend.hasBackend()) {
|
|
774
|
+
try {
|
|
775
|
+
const entry = await fetcher.entry(slug);
|
|
776
|
+
if (entry) return await transform(entry);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
fetcher.backend.remember(error);
|
|
779
|
+
fetcher.backend.warn(`Entry "${slug}" failed, using fallback: ${errorMessage(error)}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return (await all()).find((item) => slugOf(item) === slug);
|
|
783
|
+
}
|
|
784
|
+
function all() {
|
|
785
|
+
allPromise ??= load();
|
|
786
|
+
return allPromise;
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
all,
|
|
790
|
+
bySlug(slug) {
|
|
791
|
+
let cached = bySlugCache.get(slug);
|
|
792
|
+
if (!cached) {
|
|
793
|
+
cached = loadBySlug(slug);
|
|
794
|
+
bySlugCache.set(slug, cached);
|
|
795
|
+
}
|
|
796
|
+
return cached;
|
|
797
|
+
},
|
|
798
|
+
async allFeatured() {
|
|
799
|
+
return (await all()).filter(isFeatured);
|
|
800
|
+
},
|
|
801
|
+
async featured() {
|
|
802
|
+
const items = await all();
|
|
803
|
+
return items.find(isFeatured) ?? items[0];
|
|
804
|
+
},
|
|
805
|
+
reset() {
|
|
806
|
+
allPromise = null;
|
|
807
|
+
bySlugCache.clear();
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/sections.ts
|
|
813
|
+
async function loadSectionLanding(fetcher, slug, renderMarkdown = createMarkdownRenderer(), options = {}) {
|
|
814
|
+
const entry = await fetcher.entry(slug);
|
|
815
|
+
if (!entry) return {};
|
|
816
|
+
const source = asString(entryField(entry, options.bodyField ?? "body")) ?? asString(entry.summary);
|
|
817
|
+
return {
|
|
818
|
+
title: asString(entry.title),
|
|
819
|
+
introHtml: source ? await renderMarkdown(preserveSoftBreaks(source)) : void 0,
|
|
820
|
+
hero: assetUrl(selectAssetByRole(entry, options.heroRole ?? "hero"))
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/site.ts
|
|
825
|
+
function buildSeo(raw, identity) {
|
|
826
|
+
const seo = raw ?? {};
|
|
827
|
+
const verification = asRecord(seo.verification);
|
|
828
|
+
return {
|
|
829
|
+
title: asString(seo.title) ?? identity.title,
|
|
830
|
+
titleTemplate: asString(seo.titleTemplate),
|
|
831
|
+
description: asString(seo.description) ?? identity.description,
|
|
832
|
+
keywords: Array.isArray(seo.keywords) ? seo.keywords.filter((keyword) => typeof keyword === "string") : [],
|
|
833
|
+
robots: asString(seo.robots) ?? "index,follow",
|
|
834
|
+
image: asString(seo.image),
|
|
835
|
+
imageAlt: asString(seo.imageAlt),
|
|
836
|
+
ogType: asString(seo.ogType) ?? "website",
|
|
837
|
+
siteName: asString(seo.siteName) ?? identity.siteName,
|
|
838
|
+
twitterCard: asString(seo.twitterCard) ?? "summary_large_image",
|
|
839
|
+
twitterHandle: asString(seo.twitterHandle),
|
|
840
|
+
twitterCreator: asString(seo.twitterCreator),
|
|
841
|
+
fbAppId: asString(seo.fbAppId),
|
|
842
|
+
themeColor: asString(seo.themeColor),
|
|
843
|
+
publisher: asString(seo.publisher),
|
|
844
|
+
canonicalUrl: identity.canonicalUrl,
|
|
845
|
+
verification: {
|
|
846
|
+
google: asString(verification.google),
|
|
847
|
+
bing: asString(verification.bing),
|
|
848
|
+
pinterest: asString(verification.pinterest),
|
|
849
|
+
yandex: asString(verification.yandex)
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function normalizeSocial(social) {
|
|
854
|
+
return Object.fromEntries(
|
|
855
|
+
Object.entries(asRecord(social)).filter(
|
|
856
|
+
(entry) => typeof entry[1] === "string" && entry[1].length > 0
|
|
857
|
+
)
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
function buildFallbackSite(options = {}) {
|
|
861
|
+
const provided = typeof options.fallback === "function" ? options.fallback() : options.fallback;
|
|
862
|
+
const fallback = provided ?? {};
|
|
863
|
+
const title = fallback.title ?? fallback.name ?? "Untitled site";
|
|
864
|
+
const description = fallback.description ?? "";
|
|
865
|
+
return {
|
|
866
|
+
...fallback,
|
|
867
|
+
name: fallback.name ?? title,
|
|
868
|
+
title,
|
|
869
|
+
description,
|
|
870
|
+
locale: fallback.locale ?? options.defaultLocale ?? "en-US",
|
|
871
|
+
timezone: fallback.timezone ?? options.defaultTimezone ?? "UTC",
|
|
872
|
+
social: fallback.social ?? {},
|
|
873
|
+
metadata: fallback.metadata ?? {},
|
|
874
|
+
seo: fallback.seo ?? buildSeo(void 0, { title, description, siteName: fallback.name ?? title })
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function transformMarvinSite(marvinSite, fallback) {
|
|
878
|
+
const config = marvinSite.site;
|
|
879
|
+
const metadata = asRecord(config.metadataJson);
|
|
880
|
+
const title = asString(config.title) ?? asString(marvinSite.workspace?.name) ?? fallback.title;
|
|
881
|
+
const description = asString(config.description) ?? fallback.description;
|
|
882
|
+
return {
|
|
883
|
+
workspaceSlug: asString(marvinSite.workspace?.slug),
|
|
884
|
+
workspaceName: asString(marvinSite.workspace?.name),
|
|
885
|
+
name: title,
|
|
886
|
+
title,
|
|
887
|
+
tagline: asString(config.tagline) ?? fallback.tagline,
|
|
888
|
+
description,
|
|
889
|
+
canonicalUrl: asString(config.canonicalUrl) ?? fallback.canonicalUrl,
|
|
890
|
+
logo: asString(config.logo) ?? fallback.logo,
|
|
891
|
+
favicon: asString(config.favicon) ?? fallback.favicon,
|
|
892
|
+
locale: asString(config.locale) ?? fallback.locale,
|
|
893
|
+
timezone: asString(config.timezone) ?? fallback.timezone,
|
|
894
|
+
email: asString(config.contactEmail) ?? fallback.email,
|
|
895
|
+
imprint: asString(metadata.imprint) ?? fallback.imprint,
|
|
896
|
+
social: { ...fallback.social, ...normalizeSocial(config.social) },
|
|
897
|
+
// Typed `config.seo` when the SDK declares it, else the raw `metadata.seo` blob — older
|
|
898
|
+
// SDKs drop the typed field but pass the blob through untouched, and workspaces configured
|
|
899
|
+
// before the typed field existed still only have the blob.
|
|
900
|
+
seo: buildSeo(asRecord(config.seo ?? metadata.seo), {
|
|
901
|
+
title,
|
|
902
|
+
description,
|
|
903
|
+
canonicalUrl: asString(config.canonicalUrl),
|
|
904
|
+
siteName: title
|
|
905
|
+
}),
|
|
906
|
+
metadata
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function looksLikeAssetSlug(value) {
|
|
910
|
+
return !/^https?:\/\//.test(value) && !value.startsWith("/") && !value.startsWith("data:");
|
|
911
|
+
}
|
|
912
|
+
function createSiteLoader(fetcher, options = {}) {
|
|
913
|
+
const fallback = buildFallbackSite(options);
|
|
914
|
+
let promise = null;
|
|
915
|
+
async function resolveAssetSlug(slug) {
|
|
916
|
+
if (!slug) return void 0;
|
|
917
|
+
return assetUrl(await fetcher.asset(slug));
|
|
918
|
+
}
|
|
919
|
+
async function load() {
|
|
920
|
+
const marvinSite = await fetcher.site();
|
|
921
|
+
if (!marvinSite) return fallback;
|
|
922
|
+
const site = transformMarvinSite(marvinSite, fallback);
|
|
923
|
+
const brandPairs = Object.entries(asRecord(site.metadata.brand)).filter(
|
|
924
|
+
(entry) => typeof entry[1] === "string" && entry[1].trim().length > 0
|
|
925
|
+
);
|
|
926
|
+
const resolved = await Promise.all(
|
|
927
|
+
brandPairs.map(async ([name, slug]) => [name, await resolveAssetSlug(slug)])
|
|
928
|
+
);
|
|
929
|
+
const brand = {};
|
|
930
|
+
const brandBySlug = /* @__PURE__ */ new Map();
|
|
931
|
+
for (const [index, [name, url]] of resolved.entries()) {
|
|
932
|
+
if (!url) continue;
|
|
933
|
+
brand[name] = url;
|
|
934
|
+
brandBySlug.set(brandPairs[index][1], url);
|
|
935
|
+
}
|
|
936
|
+
const rawImage = site.seo.image;
|
|
937
|
+
const ogImage = rawImage && looksLikeAssetSlug(rawImage) ? brandBySlug.get(rawImage) ?? await resolveAssetSlug(rawImage) : void 0;
|
|
938
|
+
return {
|
|
939
|
+
...site,
|
|
940
|
+
brand,
|
|
941
|
+
// Convenience aliases for common callers; all of these also live in `site.brand`.
|
|
942
|
+
logo: brand.logo ?? site.logo,
|
|
943
|
+
favicon: brand.favicon ?? site.favicon,
|
|
944
|
+
seal: brand.seal ?? site.seal,
|
|
945
|
+
seo: { ...site.seo, image: ogImage ?? site.seo.image }
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
get() {
|
|
950
|
+
promise ??= load();
|
|
951
|
+
return promise;
|
|
952
|
+
},
|
|
953
|
+
reset() {
|
|
954
|
+
promise = null;
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/index.ts
|
|
960
|
+
function createMarvinContent(options = {}) {
|
|
961
|
+
const backend = createBackend(options);
|
|
962
|
+
const fetcher = createFetcher(backend);
|
|
963
|
+
const renderMarkdown = createMarkdownRenderer(backend.config.markdown);
|
|
964
|
+
const siteLoader = createSiteLoader(fetcher, options.site);
|
|
965
|
+
const chromeLoader = createChromeLoader(fetcher, siteLoader, options.chrome);
|
|
966
|
+
const repositories = [];
|
|
967
|
+
return {
|
|
968
|
+
/** The resolved configuration, with the token still in place — don't log it. */
|
|
969
|
+
config: backend.config,
|
|
970
|
+
/** Latch control and the raw SDK client. */
|
|
971
|
+
backend,
|
|
972
|
+
/** Guarded, never-throwing wrappers around every published endpoint. */
|
|
973
|
+
fetch: fetcher,
|
|
974
|
+
renderMarkdown,
|
|
975
|
+
/** True when Marvin is configured and not currently latched out as unreachable. */
|
|
976
|
+
hasBackend: () => backend.hasBackend(),
|
|
977
|
+
/** Resolved site identity, SEO and brand assets. Memoized per process. */
|
|
978
|
+
getSite() {
|
|
979
|
+
return siteLoader.get();
|
|
980
|
+
},
|
|
981
|
+
/** Navigation, footer, legal, social and inquiry links. Memoized per process. */
|
|
982
|
+
getSiteChrome() {
|
|
983
|
+
return chromeLoader.get();
|
|
984
|
+
},
|
|
985
|
+
/** Title/intro/hero for an index page, driven by a `page` entry of the same slug. */
|
|
986
|
+
getSectionLanding(slug, sectionOptions) {
|
|
987
|
+
return loadSectionLanding(fetcher, slug, renderMarkdown, sectionOptions);
|
|
988
|
+
},
|
|
989
|
+
/** A collection-backed content repository: `all()` / `bySlug()` / `featured()`. */
|
|
990
|
+
repository(repositoryOptions) {
|
|
991
|
+
const repository = createRepository({ fetcher, renderMarkdown }, repositoryOptions);
|
|
992
|
+
repositories.push(repository);
|
|
993
|
+
return repository;
|
|
994
|
+
},
|
|
995
|
+
/** Drop every memoized result and re-open the failure latch. */
|
|
996
|
+
reset() {
|
|
997
|
+
backend.clearLatch();
|
|
998
|
+
siteLoader.reset();
|
|
999
|
+
chromeLoader.reset();
|
|
1000
|
+
for (const repository of repositories) repository.reset();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
export {
|
|
1005
|
+
DEFAULT_DEV_RETRY_MS,
|
|
1006
|
+
ENV_KEYS,
|
|
1007
|
+
asNumber,
|
|
1008
|
+
asRecord,
|
|
1009
|
+
asString,
|
|
1010
|
+
asStringArray,
|
|
1011
|
+
assetAlt,
|
|
1012
|
+
assetField,
|
|
1013
|
+
assetUrl,
|
|
1014
|
+
buildFallbackSite,
|
|
1015
|
+
buildSeo,
|
|
1016
|
+
collectionRole,
|
|
1017
|
+
collectionSlugs,
|
|
1018
|
+
createBackend,
|
|
1019
|
+
createChromeLoader,
|
|
1020
|
+
createFetcher,
|
|
1021
|
+
createFieldAccessor,
|
|
1022
|
+
createMarkdownRenderer,
|
|
1023
|
+
createMarvinContent,
|
|
1024
|
+
createRepository,
|
|
1025
|
+
createSiteLoader,
|
|
1026
|
+
defaultResolveHref,
|
|
1027
|
+
describeConfig,
|
|
1028
|
+
entryAssets,
|
|
1029
|
+
entryData,
|
|
1030
|
+
entryField,
|
|
1031
|
+
entryMetadata,
|
|
1032
|
+
entryResources,
|
|
1033
|
+
errorMessage,
|
|
1034
|
+
field,
|
|
1035
|
+
focalPoint,
|
|
1036
|
+
formatDisplayDate,
|
|
1037
|
+
isExternalHref,
|
|
1038
|
+
isNetworkFailure,
|
|
1039
|
+
loadSectionLanding,
|
|
1040
|
+
metadataLink,
|
|
1041
|
+
preserveSoftBreaks,
|
|
1042
|
+
readEnv,
|
|
1043
|
+
resolveConfig,
|
|
1044
|
+
resourceField,
|
|
1045
|
+
resourceRole,
|
|
1046
|
+
selectAssetByRole,
|
|
1047
|
+
selectEntryAsset,
|
|
1048
|
+
selectFeaturedAsset,
|
|
1049
|
+
selectIconAsset,
|
|
1050
|
+
selectImageAsset,
|
|
1051
|
+
selectValuesForPage,
|
|
1052
|
+
socialLinkFromKey,
|
|
1053
|
+
toNavigationLink
|
|
1054
|
+
};
|
|
1055
|
+
//# sourceMappingURL=index.js.map
|