@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.
- package/README.md +175 -9
- package/dist/embeds-BTNryQA6.d.ts +79 -0
- package/dist/index.d.ts +42 -3
- package/dist/index.js +143 -9
- 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
package/src/fields.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field accessor handed to a repository's `transform`.
|
|
3
|
+
*
|
|
4
|
+
* Every reader defaults to the documented precedence — `data_json` first, `metadata_json`
|
|
5
|
+
* second — so a transform reads like a field list instead of a pile of coalescing operators.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { entryEmbeds, findEmbed, type MarvinEmbed } from './embeds.js';
|
|
9
|
+
import type { MarkdownRenderer } from './markdown.js';
|
|
10
|
+
import { preserveSoftBreaks } from './markdown.js';
|
|
11
|
+
import {
|
|
12
|
+
asNumber,
|
|
13
|
+
asRecord,
|
|
14
|
+
asString,
|
|
15
|
+
asStringArray,
|
|
16
|
+
assetAlt,
|
|
17
|
+
assetField,
|
|
18
|
+
assetUrl,
|
|
19
|
+
collectionRole,
|
|
20
|
+
collectionSlugs,
|
|
21
|
+
entryAssets,
|
|
22
|
+
entryData,
|
|
23
|
+
entryMetadata,
|
|
24
|
+
entryResources,
|
|
25
|
+
field,
|
|
26
|
+
focalPoint,
|
|
27
|
+
resourceField,
|
|
28
|
+
selectAssetByRole,
|
|
29
|
+
selectFeaturedAsset,
|
|
30
|
+
selectIconAsset,
|
|
31
|
+
selectImageAsset,
|
|
32
|
+
} from './normalize.js';
|
|
33
|
+
import { formatDisplayDate } from './format.js';
|
|
34
|
+
import type {
|
|
35
|
+
ApiImage,
|
|
36
|
+
ApiResourceLink,
|
|
37
|
+
AssetSelectOptions,
|
|
38
|
+
MarvinContentEntry,
|
|
39
|
+
} from './types.js';
|
|
40
|
+
|
|
41
|
+
export type ImageFieldOptions = Omit<AssetSelectOptions, 'type' | 'types'> & {
|
|
42
|
+
/**
|
|
43
|
+
* Exact roles to prefer, in order, before the looser role/usage match. Use for derived
|
|
44
|
+
* variants — e.g. `['hero-grade']` to prefer a colour-graded hero over the raw upload.
|
|
45
|
+
*/
|
|
46
|
+
preferRoles?: string[];
|
|
47
|
+
/** `metadata_json` key holding a hand-authored `{src, alt, focalPoint}`. */
|
|
48
|
+
metadataKey?: string | false;
|
|
49
|
+
/** Alt text when the asset carries none. Defaults to the entry title. */
|
|
50
|
+
alt?: string;
|
|
51
|
+
/** Fall back to the list item's `featuredAsset`. Default `true`. */
|
|
52
|
+
fallbackToFeatured?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type IconFieldOptions = {
|
|
56
|
+
roles?: string[];
|
|
57
|
+
/** Fall back to the list item's `featuredAsset` when no icon asset is attached. */
|
|
58
|
+
fallbackToFeatured?: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type MarkdownFieldOptions = {
|
|
62
|
+
/** Keep authored single newlines as hard breaks. Default `false`. */
|
|
63
|
+
softBreaks?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Render a bare provider URL on its own line as its player, using the entry's `embeds`.
|
|
66
|
+
* Default `true`; `false` keeps every URL a plain link.
|
|
67
|
+
*/
|
|
68
|
+
embeds?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type ResourceFieldOptions = {
|
|
72
|
+
/** Keep only these resource types, e.g. `['material', 'construction']`. */
|
|
73
|
+
types?: string[];
|
|
74
|
+
/** Keep only resources attached with this role. */
|
|
75
|
+
role?: string;
|
|
76
|
+
/** Build an href from the resource slug. */
|
|
77
|
+
href?: (slug: string) => string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const DEFAULT_IMAGE_ROLES = ['hero', 'featured', 'card'];
|
|
81
|
+
|
|
82
|
+
export type FieldAccessor = ReturnType<typeof createFieldAccessor>;
|
|
83
|
+
|
|
84
|
+
export type FieldAccessorContext = {
|
|
85
|
+
renderMarkdown: MarkdownRenderer;
|
|
86
|
+
/** The route this entry resolves to, if the repository was given an `href` builder. */
|
|
87
|
+
href?: string;
|
|
88
|
+
/** The collection the entry was loaded from, when it came from one. */
|
|
89
|
+
collection?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function createFieldAccessor(
|
|
93
|
+
entry: MarvinContentEntry,
|
|
94
|
+
context: FieldAccessorContext
|
|
95
|
+
) {
|
|
96
|
+
const title = asString((entry as { title?: unknown }).title);
|
|
97
|
+
const embeds = entryEmbeds(entry);
|
|
98
|
+
|
|
99
|
+
const accessor = {
|
|
100
|
+
/** The entry itself, for anything the accessor doesn't cover. */
|
|
101
|
+
entry,
|
|
102
|
+
/** The route this entry resolves to (from the repository's `href` option). */
|
|
103
|
+
href: context.href,
|
|
104
|
+
collection: context.collection,
|
|
105
|
+
|
|
106
|
+
/** `data_json` → `metadata_json`, untyped. */
|
|
107
|
+
raw(key: string): unknown {
|
|
108
|
+
return field(entry, key);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
string(key: string): string | undefined {
|
|
112
|
+
return asString(field(entry, key));
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
number(key: string): number | undefined {
|
|
116
|
+
return asNumber(field(entry, key));
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Booleans authored as strings (`"true"`, `"1"`, `"yes"`) read as booleans. */
|
|
120
|
+
bool(key: string): boolean {
|
|
121
|
+
const value = field(entry, key);
|
|
122
|
+
if (typeof value === 'boolean') return value;
|
|
123
|
+
if (typeof value === 'string') {
|
|
124
|
+
return ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === 'number') return value !== 0;
|
|
127
|
+
return Boolean(value);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/** A string array, tolerating a single string authored in place of a list. */
|
|
131
|
+
list(key: string): string[] | undefined {
|
|
132
|
+
const value = field(entry, key);
|
|
133
|
+
const array = asStringArray(value);
|
|
134
|
+
if (array) return array;
|
|
135
|
+
const single = asString(value);
|
|
136
|
+
return single ? [single] : undefined;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* An enum-guarded read: the value if it's in `allowed`, else `fallback`. Replaces the
|
|
141
|
+
* per-field `normalizeStatus`/`normalizeCategory`/`normalizeTone` guards that every site
|
|
142
|
+
* ends up writing.
|
|
143
|
+
*/
|
|
144
|
+
oneOf<T extends string>(key: string, allowed: readonly T[] | Set<T>, fallback: T): T {
|
|
145
|
+
const value = asString(field(entry, key));
|
|
146
|
+
const set = allowed instanceof Set ? allowed : new Set<string>(allowed);
|
|
147
|
+
return value && set.has(value as T) ? (value as T) : fallback;
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
/** A field read as a display date ("Mon DD, YYYY"); already-formatted values pass through. */
|
|
151
|
+
date(key: string): string | undefined {
|
|
152
|
+
return formatDisplayDate(asString(field(entry, key)));
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** The raw ISO publish timestamp, if any. */
|
|
156
|
+
publishedAt(): string | undefined {
|
|
157
|
+
const value = (entry as { publishedAt?: unknown }).publishedAt;
|
|
158
|
+
return value != null ? String(value) : undefined;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Render a markdown field to HTML. Falls back to the entry's `contentMarkdown` when the
|
|
163
|
+
* named field is absent. Returns `undefined` when there is nothing to render, so the
|
|
164
|
+
* caller can omit the property rather than emit an empty string.
|
|
165
|
+
*/
|
|
166
|
+
async markdown(
|
|
167
|
+
key = 'body',
|
|
168
|
+
options: MarkdownFieldOptions = {}
|
|
169
|
+
): Promise<string | undefined> {
|
|
170
|
+
const raw =
|
|
171
|
+
field<string | string[]>(entry, key) ??
|
|
172
|
+
(entry as { contentMarkdown?: string | string[] }).contentMarkdown;
|
|
173
|
+
const source = Array.isArray(raw) ? raw.join('\n\n') : asString(raw);
|
|
174
|
+
if (!source) return undefined;
|
|
175
|
+
return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source, {
|
|
176
|
+
embeds: options.embeds === false ? undefined : embeds,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The resolved embed for an `embed` field (its value is the provider URL), or `undefined`
|
|
182
|
+
* when the field is empty or the server hasn't resolved it. Render it with `<Embed>` from
|
|
183
|
+
* `@inneropen/marvin-astro/components`, or output `embed.html`.
|
|
184
|
+
*/
|
|
185
|
+
embed(key: string): MarvinEmbed | undefined {
|
|
186
|
+
return findEmbed(embeds, asString(field(entry, key)));
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
|
|
191
|
+
* the exact `preferRoles`, a role/usage match over the entry's image assets, and finally the
|
|
192
|
+
* list item's `featuredAsset`.
|
|
193
|
+
*/
|
|
194
|
+
image(options: ImageFieldOptions = {}): ApiImage | undefined {
|
|
195
|
+
const {
|
|
196
|
+
preferRoles,
|
|
197
|
+
metadataKey = 'featuredImage',
|
|
198
|
+
alt: altFallback,
|
|
199
|
+
fallbackToFeatured = true,
|
|
200
|
+
roles = DEFAULT_IMAGE_ROLES,
|
|
201
|
+
usages = DEFAULT_IMAGE_ROLES,
|
|
202
|
+
allowUnroled = true,
|
|
203
|
+
...rest
|
|
204
|
+
} = options;
|
|
205
|
+
const fallbackAlt = altFallback ?? title ?? '';
|
|
206
|
+
|
|
207
|
+
if (metadataKey) {
|
|
208
|
+
const authored = asRecord(entryMetadata(entry)[metadataKey]);
|
|
209
|
+
const src = asString(authored.src);
|
|
210
|
+
if (src) {
|
|
211
|
+
return {
|
|
212
|
+
src,
|
|
213
|
+
alt: asString(authored.alt) ?? fallbackAlt,
|
|
214
|
+
focalPoint: focalPoint(authored.focalPoint),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const asset =
|
|
220
|
+
(preferRoles?.length ? selectAssetByRole(entry, ...preferRoles) : undefined) ??
|
|
221
|
+
selectImageAsset(entry, { ...rest, roles, usages, allowUnroled }) ??
|
|
222
|
+
(fallbackToFeatured ? selectFeaturedAsset(entry) : undefined);
|
|
223
|
+
|
|
224
|
+
const src = assetUrl(asset);
|
|
225
|
+
if (!src) return undefined;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
src,
|
|
229
|
+
alt: assetAlt(asset, fallbackAlt) ?? fallbackAlt,
|
|
230
|
+
focalPoint: focalPoint(assetField(asset, 'focalPoint')),
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/** URL of the entry's icon asset (`role: icon`, SVG preferred). */
|
|
235
|
+
icon(options: IconFieldOptions = {}): string | undefined {
|
|
236
|
+
const { roles = ['icon'], fallbackToFeatured = false } = options;
|
|
237
|
+
const asset = selectAssetByRole(entry, ...roles) ?? selectIconAsset(entry);
|
|
238
|
+
return (
|
|
239
|
+
assetUrl(asset) ?? (fallbackToFeatured ? assetUrl(selectFeaturedAsset(entry)) : undefined)
|
|
240
|
+
);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/** The raw asset placement matching `options` — when you need more than src/alt/focal. */
|
|
244
|
+
asset(options: AssetSelectOptions = {}): Record<string, unknown> | undefined {
|
|
245
|
+
return selectImageAsset(entry, options) ?? selectFeaturedAsset(entry);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
/** The asset whose role is EXACTLY one of `roles`, in preference order. */
|
|
249
|
+
assetByRole(...roles: string[]): Record<string, unknown> | undefined {
|
|
250
|
+
return selectAssetByRole(entry, ...roles);
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/** All asset placements on the entry. */
|
|
254
|
+
assets(): Record<string, unknown>[] {
|
|
255
|
+
return entryAssets(entry);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/** Every image asset resolved, filtered by role/usage — support/detail galleries. */
|
|
259
|
+
images(options: ImageFieldOptions = {}): (ApiImage & { usage?: string })[] {
|
|
260
|
+
const wanted = new Set([...(options.roles ?? []), ...(options.usages ?? [])]);
|
|
261
|
+
const fallbackAlt = options.alt ?? title ?? '';
|
|
262
|
+
const images: (ApiImage & { usage?: string })[] = [];
|
|
263
|
+
|
|
264
|
+
for (const asset of entryAssets(entry)) {
|
|
265
|
+
const usage = asString(assetField(asset, 'usage')) ?? asString(assetField(asset, 'role'));
|
|
266
|
+
const src = assetUrl(asset);
|
|
267
|
+
if (!src) continue;
|
|
268
|
+
if (wanted.size > 0 && (!usage || !wanted.has(usage))) continue;
|
|
269
|
+
|
|
270
|
+
images.push({
|
|
271
|
+
src,
|
|
272
|
+
alt: assetAlt(asset, fallbackAlt) ?? fallbackAlt,
|
|
273
|
+
focalPoint: focalPoint(assetField(asset, 'focalPoint')),
|
|
274
|
+
usage,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return images;
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/** Attached resources, normalized to name/type/role/href. */
|
|
282
|
+
resources(options: ResourceFieldOptions = {}): ApiResourceLink[] {
|
|
283
|
+
const types = options.types?.length ? new Set(options.types) : undefined;
|
|
284
|
+
|
|
285
|
+
return entryResources(entry)
|
|
286
|
+
.map((relationship) => {
|
|
287
|
+
const nested = asRecord(relationship.resource);
|
|
288
|
+
const source = Object.keys(nested).length > 0 ? nested : relationship;
|
|
289
|
+
const slug = asString(resourceField(source, 'slug'));
|
|
290
|
+
return {
|
|
291
|
+
name: asString(resourceField(source, 'name')) ?? '',
|
|
292
|
+
type: asString(resourceField(source, 'resourceType')) ?? '',
|
|
293
|
+
role: asString(assetField(relationship, 'role')),
|
|
294
|
+
slug,
|
|
295
|
+
href: slug && options.href ? options.href(slug) : undefined,
|
|
296
|
+
};
|
|
297
|
+
})
|
|
298
|
+
.filter((resource) => Boolean(resource.name))
|
|
299
|
+
.filter((resource) => !types || types.has(resource.type))
|
|
300
|
+
.filter((resource) => !options.role || resource.role === options.role);
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/** The first attached resource matching `options`. */
|
|
304
|
+
resource(options: ResourceFieldOptions = {}): ApiResourceLink | undefined {
|
|
305
|
+
return accessor.resources(options)[0];
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
/** The `metadata_json` blob. */
|
|
309
|
+
metadata(): Record<string, unknown> {
|
|
310
|
+
return entryMetadata(entry);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
/** The `data_json` blob. Empty on a list item that was not hydrated. */
|
|
314
|
+
data(): Record<string, unknown> {
|
|
315
|
+
return entryData(entry);
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/** Slugs of every collection the entry belongs to. */
|
|
319
|
+
collections(): string[] {
|
|
320
|
+
return collectionSlugs(entry);
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/** The entry's membership role within `collectionSlug`. */
|
|
324
|
+
role(collectionSlug: string): string | undefined {
|
|
325
|
+
return collectionRole(entry, collectionSlug);
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
return accessor;
|
|
330
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MONTHS = [
|
|
2
|
+
'Jan',
|
|
3
|
+
'Feb',
|
|
4
|
+
'Mar',
|
|
5
|
+
'Apr',
|
|
6
|
+
'May',
|
|
7
|
+
'Jun',
|
|
8
|
+
'Jul',
|
|
9
|
+
'Aug',
|
|
10
|
+
'Sep',
|
|
11
|
+
'Oct',
|
|
12
|
+
'Nov',
|
|
13
|
+
'Dec',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format a date value for display as "Mon DD, YYYY".
|
|
18
|
+
*
|
|
19
|
+
* ISO dates/timestamps (e.g. a raw `publishedAt` like "2026-07-21T21:22:42.045708Z") are read in
|
|
20
|
+
* UTC — via the date part only, so there's no off-by-one day shift from the viewer's timezone.
|
|
21
|
+
* Values that are already human-readable (e.g. a hand-entered "Jul 02, 2024") pass through
|
|
22
|
+
* unchanged, and empty input returns `undefined` so callers can omit the field.
|
|
23
|
+
*/
|
|
24
|
+
export function formatDisplayDate(value: string | null | undefined): string | undefined {
|
|
25
|
+
if (!value) return undefined;
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (!trimmed) return undefined;
|
|
28
|
+
|
|
29
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
|
30
|
+
if (iso) {
|
|
31
|
+
const [, year, month, day] = iso;
|
|
32
|
+
const name = MONTHS[Number(month) - 1];
|
|
33
|
+
if (name) return `${name} ${day}, ${year}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return trimmed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pick `count` items deterministically from `items`, seeded by `pageSlug`.
|
|
41
|
+
*
|
|
42
|
+
* Same page → same selection on every build, different pages → different selections. Use for
|
|
43
|
+
* rotating pull-quotes, value bands, related links: variety across the site without the
|
|
44
|
+
* hydration mismatch a random pick would cause.
|
|
45
|
+
*/
|
|
46
|
+
export function selectValuesForPage<T>(items: T[], pageSlug: string, count = 4): T[] {
|
|
47
|
+
const seeded = [...items];
|
|
48
|
+
let seed = 0;
|
|
49
|
+
|
|
50
|
+
for (const character of pageSlug) {
|
|
51
|
+
seed = (seed * 31 + character.charCodeAt(0)) >>> 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (let index = seeded.length - 1; index > 0; index -= 1) {
|
|
55
|
+
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
56
|
+
|
|
57
|
+
const target = seed % (index + 1);
|
|
58
|
+
|
|
59
|
+
[seeded[index], seeded[target]] = [seeded[target], seeded[index]];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return seeded.slice(0, count);
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@inneropen/marvin-astro` — site integration for Marvin CMS.
|
|
3
|
+
*
|
|
4
|
+
* Sits above `@inneropen/marvin-sdk` (transport). This layer is the part every Marvin-backed site
|
|
5
|
+
* rewrites: repositories with a static fallback, site chrome from collections, and payload
|
|
6
|
+
* normalization. Astro components (forms, media embeds, entry-type renderers) live under
|
|
7
|
+
* `@inneropen/marvin-astro/components`.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // src/lib/content.ts
|
|
11
|
+
* export const marvin = createMarvinContent({ site: { fallback: staticSite } });
|
|
12
|
+
*
|
|
13
|
+
* export const posts = marvin.repository({
|
|
14
|
+
* collections: ['bench-notes', 'journal', 'blog'],
|
|
15
|
+
* hydrate: true,
|
|
16
|
+
* href: (slug) => `/bench-notes/${slug}`,
|
|
17
|
+
* fallback: () => staticPosts,
|
|
18
|
+
* transform: async (entry, f) => ({
|
|
19
|
+
* slug: entry.slug,
|
|
20
|
+
* title: entry.title ?? 'Untitled',
|
|
21
|
+
* noteNumber: f.string('noteNumber'),
|
|
22
|
+
* bodyHtml: await f.markdown('body'),
|
|
23
|
+
* image: f.image({ roles: ['hero', 'featured', 'card'] }),
|
|
24
|
+
* }),
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createBackend } from './client.js';
|
|
30
|
+
import { createChromeLoader, type ChromeOptions } from './chrome.js';
|
|
31
|
+
import { createFetcher } from './fetch.js';
|
|
32
|
+
import { createMarkdownRenderer } from './markdown.js';
|
|
33
|
+
import { createRepository, type Repository, type RepositoryOptions } from './repository.js';
|
|
34
|
+
import { loadSectionLanding, type SectionLandingOptions } from './sections.js';
|
|
35
|
+
import { createSiteLoader, type SiteOptions } from './site.js';
|
|
36
|
+
import type { MarvinAstroConfig } from './config.js';
|
|
37
|
+
import type { ApiSite, ApiSiteChrome, SectionLanding } from './types.js';
|
|
38
|
+
|
|
39
|
+
export type MarvinContentOptions = MarvinAstroConfig & {
|
|
40
|
+
site?: SiteOptions;
|
|
41
|
+
chrome?: ChromeOptions;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type MarvinContent = ReturnType<typeof createMarvinContent>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the content layer for one Marvin workspace.
|
|
48
|
+
*
|
|
49
|
+
* Connection settings come from `MARVIN_API_URL`, `MARVIN_SITE_CLIENT_TOKEN` and
|
|
50
|
+
* `MARVIN_WORKSPACE_SLUG` unless passed explicitly. Everything returned degrades to its static
|
|
51
|
+
* fallback when the backend is unreachable, so a site still builds offline.
|
|
52
|
+
*/
|
|
53
|
+
export function createMarvinContent(options: MarvinContentOptions = {}) {
|
|
54
|
+
const backend = createBackend(options);
|
|
55
|
+
const fetcher = createFetcher(backend);
|
|
56
|
+
const renderMarkdown = createMarkdownRenderer(backend.config.markdown);
|
|
57
|
+
const siteLoader = createSiteLoader(fetcher, options.site);
|
|
58
|
+
const chromeLoader = createChromeLoader(fetcher, siteLoader, options.chrome);
|
|
59
|
+
const repositories: Repository<unknown>[] = [];
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/** The resolved configuration, with the token still in place — don't log it. */
|
|
63
|
+
config: backend.config,
|
|
64
|
+
/** Latch control and the raw SDK client. */
|
|
65
|
+
backend,
|
|
66
|
+
/** Guarded, never-throwing wrappers around every published endpoint. */
|
|
67
|
+
fetch: fetcher,
|
|
68
|
+
renderMarkdown,
|
|
69
|
+
|
|
70
|
+
/** True when Marvin is configured and not currently latched out as unreachable. */
|
|
71
|
+
hasBackend: () => backend.hasBackend(),
|
|
72
|
+
|
|
73
|
+
/** Resolved site identity, SEO and brand assets. Memoized per process. */
|
|
74
|
+
getSite(): Promise<ApiSite> {
|
|
75
|
+
return siteLoader.get();
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/** Navigation, footer, legal, social and inquiry links. Memoized per process. */
|
|
79
|
+
getSiteChrome(): Promise<ApiSiteChrome> {
|
|
80
|
+
return chromeLoader.get();
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** Title/intro/hero for an index page, driven by a `page` entry of the same slug. */
|
|
84
|
+
getSectionLanding(slug: string, sectionOptions?: SectionLandingOptions): Promise<SectionLanding> {
|
|
85
|
+
return loadSectionLanding(fetcher, slug, renderMarkdown, sectionOptions);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** A collection-backed content repository: `all()` / `bySlug()` / `featured()`. */
|
|
89
|
+
repository<T>(repositoryOptions: RepositoryOptions<T>): Repository<T> {
|
|
90
|
+
const repository = createRepository<T>({ fetcher, renderMarkdown }, repositoryOptions);
|
|
91
|
+
repositories.push(repository as Repository<unknown>);
|
|
92
|
+
return repository;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/** Drop every memoized result and re-open the failure latch. */
|
|
96
|
+
reset() {
|
|
97
|
+
backend.clearLatch();
|
|
98
|
+
siteLoader.reset();
|
|
99
|
+
chromeLoader.reset();
|
|
100
|
+
for (const repository of repositories) repository.reset();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export {
|
|
106
|
+
createBackend,
|
|
107
|
+
errorMessage,
|
|
108
|
+
isNetworkFailure,
|
|
109
|
+
type MarvinBackend,
|
|
110
|
+
} from './client.js';
|
|
111
|
+
export {
|
|
112
|
+
DEFAULT_DEV_RETRY_MS,
|
|
113
|
+
DEFAULT_HYDRATE_CONCURRENCY,
|
|
114
|
+
ENV_KEYS,
|
|
115
|
+
describeConfig,
|
|
116
|
+
readEnv,
|
|
117
|
+
resolveConfig,
|
|
118
|
+
type MarvinAstroConfig,
|
|
119
|
+
type MarvinLogger,
|
|
120
|
+
type ResolvedConfig,
|
|
121
|
+
} from './config.js';
|
|
122
|
+
export { createFetcher, type HydratedEntry, type MarvinFetcher } from './fetch.js';
|
|
123
|
+
export {
|
|
124
|
+
createFieldAccessor,
|
|
125
|
+
type FieldAccessor,
|
|
126
|
+
type IconFieldOptions,
|
|
127
|
+
type ImageFieldOptions,
|
|
128
|
+
type MarkdownFieldOptions,
|
|
129
|
+
type ResourceFieldOptions,
|
|
130
|
+
} from './fields.js';
|
|
131
|
+
export { formatDisplayDate, selectValuesForPage } from './format.js';
|
|
132
|
+
export {
|
|
133
|
+
createMarkdownRenderer,
|
|
134
|
+
preserveSoftBreaks,
|
|
135
|
+
type MarkdownOptions,
|
|
136
|
+
type MarkdownRenderer,
|
|
137
|
+
type RenderMarkdownOptions,
|
|
138
|
+
} from './markdown.js';
|
|
139
|
+
export { entryEmbeds, findEmbed, siteEmbeds } from './embeds.js';
|
|
140
|
+
export * from './normalize.js';
|
|
141
|
+
export {
|
|
142
|
+
createRepository,
|
|
143
|
+
type Repository,
|
|
144
|
+
type RepositoryContext,
|
|
145
|
+
type RepositoryOptions,
|
|
146
|
+
} from './repository.js';
|
|
147
|
+
export {
|
|
148
|
+
buildFallbackSite,
|
|
149
|
+
buildSeo,
|
|
150
|
+
createSiteLoader,
|
|
151
|
+
type SiteLoader,
|
|
152
|
+
type SiteOptions,
|
|
153
|
+
} from './site.js';
|
|
154
|
+
export {
|
|
155
|
+
createChromeLoader,
|
|
156
|
+
defaultResolveHref,
|
|
157
|
+
metadataLink,
|
|
158
|
+
socialLinkFromKey,
|
|
159
|
+
toNavigationLink,
|
|
160
|
+
type ChromeLoader,
|
|
161
|
+
type ChromeOptions,
|
|
162
|
+
type HrefContext,
|
|
163
|
+
type NavigationContext,
|
|
164
|
+
type NavigationLinkInput,
|
|
165
|
+
type ResolveHref,
|
|
166
|
+
type SocialLinkInput,
|
|
167
|
+
} from './chrome.js';
|
|
168
|
+
export { loadSectionLanding, type SectionLandingOptions } from './sections.js';
|
|
169
|
+
export type * from './types.js';
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Marked, type MarkedOptions, type Tokens } from 'marked';
|
|
2
|
+
import { findEmbed, type MarvinEmbeds } from './embeds.js';
|
|
3
|
+
|
|
4
|
+
export type MarkdownOptions = MarkedOptions;
|
|
5
|
+
|
|
6
|
+
/** Per-call options for a {@link MarkdownRenderer}. */
|
|
7
|
+
export type RenderMarkdownOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* The entry's `embeds` map. A paragraph that is exactly a URL with an entry here renders as
|
|
10
|
+
* that embed's Marvin-built HTML instead of a link.
|
|
11
|
+
*/
|
|
12
|
+
embeds?: MarvinEmbeds | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MarkdownRenderer = (
|
|
16
|
+
markdown: string | string[] | null | undefined,
|
|
17
|
+
options?: RenderMarkdownOptions
|
|
18
|
+
) => Promise<string>;
|
|
19
|
+
|
|
20
|
+
// Per-call data rides on marked's parse options: marked merges them over the instance defaults and
|
|
21
|
+
// hands the merged object to the renderer as `this.options` for that parse only. A symbol key
|
|
22
|
+
// keeps it clear of marked's own option names.
|
|
23
|
+
const EMBEDS = Symbol('marvin.embeds');
|
|
24
|
+
type ParseOptionsWithEmbeds = MarkedOptions & { [EMBEDS]?: MarvinEmbeds | null };
|
|
25
|
+
|
|
26
|
+
// One run of non-space characters starting with a scheme. `<url>`, `[text](url)` and a URL inside
|
|
27
|
+
// a sentence all fail this, so they stay links.
|
|
28
|
+
const BARE_URL = /^https?:\/\/\S+$/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The embed for a paragraph that is exactly a URL with an `embeds` entry, else `undefined`.
|
|
32
|
+
* Exported for tests; the renderer below is the only caller.
|
|
33
|
+
*/
|
|
34
|
+
export function paragraphEmbedHtml(
|
|
35
|
+
token: Pick<Tokens.Paragraph, 'text'>,
|
|
36
|
+
embeds: MarvinEmbeds | null | undefined
|
|
37
|
+
): string | undefined {
|
|
38
|
+
if (!embeds) return undefined;
|
|
39
|
+
const text = token.text.trim();
|
|
40
|
+
if (!BARE_URL.test(text)) return undefined;
|
|
41
|
+
const html = findEmbed(embeds, text)?.html;
|
|
42
|
+
return typeof html === 'string' && html.trim() ? html : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A markdown renderer with its own `marked` instance — options are per-site, not global, so two
|
|
47
|
+
* `createMarvinContent()` instances in one process can't clobber each other's settings.
|
|
48
|
+
*
|
|
49
|
+
* `breaks: false` is the default because CMS body copy is authored as prose: a soft-wrapped
|
|
50
|
+
* paragraph should render as one paragraph. Where authored line breaks must survive (a landing
|
|
51
|
+
* intro, an address block), pass `breaks: true` or pre-convert with a trailing double space.
|
|
52
|
+
*
|
|
53
|
+
* Pass the entry's `embeds` to turn a bare provider URL on its own line into its player:
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* await renderMarkdown(body, { embeds: entryEmbeds(entry) });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function createMarkdownRenderer(options: MarkdownOptions = {}): MarkdownRenderer {
|
|
60
|
+
const marked = new Marked();
|
|
61
|
+
marked.setOptions({ gfm: true, breaks: false, ...options });
|
|
62
|
+
marked.use({
|
|
63
|
+
renderer: {
|
|
64
|
+
paragraph(token) {
|
|
65
|
+
const embeds = (this.options as ParseOptionsWithEmbeds)[EMBEDS];
|
|
66
|
+
const html = paragraphEmbedHtml(token, embeds);
|
|
67
|
+
// `false` hands the token back to marked's default paragraph renderer.
|
|
68
|
+
return html === undefined ? false : `${html}\n`;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return async (markdown, renderOptions = {}) => {
|
|
74
|
+
const source = Array.isArray(markdown) ? markdown.join('\n\n') : (markdown ?? '');
|
|
75
|
+
if (!source) return '';
|
|
76
|
+
const parseOptions: ParseOptionsWithEmbeds = { [EMBEDS]: renderOptions.embeds ?? null };
|
|
77
|
+
return await marked.parse(source, parseOptions);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Turn single newlines into hard breaks, leaving paragraph breaks alone. */
|
|
82
|
+
export function preserveSoftBreaks(source: string): string {
|
|
83
|
+
return source.replace(/(?<!\n)\n(?!\n)/g, ' \n');
|
|
84
|
+
}
|