@inneropen/marvin-astro 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +173 -14
  2. package/dist/embeds-BTNryQA6.d.ts +79 -0
  3. package/dist/index.d.ts +43 -6
  4. package/dist/index.js +116 -14
  5. package/dist/index.js.map +1 -1
  6. package/dist/renderers/index.d.ts +119 -0
  7. package/dist/renderers/index.js +251 -0
  8. package/dist/renderers/index.js.map +1 -0
  9. package/dist/types.d.ts +8 -1
  10. package/package.json +16 -6
  11. package/src/chrome.ts +242 -0
  12. package/src/client.ts +119 -0
  13. package/src/components/Embed.astro +119 -0
  14. package/src/components/EmbedLoader.astro +24 -0
  15. package/src/components/EntryRenderer.astro +40 -0
  16. package/src/components/FormRenderer.astro +188 -0
  17. package/src/components/LinkCard.astro +42 -0
  18. package/src/components/embed-frame.ts +168 -0
  19. package/src/components/embeds.css +137 -0
  20. package/src/components/index.ts +29 -0
  21. package/src/components/integration.ts +62 -0
  22. package/src/components/markdown.ts +4 -0
  23. package/src/components/registry.ts +34 -0
  24. package/src/components/renderers/ArticleRenderer.astro +56 -0
  25. package/src/components/renderers/FaqRenderer.astro +26 -0
  26. package/src/components/renderers/NavigationRenderer.astro +27 -0
  27. package/src/components/renderers/PageRenderer.astro +51 -0
  28. package/src/config.ts +149 -0
  29. package/src/embeds.ts +137 -0
  30. package/src/env.d.ts +5 -0
  31. package/src/fetch.ts +208 -0
  32. package/src/fields.ts +330 -0
  33. package/src/format.ts +63 -0
  34. package/src/index.ts +169 -0
  35. package/src/markdown.ts +84 -0
  36. package/src/normalize.ts +284 -0
  37. package/src/renderers/debug.ts +6 -0
  38. package/src/renderers/index.ts +48 -0
  39. package/src/renderers/registry.ts +133 -0
  40. package/src/renderers/resolve.ts +134 -0
  41. package/src/renderers/types.ts +71 -0
  42. package/src/renderers/validation-types.ts +23 -0
  43. package/src/renderers/validation.ts +55 -0
  44. package/src/repository.ts +206 -0
  45. package/src/sections.ts +39 -0
  46. package/src/site.ts +222 -0
  47. package/src/types.ts +188 -0
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, isFullEntry, 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';
@@ -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
+ }