@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/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.
@@ -78,12 +80,16 @@ page — and only fetches the entry directly when nothing is loaded yet or the s
78
80
 
79
81
  ## Why `hydrate`
80
82
 
81
- The collection endpoint returns `PublishedEntryListItem`, which carries core fields and
82
- `metadata_json` but **not** `data_json`. The single-entry endpoint returns `PublishedEntryRead`,
83
- which includes it.
83
+ The collection endpoint returns `PublishedEntryListItem`: core fields, `metadata_json` and (on
84
+ current servers) `data`, but only the *slugs* of its assets and resources — no `assets[]`
85
+ placements (so no roles, no `hero-grade` image) and no `resources[]`. The single-entry endpoint
86
+ returns `PublishedEntryRead`, which has all of it.
84
87
 
85
- So if a transform reads any schema-defined field — anything beyond title/slug/summary/metadata —
86
- `hydrate: true` is required or those fields come back `undefined`. It costs one request per entry.
88
+ So if a transform reads asset roles, resources, or (on an older server, whose list items lack
89
+ `data`) any schema field, `hydrate: true` is required. It costs one request per entry. Items are
90
+ re-read unless they're already full entries (`isFullEntry`: `assets[]`/`resources[]` present, no
91
+ `assetSlugs`) — carrying `data` isn't enough. `bySlug()` serves a detail page from the loaded list
92
+ only when that item came from a full entry; otherwise it fetches the entry.
87
93
 
88
94
  Those requests run at most `hydrateConcurrency` at a time (default 6) rather than all at once, and
89
95
  a read that fails is retried with backoff (3 attempts) before the entry is dropped, so a single
@@ -104,7 +110,8 @@ fall-through a legacy value that *is* set would never surface.
104
110
  | `f.string(key)` `f.number(key)` `f.bool(key)` `f.list(key)` | scalars; `bool` reads `"true"`/`"1"`/`"yes"` |
105
111
  | `f.oneOf(key, allowed, fallback)` | enum guard — replaces per-field `normalizeStatus`-style helpers |
106
112
  | `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 |
113
+ | `f.markdown(key?, { softBreaks, embeds })` | renders to HTML, with [media embeds](#media-embeds); `undefined` when there is nothing to render |
114
+ | `f.embed(key)` | the resolved embed for an `embed` field — render with `<Embed>` |
108
115
  | `f.date(key)` `f.publishedAt()` | display date ("Mon DD, YYYY") / raw ISO stamp |
109
116
  | `f.image(options)` `f.images(options)` `f.icon(options)` | resolved `{src, alt, focalPoint}` |
110
117
  | `f.asset(options)` `f.assetByRole(...roles)` `f.assets()` | raw asset placements |
@@ -143,6 +150,153 @@ The default prefixes the entry's own non-navigation collection: an entry in `wor
143
150
  becomes `/workshop-reference/<slug>`, an entry in no other collection becomes `/<slug>`. Override
144
151
  when routes don't mirror collections.
145
152
 
153
+ ## Media embeds
154
+
155
+ Paste a YouTube, Vimeo, Spotify, SoundCloud, Apple Music/Podcasts, Tidal or podcast link on its
156
+ own line in a markdown field and Marvin resolves it on the server. The published entry then
157
+ carries `embeds`, keyed by the URL exactly as written, each with Marvin-built `html` for the
158
+ site's embed mode and the structured fields behind it. The stored markdown never changes, so a
159
+ site that doesn't render embeds just shows the link.
160
+
161
+ **Markdown.** `f.markdown()` passes the entry's embeds automatically. A paragraph that is exactly
162
+ a URL with an `embeds` entry renders as that embed's `html`; `<url>`, `[text](url)`, a URL inside
163
+ a sentence and anything in code stay as they are. Opt a field out with `f.markdown('body',
164
+ { embeds: false })`. Where you call the renderer yourself, pass the embeds:
165
+
166
+ ```ts
167
+ import { entryEmbeds } from '@inneropen/marvin-astro';
168
+
169
+ const bodyHtml = await marvin.renderMarkdown(body, { embeds: entryEmbeds(entry) });
170
+ ```
171
+
172
+ **`embed` fields.** An `embed` field's value is the provider URL; `f.embed(key)` returns its
173
+ resolved embed. Render it with `<Embed>`, which builds the player, facade or link card from the
174
+ structured fields:
175
+
176
+ ```astro
177
+ ---
178
+ import { Embed } from '@inneropen/marvin-astro/components';
179
+ const site = await marvin.getSite();
180
+ ---
181
+ <Embed
182
+ embed={post.video}
183
+ mode={site.embeds?.mode}
184
+ consentText={site.embeds?.consentText}
185
+ frameSources={site.embeds?.frameSources}
186
+ />
187
+ ```
188
+
189
+ **The loader and CSS.** In `click_to_load` mode (Marvin's default) an embed is a facade — a
190
+ button and a plain link — and nothing loads from the provider until the visitor clicks. Put
191
+ `<EmbedLoader />` once in your layout:
192
+
193
+ ```astro
194
+ ---
195
+ import { EmbedLoader } from '@inneropen/marvin-astro/components';
196
+ ---
197
+ <body>
198
+ <slot />
199
+ <EmbedLoader />
200
+ </body>
201
+ ```
202
+
203
+ On click (or Enter/Space) it swaps the button for the iframe. It builds the iframe only from the
204
+ facade's `data-marvin-embed-attrs`, keeps only `src`, `title`, `allow`, `sandbox`,
205
+ `referrerpolicy`, `loading` and `style`, refuses any `src` that isn't https on a host in
206
+ `data-marvin-embed-hosts`, and never parses HTML. It also brings the base `.marvin-embed` CSS:
207
+ responsive aspect ratio for video, fixed height for audio, facade and link-card styles. The
208
+ rules have zero specificity, so any site rule wins; theme through custom properties:
209
+
210
+ ```css
211
+ .marvin-embed, .marvin-embed-link {
212
+ --marvin-embed-radius: 0;
213
+ --marvin-embed-bg: var(--ink);
214
+ --marvin-embed-fg: var(--paper);
215
+ --marvin-embed-max-width: 48rem;
216
+ }
217
+ ```
218
+
219
+ The full list (`--marvin-embed-aspect`, `-height`, `-margin`, `-muted`, `-border`, `-focus`, …)
220
+ is at the top of `src/components/embeds.css`. For CSS without the loader (a `direct`-mode site),
221
+ `import '@inneropen/marvin-astro/components/embeds.css'`.
222
+
223
+ **CSP.** `site.embeds.frameSources` lists every origin the site's embeds may frame. Use it for a
224
+ `frame-src` directive:
225
+
226
+ ```astro
227
+ ---
228
+ const site = await marvin.getSite();
229
+ const frameSrc = ["'self'", ...(site.embeds?.frameSources ?? [])].join(' ');
230
+ ---
231
+ <meta http-equiv="Content-Security-Policy" content={`frame-src ${frameSrc}`} />
232
+ ```
233
+
234
+ The loader script is small enough that Astro inlines it; if your CSP forbids inline scripts, hash
235
+ it or set `vite.build.assetsInlineLimit: 0` so Astro emits it as a file.
236
+
237
+ The embed types (`MarvinEmbed`, `MarvinSiteEmbeds`, …) are declared in this package, so none of
238
+ this needs a newer SDK.
239
+
240
+ ## Components
241
+
242
+ `@inneropen/marvin-astro/components` ships Astro source, compiled by your site's build.
243
+
244
+ | Component | Use |
245
+ |---|---|
246
+ | `FormRenderer` | a public form from a submittable entry type's `formSchema`; unstyled, progressive-enhancement submit |
247
+ | `Embed`, `LinkCard`, `EmbedLoader` | media embeds (above) |
248
+ | `EntryRenderer` | picks the renderer for an entry from its entry type's `rendering` |
249
+ | `PageRenderer`, `ArticleRenderer`, `FaqRenderer`, `NavigationRenderer` | unstyled, semantic core renderers with `data-renderer`/`data-role` hooks |
250
+
251
+ Page and Article render the markdown body (with embeds); pass `renderMarkdown={marvin.renderMarkdown}`
252
+ to use your site's markdown options.
253
+
254
+ The registry and logic: `getRenderer`, `astroRegistry`, `coreRendererPackage` from
255
+ `@inneropen/marvin-astro/components` (or `/components/registry`), and the framework-agnostic
256
+ helpers — `resolveRendererName`, `resolveRendererConfig`, `extractBody`, `extractField`,
257
+ `getFeaturedAsset`, `createRegistry`, `createPackageRegistry`, `createRendererPackage`,
258
+ `validateRenderers`, … — from `@inneropen/marvin-astro/renderers`.
259
+
260
+ Build-time check that every rendered entry type has a renderer:
261
+
262
+ ```js
263
+ // astro.config.mjs
264
+ import { marvinIntegration } from '@inneropen/marvin-astro/components/integration';
265
+ import { astroRegistry } from '@inneropen/marvin-astro/components/registry';
266
+
267
+ export default defineConfig({
268
+ integrations: [marvinIntegration({ registry: astroRegistry })],
269
+ });
270
+ ```
271
+
272
+ Options: `registry` (required), `apiUrl`, `siteToken` (default to the `MARVIN_*` env vars),
273
+ `strict` (throw instead of warn), `ignore` (entry-type slugs to skip).
274
+
275
+ Marvin's system entry types name their renderer package `@inneropen/marvin-renderers-core`; the
276
+ core renderers still answer to that name (and to `@inneropen/marvin-astro`).
277
+
278
+ ## Migrating from @inneropen/marvin-renderers-core
279
+
280
+ The components and logic moved here with their names unchanged. Swap the import paths, then
281
+ remove `@inneropen/marvin-renderers-core` from `package.json`:
282
+
283
+ | Before (`@inneropen/marvin-renderers-core…`) | After (`@inneropen/marvin-astro…`) |
284
+ |---|---|
285
+ | `/astro` | `/components` |
286
+ | `/astro/registry` | `/components/registry` |
287
+ | `/astro/integration` | `/components/integration` |
288
+ | `/astro/renderers/PageRenderer.astro` (etc.) | `/components/renderers/PageRenderer.astro` |
289
+ | `/logic` | `/renderers` |
290
+
291
+ ```diff
292
+ -import { FormRenderer } from '@inneropen/marvin-renderers-core/astro';
293
+ +import { FormRenderer } from '@inneropen/marvin-astro/components';
294
+ ```
295
+
296
+ Behaviour changes: Page/Article now render the body as markdown instead of injecting it raw, and
297
+ `EntryRenderer` renders core entry types without a custom registry (it silently rendered only
298
+ its slot before).
299
+
146
300
  ## The failure latch
147
301
 
148
302
  A static build asks for content once per path. When the backend is down that means N failed
@@ -162,8 +316,8 @@ marvin.backend.clearLatch();
162
316
 
163
317
  ## SEO head (optional)
164
318
 
165
- One component ships, behind its own export path, so the core package stays pure TypeScript and
166
- Astro stays an optional peer dependency.
319
+ `SeoHead` ships behind its own export path (as do the [components](#components)), so the core
320
+ package stays pure TypeScript and Astro stays an optional peer dependency.
167
321
 
168
322
  ```astro
169
323
  ---
@@ -187,18 +341,23 @@ verification tags. No styling, no site coupling.
187
341
  | `@inneropen/marvin-astro` | `createMarvinContent` and every helper below it |
188
342
  | `@inneropen/marvin-astro/types` | resolved types only (`ApiSite`, `ApiSeo`, `ApiSiteChrome`, …) |
189
343
  | `@inneropen/marvin-astro/astro` | `SeoHead` |
344
+ | `@inneropen/marvin-astro/components` | `FormRenderer`, `Embed`, `LinkCard`, `EmbedLoader`, `EntryRenderer`, the core renderers, registry, `marvinIntegration` |
345
+ | `@inneropen/marvin-astro/components/registry` | `getRenderer`, `astroRegistry`, `coreRendererPackage` |
346
+ | `@inneropen/marvin-astro/components/integration` | `marvinIntegration` (for `astro.config.mjs`) |
347
+ | `@inneropen/marvin-astro/components/*` | individual components, `renderers/*.astro`, `embeds.css` |
348
+ | `@inneropen/marvin-astro/renderers` | renderer logic (compiled): resolve, registry, validation |
190
349
 
191
350
  Beyond `createMarvinContent`, the pieces are usable on their own: `createBackend`,
192
351
  `createFetcher`, `createRepository`, `createSiteLoader`, `createChromeLoader`,
193
- `createFieldAccessor`, `createMarkdownRenderer`, `formatDisplayDate`, `selectValuesForPage`, and
194
- the whole `normalize` surface.
352
+ `createFieldAccessor`, `createMarkdownRenderer`, `entryEmbeds`, `findEmbed`, `siteEmbeds`,
353
+ `formatDisplayDate`, `selectValuesForPage`, and the whole `normalize` surface.
195
354
 
196
355
  ## Development
197
356
 
198
357
  ```bash
199
358
  npm install
200
359
  npm run typecheck
201
- npm test # vitest, fixture-driven, no network
360
+ npm test # vitest, fixture-driven, no network; .astro components via Astro's Container API
202
361
  npm run build # tsup → dist/ with .d.ts
203
362
  ```
204
363
 
@@ -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. */
@@ -139,6 +155,15 @@ declare function createBackend(options?: MarvinAstroConfig): MarvinBackend;
139
155
  /** An entry that carries `data_json` — i.e. schema fields are readable. */
140
156
  type HydratedEntry = MarvinEntry | Entry;
141
157
  type MarvinFetcher = ReturnType<typeof createFetcher>;
158
+ /**
159
+ * True for a full entry (`PublishedEntryRead`), false for a list item (`PublishedEntryListItem`).
160
+ *
161
+ * `data` doesn't tell them apart: list items carry it too now. What differs is attachments. A full
162
+ * read has `assets[]` and `resources[]` (placement objects, always present, possibly empty); a list
163
+ * item has only `assetSlugs` / `resourceSlugs` and a `featuredAsset`. The SDK's `Entry` wrapper
164
+ * only ever comes from the single-entry read.
165
+ */
166
+ declare function isFullEntry(item: unknown): boolean;
142
167
  declare function createFetcher(backend: MarvinBackend): {
143
168
  backend: MarvinBackend;
144
169
  collections(): Promise<PublishedCollectionSummary[]>;
@@ -291,6 +316,11 @@ type IconFieldOptions = {
291
316
  type MarkdownFieldOptions = {
292
317
  /** Keep authored single newlines as hard breaks. Default `false`. */
293
318
  softBreaks?: boolean;
319
+ /**
320
+ * Render a bare provider URL on its own line as its player, using the entry's `embeds`.
321
+ * Default `true`; `false` keeps every URL a plain link.
322
+ */
323
+ embeds?: boolean;
294
324
  };
295
325
  type ResourceFieldOptions = {
296
326
  /** Keep only these resource types, e.g. `['material', 'construction']`. */
@@ -338,6 +368,12 @@ declare function createFieldAccessor(entry: MarvinContentEntry, context: FieldAc
338
368
  * caller can omit the property rather than emit an empty string.
339
369
  */
340
370
  markdown(key?: string, options?: MarkdownFieldOptions): Promise<string | undefined>;
371
+ /**
372
+ * The resolved embed for an `embed` field (its value is the provider URL), or `undefined`
373
+ * when the field is empty or the server hasn't resolved it. Render it with `<Embed>` from
374
+ * `@inneropen/marvin-astro/components`, or output `embed.html`.
375
+ */
376
+ embed(key: string): MarvinEmbed | undefined;
341
377
  /**
342
378
  * The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
343
379
  * the exact `preferRoles`, a role/usage match over the entry's image assets, and finally the
@@ -389,10 +425,11 @@ type RepositoryOptions<T> = {
389
425
  /**
390
426
  * Fetch each list item as a full entry before transforming.
391
427
  *
392
- * The collection endpoint returns `PublishedEntryListItem`, which omits `data_json`; the
393
- * single-entry endpoint returns `PublishedEntryRead`, which includes it. So if the transform
394
- * reads ANY schema-defined field — anything not title/slug/summary/metadata — this must be
395
- * `true` or those fields come back `undefined`. It costs one request per entry.
428
+ * The collection endpoint returns `PublishedEntryListItem`, which has no `assets[]` or
429
+ * `resources[]` (only their slugs) and, on older servers, no `data_json`; the single-entry
430
+ * endpoint returns `PublishedEntryRead`, which has all of it. So if the transform reads asset
431
+ * roles, resources, or (on an older server) any schema field, this must be `true`. It costs one
432
+ * request per entry. `bySlug` always works from full entries either way.
396
433
  */
397
434
  hydrate?: boolean;
398
435
  /** Build the resolved item from an entry. May be async (e.g. to render markdown). */
@@ -585,4 +622,4 @@ declare function createMarvinContent(options?: MarvinContentOptions): {
585
622
  reset(): void;
586
623
  };
587
624
 
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 };
625
+ 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, isFullEntry, isNetworkFailure, loadSectionLanding, metadataLink, preserveSoftBreaks, readEnv, resolveConfig, resourceField, resourceRole, selectAssetByRole, selectEntryAsset, selectFeaturedAsset, selectIconAsset, selectImageAsset, selectValuesForPage, socialLinkFromKey, toNavigationLink };
package/dist/index.js CHANGED
@@ -404,6 +404,13 @@ function createChromeLoader(fetcher, siteLoader, options = {}) {
404
404
  }
405
405
 
406
406
  // src/fetch.ts
407
+ function isFullEntry(item) {
408
+ if (!item || typeof item !== "object") return false;
409
+ if (typeof item.field === "function") return true;
410
+ const raw = item;
411
+ if ("assetSlugs" in raw || "resourceSlugs" in raw) return false;
412
+ return Array.isArray(raw.assets) || Array.isArray(raw.resources);
413
+ }
407
414
  var HYDRATE_ATTEMPTS = 3;
408
415
  var HYDRATE_BACKOFF_MS = 500;
409
416
  function sleep(ms) {
@@ -461,9 +468,7 @@ function createFetcher(backend) {
461
468
  entries,
462
469
  backend.config.hydrateConcurrency,
463
470
  async (item) => {
464
- if (typeof item.field === "function" || "data" in item || "dataJson" in item) {
465
- return item;
466
- }
471
+ if (isFullEntry(item)) return item;
467
472
  return entryForHydration(item.slug ?? "");
468
473
  }
469
474
  );
@@ -527,13 +532,79 @@ function createFetcher(backend) {
527
532
 
528
533
  // src/markdown.ts
529
534
  import { Marked } from "marked";
535
+
536
+ // src/embeds.ts
537
+ var EMBED_MODES = /* @__PURE__ */ new Set(["direct", "click_to_load"]);
538
+ function readEmbedsField(entry) {
539
+ if (!entry || typeof entry !== "object") return void 0;
540
+ const direct = entry.embeds;
541
+ if (direct !== void 0) return direct;
542
+ const toJSON = entry.toJSON;
543
+ if (typeof toJSON === "function") {
544
+ return asRecord(toJSON.call(entry)).embeds;
545
+ }
546
+ return void 0;
547
+ }
548
+ function entryEmbeds(entry) {
549
+ const raw = readEmbedsField(entry);
550
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
551
+ const embeds = {};
552
+ for (const [key, value] of Object.entries(raw)) {
553
+ if (value && typeof value === "object" && !Array.isArray(value)) {
554
+ embeds[key] = value;
555
+ }
556
+ }
557
+ return Object.keys(embeds).length > 0 ? embeds : void 0;
558
+ }
559
+ function findEmbed(embeds, url) {
560
+ if (!embeds || !url) return void 0;
561
+ const key = url.trim();
562
+ if (!key) return void 0;
563
+ return Object.prototype.hasOwnProperty.call(embeds, key) ? embeds[key] : void 0;
564
+ }
565
+ function siteEmbeds(raw) {
566
+ if (!raw || typeof raw !== "object") return void 0;
567
+ const value = asRecord(raw);
568
+ const mode = asString(value.mode);
569
+ const frameSources = Array.isArray(value.frameSources) ? value.frameSources.filter(
570
+ (source) => typeof source === "string" && source.trim().length > 0
571
+ ) : [];
572
+ return {
573
+ // Unknown or missing mode → the privacy-preserving default.
574
+ mode: mode && EMBED_MODES.has(mode) ? mode : "click_to_load",
575
+ consentText: asString(value.consentText) ?? "",
576
+ frameSources
577
+ };
578
+ }
579
+
580
+ // src/markdown.ts
581
+ var EMBEDS = /* @__PURE__ */ Symbol("marvin.embeds");
582
+ var BARE_URL = /^https?:\/\/\S+$/i;
583
+ function paragraphEmbedHtml(token, embeds) {
584
+ if (!embeds) return void 0;
585
+ const text = token.text.trim();
586
+ if (!BARE_URL.test(text)) return void 0;
587
+ const html = findEmbed(embeds, text)?.html;
588
+ return typeof html === "string" && html.trim() ? html : void 0;
589
+ }
530
590
  function createMarkdownRenderer(options = {}) {
531
591
  const marked = new Marked();
532
592
  marked.setOptions({ gfm: true, breaks: false, ...options });
533
- return async (markdown) => {
593
+ marked.use({
594
+ renderer: {
595
+ paragraph(token) {
596
+ const embeds = this.options[EMBEDS];
597
+ const html = paragraphEmbedHtml(token, embeds);
598
+ return html === void 0 ? false : `${html}
599
+ `;
600
+ }
601
+ }
602
+ });
603
+ return async (markdown, renderOptions = {}) => {
534
604
  const source = Array.isArray(markdown) ? markdown.join("\n\n") : markdown ?? "";
535
605
  if (!source) return "";
536
- return await marked.parse(source);
606
+ const parseOptions = { [EMBEDS]: renderOptions.embeds ?? null };
607
+ return await marked.parse(source, parseOptions);
537
608
  };
538
609
  }
539
610
  function preserveSoftBreaks(source) {
@@ -585,6 +656,7 @@ function selectValuesForPage(items, pageSlug, count = 4) {
585
656
  var DEFAULT_IMAGE_ROLES = ["hero", "featured", "card"];
586
657
  function createFieldAccessor(entry, context) {
587
658
  const title = asString(entry.title);
659
+ const embeds = entryEmbeds(entry);
588
660
  const accessor = {
589
661
  /** The entry itself, for anything the accessor doesn't cover. */
590
662
  entry,
@@ -647,7 +719,17 @@ function createFieldAccessor(entry, context) {
647
719
  const raw = field(entry, key) ?? entry.contentMarkdown;
648
720
  const source = Array.isArray(raw) ? raw.join("\n\n") : asString(raw);
649
721
  if (!source) return void 0;
650
- return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source);
722
+ return context.renderMarkdown(options.softBreaks ? preserveSoftBreaks(source) : source, {
723
+ embeds: options.embeds === false ? void 0 : embeds
724
+ });
725
+ },
726
+ /**
727
+ * The resolved embed for an `embed` field (its value is the provider URL), or `undefined`
728
+ * when the field is empty or the server hasn't resolved it. Render it with `<Embed>` from
729
+ * `@inneropen/marvin-astro/components`, or output `embed.html`.
730
+ */
731
+ embed(key) {
732
+ return findEmbed(embeds, asString(field(entry, key)));
651
733
  },
652
734
  /**
653
735
  * The entry's primary image. Checks, in order: a hand-authored `metadata_json.featuredImage`,
@@ -776,6 +858,7 @@ function createRepository(context, options) {
776
858
  const collections = options.collections ?? (options.collection ? [options.collection] : []);
777
859
  const slugOf = options.slugOf ?? defaultSlugOf;
778
860
  const isFeatured = options.isFeatured ?? defaultIsFeatured;
861
+ let loadedPromise = null;
779
862
  let allPromise = null;
780
863
  const bySlugCache = /* @__PURE__ */ new Map();
781
864
  function arrange(items) {
@@ -792,7 +875,7 @@ function createRepository(context, options) {
792
875
  return Promise.resolve(options.transform(entry, fields));
793
876
  }
794
877
  async function useFallback() {
795
- return arrange(await options.fallback?.() ?? []);
878
+ return { items: arrange(await options.fallback?.() ?? []), fromFullEntry: /* @__PURE__ */ new Set() };
796
879
  }
797
880
  async function load() {
798
881
  if (collections.length > 0 && fetcher.backend.hasBackend()) {
@@ -800,7 +883,12 @@ function createRepository(context, options) {
800
883
  const entries = options.hydrate ? await fetcher.hydratedCollectionEntriesFallback(collections) : await fetcher.collectionEntriesFallback(collections);
801
884
  if (entries.length > 0) {
802
885
  const items = await Promise.all(entries.map((entry) => transform(entry)));
803
- return arrange(items);
886
+ const fromFullEntry = /* @__PURE__ */ new Set();
887
+ items.forEach((item, index) => {
888
+ const slug = slugOf(item);
889
+ if (slug !== void 0 && isFullEntry(entries[index])) fromFullEntry.add(slug);
890
+ });
891
+ return { items: arrange(items), fromFullEntry };
804
892
  }
805
893
  } catch (error) {
806
894
  fetcher.backend.remember(error);
@@ -815,9 +903,11 @@ function createRepository(context, options) {
815
903
  return items.find((item) => slugOf(item) === slug);
816
904
  }
817
905
  async function loadBySlug(slug) {
818
- if (allPromise) {
819
- const listed = findInList(await allPromise, slug);
820
- if (listed) return listed;
906
+ let listed;
907
+ if (loadedPromise) {
908
+ const loaded = await loadedPromise;
909
+ listed = findInList(loaded.items, slug);
910
+ if (listed && loaded.fromFullEntry.has(slug)) return listed;
821
911
  }
822
912
  if (fetcher.backend.hasBackend()) {
823
913
  try {
@@ -828,10 +918,13 @@ function createRepository(context, options) {
828
918
  fetcher.backend.warn(`Entry "${slug}" failed, using fallback: ${errorMessage(error)}`);
829
919
  }
830
920
  }
831
- return findInList(await all(), slug);
921
+ return listed ?? findInList(await all(), slug);
832
922
  }
833
923
  function all() {
834
- allPromise ??= load();
924
+ if (!allPromise) {
925
+ loadedPromise = load();
926
+ allPromise = loadedPromise.then((loaded) => loaded.items);
927
+ }
835
928
  return allPromise;
836
929
  }
837
930
  return {
@@ -853,6 +946,7 @@ function createRepository(context, options) {
853
946
  },
854
947
  reset() {
855
948
  allPromise = null;
949
+ loadedPromise = null;
856
950
  bySlugCache.clear();
857
951
  }
858
952
  };
@@ -865,7 +959,7 @@ async function loadSectionLanding(fetcher, slug, renderMarkdown = createMarkdown
865
959
  const source = asString(entryField(entry, options.bodyField ?? "body")) ?? asString(entry.summary);
866
960
  return {
867
961
  title: asString(entry.title),
868
- introHtml: source ? await renderMarkdown(preserveSoftBreaks(source)) : void 0,
962
+ introHtml: source ? await renderMarkdown(preserveSoftBreaks(source), { embeds: entryEmbeds(entry) }) : void 0,
869
963
  hero: assetUrl(selectAssetByRole(entry, options.heroRole ?? "hero"))
870
964
  };
871
965
  }
@@ -952,6 +1046,10 @@ function transformMarvinSite(marvinSite, fallback) {
952
1046
  canonicalUrl: asString(config.canonicalUrl),
953
1047
  siteName: title
954
1048
  }),
1049
+ // Older servers and SDKs don't send it; `siteEmbeds` returns undefined then.
1050
+ embeds: siteEmbeds(
1051
+ config.embeds ?? marvinSite.embeds
1052
+ ),
955
1053
  metadata
956
1054
  };
957
1055
  }
@@ -1083,14 +1181,17 @@ export {
1083
1181
  describeConfig,
1084
1182
  entryAssets,
1085
1183
  entryData,
1184
+ entryEmbeds,
1086
1185
  entryField,
1087
1186
  entryMetadata,
1088
1187
  entryResources,
1089
1188
  errorMessage,
1090
1189
  field,
1190
+ findEmbed,
1091
1191
  focalPoint,
1092
1192
  formatDisplayDate,
1093
1193
  isExternalHref,
1194
+ isFullEntry,
1094
1195
  isNetworkFailure,
1095
1196
  loadSectionLanding,
1096
1197
  metadataLink,
@@ -1105,6 +1206,7 @@ export {
1105
1206
  selectIconAsset,
1106
1207
  selectImageAsset,
1107
1208
  selectValuesForPage,
1209
+ siteEmbeds,
1108
1210
  socialLinkFromKey,
1109
1211
  toNavigationLink
1110
1212
  };