@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.
- package/README.md +173 -14
- package/dist/embeds-BTNryQA6.d.ts +79 -0
- package/dist/index.d.ts +43 -6
- package/dist/index.js +116 -14
- 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 +208 -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 +206 -0
- package/src/sections.ts +39 -0
- package/src/site.ts +222 -0
- package/src/types.ts +188 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Marvin payloads.
|
|
3
|
+
*
|
|
4
|
+
* The published API returns the same logical value in several shapes depending on which
|
|
5
|
+
* endpoint produced it (list item vs full read, SDK `Entry` wrapper vs raw JSON, placement
|
|
6
|
+
* metadata vs asset metadata). Every reader here is total: it takes `unknown`, checks, and
|
|
7
|
+
* returns `undefined` rather than throwing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AssetSelectOptions, MarvinContentEntry } from './types.js';
|
|
11
|
+
|
|
12
|
+
/** A non-empty string, or `undefined`. Empty/whitespace strings are treated as absent. */
|
|
13
|
+
export function asString(value: unknown): string | undefined {
|
|
14
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function asRecord(value: unknown): Record<string, unknown> {
|
|
18
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function asNumber(value: unknown): number | undefined {
|
|
22
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
23
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function asStringArray(value: unknown): string[] | undefined {
|
|
31
|
+
if (!Array.isArray(value)) return undefined;
|
|
32
|
+
|
|
33
|
+
const items = value.filter(
|
|
34
|
+
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
|
35
|
+
);
|
|
36
|
+
return items.length > 0 ? items : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `metadata_json` — the free-form blob every entry carries, whatever its schema. */
|
|
40
|
+
export function entryMetadata(entry: MarvinContentEntry): Record<string, unknown> {
|
|
41
|
+
const value = entry as { metadata?: unknown; metadataJson?: unknown };
|
|
42
|
+
return asRecord(value.metadata ?? value.metadataJson);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `data_json` — the schema-defined fields. Absent on list items until hydrated. */
|
|
46
|
+
export function entryData(entry: MarvinContentEntry): Record<string, unknown> {
|
|
47
|
+
const value = entry as { data?: unknown; dataJson?: unknown };
|
|
48
|
+
return asRecord(value.data ?? value.dataJson);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read a schema field (`data_json`) from an entry, through the SDK's `Entry` accessor when
|
|
53
|
+
* present and off the raw payload otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function entryField<T = unknown>(entry: MarvinContentEntry, key: string): T | undefined {
|
|
56
|
+
const accessor = (entry as { field?: (key: string) => unknown }).field;
|
|
57
|
+
if (typeof accessor === 'function') {
|
|
58
|
+
return accessor.call(entry, key) as T | undefined;
|
|
59
|
+
}
|
|
60
|
+
return entryData(entry)[key] as T | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The precedence rule: structured schema field first (`data_json`), legacy blob second
|
|
65
|
+
* (`metadata_json`).
|
|
66
|
+
*
|
|
67
|
+
* An empty string counts as absent, which matters more than it sounds — an entry type that
|
|
68
|
+
* declares a field the author left blank stores `""`, and without this the legacy value that
|
|
69
|
+
* *is* set would never surface.
|
|
70
|
+
*/
|
|
71
|
+
export function field<T = unknown>(entry: MarvinContentEntry, key: string): T | undefined {
|
|
72
|
+
const value = entryField(entry, key);
|
|
73
|
+
if (value !== undefined && value !== null && value !== '') return value as T;
|
|
74
|
+
const fallback = entryMetadata(entry)[key];
|
|
75
|
+
return (fallback === '' ? undefined : fallback) as T | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read a value off a published asset placement, checking every level the API might have put it
|
|
80
|
+
* at: the placement itself, its metadata blobs, then the underlying asset and its blobs.
|
|
81
|
+
*/
|
|
82
|
+
export function assetField<T = unknown>(asset: unknown, key: string): T | undefined {
|
|
83
|
+
if (!asset || typeof asset !== 'object') return undefined;
|
|
84
|
+
|
|
85
|
+
const value = asset as Record<string, unknown>;
|
|
86
|
+
const nested = asRecord(value.asset);
|
|
87
|
+
return (value[key] ??
|
|
88
|
+
asRecord(value.entryMetadata)[key] ??
|
|
89
|
+
asRecord(value.placementMetadata)[key] ??
|
|
90
|
+
asRecord(value.metadataJson)[key] ??
|
|
91
|
+
asRecord(value.metadata_)[key] ??
|
|
92
|
+
asRecord(value.metadata)[key] ??
|
|
93
|
+
nested[key] ??
|
|
94
|
+
asRecord(nested.metadataJson)[key] ??
|
|
95
|
+
asRecord(nested.metadata_)[key] ??
|
|
96
|
+
asRecord(nested.metadata)[key]) as T | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Same layered read as {@link assetField}, for resource relationships. */
|
|
100
|
+
export function resourceField<T = unknown>(resource: unknown, key: string): T | undefined {
|
|
101
|
+
if (!resource || typeof resource !== 'object') return undefined;
|
|
102
|
+
|
|
103
|
+
const value = resource as Record<string, unknown>;
|
|
104
|
+
const nested = asRecord(value.resource);
|
|
105
|
+
return (value[key] ??
|
|
106
|
+
asRecord(value.entryMetadata)[key] ??
|
|
107
|
+
asRecord(value.metadataJson)[key] ??
|
|
108
|
+
asRecord(value.metadata_)[key] ??
|
|
109
|
+
asRecord(value.metadata)[key] ??
|
|
110
|
+
nested[key] ??
|
|
111
|
+
asRecord(nested.metadataJson)[key] ??
|
|
112
|
+
asRecord(nested.metadata_)[key] ??
|
|
113
|
+
asRecord(nested.metadata)[key]) as T | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The entry's collection memberships as slugs. Collections arrive as bare slugs on list items,
|
|
118
|
+
* flat `{slug}` objects, or the SDK's nested `{collection: {slug}}` — all three are read here.
|
|
119
|
+
*/
|
|
120
|
+
export function collectionSlugs(entry: MarvinContentEntry): string[] {
|
|
121
|
+
const direct = (entry as { collectionSlugs?: unknown }).collectionSlugs;
|
|
122
|
+
if (Array.isArray(direct)) {
|
|
123
|
+
return direct.filter((slug): slug is string => typeof slug === 'string');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const raw = (entry as { collections?: unknown }).collections;
|
|
127
|
+
if (!Array.isArray(raw)) return [];
|
|
128
|
+
|
|
129
|
+
return raw
|
|
130
|
+
.map((item) =>
|
|
131
|
+
typeof item === 'string' ? item : (asRecord(asRecord(item).collection).slug ?? asRecord(item).slug)
|
|
132
|
+
)
|
|
133
|
+
.filter((slug): slug is string => typeof slug === 'string');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The entry's membership role within one specific collection (e.g. `'legal'` in
|
|
138
|
+
* `footer-navigation`). A hydrated entry carries all of its memberships; this finds the one for
|
|
139
|
+
* the given collection.
|
|
140
|
+
*/
|
|
141
|
+
export function collectionRole(
|
|
142
|
+
entry: MarvinContentEntry,
|
|
143
|
+
collectionSlug: string
|
|
144
|
+
): string | undefined {
|
|
145
|
+
const raw = (entry as { collections?: unknown }).collections;
|
|
146
|
+
if (!Array.isArray(raw)) return undefined;
|
|
147
|
+
|
|
148
|
+
for (const item of raw) {
|
|
149
|
+
if (typeof item === 'string') continue;
|
|
150
|
+
const record = asRecord(item);
|
|
151
|
+
const slug = asString(record.slug) ?? asString(asRecord(record.collection).slug);
|
|
152
|
+
if (slug === collectionSlug) {
|
|
153
|
+
return asString(asRecord(record.entryMetadata).role) ?? asString(record.role);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A CSS `object-position` value from a focal point. Marvin stores these either pre-formatted
|
|
161
|
+
* (`"40% 60%"`) or as `{x, y}` in 0–1 or 0–100 units.
|
|
162
|
+
*/
|
|
163
|
+
export function focalPoint(value: unknown): string | undefined {
|
|
164
|
+
const direct = asString(value);
|
|
165
|
+
if (direct) return direct;
|
|
166
|
+
|
|
167
|
+
const point = asRecord(value);
|
|
168
|
+
const x = typeof point.x === 'number' ? point.x : undefined;
|
|
169
|
+
const y = typeof point.y === 'number' ? point.y : undefined;
|
|
170
|
+
if (x == null || y == null) return undefined;
|
|
171
|
+
|
|
172
|
+
return `${x > 1 ? x : x * 100}% ${y > 1 ? y : y * 100}%`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function entryAssets(entry: MarvinContentEntry): Record<string, unknown>[] {
|
|
176
|
+
const assets = (entry as { assets?: unknown }).assets;
|
|
177
|
+
return Array.isArray(assets) ? (assets as Record<string, unknown>[]) : [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function entryResources(entry: MarvinContentEntry): Record<string, unknown>[] {
|
|
181
|
+
const resources = (entry as { resources?: unknown }).resources;
|
|
182
|
+
return Array.isArray(resources) ? (resources as Record<string, unknown>[]) : [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function includesPreferred(actual: string | undefined, exact?: string, any?: string[]): boolean {
|
|
186
|
+
if (!exact && !any?.length) return true;
|
|
187
|
+
if (!actual) return false;
|
|
188
|
+
return actual === exact || Boolean(any?.includes(actual));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function assetMatches(asset: Record<string, unknown>, options: AssetSelectOptions): boolean {
|
|
192
|
+
const role = asString(assetField(asset, 'role'));
|
|
193
|
+
const usage = asString(assetField(asset, 'usage'));
|
|
194
|
+
const type = asString(assetField(asset, 'assetType'));
|
|
195
|
+
const mimeType = asString(assetField(asset, 'mimeType'));
|
|
196
|
+
|
|
197
|
+
const hasRoleCriteria = Boolean(options.role || options.roles?.length);
|
|
198
|
+
const hasUsageCriteria = Boolean(options.usage || options.usages?.length);
|
|
199
|
+
const roleMatches = includesPreferred(role, options.role, options.roles);
|
|
200
|
+
const usageMatches = includesPreferred(usage, options.usage, options.usages);
|
|
201
|
+
const typeMatches = includesPreferred(type, options.type, options.types);
|
|
202
|
+
const mimeMatches = options.mimeType ? mimeType === options.mimeType : true;
|
|
203
|
+
const unroledAllowed = Boolean(options.allowUnroled) && !role && !usage;
|
|
204
|
+
|
|
205
|
+
// Role and usage are alternatives, not a conjunction: an asset placed with `role: hero` and
|
|
206
|
+
// one placed with `usage: hero` both satisfy a hero request.
|
|
207
|
+
const relationshipMatches =
|
|
208
|
+
hasRoleCriteria || hasUsageCriteria ? roleMatches || usageMatches || unroledAllowed : true;
|
|
209
|
+
|
|
210
|
+
return relationshipMatches && typeMatches && mimeMatches;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function selectEntryAsset(
|
|
214
|
+
entry: MarvinContentEntry,
|
|
215
|
+
options: AssetSelectOptions = {}
|
|
216
|
+
): Record<string, unknown> | undefined {
|
|
217
|
+
return entryAssets(entry).find((asset) => assetMatches(asset, options));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Match on role ALONE, exactly. `selectEntryAsset` ORs role against a vacuously-true usage
|
|
222
|
+
* check, so `{roles: ['hero-grade']}` there matches the entry's first asset whatever its role.
|
|
223
|
+
* When you mean "the asset whose role is exactly this", use this.
|
|
224
|
+
*/
|
|
225
|
+
export function selectAssetByRole(
|
|
226
|
+
entry: MarvinContentEntry,
|
|
227
|
+
...roles: string[]
|
|
228
|
+
): Record<string, unknown> | undefined {
|
|
229
|
+
for (const role of roles) {
|
|
230
|
+
const found = entryAssets(entry).find((asset) => asString(assetField(asset, 'role')) === role);
|
|
231
|
+
if (found) return found;
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The list-item shorthand: `featuredAsset` is present on collection entries. */
|
|
237
|
+
export function selectFeaturedAsset(
|
|
238
|
+
entry: MarvinContentEntry
|
|
239
|
+
): Record<string, unknown> | undefined {
|
|
240
|
+
const featured = asRecord((entry as { featuredAsset?: unknown }).featuredAsset);
|
|
241
|
+
return Object.keys(featured).length > 0 ? featured : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function selectImageAsset(
|
|
245
|
+
entry: MarvinContentEntry,
|
|
246
|
+
options: Omit<AssetSelectOptions, 'type' | 'types'> = {}
|
|
247
|
+
): Record<string, unknown> | undefined {
|
|
248
|
+
return (
|
|
249
|
+
selectEntryAsset(entry, { ...options, type: 'image' }) ??
|
|
250
|
+
selectEntryAsset(entry, { ...options, types: ['image'], allowUnroled: true })
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function selectIconAsset(entry: MarvinContentEntry): Record<string, unknown> | undefined {
|
|
255
|
+
return (
|
|
256
|
+
selectEntryAsset(entry, {
|
|
257
|
+
roles: ['icon'],
|
|
258
|
+
types: ['svg', 'image'],
|
|
259
|
+
mimeType: 'image/svg+xml',
|
|
260
|
+
allowUnroled: true,
|
|
261
|
+
}) ??
|
|
262
|
+
selectEntryAsset(entry, {
|
|
263
|
+
roles: ['icon'],
|
|
264
|
+
types: ['svg', 'image'],
|
|
265
|
+
allowUnroled: true,
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function assetUrl(asset: unknown): string | undefined {
|
|
271
|
+
return asString(assetField(asset, 'publicUrl'));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function assetAlt(asset: unknown, fallback?: string): string | undefined {
|
|
275
|
+
return asString(assetField(asset, 'altText')) ?? fallback;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function resourceRole(resource: unknown): string | undefined {
|
|
279
|
+
return asString(resourceField(resource, 'role'));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function isExternalHref(href: string): boolean {
|
|
283
|
+
return /^(https?:|mailto:|tel:)/.test(href);
|
|
284
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@inneropen/marvin-astro/renderers` — framework-agnostic renderer logic: resolve an entry's
|
|
3
|
+
* renderer, build registries, and validate a registry against a workspace's entry types.
|
|
4
|
+
* (Formerly `@inneropen/marvin-renderers-core/logic`.) The Astro components live in
|
|
5
|
+
* `@inneropen/marvin-astro/components`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
CoreRendererName,
|
|
10
|
+
EntryTypeInfo,
|
|
11
|
+
RendererEntry,
|
|
12
|
+
RendererPackage,
|
|
13
|
+
RendererPackageOptions,
|
|
14
|
+
RendererPackageRegistry,
|
|
15
|
+
RendererProps,
|
|
16
|
+
RendererRegistry,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
resolveRendererName,
|
|
21
|
+
resolveRendererRequirement,
|
|
22
|
+
resolveRendererConfig,
|
|
23
|
+
extractBody,
|
|
24
|
+
extractField,
|
|
25
|
+
getFeaturedAsset,
|
|
26
|
+
isRoutable,
|
|
27
|
+
shouldRenderEntry,
|
|
28
|
+
} from './resolve.js';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
CORE_RENDERER_PACKAGE,
|
|
32
|
+
CORE_RENDERER_PACKAGES,
|
|
33
|
+
isCoreRendererPackage,
|
|
34
|
+
createPackageRegistry,
|
|
35
|
+
createRegistry,
|
|
36
|
+
createRendererPackage,
|
|
37
|
+
resolveRenderer,
|
|
38
|
+
} from './registry.js';
|
|
39
|
+
|
|
40
|
+
export type {
|
|
41
|
+
MissingRenderer,
|
|
42
|
+
RendererCheckOptions,
|
|
43
|
+
ValidationResult,
|
|
44
|
+
} from './validation-types.js';
|
|
45
|
+
|
|
46
|
+
export { validateRenderers } from './validation.js';
|
|
47
|
+
|
|
48
|
+
export { debug } from './debug.js';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RendererDefinition,
|
|
3
|
+
RendererPackage,
|
|
4
|
+
RendererPackageOptions,
|
|
5
|
+
RendererPackageRegistry,
|
|
6
|
+
RendererRegistry,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The renderer-package name Marvin's system entry types declare (`renderingJson.package`). The
|
|
11
|
+
* core renderers moved into `@inneropen/marvin-astro`, but existing workspaces still name the old
|
|
12
|
+
* package, so both identify the core set.
|
|
13
|
+
*/
|
|
14
|
+
export const CORE_RENDERER_PACKAGE = '@inneropen/marvin-renderers-core';
|
|
15
|
+
export const CORE_RENDERER_PACKAGES: readonly string[] = [
|
|
16
|
+
CORE_RENDERER_PACKAGE,
|
|
17
|
+
'@inneropen/marvin-astro',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function isCoreRendererPackage(packageName: string | null | undefined): boolean {
|
|
21
|
+
return Boolean(packageName) && CORE_RENDERER_PACKAGES.includes(packageName as string);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRendererRegistry<T>(value: RendererRegistry<T> | Record<string, T> | undefined): value is RendererRegistry<T> {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && 'get' in value && typeof value.get === 'function';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveRenderer<T>(
|
|
29
|
+
name: string,
|
|
30
|
+
customRegistry?: RendererRegistry<T> | Record<string, T>,
|
|
31
|
+
fallbackRegistry?: RendererRegistry<T> | Record<string, T>,
|
|
32
|
+
): T | undefined {
|
|
33
|
+
const getValue = (registry: RendererRegistry<T> | Record<string, T> | undefined) => {
|
|
34
|
+
if (!registry) return undefined;
|
|
35
|
+
if (isRendererRegistry(registry)) {
|
|
36
|
+
return registry.get(name);
|
|
37
|
+
}
|
|
38
|
+
return registry[name];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return getValue(customRegistry) ?? getValue(fallbackRegistry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createRegistry<T>(
|
|
45
|
+
map: Record<string, T>,
|
|
46
|
+
): RendererRegistry<T> {
|
|
47
|
+
const registryMap = { ...map };
|
|
48
|
+
|
|
49
|
+
const instance: RendererRegistry<T> = {
|
|
50
|
+
get(name: string): T | undefined {
|
|
51
|
+
return registryMap[name];
|
|
52
|
+
},
|
|
53
|
+
has(name: string): boolean {
|
|
54
|
+
return name in registryMap;
|
|
55
|
+
},
|
|
56
|
+
names(): string[] {
|
|
57
|
+
return Object.keys(registryMap);
|
|
58
|
+
},
|
|
59
|
+
use(packageDefinition: RendererPackage<T>): RendererRegistry<T> {
|
|
60
|
+
for (const [name, value] of Object.entries(packageDefinition.registry.names().reduce<Record<string, T>>((acc, item) => {
|
|
61
|
+
acc[item] = packageDefinition.registry.get(item)!;
|
|
62
|
+
return acc;
|
|
63
|
+
}, {}))) {
|
|
64
|
+
if (!registryMap[name]) {
|
|
65
|
+
registryMap[name] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return instance;
|
|
69
|
+
},
|
|
70
|
+
register(definition: RendererDefinition<T>): RendererRegistry<T> {
|
|
71
|
+
if (registryMap[definition.key]) {
|
|
72
|
+
throw new Error(`Duplicate renderer registration for "${definition.key}"`);
|
|
73
|
+
}
|
|
74
|
+
registryMap[definition.key] = definition.component;
|
|
75
|
+
return instance;
|
|
76
|
+
},
|
|
77
|
+
override(overrides: Record<string, T>): RendererRegistry<T> {
|
|
78
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
79
|
+
registryMap[name] = value;
|
|
80
|
+
}
|
|
81
|
+
return instance;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return instance;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createRendererPackage<T>(
|
|
89
|
+
options: RendererPackageOptions<T>,
|
|
90
|
+
): RendererPackage<T> {
|
|
91
|
+
return {
|
|
92
|
+
packageName: options.packageName,
|
|
93
|
+
version: options.version,
|
|
94
|
+
registry: createRegistry(options.renderers),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createPackageRegistry<T>(
|
|
99
|
+
packages: RendererPackage<T>[],
|
|
100
|
+
): RendererPackageRegistry<T> {
|
|
101
|
+
const byPackage = new Map(packages.map((item) => [item.packageName, item]));
|
|
102
|
+
|
|
103
|
+
function getPackage(packageName: string): RendererPackage<T> | undefined {
|
|
104
|
+
return byPackage.get(packageName);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findLocalRenderer(name: string): T | undefined {
|
|
108
|
+
for (const item of packages) {
|
|
109
|
+
const renderer = item.registry.get(name);
|
|
110
|
+
if (renderer) return renderer;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
get(name: string, packageName?: string | null): T | undefined {
|
|
117
|
+
if (packageName) {
|
|
118
|
+
return getPackage(packageName)?.registry.get(name);
|
|
119
|
+
}
|
|
120
|
+
return findLocalRenderer(name);
|
|
121
|
+
},
|
|
122
|
+
has(name: string, packageName?: string | null): boolean {
|
|
123
|
+
return Boolean(this.get(name, packageName));
|
|
124
|
+
},
|
|
125
|
+
names(): string[] {
|
|
126
|
+
return packages.flatMap((item) => item.registry.names());
|
|
127
|
+
},
|
|
128
|
+
packages(): RendererPackage<T>[] {
|
|
129
|
+
return packages;
|
|
130
|
+
},
|
|
131
|
+
getPackage,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { MarvinAsset } from '@inneropen/marvin-sdk';
|
|
2
|
+
import type { RendererEntry, RendererRequirement } from './types.js';
|
|
3
|
+
import { debug } from './debug.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RENDERER = 'page';
|
|
6
|
+
|
|
7
|
+
export function resolveRendererRequirement(entry: RendererEntry): RendererRequirement {
|
|
8
|
+
const config = entry.entryTypeInfo?.config ?? {};
|
|
9
|
+
const key = entry.entryTypeInfo?.renderer ?? DEFAULT_RENDERER;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
key,
|
|
13
|
+
packageName: entry.entryTypeInfo?.package ?? undefined,
|
|
14
|
+
versionRange: entry.entryTypeInfo?.version ?? undefined,
|
|
15
|
+
config,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveRendererName(entry: RendererEntry): string {
|
|
20
|
+
const name = entry.entryTypeInfo?.renderer ?? DEFAULT_RENDERER;
|
|
21
|
+
debug('resolveRendererName', { slug: entry.slug, renderer: name });
|
|
22
|
+
return name;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveRendererConfig(
|
|
26
|
+
entry: RendererEntry,
|
|
27
|
+
overrides?: Record<string, unknown>,
|
|
28
|
+
): Record<string, unknown> {
|
|
29
|
+
const base = entry.entryTypeInfo?.config ?? {};
|
|
30
|
+
const merged = overrides ? { ...base, ...overrides } : base;
|
|
31
|
+
debug('resolveRendererConfig', { slug: entry.slug, config: merged });
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The entry's field bag, merging `dataJson` under `data` (per-key, `data` wins).
|
|
37
|
+
*
|
|
38
|
+
* `data` is a REQUIRED field on a published entry, so the older
|
|
39
|
+
* `entry.data ?? entry.dataJson` short-circuited on an empty `{}` and never
|
|
40
|
+
* consulted `dataJson` — making that fallback dead. Merging keeps published
|
|
41
|
+
* behaviour identical (published entries carry no `dataJson`) while letting a
|
|
42
|
+
* platform-shaped entry's `dataJson` supply keys `data` doesn't have, matching
|
|
43
|
+
* how `extractField` already resolves values.
|
|
44
|
+
*/
|
|
45
|
+
function entryData(entry: RendererEntry): Record<string, unknown> {
|
|
46
|
+
return { ...(entry.dataJson ?? {}), ...(entry.data ?? {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function extractBody(entry: RendererEntry): string | undefined {
|
|
50
|
+
const body = entryData(entry).body;
|
|
51
|
+
if (typeof body === 'string') return body;
|
|
52
|
+
return entry.contentMarkdown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function extractField<T = unknown>(
|
|
56
|
+
entry: RendererEntry,
|
|
57
|
+
key: string,
|
|
58
|
+
): T | undefined {
|
|
59
|
+
return (entry.data?.[key] ?? entry.dataJson?.[key]) as T | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getFeaturedAsset(
|
|
63
|
+
entry: RendererEntry,
|
|
64
|
+
): MarvinAsset | undefined {
|
|
65
|
+
const featuredAsset = normalizeEntryAsset(entry.featuredAsset);
|
|
66
|
+
if (featuredAsset?.publicUrl) {
|
|
67
|
+
debug('getFeaturedAsset', { slug: entry.slug, source: 'featuredAsset', url: featuredAsset.publicUrl });
|
|
68
|
+
return featuredAsset as unknown as MarvinAsset;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const assets = entry.assets;
|
|
72
|
+
if (!assets?.length) return undefined;
|
|
73
|
+
|
|
74
|
+
const featured = assets.find(
|
|
75
|
+
(a) => {
|
|
76
|
+
const value = a as unknown as Record<string, unknown>;
|
|
77
|
+
const role = value.role ?? (value.metadata as Record<string, unknown> | undefined)?.role;
|
|
78
|
+
return role === 'featured' || role === 'hero';
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
const result = normalizeEntryAsset(featured ?? assets[0]) as unknown as MarvinAsset | undefined;
|
|
82
|
+
debug('getFeaturedAsset', { slug: entry.slug, source: featured ? 'role-match' : 'first-asset', url: result?.publicUrl });
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isRoutable(entry: RendererEntry): boolean {
|
|
87
|
+
return entry.entryTypeInfo?.routable !== false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function shouldRenderEntry(entry: RendererEntry, rendererName?: string): boolean {
|
|
91
|
+
const data = entryData(entry);
|
|
92
|
+
const flags = [
|
|
93
|
+
rendererName && typeof rendererName === 'string' ? data[rendererName] : undefined,
|
|
94
|
+
data.enabled,
|
|
95
|
+
data.visible,
|
|
96
|
+
data.show,
|
|
97
|
+
data.display,
|
|
98
|
+
data.include,
|
|
99
|
+
].filter((value): value is unknown => value !== undefined);
|
|
100
|
+
|
|
101
|
+
for (const value of flags) {
|
|
102
|
+
if (typeof value === 'boolean') {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof value === 'string') {
|
|
107
|
+
const normalized = value.trim().toLowerCase();
|
|
108
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
109
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof value === 'number') {
|
|
113
|
+
return value !== 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeEntryAsset(asset: unknown): Record<string, unknown> | undefined {
|
|
121
|
+
if (!asset || typeof asset !== 'object') return undefined;
|
|
122
|
+
|
|
123
|
+
const value = asset as Record<string, unknown>;
|
|
124
|
+
const nestedAsset = value.asset as Record<string, unknown> | undefined;
|
|
125
|
+
if (!nestedAsset) return value;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...nestedAsset,
|
|
129
|
+
role: value.role,
|
|
130
|
+
position: value.position,
|
|
131
|
+
metadata: value.metadata ?? value.metadataJson,
|
|
132
|
+
metadataJson: value.metadataJson ?? nestedAsset.metadataJson,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MarvinEntry } from '@inneropen/marvin-sdk';
|
|
2
|
+
import type { MarvinEmbeds } from '../embeds.js';
|
|
3
|
+
|
|
4
|
+
export interface EntryTypeInfo {
|
|
5
|
+
slug: string;
|
|
6
|
+
renderer?: string;
|
|
7
|
+
package?: string;
|
|
8
|
+
version?: string;
|
|
9
|
+
config?: Record<string, unknown>;
|
|
10
|
+
publishable: boolean;
|
|
11
|
+
submittable: boolean;
|
|
12
|
+
routable: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RendererEntry extends MarvinEntry {
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
dataJson?: Record<string, unknown>;
|
|
18
|
+
contentMarkdown?: string;
|
|
19
|
+
featuredAsset?: unknown;
|
|
20
|
+
entryTypeInfo?: EntryTypeInfo;
|
|
21
|
+
/** Resolved media embeds, keyed by the URL as written (Marvin with media embeds only). */
|
|
22
|
+
embeds?: MarvinEmbeds;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CoreRendererName = 'page' | 'article' | 'faq' | 'navigation';
|
|
26
|
+
|
|
27
|
+
export interface RendererProps {
|
|
28
|
+
entry: RendererEntry;
|
|
29
|
+
config?: Record<string, unknown>;
|
|
30
|
+
class?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RendererRequirement {
|
|
34
|
+
key: string;
|
|
35
|
+
packageName?: string;
|
|
36
|
+
versionRange?: string;
|
|
37
|
+
config: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RendererDefinition<T = unknown> {
|
|
41
|
+
key: string;
|
|
42
|
+
component: T;
|
|
43
|
+
defaultConfig?: Record<string, unknown>;
|
|
44
|
+
description?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RendererRegistry<T = unknown> {
|
|
48
|
+
get(name: string, packageName?: string | null): T | undefined;
|
|
49
|
+
has(name: string, packageName?: string | null): boolean;
|
|
50
|
+
names(): string[];
|
|
51
|
+
use?(packageDefinition: RendererPackage<T>): RendererRegistry<T>;
|
|
52
|
+
register?(definition: RendererDefinition<T>): RendererRegistry<T>;
|
|
53
|
+
override?(overrides: Record<string, T>): RendererRegistry<T>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RendererPackage<T = unknown> {
|
|
57
|
+
packageName: string;
|
|
58
|
+
version: string;
|
|
59
|
+
registry: RendererRegistry<T>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RendererPackageOptions<T = unknown> {
|
|
63
|
+
packageName: string;
|
|
64
|
+
version: string;
|
|
65
|
+
renderers: Record<string, T>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RendererPackageRegistry<T = unknown> extends RendererRegistry<T> {
|
|
69
|
+
packages(): RendererPackage<T>[];
|
|
70
|
+
getPackage(packageName: string): RendererPackage<T> | undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RendererRegistry } from './types.js';
|
|
2
|
+
|
|
3
|
+
export interface RendererCheckOptions {
|
|
4
|
+
registry: RendererRegistry | Record<string, unknown>;
|
|
5
|
+
apiUrl?: string;
|
|
6
|
+
siteToken?: string;
|
|
7
|
+
strict?: boolean;
|
|
8
|
+
ignore?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MissingRenderer {
|
|
12
|
+
entryTypeSlug: string;
|
|
13
|
+
entryTypeName: string;
|
|
14
|
+
renderer: string;
|
|
15
|
+
package?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ValidationResult {
|
|
20
|
+
total: number;
|
|
21
|
+
covered: number;
|
|
22
|
+
missing: MissingRenderer[];
|
|
23
|
+
}
|