@inneropen/marvin-astro 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +164 -9
  2. package/dist/embeds-BTNryQA6.d.ts +79 -0
  3. package/dist/index.d.ts +29 -2
  4. package/dist/index.js +88 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/renderers/index.d.ts +119 -0
  7. package/dist/renderers/index.js +251 -0
  8. package/dist/renderers/index.js.map +1 -0
  9. package/dist/types.d.ts +8 -1
  10. package/package.json +16 -6
  11. package/src/chrome.ts +242 -0
  12. package/src/client.ts +119 -0
  13. package/src/components/Embed.astro +119 -0
  14. package/src/components/EmbedLoader.astro +24 -0
  15. package/src/components/EntryRenderer.astro +40 -0
  16. package/src/components/FormRenderer.astro +188 -0
  17. package/src/components/LinkCard.astro +42 -0
  18. package/src/components/embed-frame.ts +168 -0
  19. package/src/components/embeds.css +137 -0
  20. package/src/components/index.ts +29 -0
  21. package/src/components/integration.ts +62 -0
  22. package/src/components/markdown.ts +4 -0
  23. package/src/components/registry.ts +34 -0
  24. package/src/components/renderers/ArticleRenderer.astro +56 -0
  25. package/src/components/renderers/FaqRenderer.astro +26 -0
  26. package/src/components/renderers/NavigationRenderer.astro +27 -0
  27. package/src/components/renderers/PageRenderer.astro +51 -0
  28. package/src/config.ts +149 -0
  29. package/src/embeds.ts +137 -0
  30. package/src/env.d.ts +5 -0
  31. package/src/fetch.ts +198 -0
  32. package/src/fields.ts +330 -0
  33. package/src/format.ts +63 -0
  34. package/src/index.ts +169 -0
  35. package/src/markdown.ts +84 -0
  36. package/src/normalize.ts +284 -0
  37. package/src/renderers/debug.ts +6 -0
  38. package/src/renderers/index.ts +48 -0
  39. package/src/renderers/registry.ts +133 -0
  40. package/src/renderers/resolve.ts +134 -0
  41. package/src/renderers/types.ts +71 -0
  42. package/src/renderers/validation-types.ts +23 -0
  43. package/src/renderers/validation.ts +55 -0
  44. package/src/repository.ts +190 -0
  45. package/src/sections.ts +39 -0
  46. package/src/site.ts +222 -0
  47. package/src/types.ts +188 -0
package/README.md CHANGED
@@ -3,13 +3,15 @@
3
3
  Site integration for [Marvin CMS](https://github.com/inneropen) on Astro: content repositories
4
4
  with static fallbacks, site chrome from collections, and payload normalization.
5
5
 
6
- Three packages, three concerns:
6
+ Two packages, two concerns:
7
7
 
8
8
  | Package | Concern |
9
9
  |---|---|
10
10
  | `@inneropen/marvin-sdk` | transport — HTTP client, entries, collections, assets |
11
- | `@inneropen/marvin-renderers-core` | entry-type → Astro renderer component mapping |
12
- | **`@inneropen/marvin-astro`** | **site integration — repositories, chrome, normalization** |
11
+ | **`@inneropen/marvin-astro`** | **site integration — repositories, chrome, normalization, and the Astro components (forms, media embeds, entry-type renderers)** |
12
+
13
+ `@inneropen/marvin-renderers-core` has been folded in here — see
14
+ [Migrating from @inneropen/marvin-renderers-core](#migrating-from-inneropenmarvin-renderers-core).
13
15
 
14
16
  A new site wires up to a Marvin workspace by installing one package, setting three env vars, and
15
17
  writing only its own transform functions.
@@ -104,7 +106,8 @@ fall-through a legacy value that *is* set would never surface.
104
106
  | `f.string(key)` `f.number(key)` `f.bool(key)` `f.list(key)` | scalars; `bool` reads `"true"`/`"1"`/`"yes"` |
105
107
  | `f.oneOf(key, allowed, fallback)` | enum guard — replaces per-field `normalizeStatus`-style helpers |
106
108
  | `f.raw(key)` `f.data()` `f.metadata()` | untyped escape hatches |
107
- | `f.markdown(key?, { softBreaks })` | renders to HTML; `undefined` when there is nothing to render |
109
+ | `f.markdown(key?, { softBreaks, embeds })` | renders to HTML, with [media embeds](#media-embeds); `undefined` when there is nothing to render |
110
+ | `f.embed(key)` | the resolved embed for an `embed` field — render with `<Embed>` |
108
111
  | `f.date(key)` `f.publishedAt()` | display date ("Mon DD, YYYY") / raw ISO stamp |
109
112
  | `f.image(options)` `f.images(options)` `f.icon(options)` | resolved `{src, alt, focalPoint}` |
110
113
  | `f.asset(options)` `f.assetByRole(...roles)` `f.assets()` | raw asset placements |
@@ -143,6 +146,153 @@ The default prefixes the entry's own non-navigation collection: an entry in `wor
143
146
  becomes `/workshop-reference/<slug>`, an entry in no other collection becomes `/<slug>`. Override
144
147
  when routes don't mirror collections.
145
148
 
149
+ ## Media embeds
150
+
151
+ Paste a YouTube, Vimeo, Spotify, SoundCloud, Apple Music/Podcasts, Tidal or podcast link on its
152
+ own line in a markdown field and Marvin resolves it on the server. The published entry then
153
+ carries `embeds`, keyed by the URL exactly as written, each with Marvin-built `html` for the
154
+ site's embed mode and the structured fields behind it. The stored markdown never changes, so a
155
+ site that doesn't render embeds just shows the link.
156
+
157
+ **Markdown.** `f.markdown()` passes the entry's embeds automatically. A paragraph that is exactly
158
+ a URL with an `embeds` entry renders as that embed's `html`; `<url>`, `[text](url)`, a URL inside
159
+ a sentence and anything in code stay as they are. Opt a field out with `f.markdown('body',
160
+ { embeds: false })`. Where you call the renderer yourself, pass the embeds:
161
+
162
+ ```ts
163
+ import { entryEmbeds } from '@inneropen/marvin-astro';
164
+
165
+ const bodyHtml = await marvin.renderMarkdown(body, { embeds: entryEmbeds(entry) });
166
+ ```
167
+
168
+ **`embed` fields.** An `embed` field's value is the provider URL; `f.embed(key)` returns its
169
+ resolved embed. Render it with `<Embed>`, which builds the player, facade or link card from the
170
+ structured fields:
171
+
172
+ ```astro
173
+ ---
174
+ import { Embed } from '@inneropen/marvin-astro/components';
175
+ const site = await marvin.getSite();
176
+ ---
177
+ <Embed
178
+ embed={post.video}
179
+ mode={site.embeds?.mode}
180
+ consentText={site.embeds?.consentText}
181
+ frameSources={site.embeds?.frameSources}
182
+ />
183
+ ```
184
+
185
+ **The loader and CSS.** In `click_to_load` mode (Marvin's default) an embed is a facade — a
186
+ button and a plain link — and nothing loads from the provider until the visitor clicks. Put
187
+ `<EmbedLoader />` once in your layout:
188
+
189
+ ```astro
190
+ ---
191
+ import { EmbedLoader } from '@inneropen/marvin-astro/components';
192
+ ---
193
+ <body>
194
+ <slot />
195
+ <EmbedLoader />
196
+ </body>
197
+ ```
198
+
199
+ On click (or Enter/Space) it swaps the button for the iframe. It builds the iframe only from the
200
+ facade's `data-marvin-embed-attrs`, keeps only `src`, `title`, `allow`, `sandbox`,
201
+ `referrerpolicy`, `loading` and `style`, refuses any `src` that isn't https on a host in
202
+ `data-marvin-embed-hosts`, and never parses HTML. It also brings the base `.marvin-embed` CSS:
203
+ responsive aspect ratio for video, fixed height for audio, facade and link-card styles. The
204
+ rules have zero specificity, so any site rule wins; theme through custom properties:
205
+
206
+ ```css
207
+ .marvin-embed, .marvin-embed-link {
208
+ --marvin-embed-radius: 0;
209
+ --marvin-embed-bg: var(--ink);
210
+ --marvin-embed-fg: var(--paper);
211
+ --marvin-embed-max-width: 48rem;
212
+ }
213
+ ```
214
+
215
+ The full list (`--marvin-embed-aspect`, `-height`, `-margin`, `-muted`, `-border`, `-focus`, …)
216
+ is at the top of `src/components/embeds.css`. For CSS without the loader (a `direct`-mode site),
217
+ `import '@inneropen/marvin-astro/components/embeds.css'`.
218
+
219
+ **CSP.** `site.embeds.frameSources` lists every origin the site's embeds may frame. Use it for a
220
+ `frame-src` directive:
221
+
222
+ ```astro
223
+ ---
224
+ const site = await marvin.getSite();
225
+ const frameSrc = ["'self'", ...(site.embeds?.frameSources ?? [])].join(' ');
226
+ ---
227
+ <meta http-equiv="Content-Security-Policy" content={`frame-src ${frameSrc}`} />
228
+ ```
229
+
230
+ The loader script is small enough that Astro inlines it; if your CSP forbids inline scripts, hash
231
+ it or set `vite.build.assetsInlineLimit: 0` so Astro emits it as a file.
232
+
233
+ The embed types (`MarvinEmbed`, `MarvinSiteEmbeds`, …) are declared in this package, so none of
234
+ this needs a newer SDK.
235
+
236
+ ## Components
237
+
238
+ `@inneropen/marvin-astro/components` ships Astro source, compiled by your site's build.
239
+
240
+ | Component | Use |
241
+ |---|---|
242
+ | `FormRenderer` | a public form from a submittable entry type's `formSchema`; unstyled, progressive-enhancement submit |
243
+ | `Embed`, `LinkCard`, `EmbedLoader` | media embeds (above) |
244
+ | `EntryRenderer` | picks the renderer for an entry from its entry type's `rendering` |
245
+ | `PageRenderer`, `ArticleRenderer`, `FaqRenderer`, `NavigationRenderer` | unstyled, semantic core renderers with `data-renderer`/`data-role` hooks |
246
+
247
+ Page and Article render the markdown body (with embeds); pass `renderMarkdown={marvin.renderMarkdown}`
248
+ to use your site's markdown options.
249
+
250
+ The registry and logic: `getRenderer`, `astroRegistry`, `coreRendererPackage` from
251
+ `@inneropen/marvin-astro/components` (or `/components/registry`), and the framework-agnostic
252
+ helpers — `resolveRendererName`, `resolveRendererConfig`, `extractBody`, `extractField`,
253
+ `getFeaturedAsset`, `createRegistry`, `createPackageRegistry`, `createRendererPackage`,
254
+ `validateRenderers`, … — from `@inneropen/marvin-astro/renderers`.
255
+
256
+ Build-time check that every rendered entry type has a renderer:
257
+
258
+ ```js
259
+ // astro.config.mjs
260
+ import { marvinIntegration } from '@inneropen/marvin-astro/components/integration';
261
+ import { astroRegistry } from '@inneropen/marvin-astro/components/registry';
262
+
263
+ export default defineConfig({
264
+ integrations: [marvinIntegration({ registry: astroRegistry })],
265
+ });
266
+ ```
267
+
268
+ Options: `registry` (required), `apiUrl`, `siteToken` (default to the `MARVIN_*` env vars),
269
+ `strict` (throw instead of warn), `ignore` (entry-type slugs to skip).
270
+
271
+ Marvin's system entry types name their renderer package `@inneropen/marvin-renderers-core`; the
272
+ core renderers still answer to that name (and to `@inneropen/marvin-astro`).
273
+
274
+ ## Migrating from @inneropen/marvin-renderers-core
275
+
276
+ The components and logic moved here with their names unchanged. Swap the import paths, then
277
+ remove `@inneropen/marvin-renderers-core` from `package.json`:
278
+
279
+ | Before (`@inneropen/marvin-renderers-core…`) | After (`@inneropen/marvin-astro…`) |
280
+ |---|---|
281
+ | `/astro` | `/components` |
282
+ | `/astro/registry` | `/components/registry` |
283
+ | `/astro/integration` | `/components/integration` |
284
+ | `/astro/renderers/PageRenderer.astro` (etc.) | `/components/renderers/PageRenderer.astro` |
285
+ | `/logic` | `/renderers` |
286
+
287
+ ```diff
288
+ -import { FormRenderer } from '@inneropen/marvin-renderers-core/astro';
289
+ +import { FormRenderer } from '@inneropen/marvin-astro/components';
290
+ ```
291
+
292
+ Behaviour changes: Page/Article now render the body as markdown instead of injecting it raw, and
293
+ `EntryRenderer` renders core entry types without a custom registry (it silently rendered only
294
+ its slot before).
295
+
146
296
  ## The failure latch
147
297
 
148
298
  A static build asks for content once per path. When the backend is down that means N failed
@@ -162,8 +312,8 @@ marvin.backend.clearLatch();
162
312
 
163
313
  ## SEO head (optional)
164
314
 
165
- One component ships, behind its own export path, so the core package stays pure TypeScript and
166
- Astro stays an optional peer dependency.
315
+ `SeoHead` ships behind its own export path (as do the [components](#components)), so the core
316
+ package stays pure TypeScript and Astro stays an optional peer dependency.
167
317
 
168
318
  ```astro
169
319
  ---
@@ -187,18 +337,23 @@ verification tags. No styling, no site coupling.
187
337
  | `@inneropen/marvin-astro` | `createMarvinContent` and every helper below it |
188
338
  | `@inneropen/marvin-astro/types` | resolved types only (`ApiSite`, `ApiSeo`, `ApiSiteChrome`, …) |
189
339
  | `@inneropen/marvin-astro/astro` | `SeoHead` |
340
+ | `@inneropen/marvin-astro/components` | `FormRenderer`, `Embed`, `LinkCard`, `EmbedLoader`, `EntryRenderer`, the core renderers, registry, `marvinIntegration` |
341
+ | `@inneropen/marvin-astro/components/registry` | `getRenderer`, `astroRegistry`, `coreRendererPackage` |
342
+ | `@inneropen/marvin-astro/components/integration` | `marvinIntegration` (for `astro.config.mjs`) |
343
+ | `@inneropen/marvin-astro/components/*` | individual components, `renderers/*.astro`, `embeds.css` |
344
+ | `@inneropen/marvin-astro/renderers` | renderer logic (compiled): resolve, registry, validation |
190
345
 
191
346
  Beyond `createMarvinContent`, the pieces are usable on their own: `createBackend`,
192
347
  `createFetcher`, `createRepository`, `createSiteLoader`, `createChromeLoader`,
193
- `createFieldAccessor`, `createMarkdownRenderer`, `formatDisplayDate`, `selectValuesForPage`, and
194
- the whole `normalize` surface.
348
+ `createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`,
349
+ `formatDisplayDate`, `selectValuesForPage`, and the whole `normalize` surface.
195
350
 
196
351
  ## Development
197
352
 
198
353
  ```bash
199
354
  npm install
200
355
  npm run typecheck
201
- npm test # vitest, fixture-driven, no network
356
+ npm test # vitest, fixture-driven, no network; .astro components via Astro's Container API
202
357
  npm run build # tsup → dist/ with .d.ts
203
358
  ```
204
359
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Media embeds: the published shapes Marvin hands a site, read tolerantly.
3
+ *
4
+ * Marvin resolves a pasted provider link (YouTube, Vimeo, Spotify, …) on the server and publishes
5
+ * the result next to the entry as `entry.embeds`, keyed by the URL exactly as the author wrote it.
6
+ * Stored markdown is never rewritten: a site that knows about embeds swaps the link for the
7
+ * player; an older site just shows the link.
8
+ *
9
+ * These types are declared here rather than imported from `@inneropen/marvin-sdk` so the package
10
+ * works with SDKs that predate embeds. Kind and status are open-ended so a newer server value
11
+ * doesn't break an older site's types.
12
+ */
13
+ type MarvinEmbedKind = 'video' | 'audio' | 'podcast' | 'playlist';
14
+ type MarvinEmbedStatus = 'ok' | 'link' | 'unavailable';
15
+ type MarvinEmbedMode = 'direct' | 'click_to_load';
16
+ /**
17
+ * A Marvin-built iframe. Every value comes from Marvin's provider registry. Exactly one of
18
+ * `aspectRatio` / `height` is set; the other is `null`.
19
+ */
20
+ type MarvinEmbedIframe = {
21
+ src: string;
22
+ title: string;
23
+ allow?: string | null;
24
+ sandbox?: string | null;
25
+ referrerpolicy?: string | null;
26
+ /** CSS aspect ratio for video players, e.g. `"16/9"` (`"9/16"` for Shorts); `null` otherwise. */
27
+ aspectRatio?: string | null;
28
+ /** Fixed pixel height for audio / podcast / playlist players; `null` for video. */
29
+ height?: number | null;
30
+ };
31
+ /** The plain link every embed falls back to. */
32
+ type MarvinEmbedLink = {
33
+ href: string;
34
+ title: string;
35
+ providerName: string;
36
+ };
37
+ /** Optional fields arrive as `null` rather than omitted; every reader here treats both alike. */
38
+ type MarvinEmbed = {
39
+ /** The URL exactly as written in the content. */
40
+ url: string;
41
+ canonicalUrl?: string | null;
42
+ /** Provider key, e.g. `youtube`. */
43
+ provider: string;
44
+ /** Display name, e.g. `YouTube`. */
45
+ providerName: string;
46
+ kind: MarvinEmbedKind | (string & {});
47
+ /** `ok` has an iframe; `link` and `unavailable` render as a link card. */
48
+ status: MarvinEmbedStatus | (string & {});
49
+ title?: string | null;
50
+ authorName?: string | null;
51
+ thumbnailUrl?: string | null;
52
+ iframe?: MarvinEmbedIframe | null;
53
+ link: MarvinEmbedLink;
54
+ /** Marvin-built HTML for the site's embed mode (an iframe, a click-to-load facade, or a link). */
55
+ html: string;
56
+ };
57
+ /** `entry.embeds`: every resolved embed on the entry, keyed by the URL as written. */
58
+ type MarvinEmbeds = Record<string, MarvinEmbed>;
59
+ /**
60
+ * `site.embeds` (next to `site.seo`): the site's embed privacy mode, consent text (`{provider}`
61
+ * already substituted per embed in the published `html`) and the frame sources a CSP must allow.
62
+ */
63
+ type MarvinSiteEmbeds = {
64
+ mode: MarvinEmbedMode;
65
+ consentText: string;
66
+ /** Origins/hosts the site's embeds may frame — feed these into a `frame-src` directive. */
67
+ frameSources: string[];
68
+ };
69
+ /**
70
+ * The entry's `embeds` map, or `undefined` when it has none (an older server, or no embeds).
71
+ * Values that aren't objects carrying a URL are dropped.
72
+ */
73
+ declare function entryEmbeds(entry: unknown): MarvinEmbeds | undefined;
74
+ /** The embed for `url` as written, tolerating surrounding whitespace. */
75
+ declare function findEmbed(embeds: MarvinEmbeds | null | undefined, url: string | null | undefined): MarvinEmbed | undefined;
76
+ /** Normalize `site.embeds`; `undefined` when the server doesn't send it. */
77
+ declare function siteEmbeds(raw: unknown): MarvinSiteEmbeds | undefined;
78
+
79
+ export { type MarvinEmbeds as M, type MarvinEmbed as a, type MarvinEmbedIframe as b, type MarvinEmbedKind as c, type MarvinEmbedLink as d, type MarvinEmbedMode as e, type MarvinEmbedStatus as f, type MarvinSiteEmbeds as g, entryEmbeds as h, findEmbed as i, siteEmbeds as s };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import { MarkedOptions } from 'marked';
2
+ import { M as MarvinEmbeds, a as MarvinEmbed } from './embeds-BTNryQA6.js';
3
+ export { b as MarvinEmbedIframe, c as MarvinEmbedKind, d as MarvinEmbedLink, e as MarvinEmbedMode, f as MarvinEmbedStatus, g as MarvinSiteEmbeds, h as entryEmbeds, i as findEmbed, s as siteEmbeds } from './embeds-BTNryQA6.js';
2
4
  import { MarvinContentEntry, ApiSite, ApiSeo, ApiSiteChrome, ApiNavigationLink, ApiSocialLink, AssetSelectOptions, ApiImage, ApiResourceLink, SectionLanding } from './types.js';
3
5
  export { ApiActionVariant, ApiHero, ApiHeroAction, ApiPageAction } from './types.js';
4
6
  import * as _inneropen_marvin_sdk from '@inneropen/marvin-sdk';
5
7
  import { MarvinClient, MarvinEntry, Entry, PublishedCollectionSummary, Collection, CollectionEntry, MarvinSite, Workspace, MarvinAsset, MarvinResource } from '@inneropen/marvin-sdk';
6
8
 
7
9
  type MarkdownOptions = MarkedOptions;
8
- type MarkdownRenderer = (markdown: string | string[] | null | undefined) => Promise<string>;
10
+ /** Per-call options for a {@link MarkdownRenderer}. */
11
+ type RenderMarkdownOptions = {
12
+ /**
13
+ * The entry's `embeds` map. A paragraph that is exactly a URL with an entry here renders as
14
+ * that embed's Marvin-built HTML instead of a link.
15
+ */
16
+ embeds?: MarvinEmbeds | null;
17
+ };
18
+ type MarkdownRenderer = (markdown: string | string[] | null | undefined, options?: RenderMarkdownOptions) => Promise<string>;
9
19
  /**
10
20
  * A markdown renderer with its own `marked` instance — options are per-site, not global, so two
11
21
  * `createMarvinContent()` instances in one process can't clobber each other's settings.
@@ -13,6 +23,12 @@ type MarkdownRenderer = (markdown: string | string[] | null | undefined) => Prom
13
23
  * `breaks: false` is the default because CMS body copy is authored as prose: a soft-wrapped
14
24
  * paragraph should render as one paragraph. Where authored line breaks must survive (a landing
15
25
  * intro, an address block), pass `breaks: true` or pre-convert with a trailing double space.
26
+ *
27
+ * Pass the entry's `embeds` to turn a bare provider URL on its own line into its player:
28
+ *
29
+ * ```ts
30
+ * await renderMarkdown(body, { embeds: entryEmbeds(entry) });
31
+ * ```
16
32
  */
17
33
  declare function createMarkdownRenderer(options?: MarkdownOptions): MarkdownRenderer;
18
34
  /** Turn single newlines into hard breaks, leaving paragraph breaks alone. */
@@ -291,6 +307,11 @@ type IconFieldOptions = {
291
307
  type MarkdownFieldOptions = {
292
308
  /** Keep authored single newlines as hard breaks. Default `false`. */
293
309
  softBreaks?: boolean;
310
+ /**
311
+ * Render a bare provider URL on its own line as its player, using the entry's `embeds`.
312
+ * Default `true`; `false` keeps every URL a plain link.
313
+ */
314
+ embeds?: boolean;
294
315
  };
295
316
  type ResourceFieldOptions = {
296
317
  /** Keep only these resource types, e.g. `['material', 'construction']`. */
@@ -338,6 +359,12 @@ declare function createFieldAccessor(entry: MarvinContentEntry, context: FieldAc
338
359
  * caller can omit the property rather than emit an empty string.
339
360
  */
340
361
  markdown(key?: string, options?: MarkdownFieldOptions): Promise<string | undefined>;
362
+ /**
363
+ * The resolved embed for an `embed` field (its value is the provider URL), or `undefined`
364
+ * when the field is empty or the server hasn't resolved it. Render it with `<Embed>` from
365
+ * `@inneropen/marvin-astro/components`, or output `embed.html`.
366
+ */
367
+ embed(key: string): MarvinEmbed | undefined;
341
368
  /**
342
369
  * The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
343
370
  * the exact `preferRoles`, a role/usage match over the entry's image assets, and finally the
@@ -585,4 +612,4 @@ declare function createMarvinContent(options?: MarvinContentOptions): {
585
612
  reset(): void;
586
613
  };
587
614
 
588
- export { ApiImage, ApiNavigationLink, ApiResourceLink, ApiSeo, ApiSite, ApiSiteChrome, ApiSocialLink, AssetSelectOptions, type ChromeLoader, type ChromeOptions, DEFAULT_DEV_RETRY_MS, DEFAULT_HYDRATE_CONCURRENCY, ENV_KEYS, type FieldAccessor, type HrefContext, type HydratedEntry, type IconFieldOptions, type ImageFieldOptions, type MarkdownFieldOptions, type MarkdownOptions, type MarkdownRenderer, type MarvinAstroConfig, type MarvinBackend, type MarvinContent, MarvinContentEntry, type MarvinContentOptions, type MarvinFetcher, type MarvinLogger, type NavigationContext, type NavigationLinkInput, type Repository, type RepositoryContext, type RepositoryOptions, type ResolveHref, type ResolvedConfig, type ResourceFieldOptions, SectionLanding, type SectionLandingOptions, type SiteLoader, type SiteOptions, type SocialLinkInput, asNumber, asRecord, asString, asStringArray, assetAlt, assetField, assetUrl, buildFallbackSite, buildSeo, collectionRole, collectionSlugs, createBackend, createChromeLoader, createFetcher, createFieldAccessor, createMarkdownRenderer, createMarvinContent, createRepository, createSiteLoader, defaultResolveHref, describeConfig, entryAssets, entryData, entryField, entryMetadata, entryResources, errorMessage, field, focalPoint, formatDisplayDate, isExternalHref, isNetworkFailure, loadSectionLanding, metadataLink, preserveSoftBreaks, readEnv, resolveConfig, resourceField, resourceRole, selectAssetByRole, selectEntryAsset, selectFeaturedAsset, selectIconAsset, selectImageAsset, selectValuesForPage, socialLinkFromKey, toNavigationLink };
615
+ export { ApiImage, ApiNavigationLink, ApiResourceLink, ApiSeo, ApiSite, ApiSiteChrome, ApiSocialLink, AssetSelectOptions, type ChromeLoader, type ChromeOptions, DEFAULT_DEV_RETRY_MS, DEFAULT_HYDRATE_CONCURRENCY, ENV_KEYS, type FieldAccessor, type HrefContext, type HydratedEntry, type IconFieldOptions, type ImageFieldOptions, type MarkdownFieldOptions, type MarkdownOptions, type MarkdownRenderer, type MarvinAstroConfig, type MarvinBackend, type MarvinContent, MarvinContentEntry, type MarvinContentOptions, MarvinEmbed, MarvinEmbeds, type MarvinFetcher, type MarvinLogger, type NavigationContext, type NavigationLinkInput, type RenderMarkdownOptions, type Repository, type RepositoryContext, type RepositoryOptions, type ResolveHref, type ResolvedConfig, type ResourceFieldOptions, SectionLanding, type SectionLandingOptions, type SiteLoader, type SiteOptions, type SocialLinkInput, asNumber, asRecord, asString, asStringArray, assetAlt, assetField, assetUrl, buildFallbackSite, buildSeo, collectionRole, collectionSlugs, createBackend, createChromeLoader, createFetcher, createFieldAccessor, createMarkdownRenderer, createMarvinContent, createRepository, createSiteLoader, defaultResolveHref, describeConfig, entryAssets, entryData, entryField, entryMetadata, entryResources, errorMessage, field, focalPoint, formatDisplayDate, isExternalHref, isNetworkFailure, loadSectionLanding, metadataLink, preserveSoftBreaks, readEnv, resolveConfig, resourceField, resourceRole, selectAssetByRole, selectEntryAsset, selectFeaturedAsset, selectIconAsset, selectImageAsset, selectValuesForPage, socialLinkFromKey, toNavigationLink };
package/dist/index.js CHANGED
@@ -527,13 +527,79 @@ function createFetcher(backend) {
527
527
 
528
528
  // src/markdown.ts
529
529
  import { Marked } from "marked";
530
+
531
+ // src/embeds.ts
532
+ var EMBED_MODES = /* @__PURE__ */ new Set(["direct", "click_to_load"]);
533
+ function readEmbedsField(entry) {
534
+ if (!entry || typeof entry !== "object") return void 0;
535
+ const direct = entry.embeds;
536
+ if (direct !== void 0) return direct;
537
+ const toJSON = entry.toJSON;
538
+ if (typeof toJSON === "function") {
539
+ return asRecord(toJSON.call(entry)).embeds;
540
+ }
541
+ return void 0;
542
+ }
543
+ function entryEmbeds(entry) {
544
+ const raw = readEmbedsField(entry);
545
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
546
+ const embeds = {};
547
+ for (const [key, value] of Object.entries(raw)) {
548
+ if (value && typeof value === "object" && !Array.isArray(value)) {
549
+ embeds[key] = value;
550
+ }
551
+ }
552
+ return Object.keys(embeds).length > 0 ? embeds : void 0;
553
+ }
554
+ function findEmbed(embeds, url) {
555
+ if (!embeds || !url) return void 0;
556
+ const key = url.trim();
557
+ if (!key) return void 0;
558
+ return Object.prototype.hasOwnProperty.call(embeds, key) ? embeds[key] : void 0;
559
+ }
560
+ function siteEmbeds(raw) {
561
+ if (!raw || typeof raw !== "object") return void 0;
562
+ const value = asRecord(raw);
563
+ const mode = asString(value.mode);
564
+ const frameSources = Array.isArray(value.frameSources) ? value.frameSources.filter(
565
+ (source) => typeof source === "string" && source.trim().length > 0
566
+ ) : [];
567
+ return {
568
+ // Unknown or missing mode → the privacy-preserving default.
569
+ mode: mode && EMBED_MODES.has(mode) ? mode : "click_to_load",
570
+ consentText: asString(value.consentText) ?? "",
571
+ frameSources
572
+ };
573
+ }
574
+
575
+ // src/markdown.ts
576
+ var EMBEDS = /* @__PURE__ */ Symbol("marvin.embeds");
577
+ var BARE_URL = /^https?:\/\/\S+$/i;
578
+ function paragraphEmbedHtml(token, embeds) {
579
+ if (!embeds) return void 0;
580
+ const text = token.text.trim();
581
+ if (!BARE_URL.test(text)) return void 0;
582
+ const html = findEmbed(embeds, text)?.html;
583
+ return typeof html === "string" && html.trim() ? html : void 0;
584
+ }
530
585
  function createMarkdownRenderer(options = {}) {
531
586
  const marked = new Marked();
532
587
  marked.setOptions({ gfm: true, breaks: false, ...options });
533
- return async (markdown) => {
588
+ marked.use({
589
+ renderer: {
590
+ paragraph(token) {
591
+ const embeds = this.options[EMBEDS];
592
+ const html = paragraphEmbedHtml(token, embeds);
593
+ return html === void 0 ? false : `${html}
594
+ `;
595
+ }
596
+ }
597
+ });
598
+ return async (markdown, renderOptions = {}) => {
534
599
  const source = Array.isArray(markdown) ? markdown.join("\n\n") : markdown ?? "";
535
600
  if (!source) return "";
536
- return await marked.parse(source);
601
+ const parseOptions = { [EMBEDS]: renderOptions.embeds ?? null };
602
+ return await marked.parse(source, parseOptions);
537
603
  };
538
604
  }
539
605
  function preserveSoftBreaks(source) {
@@ -585,6 +651,7 @@ function selectValuesForPage(items, pageSlug, count = 4) {
585
651
  var DEFAULT_IMAGE_ROLES = ["hero", "featured", "card"];
586
652
  function createFieldAccessor(entry, context) {
587
653
  const title = asString(entry.title);
654
+ const embeds = entryEmbeds(entry);
588
655
  const accessor = {
589
656
  /** The entry itself, for anything the accessor doesn't cover. */
590
657
  entry,
@@ -647,7 +714,17 @@ function createFieldAccessor(entry, context) {
647
714
  const raw = field(entry, key) ?? entry.contentMarkdown;
648
715
  const source = Array.isArray(raw) ? raw.join("\n\n") : asString(raw);
649
716
  if (!source) return void 0;
650
- return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source);
717
+ return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source, {
718
+ embeds: options.embeds === false ? void 0 : embeds
719
+ });
720
+ },
721
+ /**
722
+ * The resolved embed for an `embed` field (its value is the provider URL), or `undefined`
723
+ * when the field is empty or the server hasn't resolved it. Render it with `<Embed>` from
724
+ * `@inneropen/marvin-astro/components`, or output `embed.html`.
725
+ */
726
+ embed(key) {
727
+ return findEmbed(embeds, asString(field(entry, key)));
651
728
  },
652
729
  /**
653
730
  * The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
@@ -865,7 +942,7 @@ async function loadSectionLanding(fetcher, slug, renderMarkdown = createMarkdown
865
942
  const source = asString(entryField(entry, options.bodyField ?? "body")) ?? asString(entry.summary);
866
943
  return {
867
944
  title: asString(entry.title),
868
- introHtml: source ? await renderMarkdown(preserveSoftBreaks(source)) : void 0,
945
+ introHtml: source ? await renderMarkdown(preserveSoftBreaks(source), { embeds: entryEmbeds(entry) }) : void 0,
869
946
  hero: assetUrl(selectAssetByRole(entry, options.heroRole ?? "hero"))
870
947
  };
871
948
  }
@@ -952,6 +1029,10 @@ function transformMarvinSite(marvinSite, fallback) {
952
1029
  canonicalUrl: asString(config.canonicalUrl),
953
1030
  siteName: title
954
1031
  }),
1032
+ // Older servers and SDKs don't send it; `siteEmbeds` returns undefined then.
1033
+ embeds: siteEmbeds(
1034
+ config.embeds ?? marvinSite.embeds
1035
+ ),
955
1036
  metadata
956
1037
  };
957
1038
  }
@@ -1083,11 +1164,13 @@ export {
1083
1164
  describeConfig,
1084
1165
  entryAssets,
1085
1166
  entryData,
1167
+ entryEmbeds,
1086
1168
  entryField,
1087
1169
  entryMetadata,
1088
1170
  entryResources,
1089
1171
  errorMessage,
1090
1172
  field,
1173
+ findEmbed,
1091
1174
  focalPoint,
1092
1175
  formatDisplayDate,
1093
1176
  isExternalHref,
@@ -1105,6 +1188,7 @@ export {
1105
1188
  selectIconAsset,
1106
1189
  selectImageAsset,
1107
1190
  selectValuesForPage,
1191
+ siteEmbeds,
1108
1192
  socialLinkFromKey,
1109
1193
  toNavigationLink
1110
1194
  };