@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FormRenderer — render a public form from a submittable entry type's field schema.
|
|
4
|
+
*
|
|
5
|
+
* A submittable entry type IS a form; its `schema_json.fields` ARE the form fields. Fetch the
|
|
6
|
+
* definition from the Marvin publishing API at build time — `GET /publish/{ws}/forms/{slug}`
|
|
7
|
+
* returns `{ formSchema: { fields }, metadata: { successMessage, honeypotField } }` — and pass it
|
|
8
|
+
* here. The form then renders from the same schema the submission validates against, so a form no
|
|
9
|
+
* longer has to be hand-coded per page or per site.
|
|
10
|
+
*
|
|
11
|
+
* Intentionally UNSTYLED (semantic markup + `data-*` hooks) so the consuming site owns the look;
|
|
12
|
+
* the only inline style is the visually-hidden honeypot trap.
|
|
13
|
+
*/
|
|
14
|
+
export interface FormFieldSchema {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** text | textarea | select | number | boolean | date | datetime (others render as text). */
|
|
18
|
+
type: string;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
helpText?: string;
|
|
22
|
+
defaultValue?: unknown;
|
|
23
|
+
options?: string[]; // select
|
|
24
|
+
rows?: number; // textarea
|
|
25
|
+
min?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
step?: number;
|
|
28
|
+
pattern?: string; // text
|
|
29
|
+
format?: string; // 'email' | 'tel' | 'url' — hints the input type
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Props {
|
|
33
|
+
/** The entry type's field schema (`formSchema` from the publishing API). */
|
|
34
|
+
schema: { fields: FormFieldSchema[] } | null | undefined;
|
|
35
|
+
/** Where the form POSTs, e.g. `/api/forms/inquiry`. */
|
|
36
|
+
action: string;
|
|
37
|
+
/** Hidden anti-spam field name; omit to skip the honeypot. */
|
|
38
|
+
honeypotField?: string;
|
|
39
|
+
/** Message shown after a successful (JS) submit. */
|
|
40
|
+
successMessage?: string;
|
|
41
|
+
/** Submit button label. */
|
|
42
|
+
submitLabel?: string;
|
|
43
|
+
class?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
schema,
|
|
48
|
+
action,
|
|
49
|
+
honeypotField,
|
|
50
|
+
successMessage = 'Thank you for your submission.',
|
|
51
|
+
submitLabel = 'Send',
|
|
52
|
+
class: className,
|
|
53
|
+
} = Astro.props;
|
|
54
|
+
|
|
55
|
+
const fields = schema?.fields ?? [];
|
|
56
|
+
|
|
57
|
+
// A text field's input type — inferred from an explicit format hint or the field key.
|
|
58
|
+
function textInputType(f: FormFieldSchema): string {
|
|
59
|
+
if (f.format === 'email' || f.key === 'email') return 'email';
|
|
60
|
+
if (f.format === 'tel' || /phone|tel/i.test(f.key)) return 'tel';
|
|
61
|
+
if (f.format === 'url' || f.key === 'url') return 'url';
|
|
62
|
+
return 'text';
|
|
63
|
+
}
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
{fields.length > 0 && (
|
|
67
|
+
<form
|
|
68
|
+
method="post"
|
|
69
|
+
action={action}
|
|
70
|
+
data-marvin-form
|
|
71
|
+
data-success={successMessage}
|
|
72
|
+
class={className}
|
|
73
|
+
>
|
|
74
|
+
{honeypotField && (
|
|
75
|
+
<input
|
|
76
|
+
type="text"
|
|
77
|
+
name={honeypotField}
|
|
78
|
+
tabindex="-1"
|
|
79
|
+
autocomplete="off"
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0"
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{fields.map((f) => (
|
|
86
|
+
<div class="marvin-field" data-field-type={f.type}>
|
|
87
|
+
{f.type !== 'boolean' && (
|
|
88
|
+
<label for={`mf-${f.key}`}>
|
|
89
|
+
{f.label}
|
|
90
|
+
{f.required && <span aria-hidden="true"> *</span>}
|
|
91
|
+
</label>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{(f.type === 'text' || f.type === 'number' || f.type === 'date' || f.type === 'datetime') && (
|
|
95
|
+
<input
|
|
96
|
+
id={`mf-${f.key}`}
|
|
97
|
+
name={f.key}
|
|
98
|
+
type={
|
|
99
|
+
f.type === 'number'
|
|
100
|
+
? 'number'
|
|
101
|
+
: f.type === 'date'
|
|
102
|
+
? 'date'
|
|
103
|
+
: f.type === 'datetime'
|
|
104
|
+
? 'datetime-local'
|
|
105
|
+
: textInputType(f)
|
|
106
|
+
}
|
|
107
|
+
required={f.required}
|
|
108
|
+
placeholder={f.placeholder}
|
|
109
|
+
value={f.defaultValue as string | undefined}
|
|
110
|
+
min={f.min}
|
|
111
|
+
max={f.max}
|
|
112
|
+
step={f.step}
|
|
113
|
+
pattern={f.pattern}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{f.type === 'textarea' && (
|
|
118
|
+
<textarea id={`mf-${f.key}`} name={f.key} required={f.required} placeholder={f.placeholder} rows={f.rows ?? 4}>
|
|
119
|
+
{f.defaultValue as string | undefined}
|
|
120
|
+
</textarea>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{f.type === 'select' && (
|
|
124
|
+
<select id={`mf-${f.key}`} name={f.key} required={f.required}>
|
|
125
|
+
{!f.required && <option value="">{f.placeholder ?? 'Select…'}</option>}
|
|
126
|
+
{(f.options ?? []).map((o) => (
|
|
127
|
+
<option value={o} selected={f.defaultValue === o}>
|
|
128
|
+
{o}
|
|
129
|
+
</option>
|
|
130
|
+
))}
|
|
131
|
+
</select>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{f.type === 'boolean' && (
|
|
135
|
+
<label class="marvin-check" for={`mf-${f.key}`}>
|
|
136
|
+
<input id={`mf-${f.key}`} name={f.key} type="checkbox" value="true" required={f.required} />
|
|
137
|
+
<span>{f.label}</span>
|
|
138
|
+
</label>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{f.helpText && <p class="marvin-help">{f.helpText}</p>}
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
|
|
145
|
+
<button type="submit">{submitLabel}</button>
|
|
146
|
+
<p data-marvin-status role="status" aria-live="polite" hidden></p>
|
|
147
|
+
</form>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<script>
|
|
151
|
+
// Progressive enhancement: submit via fetch and show a status message. The native POST is the
|
|
152
|
+
// no-JS fallback, so the form works either way.
|
|
153
|
+
const forms = document.querySelectorAll<HTMLFormElement>('form[data-marvin-form]');
|
|
154
|
+
for (const form of forms) {
|
|
155
|
+
form.addEventListener('submit', async (event) => {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
const status = form.querySelector<HTMLElement>('[data-marvin-status]');
|
|
158
|
+
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
|
159
|
+
const data: Record<string, unknown> = {};
|
|
160
|
+
for (const [key, value] of new FormData(form).entries()) {
|
|
161
|
+
data[key] = value;
|
|
162
|
+
}
|
|
163
|
+
if (button) button.disabled = true;
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetch(form.action, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
168
|
+
body: JSON.stringify(data),
|
|
169
|
+
});
|
|
170
|
+
const body = await res.json().catch(() => ({ success: res.ok }));
|
|
171
|
+
if (status) {
|
|
172
|
+
status.hidden = false;
|
|
173
|
+
status.textContent = body.success
|
|
174
|
+
? form.dataset.success || 'Thank you.'
|
|
175
|
+
: body.message || 'Something went wrong. Please try again.';
|
|
176
|
+
}
|
|
177
|
+
if (body.success) form.reset();
|
|
178
|
+
} catch {
|
|
179
|
+
if (status) {
|
|
180
|
+
status.hidden = false;
|
|
181
|
+
status.textContent = 'Could not send just now — please try again.';
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
if (button) button.disabled = false;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
</script>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LinkCard — the plain-link fallback for an embed that can't (or shouldn't) be framed: a provider
|
|
4
|
+
* Marvin couldn't resolve, a removed video, or a site that doesn't frame anything.
|
|
5
|
+
*
|
|
6
|
+
* Matches Marvin's published link card: the title, plus "on {Provider}" when the title is the
|
|
7
|
+
* media's real title (without one, the title is already "Watch on YouTube"). Pass an `embed`, or
|
|
8
|
+
* `href`/`title`/`providerName` directly. Only http(s) links render.
|
|
9
|
+
*/
|
|
10
|
+
import type { MarvinEmbed } from '../embeds.js';
|
|
11
|
+
import { safeLinkHref } from './embed-frame.js';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
embed?: MarvinEmbed | null;
|
|
15
|
+
href?: string | null;
|
|
16
|
+
title?: string | null;
|
|
17
|
+
providerName?: string | null;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { embed, class: className } = Astro.props;
|
|
22
|
+
const href = safeLinkHref(
|
|
23
|
+
Astro.props.href || embed?.link?.href || embed?.canonicalUrl || embed?.url
|
|
24
|
+
);
|
|
25
|
+
const realTitle = Astro.props.title || embed?.title || undefined;
|
|
26
|
+
const title = realTitle || embed?.link?.title || href;
|
|
27
|
+
const providerName = Astro.props.providerName || embed?.link?.providerName || embed?.providerName;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
{href && (
|
|
31
|
+
<a
|
|
32
|
+
class={['marvin-embed-link', className].filter(Boolean).join(' ')}
|
|
33
|
+
href={href}
|
|
34
|
+
rel="noopener noreferrer"
|
|
35
|
+
data-provider={embed?.provider}
|
|
36
|
+
>
|
|
37
|
+
<span class="marvin-embed-link__title">{title}</span>
|
|
38
|
+
{realTitle && providerName && (
|
|
39
|
+
<span class="marvin-embed-link__provider">on {providerName}</span>
|
|
40
|
+
)}
|
|
41
|
+
</a>
|
|
42
|
+
)}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iframe rules shared by `<Embed>` (server) and `<EmbedLoader>` (browser).
|
|
3
|
+
*
|
|
4
|
+
* A click-to-load facade carries its iframe as data: `data-marvin-embed-attrs` (a JSON object of
|
|
5
|
+
* attributes) and `data-marvin-embed-hosts` (the frame hosts it may load). The loader builds the
|
|
6
|
+
* iframe from those attributes alone, keeps only the allow-listed ones, and refuses any `src`
|
|
7
|
+
* that isn't https on an allowed host. Nothing from the page is ever parsed as HTML.
|
|
8
|
+
*
|
|
9
|
+
* This module has no imports so the browser bundle stays a few hundred bytes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Every attribute the loader will set on an iframe. Anything else in the data is dropped. */
|
|
13
|
+
export const IFRAME_ATTRIBUTES: readonly string[] = [
|
|
14
|
+
'src',
|
|
15
|
+
'title',
|
|
16
|
+
'allow',
|
|
17
|
+
'sandbox',
|
|
18
|
+
'referrerpolicy',
|
|
19
|
+
'loading',
|
|
20
|
+
'style',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Inline style that could fetch or execute something, or break out of the attribute. */
|
|
24
|
+
const UNSAFE_STYLE = /url\s*\(|expression\s*\(|@import|javascript:|\\|[<>]/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A frame-source entry reduced to a lowercase host pattern: `example.com` or `*.example.com`.
|
|
28
|
+
* Accepts the forms a CSP `frame-src` list uses (`https://example.com`, `example.com:443`,
|
|
29
|
+
* `https://*.example.com/path`).
|
|
30
|
+
*/
|
|
31
|
+
export function frameHostPattern(source: unknown): string | undefined {
|
|
32
|
+
if (typeof source !== 'string') return undefined;
|
|
33
|
+
const host = source
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
|
|
37
|
+
.replace(/[/?#].*$/, '')
|
|
38
|
+
.replace(/:\d+$/, '');
|
|
39
|
+
return /^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*$/.test(host) ? host : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when `src` is an https URL whose host matches one of `hosts`. */
|
|
43
|
+
export function isAllowedFrameSrc(src: unknown, hosts: readonly unknown[]): src is string {
|
|
44
|
+
if (typeof src !== 'string') return false;
|
|
45
|
+
let url: URL;
|
|
46
|
+
try {
|
|
47
|
+
url = new URL(src);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== 'https:' || url.username || url.password) return false;
|
|
52
|
+
|
|
53
|
+
const host = url.hostname.toLowerCase();
|
|
54
|
+
return hosts.some((entry) => {
|
|
55
|
+
const pattern = frameHostPattern(entry);
|
|
56
|
+
if (!pattern) return false;
|
|
57
|
+
if (pattern.startsWith('*.')) {
|
|
58
|
+
const suffix = pattern.slice(1); // ".example.com"
|
|
59
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
60
|
+
}
|
|
61
|
+
return host === pattern;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The attributes to put on the iframe, or `undefined` when the src isn't allowed.
|
|
67
|
+
*
|
|
68
|
+
* Only {@link IFRAME_ATTRIBUTES} survive, with string (or number) values; an unsafe `style` is
|
|
69
|
+
* dropped. `src` comes from `attrs.src`, falling back to `fallbackSrc` (the facade's
|
|
70
|
+
* `data-marvin-embed-src`); either way it must pass the host check.
|
|
71
|
+
*/
|
|
72
|
+
export function iframeAttributes(
|
|
73
|
+
attrs: unknown,
|
|
74
|
+
hosts: readonly unknown[],
|
|
75
|
+
fallbackSrc?: string | null
|
|
76
|
+
): Record<string, string> | undefined {
|
|
77
|
+
if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) return undefined;
|
|
78
|
+
|
|
79
|
+
const safe: Record<string, string> = {};
|
|
80
|
+
for (const [rawKey, rawValue] of Object.entries(attrs as Record<string, unknown>)) {
|
|
81
|
+
const key = rawKey.toLowerCase();
|
|
82
|
+
if (!IFRAME_ATTRIBUTES.includes(key)) continue;
|
|
83
|
+
if (typeof rawValue !== 'string' && typeof rawValue !== 'number') continue;
|
|
84
|
+
const value = String(rawValue);
|
|
85
|
+
if (key === 'style' && UNSAFE_STYLE.test(value)) continue;
|
|
86
|
+
safe[key] = value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const src = safe.src ?? fallbackSrc ?? undefined;
|
|
90
|
+
if (!isAllowedFrameSrc(src, hosts)) return undefined;
|
|
91
|
+
safe.src = src;
|
|
92
|
+
safe.title ||= 'Embedded media';
|
|
93
|
+
return safe;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `href` when it is an absolute http(s) URL, else `undefined` (no `javascript:` links). */
|
|
97
|
+
export function safeLinkHref(href: unknown): string | undefined {
|
|
98
|
+
if (typeof href !== 'string') return undefined;
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(href.trim());
|
|
101
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseJson(value: string | null): unknown {
|
|
108
|
+
if (!value) return undefined;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(value);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The facade buttons the loader acts on. */
|
|
117
|
+
export const FACADE_SELECTOR = 'button[data-marvin-embed-attrs], button[data-marvin-embed-src]';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Swap one facade button for its iframe. Returns the iframe, or `null` when the data is refused
|
|
121
|
+
* (the facade is then marked `marvin-embed--blocked` and its plain link stays usable).
|
|
122
|
+
*/
|
|
123
|
+
export function activateEmbedFacade(button: Element, doc: Document): HTMLIFrameElement | null {
|
|
124
|
+
const hosts = parseJson(button.getAttribute('data-marvin-embed-hosts'));
|
|
125
|
+
const attributes = iframeAttributes(
|
|
126
|
+
parseJson(button.getAttribute('data-marvin-embed-attrs')),
|
|
127
|
+
Array.isArray(hosts) ? hosts : [],
|
|
128
|
+
button.getAttribute('data-marvin-embed-src')
|
|
129
|
+
);
|
|
130
|
+
const figure = button.closest('.marvin-embed');
|
|
131
|
+
|
|
132
|
+
if (!attributes) {
|
|
133
|
+
figure?.classList.add('marvin-embed--blocked');
|
|
134
|
+
button.setAttribute('disabled', '');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const iframe = doc.createElement('iframe');
|
|
139
|
+
// `src` last, after sandbox/allow are in place.
|
|
140
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
141
|
+
if (name !== 'src') iframe.setAttribute(name, value);
|
|
142
|
+
}
|
|
143
|
+
iframe.setAttribute('src', attributes.src);
|
|
144
|
+
|
|
145
|
+
button.replaceWith(iframe);
|
|
146
|
+
figure?.classList.remove('marvin-embed--facade');
|
|
147
|
+
figure?.classList.add('marvin-embed--loaded');
|
|
148
|
+
// Keyboard users pressed Enter/Space on the button that just vanished; keep their place.
|
|
149
|
+
iframe.focus();
|
|
150
|
+
return iframe;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const INSTALLED = '__marvinEmbedLoader';
|
|
154
|
+
|
|
155
|
+
/** One delegated click listener per document, covering facades added later too. */
|
|
156
|
+
export function installEmbedLoader(doc: Document): void {
|
|
157
|
+
const marker = doc as Document & { [INSTALLED]?: boolean };
|
|
158
|
+
if (marker[INSTALLED]) return;
|
|
159
|
+
marker[INSTALLED] = true;
|
|
160
|
+
|
|
161
|
+
doc.addEventListener('click', (event) => {
|
|
162
|
+
const target = event.target as Element | null;
|
|
163
|
+
const button = typeof target?.closest === 'function' ? target.closest(FACADE_SELECTOR) : null;
|
|
164
|
+
if (!button) return;
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
activateEmbedFacade(button, doc);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Base styles for Marvin media embeds.
|
|
3
|
+
*
|
|
4
|
+
* Every selector is wrapped in :where() so it carries zero specificity: any site rule wins.
|
|
5
|
+
* Theme through the custom properties below rather than overriding the rules.
|
|
6
|
+
*
|
|
7
|
+
* --marvin-embed-aspect aspect ratio of a player (default 16 / 9)
|
|
8
|
+
* --marvin-embed-height fixed player height (audio/podcast default 152px)
|
|
9
|
+
* --marvin-embed-max-width max width of an embed (default 100%)
|
|
10
|
+
* --marvin-embed-margin outer margin (default 1.5em 0)
|
|
11
|
+
* --marvin-embed-radius corner radius (default 0.5rem)
|
|
12
|
+
* --marvin-embed-bg facade background (default #111)
|
|
13
|
+
* --marvin-embed-fg facade text colour (default #fff)
|
|
14
|
+
* --marvin-embed-muted secondary text (default currentColor at 70%)
|
|
15
|
+
* --marvin-embed-border link-card border (default 1px solid currentColor at 20%)
|
|
16
|
+
* --marvin-embed-focus focus ring colour (default Highlight)
|
|
17
|
+
*
|
|
18
|
+
* Marvin sets one of --marvin-embed-aspect (video; 9/16 for Shorts) or --marvin-embed-height
|
|
19
|
+
* (audio, podcast, playlist) on every player's figure; the defaults above are only fallbacks.
|
|
20
|
+
*
|
|
21
|
+
* Markup (Marvin's published html and <Embed> alike):
|
|
22
|
+
* figure.marvin-embed.marvin-embed--{video|audio|podcast|playlist}[.marvin-embed--facade]
|
|
23
|
+
* iframe (direct mode)
|
|
24
|
+
* button.marvin-embed__load (click-to-load facade)
|
|
25
|
+
* span.marvin-embed__provider span.marvin-embed__title span.marvin-embed__consent
|
|
26
|
+
* a.marvin-embed__link (facade fallback link)
|
|
27
|
+
* a.marvin-embed-link (link card)
|
|
28
|
+
* span.marvin-embed-link__title span.marvin-embed-link__provider
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
:where(.marvin-embed) {
|
|
32
|
+
display: block;
|
|
33
|
+
max-width: var(--marvin-embed-max-width, 100%);
|
|
34
|
+
margin: var(--marvin-embed-margin, 1.5em 0);
|
|
35
|
+
padding: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* The player, and the facade button standing in for it, share one box. */
|
|
39
|
+
:where(.marvin-embed) :where(iframe, .marvin-embed__load) {
|
|
40
|
+
display: block;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: var(--marvin-embed-height, auto);
|
|
44
|
+
aspect-ratio: var(--marvin-embed-aspect, 16 / 9);
|
|
45
|
+
border: 0;
|
|
46
|
+
border-radius: var(--marvin-embed-radius, 0.5rem);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Audio and podcast players are always fixed-height; a playlist may be either (a YouTube playlist
|
|
50
|
+
* is 16/9, a Spotify one 352px), so it keeps the general rule above. */
|
|
51
|
+
:where(.marvin-embed--audio, .marvin-embed--podcast) :where(iframe, .marvin-embed__load) {
|
|
52
|
+
height: var(--marvin-embed-height, 152px);
|
|
53
|
+
aspect-ratio: auto;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Facade */
|
|
57
|
+
:where(.marvin-embed__load) {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
gap: 0.5em;
|
|
63
|
+
padding: 1em;
|
|
64
|
+
background: var(--marvin-embed-bg, #111);
|
|
65
|
+
color: var(--marvin-embed-fg, #fff);
|
|
66
|
+
font: inherit;
|
|
67
|
+
text-align: center;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
:where(.marvin-embed__load):focus-visible {
|
|
72
|
+
outline: 3px solid var(--marvin-embed-focus, Highlight);
|
|
73
|
+
outline-offset: 2px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
:where(.marvin-embed__load):disabled {
|
|
77
|
+
cursor: not-allowed;
|
|
78
|
+
opacity: 0.6;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
:where(.marvin-embed__provider) {
|
|
82
|
+
font-size: 0.75em;
|
|
83
|
+
letter-spacing: 0.08em;
|
|
84
|
+
text-transform: uppercase;
|
|
85
|
+
opacity: 0.8;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
:where(.marvin-embed__title) {
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
:where(.marvin-embed__consent) {
|
|
93
|
+
font-size: 0.85em;
|
|
94
|
+
opacity: 0.8;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* A short fixed-height facade (a single track or episode) has little room: tighten it up. */
|
|
98
|
+
:where(.marvin-embed--audio, .marvin-embed--podcast) :where(.marvin-embed__load) {
|
|
99
|
+
gap: 0.25em;
|
|
100
|
+
padding: 0.5em 1em;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
:where(.marvin-embed__link) {
|
|
104
|
+
display: inline-block;
|
|
105
|
+
margin-top: 0.5em;
|
|
106
|
+
font-size: 0.85em;
|
|
107
|
+
color: var(--marvin-embed-muted, inherit);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Link card (status link / unavailable) */
|
|
111
|
+
:where(.marvin-embed-link) {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
gap: 0.25em;
|
|
115
|
+
max-width: var(--marvin-embed-max-width, 100%);
|
|
116
|
+
margin: var(--marvin-embed-margin, 1.5em 0);
|
|
117
|
+
padding: 0.75em 1em;
|
|
118
|
+
border: var(--marvin-embed-border, 1px solid color-mix(in srgb, currentColor 20%, transparent));
|
|
119
|
+
border-radius: var(--marvin-embed-radius, 0.5rem);
|
|
120
|
+
color: inherit;
|
|
121
|
+
text-decoration: none;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
:where(.marvin-embed-link):focus-visible {
|
|
125
|
+
outline: 3px solid var(--marvin-embed-focus, Highlight);
|
|
126
|
+
outline-offset: 2px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
:where(.marvin-embed-link__title) {
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
text-decoration: underline;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
:where(.marvin-embed-link__provider) {
|
|
135
|
+
font-size: 0.85em;
|
|
136
|
+
color: var(--marvin-embed-muted, color-mix(in srgb, currentColor 70%, transparent));
|
|
137
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@inneropen/marvin-astro/components` — Astro components for Marvin content (formerly
|
|
3
|
+
* `@inneropen/marvin-renderers-core/astro`).
|
|
4
|
+
*
|
|
5
|
+
* Shipped as source and compiled by the consuming site's Astro build. For `astro.config.mjs`,
|
|
6
|
+
* import `marvinIntegration` from `@inneropen/marvin-astro/components/integration` instead: this
|
|
7
|
+
* entry pulls in `.astro` files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
PageRenderer,
|
|
12
|
+
ArticleRenderer,
|
|
13
|
+
FaqRenderer,
|
|
14
|
+
NavigationRenderer,
|
|
15
|
+
astroRegistry,
|
|
16
|
+
coreRendererPackage,
|
|
17
|
+
getRenderer,
|
|
18
|
+
} from './registry.js';
|
|
19
|
+
|
|
20
|
+
export { default as EntryRenderer } from './EntryRenderer.astro';
|
|
21
|
+
export { default as FormRenderer } from './FormRenderer.astro';
|
|
22
|
+
export { default as Embed } from './Embed.astro';
|
|
23
|
+
export { default as LinkCard } from './LinkCard.astro';
|
|
24
|
+
export { default as EmbedLoader } from './EmbedLoader.astro';
|
|
25
|
+
export { marvinIntegration } from './integration.js';
|
|
26
|
+
export { iframeAttributes, installEmbedLoader, isAllowedFrameSrc } from './embed-frame.js';
|
|
27
|
+
|
|
28
|
+
// The framework-agnostic renderer logic, so one import covers a renderer component's needs.
|
|
29
|
+
export * from '../renderers/index.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
import { createMarvinClient } from '@inneropen/marvin-sdk';
|
|
3
|
+
import { validateRenderers } from '../renderers/validation.js';
|
|
4
|
+
import type { RendererCheckOptions } from '../renderers/validation-types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build-time check that every rendered entry type in the workspace has a renderer in `registry`.
|
|
8
|
+
* Import it from `@inneropen/marvin-astro/components/integration` in `astro.config.mjs` (that
|
|
9
|
+
* path has no `.astro` imports, so the config loader can read it).
|
|
10
|
+
*/
|
|
11
|
+
export function marvinIntegration(options: RendererCheckOptions): AstroIntegration {
|
|
12
|
+
const { registry, strict = false, ignore } = options;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
name: '@inneropen/marvin-astro/renderers',
|
|
16
|
+
hooks: {
|
|
17
|
+
'astro:config:done': async ({ logger }) => {
|
|
18
|
+
const apiUrl = options.apiUrl || process.env.MARVIN_API_URL;
|
|
19
|
+
const siteToken = options.siteToken || process.env.MARVIN_SITE_CLIENT_TOKEN;
|
|
20
|
+
const workspaceSlug = process.env.MARVIN_WORKSPACE_SLUG;
|
|
21
|
+
|
|
22
|
+
if (!apiUrl || !siteToken || !workspaceSlug) {
|
|
23
|
+
logger.warn(
|
|
24
|
+
'Skipping renderer validation — missing MARVIN_API_URL, MARVIN_SITE_CLIENT_TOKEN, or MARVIN_WORKSPACE_SLUG'
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let workspaceRenderers;
|
|
30
|
+
try {
|
|
31
|
+
const client = createMarvinClient({ apiUrl, siteClientToken: siteToken, workspaceSlug });
|
|
32
|
+
workspaceRenderers = await client.renderers.list();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
logger.warn(
|
|
35
|
+
`Renderer validation failed — could not reach Marvin API: ${error instanceof Error ? error.message : error}`
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = validateRenderers(workspaceRenderers, registry, { ignore });
|
|
41
|
+
|
|
42
|
+
if (result.missing.length === 0) {
|
|
43
|
+
logger.info(
|
|
44
|
+
`All ${result.total} entry type renderer(s) covered`
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lines = result.missing.map(
|
|
50
|
+
(m) => ` - "${m.renderer}" for entry type "${m.entryTypeName}" (${m.entryTypeSlug})`
|
|
51
|
+
);
|
|
52
|
+
const message = `Missing ${result.missing.length} renderer(s):\n${lines.join('\n')}`;
|
|
53
|
+
|
|
54
|
+
if (strict) {
|
|
55
|
+
throw new Error(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.warn(message);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import PageRenderer from './renderers/PageRenderer.astro';
|
|
2
|
+
import ArticleRenderer from './renderers/ArticleRenderer.astro';
|
|
3
|
+
import FaqRenderer from './renderers/FaqRenderer.astro';
|
|
4
|
+
import NavigationRenderer from './renderers/NavigationRenderer.astro';
|
|
5
|
+
import {
|
|
6
|
+
CORE_RENDERER_PACKAGE,
|
|
7
|
+
createRendererPackage,
|
|
8
|
+
createRegistry,
|
|
9
|
+
} from '../renderers/registry.js';
|
|
10
|
+
import type { CoreRendererName } from '../renderers/types.js';
|
|
11
|
+
import packageJson from '../../package.json';
|
|
12
|
+
|
|
13
|
+
export { PageRenderer, ArticleRenderer, FaqRenderer, NavigationRenderer };
|
|
14
|
+
|
|
15
|
+
const rendererMap: Record<CoreRendererName, typeof PageRenderer> = {
|
|
16
|
+
page: PageRenderer,
|
|
17
|
+
article: ArticleRenderer,
|
|
18
|
+
faq: FaqRenderer,
|
|
19
|
+
navigation: NavigationRenderer,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const astroRegistry = createRegistry(rendererMap);
|
|
23
|
+
|
|
24
|
+
// Named after the package Marvin's system entry types declare, so a package registry keyed by
|
|
25
|
+
// `renderingJson.package` still finds these renderers.
|
|
26
|
+
export const coreRendererPackage = createRendererPackage({
|
|
27
|
+
packageName: CORE_RENDERER_PACKAGE,
|
|
28
|
+
version: packageJson.version,
|
|
29
|
+
renderers: rendererMap,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function getRenderer(name: string) {
|
|
33
|
+
return astroRegistry.get(name);
|
|
34
|
+
}
|