@inneropen/marvin-astro 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -9
- package/dist/embeds-BTNryQA6.d.ts +79 -0
- package/dist/index.d.ts +42 -3
- package/dist/index.js +143 -9
- package/dist/index.js.map +1 -1
- package/dist/renderers/index.d.ts +119 -0
- package/dist/renderers/index.js +251 -0
- package/dist/renderers/index.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/package.json +16 -6
- package/src/chrome.ts +242 -0
- package/src/client.ts +119 -0
- package/src/components/Embed.astro +119 -0
- package/src/components/EmbedLoader.astro +24 -0
- package/src/components/EntryRenderer.astro +40 -0
- package/src/components/FormRenderer.astro +188 -0
- package/src/components/LinkCard.astro +42 -0
- package/src/components/embed-frame.ts +168 -0
- package/src/components/embeds.css +137 -0
- package/src/components/index.ts +29 -0
- package/src/components/integration.ts +62 -0
- package/src/components/markdown.ts +4 -0
- package/src/components/registry.ts +34 -0
- package/src/components/renderers/ArticleRenderer.astro +56 -0
- package/src/components/renderers/FaqRenderer.astro +26 -0
- package/src/components/renderers/NavigationRenderer.astro +27 -0
- package/src/components/renderers/PageRenderer.astro +51 -0
- package/src/config.ts +149 -0
- package/src/embeds.ts +137 -0
- package/src/env.d.ts +5 -0
- package/src/fetch.ts +198 -0
- package/src/fields.ts +330 -0
- package/src/format.ts +63 -0
- package/src/index.ts +169 -0
- package/src/markdown.ts +84 -0
- package/src/normalize.ts +284 -0
- package/src/renderers/debug.ts +6 -0
- package/src/renderers/index.ts +48 -0
- package/src/renderers/registry.ts +133 -0
- package/src/renderers/resolve.ts +134 -0
- package/src/renderers/types.ts +71 -0
- package/src/renderers/validation-types.ts +23 -0
- package/src/renderers/validation.ts +55 -0
- package/src/repository.ts +190 -0
- package/src/sections.ts +39 -0
- package/src/site.ts +222 -0
- package/src/types.ts +188 -0
package/src/chrome.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site chrome: main nav, footer columns, legal strip, social links, inquiry link.
|
|
3
|
+
*
|
|
4
|
+
* All of it comes from ordinary Marvin collections, so a workspace can reorder its own
|
|
5
|
+
* navigation without a deploy. Every piece has a static fallback for when the backend is down.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { MarvinFetcher, HydratedEntry } from './fetch.js';
|
|
9
|
+
import {
|
|
10
|
+
asRecord,
|
|
11
|
+
asString,
|
|
12
|
+
collectionRole,
|
|
13
|
+
collectionSlugs,
|
|
14
|
+
entryField,
|
|
15
|
+
isExternalHref,
|
|
16
|
+
} from './normalize.js';
|
|
17
|
+
import type { SiteLoader } from './site.js';
|
|
18
|
+
import type { ApiNavigationLink, ApiSiteChrome, ApiSocialLink, MarvinContentEntry } from './types.js';
|
|
19
|
+
|
|
20
|
+
export type NavigationContext = 'main' | 'footer';
|
|
21
|
+
|
|
22
|
+
export type HrefContext = {
|
|
23
|
+
/** Where this link is being rendered. */
|
|
24
|
+
context: NavigationContext;
|
|
25
|
+
/** The nav collection the entry was read from. */
|
|
26
|
+
collectionSlug: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
/** Every collection the entry belongs to, nav collections included. */
|
|
29
|
+
collections: string[];
|
|
30
|
+
/** The collection slugs treated as navigation, i.e. not route prefixes. */
|
|
31
|
+
navCollections: Set<string>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ResolveHref = (entry: MarvinContentEntry, context: HrefContext) => string;
|
|
35
|
+
|
|
36
|
+
/** A link authored in site code rather than in the CMS. */
|
|
37
|
+
export type NavigationLinkInput = {
|
|
38
|
+
label: string;
|
|
39
|
+
href: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
role?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SocialLinkInput = NavigationLinkInput & { icon: string };
|
|
45
|
+
|
|
46
|
+
export type ChromeOptions = {
|
|
47
|
+
/** Collection holding the main nav. Default `'main-navigation'`. */
|
|
48
|
+
mainCollection?: string;
|
|
49
|
+
/** Collection holding the footer nav. Default `'footer-navigation'`. */
|
|
50
|
+
footerCollection?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Route for a nav entry that carries no explicit `href`/`url`/`path` field.
|
|
53
|
+
*
|
|
54
|
+
* The default prefixes the entry's own (non-navigation) collection: an entry in
|
|
55
|
+
* `workshop-reference` becomes `/workshop-reference/<slug>`, and an entry in no other
|
|
56
|
+
* collection becomes `/<slug>`. Override when routes don't mirror collections.
|
|
57
|
+
*/
|
|
58
|
+
resolveHref?: ResolveHref;
|
|
59
|
+
/** Membership role that marks a footer link as legal (Terms, Privacy…). Default `'legal'`. */
|
|
60
|
+
legalRole?: string;
|
|
61
|
+
/** Footer links per column once split. Columns are only created above this count. */
|
|
62
|
+
footerColumnThreshold?: number;
|
|
63
|
+
fallback?: {
|
|
64
|
+
mainNavigation?: NavigationLinkInput[];
|
|
65
|
+
footerNavigation?: NavigationLinkInput[][];
|
|
66
|
+
legalLinks?: NavigationLinkInput[];
|
|
67
|
+
socialLinks?: SocialLinkInput[];
|
|
68
|
+
inquiry?: NavigationLinkInput;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function toNavigationLink(link: NavigationLinkInput): ApiNavigationLink {
|
|
73
|
+
return {
|
|
74
|
+
label: link.label,
|
|
75
|
+
href: link.href,
|
|
76
|
+
description: link.description,
|
|
77
|
+
external: isExternalHref(link.href),
|
|
78
|
+
role: link.role,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Collection-prefixed routing: `/<owning-collection>/<slug>`, else `/<slug>`. */
|
|
83
|
+
export const defaultResolveHref: ResolveHref = (_entry, context) => {
|
|
84
|
+
const owning = context.collections.find((slug) => !context.navCollections.has(slug));
|
|
85
|
+
return owning ? `/${owning}/${context.slug}` : `/${context.slug}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A `{label, href}` object stored loose in site metadata (e.g. `metadata.inquiry`). */
|
|
89
|
+
export function metadataLink(value: unknown): ApiNavigationLink | undefined {
|
|
90
|
+
const link = asRecord(value);
|
|
91
|
+
const label = asString(link.label);
|
|
92
|
+
const href = asString(link.href);
|
|
93
|
+
if (!label || !href) return undefined;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
label,
|
|
97
|
+
href,
|
|
98
|
+
description: asString(link.description) ?? asString(link.subject),
|
|
99
|
+
external: isExternalHref(href),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Title-case a social key: `x_handle` → `X Handle`, `email` → `Email`. */
|
|
104
|
+
function socialLabel(key: string): string {
|
|
105
|
+
return key.replace(/[-_]/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function socialLinkFromKey(key: string, href: string): ApiSocialLink {
|
|
109
|
+
return {
|
|
110
|
+
label: socialLabel(key),
|
|
111
|
+
href,
|
|
112
|
+
icon: key,
|
|
113
|
+
external: isExternalHref(href),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ChromeLoader = {
|
|
118
|
+
get(): Promise<ApiSiteChrome>;
|
|
119
|
+
reset(): void;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function createChromeLoader(
|
|
123
|
+
fetcher: MarvinFetcher,
|
|
124
|
+
siteLoader: SiteLoader,
|
|
125
|
+
options: ChromeOptions = {}
|
|
126
|
+
): ChromeLoader {
|
|
127
|
+
const mainCollection = options.mainCollection ?? 'main-navigation';
|
|
128
|
+
const footerCollection = options.footerCollection ?? 'footer-navigation';
|
|
129
|
+
const legalRole = options.legalRole ?? 'legal';
|
|
130
|
+
const footerColumnThreshold = options.footerColumnThreshold ?? 4;
|
|
131
|
+
const resolveHref = options.resolveHref ?? defaultResolveHref;
|
|
132
|
+
const navCollections = new Set([mainCollection, footerCollection]);
|
|
133
|
+
const fallback = options.fallback ?? {};
|
|
134
|
+
|
|
135
|
+
let promise: Promise<ApiSiteChrome> | null = null;
|
|
136
|
+
|
|
137
|
+
function entryToLink(
|
|
138
|
+
entry: HydratedEntry,
|
|
139
|
+
collectionSlug: string,
|
|
140
|
+
context: NavigationContext
|
|
141
|
+
): ApiNavigationLink {
|
|
142
|
+
const slug = entry.slug ?? '';
|
|
143
|
+
const href =
|
|
144
|
+
asString(entryField(entry, 'href')) ??
|
|
145
|
+
asString(entryField(entry, 'url')) ??
|
|
146
|
+
asString(entryField(entry, 'path')) ??
|
|
147
|
+
resolveHref(entry, {
|
|
148
|
+
context,
|
|
149
|
+
collectionSlug,
|
|
150
|
+
slug,
|
|
151
|
+
collections: collectionSlugs(entry),
|
|
152
|
+
navCollections,
|
|
153
|
+
});
|
|
154
|
+
const label =
|
|
155
|
+
asString(entryField(entry, 'label')) ??
|
|
156
|
+
asString(asRecord((entry as { metadataJson?: unknown }).metadataJson).label) ??
|
|
157
|
+
asString(entry.title) ??
|
|
158
|
+
href;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
label,
|
|
162
|
+
href,
|
|
163
|
+
description:
|
|
164
|
+
asString(entry.summary) ?? asString((entry as { description?: unknown }).description),
|
|
165
|
+
external: isExternalHref(href),
|
|
166
|
+
role: collectionRole(entry, collectionSlug),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function navigationCollection(
|
|
171
|
+
slug: string,
|
|
172
|
+
context: NavigationContext,
|
|
173
|
+
staticLinks: ApiNavigationLink[]
|
|
174
|
+
): Promise<{ links: ApiNavigationLink[]; fromBackend: boolean }> {
|
|
175
|
+
if (!fetcher.backend.hasBackend()) return { links: staticLinks, fromBackend: false };
|
|
176
|
+
|
|
177
|
+
// Hydrated: a nav entry's `href`/`label` overrides are schema fields, absent from list items.
|
|
178
|
+
const entries = await fetcher.hydratedCollectionEntries(slug);
|
|
179
|
+
if (entries.length === 0) return { links: staticLinks, fromBackend: false };
|
|
180
|
+
|
|
181
|
+
return { links: entries.map((entry) => entryToLink(entry, slug, context)), fromBackend: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function groupFooterLinks(links: ApiNavigationLink[]): ApiNavigationLink[][] {
|
|
185
|
+
if (links.length <= footerColumnThreshold) return [links];
|
|
186
|
+
|
|
187
|
+
const midpoint = Math.ceil(links.length / 2);
|
|
188
|
+
return [links.slice(0, midpoint), links.slice(midpoint)];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function load(): Promise<ApiSiteChrome> {
|
|
192
|
+
const site = await siteLoader.get();
|
|
193
|
+
const staticMain = (fallback.mainNavigation ?? []).map(toNavigationLink);
|
|
194
|
+
const staticFooter = (fallback.footerNavigation ?? []).map((group) =>
|
|
195
|
+
group.map(toNavigationLink)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const main = await navigationCollection(mainCollection, 'main', staticMain);
|
|
199
|
+
const footer = await navigationCollection(footerCollection, 'footer', staticFooter.flat());
|
|
200
|
+
|
|
201
|
+
const socialLinks = Object.entries(site.social).map(([key, href]) =>
|
|
202
|
+
socialLinkFromKey(key, href)
|
|
203
|
+
);
|
|
204
|
+
if (site.email && !socialLinks.some((link) => link.icon === 'email')) {
|
|
205
|
+
socialLinks.push(socialLinkFromKey('email', `mailto:${site.email}`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Split footer links by membership role: legal → the legal strip; everything else → columns.
|
|
209
|
+
const legalLinks = footer.fromBackend
|
|
210
|
+
? footer.links.filter((link) => link.role === legalRole)
|
|
211
|
+
: [];
|
|
212
|
+
const columnLinks = footer.links.filter((link) => link.role !== legalRole);
|
|
213
|
+
const staticLegal = (fallback.legalLinks ?? []).map(toNavigationLink);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
site,
|
|
217
|
+
mainNavigation: main.links,
|
|
218
|
+
footerNavigation: footer.fromBackend ? groupFooterLinks(columnLinks) : staticFooter,
|
|
219
|
+
legalLinks: legalLinks.length > 0 ? legalLinks : staticLegal,
|
|
220
|
+
socialLinks:
|
|
221
|
+
socialLinks.length > 0
|
|
222
|
+
? socialLinks
|
|
223
|
+
: (fallback.socialLinks ?? []).map((link) => ({
|
|
224
|
+
...toNavigationLink(link),
|
|
225
|
+
icon: link.icon,
|
|
226
|
+
})),
|
|
227
|
+
inquiry:
|
|
228
|
+
metadataLink(site.metadata.inquiry) ??
|
|
229
|
+
(fallback.inquiry ? toNavigationLink(fallback.inquiry) : undefined),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
get() {
|
|
235
|
+
promise ??= load();
|
|
236
|
+
return promise;
|
|
237
|
+
},
|
|
238
|
+
reset() {
|
|
239
|
+
promise = null;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK client construction and the network-failure latch.
|
|
3
|
+
*
|
|
4
|
+
* The latch exists because a static build asks for content once per path. When the backend is
|
|
5
|
+
* down, that is N failed requests with N timeouts before the build gives up. The latch trips on
|
|
6
|
+
* the first network failure and short-circuits the rest.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the hand-rolled original it is *retryable*: it expires after `retryAfterMs` so a dev
|
|
9
|
+
* server recovers on its own when the backend comes back, instead of serving stale static data
|
|
10
|
+
* until someone restarts it. Production keeps the permanent latch — a build should fail fast and
|
|
11
|
+
* consistently rather than half-succeed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createMarvinClient } from '@inneropen/marvin-sdk';
|
|
15
|
+
import type { MarvinClient } from '@inneropen/marvin-sdk';
|
|
16
|
+
import {
|
|
17
|
+
describeConfig,
|
|
18
|
+
resolveConfig,
|
|
19
|
+
type MarvinAstroConfig,
|
|
20
|
+
type ResolvedConfig,
|
|
21
|
+
} from './config.js';
|
|
22
|
+
|
|
23
|
+
export function errorMessage(error: unknown): string {
|
|
24
|
+
if (error instanceof Error) return error.message;
|
|
25
|
+
return String(error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A network failure means "the backend is unreachable", not "this request was bad". Only the
|
|
30
|
+
* former should trip the latch — a 404 on one entry says nothing about the next one.
|
|
31
|
+
*/
|
|
32
|
+
export function isNetworkFailure(error: unknown): boolean {
|
|
33
|
+
const message = errorMessage(error).toLowerCase();
|
|
34
|
+
return (
|
|
35
|
+
message.includes('network error') ||
|
|
36
|
+
message.includes('fetch failed') ||
|
|
37
|
+
message.includes('econnrefused') ||
|
|
38
|
+
message.includes('enotfound') ||
|
|
39
|
+
message.includes('etimedout')
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MarvinBackend = {
|
|
44
|
+
readonly config: ResolvedConfig;
|
|
45
|
+
/** True when Marvin is configured AND not currently latched out. */
|
|
46
|
+
hasBackend(): boolean;
|
|
47
|
+
/** The SDK client. Throws when Marvin is not configured. */
|
|
48
|
+
client(): MarvinClient;
|
|
49
|
+
/** Trip the latch if this error indicates the backend is unreachable. */
|
|
50
|
+
remember(error: unknown): void;
|
|
51
|
+
/** True while the latch is closed (backend considered unreachable). */
|
|
52
|
+
isLatched(): boolean;
|
|
53
|
+
/** Manually re-open the latch, e.g. after a deploy. */
|
|
54
|
+
clearLatch(): void;
|
|
55
|
+
warn(message: string): void;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createBackend(options: MarvinAstroConfig = {}): MarvinBackend {
|
|
59
|
+
const config = resolveConfig(options);
|
|
60
|
+
let client: MarvinClient | null = null;
|
|
61
|
+
let latchedAt: number | null = null;
|
|
62
|
+
let envLogged = false;
|
|
63
|
+
|
|
64
|
+
function isLatched(): boolean {
|
|
65
|
+
if (latchedAt === null) return false;
|
|
66
|
+
if (config.retryAfterMs === Number.POSITIVE_INFINITY) return true;
|
|
67
|
+
if (config.now() - latchedAt < config.retryAfterMs) return true;
|
|
68
|
+
|
|
69
|
+
// Expired — re-open and let the next call find out whether the backend is back.
|
|
70
|
+
latchedAt = null;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasBackend(): boolean {
|
|
75
|
+
if (config.debug && !envLogged) {
|
|
76
|
+
envLogged = true;
|
|
77
|
+
config.logger.log('[marvin-astro] env check:', describeConfig(config));
|
|
78
|
+
}
|
|
79
|
+
if (!config.configured) return false;
|
|
80
|
+
return !isLatched();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
config,
|
|
85
|
+
hasBackend,
|
|
86
|
+
isLatched,
|
|
87
|
+
clearLatch() {
|
|
88
|
+
latchedAt = null;
|
|
89
|
+
},
|
|
90
|
+
remember(error: unknown) {
|
|
91
|
+
if (isNetworkFailure(error)) latchedAt = config.now();
|
|
92
|
+
},
|
|
93
|
+
warn(message: string) {
|
|
94
|
+
config.logger.warn(`[marvin-astro] ${message}`);
|
|
95
|
+
},
|
|
96
|
+
client(): MarvinClient {
|
|
97
|
+
if (!config.configured) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'[marvin-astro] Marvin is not configured. Set MARVIN_API_URL, ' +
|
|
100
|
+
'MARVIN_SITE_CLIENT_TOKEN and MARVIN_WORKSPACE_SLUG, or pass them to createMarvinContent().'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
client ??= (config.createClient
|
|
104
|
+
? config.createClient({
|
|
105
|
+
apiUrl: config.apiUrl,
|
|
106
|
+
siteClientToken: config.siteClientToken,
|
|
107
|
+
workspaceSlug: config.workspaceSlug,
|
|
108
|
+
debug: config.debug,
|
|
109
|
+
})
|
|
110
|
+
: createMarvinClient({
|
|
111
|
+
apiUrl: config.apiUrl,
|
|
112
|
+
siteClientToken: config.siteClientToken,
|
|
113
|
+
workspaceSlug: config.workspaceSlug,
|
|
114
|
+
debug: config.debug,
|
|
115
|
+
})) as MarvinClient;
|
|
116
|
+
return client;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Embed — render a Marvin `PublishedEmbed` from its structured fields.
|
|
4
|
+
*
|
|
5
|
+
* - `status: 'ok'` with an allowed iframe → the player (`mode="direct"`) or a click-to-load facade
|
|
6
|
+
* (`mode="click_to_load"`, the default: nothing loads from the provider until the visitor clicks).
|
|
7
|
+
* - anything else → a plain link card.
|
|
8
|
+
*
|
|
9
|
+
* The markup matches the `html` Marvin publishes for the same embed and mode, so either renders
|
|
10
|
+
* the same with the same CSS. Use it for `embed` fields (`f.embed('video')`); markdown bodies
|
|
11
|
+
* already carry Marvin's `html`. Facades need `<EmbedLoader />` once in the layout.
|
|
12
|
+
*/
|
|
13
|
+
import type { MarvinEmbed, MarvinEmbedMode } from '../embeds.js';
|
|
14
|
+
import { frameHostPattern, iframeAttributes, isAllowedFrameSrc, safeLinkHref } from './embed-frame.js';
|
|
15
|
+
import LinkCard from './LinkCard.astro';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
embed: MarvinEmbed | null | undefined;
|
|
19
|
+
/** `site.embeds.mode`. Default `click_to_load`. */
|
|
20
|
+
mode?: MarvinEmbedMode;
|
|
21
|
+
/** `site.embeds.consentText`, shown on the facade. `{provider}` becomes the provider name. */
|
|
22
|
+
consentText?: string | null;
|
|
23
|
+
/** `site.embeds.frameSources`. When given, an iframe outside them renders as a link card. */
|
|
24
|
+
frameSources?: string[] | null;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Marvin's default, for a server that doesn't send `site.embeds`. */
|
|
29
|
+
const DEFAULT_CONSENT_TEXT = 'Loading this player connects to {provider}, which may set cookies.';
|
|
30
|
+
|
|
31
|
+
const { embed, mode = 'click_to_load', consentText, frameSources, class: className } = Astro.props;
|
|
32
|
+
|
|
33
|
+
const iframe = embed?.status === 'ok' ? (embed.iframe ?? undefined) : undefined;
|
|
34
|
+
const providerName = embed?.providerName || embed?.link?.providerName || 'the provider';
|
|
35
|
+
|
|
36
|
+
// A facade may load only the iframe's own host — the narrowest list, like Marvin's own facades.
|
|
37
|
+
const ownHost = iframe ? hostOf(iframe.src) : undefined;
|
|
38
|
+
const hosts = ownHost ? [ownHost] : [];
|
|
39
|
+
const withinFrameSources = !frameSources?.length || isAllowedFrameSrc(iframe?.src, frameSources);
|
|
40
|
+
const attributes =
|
|
41
|
+
iframe && withinFrameSources
|
|
42
|
+
? iframeAttributes(
|
|
43
|
+
{
|
|
44
|
+
src: iframe.src,
|
|
45
|
+
title: iframe.title || embed?.title || `${providerName} player`,
|
|
46
|
+
allow: iframe.allow,
|
|
47
|
+
sandbox: iframe.sandbox,
|
|
48
|
+
referrerpolicy: iframe.referrerpolicy || 'strict-origin-when-cross-origin',
|
|
49
|
+
loading: 'lazy',
|
|
50
|
+
},
|
|
51
|
+
hosts
|
|
52
|
+
)
|
|
53
|
+
: undefined;
|
|
54
|
+
|
|
55
|
+
function hostOf(src: unknown): string | undefined {
|
|
56
|
+
if (typeof src !== 'string') return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return frameHostPattern(new URL(src).hostname);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Size travels as a CSS custom property on the figure: a fixed height (audio, podcast, playlist)
|
|
65
|
+
// or an aspect ratio (video; 9/16 for Shorts). Validated, never passed through raw.
|
|
66
|
+
function sizeStyle(): string | undefined {
|
|
67
|
+
const height = iframe?.height;
|
|
68
|
+
if (typeof height === 'number' && Number.isInteger(height) && height > 0 && height < 5000) {
|
|
69
|
+
return `--marvin-embed-height:${height}px`;
|
|
70
|
+
}
|
|
71
|
+
const aspect = iframe?.aspectRatio;
|
|
72
|
+
if (typeof aspect === 'string' && /^\d{1,3}\/\d{1,3}$/.test(aspect)) {
|
|
73
|
+
return `--marvin-embed-aspect:${aspect}`;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const kind = embed ? String(embed.kind).replace(/[^a-z0-9-]/gi, '') || 'video' : 'video';
|
|
79
|
+
const linkTitle = embed?.link?.title || embed?.title || providerName;
|
|
80
|
+
const label = embed?.title || linkTitle;
|
|
81
|
+
const consent = (consentText || DEFAULT_CONSENT_TEXT).replaceAll('{provider}', providerName);
|
|
82
|
+
const linkHref = safeLinkHref(embed?.link?.href ?? embed?.canonicalUrl ?? embed?.url);
|
|
83
|
+
const classes = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(' ');
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
{embed && attributes && mode === 'direct' && (
|
|
87
|
+
<figure
|
|
88
|
+
class={classes('marvin-embed', `marvin-embed--${kind}`, className)}
|
|
89
|
+
data-provider={embed.provider}
|
|
90
|
+
style={sizeStyle()}
|
|
91
|
+
>
|
|
92
|
+
<iframe {...attributes} />
|
|
93
|
+
</figure>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{embed && attributes && mode !== 'direct' && (
|
|
97
|
+
<figure
|
|
98
|
+
class={classes('marvin-embed', `marvin-embed--${kind}`, 'marvin-embed--facade', className)}
|
|
99
|
+
data-provider={embed.provider}
|
|
100
|
+
style={sizeStyle()}
|
|
101
|
+
>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="marvin-embed__load"
|
|
105
|
+
data-marvin-embed-src={attributes.src}
|
|
106
|
+
data-marvin-embed-attrs={JSON.stringify(attributes)}
|
|
107
|
+
data-marvin-embed-hosts={JSON.stringify(hosts)}
|
|
108
|
+
>
|
|
109
|
+
<span class="marvin-embed__provider">{providerName}</span>
|
|
110
|
+
<span class="marvin-embed__title">{label}</span>
|
|
111
|
+
<span class="marvin-embed__consent">{consent}</span>
|
|
112
|
+
</button>
|
|
113
|
+
{linkHref && (
|
|
114
|
+
<a class="marvin-embed__link" href={linkHref} rel="noopener noreferrer">{linkTitle}</a>
|
|
115
|
+
)}
|
|
116
|
+
</figure>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{embed && !attributes && <LinkCard embed={embed} class={className} />}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EmbedLoader — put this once in your layout.
|
|
4
|
+
*
|
|
5
|
+
* In `click_to_load` mode an embed renders as a facade: a button plus a plain link, and no request
|
|
6
|
+
* to the provider. On click (or Enter/Space — it's a real button) this swaps the button for the
|
|
7
|
+
* iframe, built only from the facade's `data-marvin-embed-attrs` and refused unless its src is
|
|
8
|
+
* https on a host in `data-marvin-embed-hosts`.
|
|
9
|
+
*
|
|
10
|
+
* Also brings in the base `.marvin-embed` CSS (low-specificity, themeable through
|
|
11
|
+
* `--marvin-embed-*` custom properties).
|
|
12
|
+
*
|
|
13
|
+
* The script is processed by Astro (deduplicated to one per page, however many embeds), so it can
|
|
14
|
+
* share its tested logic with `<Embed>`. Astro inlines a script this small; if your CSP forbids
|
|
15
|
+
* inline scripts, hash it or set `vite.build.assetsInlineLimit: 0` to get a file instead.
|
|
16
|
+
*/
|
|
17
|
+
import './embeds.css';
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
import { installEmbedLoader } from './embed-frame';
|
|
22
|
+
|
|
23
|
+
installEmbedLoader(document);
|
|
24
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Entry } from '@inneropen/marvin-sdk';
|
|
3
|
+
import type { RendererEntry, RendererRegistry } from '../renderers/types.js';
|
|
4
|
+
import { resolveRendererConfig, resolveRendererName, resolveRendererRequirement, shouldRenderEntry } from '../renderers/resolve.js';
|
|
5
|
+
import { astroRegistry } from './registry.js';
|
|
6
|
+
import { isCoreRendererPackage, resolveRenderer } from '../renderers/registry.js';
|
|
7
|
+
import { debug } from '../renderers/debug.js';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
entry?: RendererEntry | Entry;
|
|
11
|
+
config?: Record<string, unknown>;
|
|
12
|
+
class?: string;
|
|
13
|
+
registry?: RendererRegistry | Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { entry, config: configOverrides, class: className, registry: customRegistry } = Astro.props;
|
|
17
|
+
const rendererEntry = typeof (entry as any)?.toJSON === 'function'
|
|
18
|
+
? (entry as Entry).toJSON() as RendererEntry
|
|
19
|
+
: entry as RendererEntry | undefined;
|
|
20
|
+
|
|
21
|
+
const rendererRequirement = rendererEntry ? resolveRendererRequirement(rendererEntry) : undefined;
|
|
22
|
+
const rendererPackage = rendererRequirement?.packageName;
|
|
23
|
+
const rendererName = rendererEntry ? resolveRendererName(rendererEntry) : undefined;
|
|
24
|
+
const Renderer = isCoreRendererPackage(rendererPackage) && rendererName
|
|
25
|
+
// The fallback is the core registry itself; passing a component here (as before) looked up
|
|
26
|
+
// `component[name]` and never rendered anything.
|
|
27
|
+
? resolveRenderer(rendererName, customRegistry, astroRegistry)
|
|
28
|
+
: undefined;
|
|
29
|
+
const canRender = rendererEntry ? shouldRenderEntry(rendererEntry, rendererName) : true;
|
|
30
|
+
const config = rendererEntry ? resolveRendererConfig(rendererEntry, configOverrides) : {};
|
|
31
|
+
debug('EntryRenderer', { slug: rendererEntry?.slug, renderer: rendererName, package: rendererPackage, hasComponent: !!Renderer, canRender });
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
{rendererEntry && Renderer && canRender ? (
|
|
35
|
+
<Renderer entry={rendererEntry} config={config} class={className}>
|
|
36
|
+
<slot />
|
|
37
|
+
</Renderer>
|
|
38
|
+
) : (
|
|
39
|
+
<slot />
|
|
40
|
+
)}
|