@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.
- package/README.md +164 -9
- package/dist/embeds-BTNryQA6.d.ts +79 -0
- package/dist/index.d.ts +29 -2
- package/dist/index.js +88 -4
- package/dist/index.js.map +1 -1
- package/dist/renderers/index.d.ts +119 -0
- package/dist/renderers/index.js +251 -0
- package/dist/renderers/index.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/package.json +16 -6
- package/src/chrome.ts +242 -0
- package/src/client.ts +119 -0
- package/src/components/Embed.astro +119 -0
- package/src/components/EmbedLoader.astro +24 -0
- package/src/components/EntryRenderer.astro +40 -0
- package/src/components/FormRenderer.astro +188 -0
- package/src/components/LinkCard.astro +42 -0
- package/src/components/embed-frame.ts +168 -0
- package/src/components/embeds.css +137 -0
- package/src/components/index.ts +29 -0
- package/src/components/integration.ts +62 -0
- package/src/components/markdown.ts +4 -0
- package/src/components/registry.ts +34 -0
- package/src/components/renderers/ArticleRenderer.astro +56 -0
- package/src/components/renderers/FaqRenderer.astro +26 -0
- package/src/components/renderers/NavigationRenderer.astro +27 -0
- package/src/components/renderers/PageRenderer.astro +51 -0
- package/src/config.ts +149 -0
- package/src/embeds.ts +137 -0
- package/src/env.d.ts +5 -0
- package/src/fetch.ts +198 -0
- package/src/fields.ts +330 -0
- package/src/format.ts +63 -0
- package/src/index.ts +169 -0
- package/src/markdown.ts +84 -0
- package/src/normalize.ts +284 -0
- package/src/renderers/debug.ts +6 -0
- package/src/renderers/index.ts +48 -0
- package/src/renderers/registry.ts +133 -0
- package/src/renderers/resolve.ts +134 -0
- package/src/renderers/types.ts +71 -0
- package/src/renderers/validation-types.ts +23 -0
- package/src/renderers/validation.ts +55 -0
- package/src/repository.ts +190 -0
- package/src/sections.ts +39 -0
- package/src/site.ts +222 -0
- package/src/types.ts +188 -0
package/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
|
-
|
|
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
|
-
|
|
|
12
|
-
|
|
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
|
-
|
|
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`, `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|