includio-cms 0.0.67 → 0.0.69
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/CHANGELOG.md +157 -0
- package/ROADMAP.md +73 -0
- package/dist/admin/client/entry/entry-form.svelte +1 -1
- package/dist/admin/client/entry/entry-form.svelte.d.ts +1 -1
- package/dist/admin/client/entry/entry.svelte +17 -6
- package/dist/admin/client/entry/hybrid/hybrid-context.svelte.d.ts +1 -0
- package/dist/admin/components/fields/array-field.svelte +126 -71
- package/dist/admin/components/fields/relation-field.svelte +6 -10
- package/dist/admin/components/layout/nav-search.svelte +43 -31
- package/dist/admin/remote/media.remote.js +3 -3
- package/dist/admin/utils/arrayMove.d.ts +5 -0
- package/dist/admin/utils/arrayMove.js +12 -0
- package/dist/cms/runtime/types.d.ts +8 -0
- package/dist/core/server/generator/fields.js +9 -3
- package/dist/core/server/generator/generator.js +4 -7
- package/dist/core/server/generator/utils.d.ts +1 -0
- package/dist/core/server/generator/utils.js +6 -0
- package/dist/core/server/media/styles/operations/generateDefaultStyles.d.ts +1 -0
- package/dist/core/server/media/styles/operations/generateDefaultStyles.js +17 -16
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +15 -5
- package/dist/core/server/utils/sanitizeRichText.d.ts +1 -0
- package/dist/core/server/utils/sanitizeRichText.js +67 -0
- package/dist/sveltekit/components/image.svelte +11 -2
- package/dist/sveltekit/components/preview.svelte +22 -14
- package/dist/sveltekit/components/preview.svelte.d.ts +38 -8
- package/dist/sveltekit/index.d.ts +1 -0
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/utils/getLink.d.ts +7 -0
- package/dist/sveltekit/utils/getLink.js +32 -0
- package/dist/sveltekit/utils/index.d.ts +2 -0
- package/dist/sveltekit/utils/index.js +2 -0
- package/dist/sveltekit/utils/media.d.ts +3 -0
- package/dist/sveltekit/utils/media.js +6 -0
- package/dist/types/index.d.ts +8 -1
- package/dist/types/index.js +7 -0
- package/dist/updates/0.0.68/index.d.ts +2 -0
- package/dist/updates/0.0.68/index.js +21 -0
- package/dist/updates/0.0.69/index.d.ts +2 -0
- package/dist/updates/0.0.69/index.js +12 -0
- package/dist/updates/index.js +3 -1
- package/package.json +7 -2
|
@@ -28,34 +28,49 @@
|
|
|
28
28
|
open = false;
|
|
29
29
|
goto(url);
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
async function getData() {
|
|
33
|
+
const [singles, collections, forms] = await Promise.all([
|
|
34
|
+
remotes.getSingles(),
|
|
35
|
+
remotes.getCollections(),
|
|
36
|
+
remotes.getForms()
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
return { singles, collections, forms };
|
|
40
|
+
}
|
|
31
41
|
</script>
|
|
32
42
|
|
|
33
43
|
<svelte:window {onkeydown} />
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
</
|
|
45
|
+
{#await getData() then { singles, collections, forms }}
|
|
46
|
+
<button
|
|
47
|
+
onclick={() => (open = true)}
|
|
48
|
+
class="text-muted-foreground mx-2 flex w-[calc(100%-1rem)] items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/10"
|
|
49
|
+
>
|
|
50
|
+
<SearchIcon class="size-4 shrink-0" />
|
|
51
|
+
<span class="flex-1 truncate text-left"
|
|
52
|
+
>{sidebarLang[interfaceLanguage.current].search.placeholder}</span
|
|
53
|
+
>
|
|
54
|
+
<kbd
|
|
55
|
+
class="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
|
56
|
+
>⌘K</kbd
|
|
57
|
+
>
|
|
58
|
+
</button>
|
|
43
59
|
|
|
44
|
-
<Command.Dialog bind:open title={sidebarLang[interfaceLanguage.current].search.placeholder}>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
<Command.Dialog bind:open title={sidebarLang[interfaceLanguage.current].search.placeholder}>
|
|
61
|
+
<Command.Input placeholder={sidebarLang[interfaceLanguage.current].search.placeholder} />
|
|
62
|
+
<Command.List>
|
|
63
|
+
<Command.Empty>{sidebarLang[interfaceLanguage.current].search.noResults}</Command.Empty>
|
|
48
64
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{#await remotes.getSingles() then singles}
|
|
65
|
+
<Command.Group heading={sidebarLang[interfaceLanguage.current].main.platform}>
|
|
66
|
+
<Command.Item onSelect={() => navigate('/admin')}>
|
|
67
|
+
<DashboardIcon class="mr-2 size-4" />
|
|
68
|
+
{sidebarLang[interfaceLanguage.current].main.dashboard}
|
|
69
|
+
</Command.Item>
|
|
70
|
+
<Command.Item onSelect={() => navigate('/admin/media')}>
|
|
71
|
+
<CameraIcon class="mr-2 size-4" />
|
|
72
|
+
{sidebarLang[interfaceLanguage.current].main.media}
|
|
73
|
+
</Command.Item>
|
|
59
74
|
{#each singles as item (item.slug)}
|
|
60
75
|
{@const name = getLocalizedLabel(item.label, interfaceLanguage.current) ?? item.slug}
|
|
61
76
|
<Command.Item onSelect={() => navigate(`/admin/entries/${item.slug}`)}>
|
|
@@ -67,14 +82,13 @@
|
|
|
67
82
|
{name}
|
|
68
83
|
</Command.Item>
|
|
69
84
|
{/each}
|
|
70
|
-
|
|
71
|
-
</Command.Group>
|
|
85
|
+
</Command.Group>
|
|
72
86
|
|
|
73
|
-
{#await remotes.getCollections() then collections}
|
|
74
87
|
{#if collections.length > 0}
|
|
75
88
|
<Command.Group heading={sidebarLang[interfaceLanguage.current].collections.title}>
|
|
76
89
|
{#each collections as item (item.slug)}
|
|
77
|
-
{@const name =
|
|
90
|
+
{@const name =
|
|
91
|
+
getLocalizedLabel(item.labels?.plural, interfaceLanguage.current) ?? item.slug}
|
|
78
92
|
<Command.Item onSelect={() => navigate(`/admin/collections/${item.slug}`)}>
|
|
79
93
|
{#if item.sidebarIcon}
|
|
80
94
|
<item.sidebarIcon class="mr-2 size-4" />
|
|
@@ -86,9 +100,7 @@
|
|
|
86
100
|
{/each}
|
|
87
101
|
</Command.Group>
|
|
88
102
|
{/if}
|
|
89
|
-
{/await}
|
|
90
103
|
|
|
91
|
-
{#await remotes.getForms() then forms}
|
|
92
104
|
{#if forms.length > 0}
|
|
93
105
|
<Command.Group heading={sidebarLang[interfaceLanguage.current].forms.title}>
|
|
94
106
|
{#each forms as item (item.slug)}
|
|
@@ -100,6 +112,6 @@
|
|
|
100
112
|
{/each}
|
|
101
113
|
</Command.Group>
|
|
102
114
|
{/if}
|
|
103
|
-
|
|
104
|
-
</Command.
|
|
105
|
-
|
|
115
|
+
</Command.List>
|
|
116
|
+
</Command.Dialog>
|
|
117
|
+
{/await}
|
|
@@ -100,11 +100,11 @@ export const setFocalPoint = command(z.object({
|
|
|
100
100
|
await cms.filesAdapter.deleteFile(fileName).catch((e) => console.warn('Style file cleanup failed:', e));
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
-
// Re-generate styles
|
|
103
|
+
// Re-generate styles with new focal point (awaited so client gets fresh URLs)
|
|
104
104
|
const file = await cms.databaseAdapter.getMediaFile({ data: { id: fileId } });
|
|
105
105
|
if (file) {
|
|
106
|
-
const {
|
|
107
|
-
|
|
106
|
+
const { generateDefaultStyles } = await import('../../core/server/media/styles/operations/generateDefaultStyles.js');
|
|
107
|
+
await generateDefaultStyles(file);
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
110
|
export const renameMediaFile = command(z.object({
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move an element from one index to another via splice, returning a new array.
|
|
3
|
+
* Returns the original array reference (unchanged) when `from === to`.
|
|
4
|
+
*/
|
|
5
|
+
export function arrayMove(arr, from, to) {
|
|
6
|
+
if (from === to)
|
|
7
|
+
return arr;
|
|
8
|
+
const copy = [...arr];
|
|
9
|
+
const [moved] = copy.splice(from, 1);
|
|
10
|
+
copy.splice(to, 0, moved);
|
|
11
|
+
return copy;
|
|
12
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ImageFieldData } from 'includio-cms/types';
|
|
2
2
|
export type SingleSlug = "settings" | "image-showcase";
|
|
3
3
|
export interface Settings {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
4
6
|
data: {
|
|
5
7
|
siteName: string;
|
|
6
8
|
description?: string;
|
|
@@ -17,6 +19,8 @@ export interface Settings {
|
|
|
17
19
|
publishedAt: Date | null;
|
|
18
20
|
}
|
|
19
21
|
export interface ImageShowcase {
|
|
22
|
+
id: string;
|
|
23
|
+
slug: string;
|
|
20
24
|
data: {
|
|
21
25
|
photo?: ImageFieldData | null;
|
|
22
26
|
};
|
|
@@ -27,6 +31,8 @@ export type SingleEntryMap = {
|
|
|
27
31
|
};
|
|
28
32
|
export type CollectionSlug = "blog-post" | "project";
|
|
29
33
|
export interface BlogPost {
|
|
34
|
+
id: string;
|
|
35
|
+
slug: string;
|
|
30
36
|
data: {
|
|
31
37
|
title: string;
|
|
32
38
|
slug?: string;
|
|
@@ -48,6 +54,8 @@ export interface BlogPost {
|
|
|
48
54
|
publishedAt: Date | null;
|
|
49
55
|
}
|
|
50
56
|
export interface Project {
|
|
57
|
+
id: string;
|
|
58
|
+
slug: string;
|
|
51
59
|
data: {
|
|
52
60
|
title: string;
|
|
53
61
|
description?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toPascalCase } from './utils.js';
|
|
1
2
|
function getFieldTypeAsString(field) {
|
|
2
3
|
switch (field.type) {
|
|
3
4
|
case 'text':
|
|
@@ -11,14 +12,19 @@ function getFieldTypeAsString(field) {
|
|
|
11
12
|
const base = field.multiple ? 'ImageFieldData[]' : 'ImageFieldData';
|
|
12
13
|
return base + (field.required ? '' : ' | null');
|
|
13
14
|
}
|
|
15
|
+
case 'media': {
|
|
16
|
+
const base = field.multiple
|
|
17
|
+
? '(ImageFieldData | VideoFieldData)[]'
|
|
18
|
+
: 'ImageFieldData | VideoFieldData';
|
|
19
|
+
return base + (field.required ? '' : ' | null');
|
|
20
|
+
}
|
|
14
21
|
case 'file': {
|
|
15
22
|
const base = field.multiple ? 'MediaFile[]' : 'MediaFile';
|
|
16
23
|
return base + (field.required ? '' : ' | null');
|
|
17
24
|
}
|
|
18
25
|
case 'relation': {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: field.collection.charAt(0).toUpperCase() + field.collection.slice(1);
|
|
26
|
+
const name = toPascalCase(field.collection);
|
|
27
|
+
return field.multiple ? `${name}[]` : name;
|
|
22
28
|
}
|
|
23
29
|
case 'select':
|
|
24
30
|
return field.multiple ? 'string[]' : 'string';
|
|
@@ -3,12 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { generateTsTypeFromFields } from './fields.js';
|
|
4
4
|
import { generateTsTypeFromFormFields } from './formFields.js';
|
|
5
5
|
import { generateZodSchemaStringFromFormFieldsAsString } from './formFieldSchemaToString.js';
|
|
6
|
-
|
|
7
|
-
return slug
|
|
8
|
-
.split('-')
|
|
9
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
10
|
-
.join('');
|
|
11
|
-
}
|
|
6
|
+
import { toPascalCase } from './utils.js';
|
|
12
7
|
function createCmsRuntimeDir() {
|
|
13
8
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
14
9
|
mkdirSync(cmsDir, { recursive: true });
|
|
@@ -22,6 +17,8 @@ function generateTypesStringForRecords(type, records) {
|
|
|
22
17
|
.map((single) => {
|
|
23
18
|
return `
|
|
24
19
|
export interface ${toPascalCase(single.slug)} {
|
|
20
|
+
id: string;
|
|
21
|
+
slug: string;
|
|
25
22
|
data: ${generateTsTypeFromFields(single.fields)}
|
|
26
23
|
publishedAt: Date | null;
|
|
27
24
|
};
|
|
@@ -71,7 +68,7 @@ function generateTypes(config) {
|
|
|
71
68
|
const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
|
|
72
69
|
const filePath = join(cmsDir, 'types.ts');
|
|
73
70
|
let code = `// This file is auto-generated. Do not edit directly.\n\n`;
|
|
74
|
-
code += `import type { MediaFile, ImageFieldData } from 'includio-cms/types';\n\n`;
|
|
71
|
+
code += `import type { MediaFile, ImageFieldData, VideoFieldData } from 'includio-cms/types';\n\n`;
|
|
75
72
|
if (config.singles && config.singles.length > 0) {
|
|
76
73
|
code += generateTypesStringForRecords('single', Object.values(config.singles));
|
|
77
74
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toPascalCase(slug: string): string;
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import { defaultStyles, isProcessableImage, expandStyleFormats, getOriginalFormat } from '../../../fields/utils/imageStyles.js';
|
|
2
2
|
import { getImageStyle } from './getImageStyle.js';
|
|
3
|
-
export function
|
|
3
|
+
export async function generateDefaultStyles(mediaFile) {
|
|
4
4
|
if (!isProcessableImage(mediaFile))
|
|
5
5
|
return;
|
|
6
6
|
const origFormat = getOriginalFormat(mediaFile);
|
|
7
7
|
const expanded = expandStyleFormats(defaultStyles, origFormat);
|
|
8
|
-
(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
});
|
|
21
|
-
}
|
|
8
|
+
for (const style of expanded) {
|
|
9
|
+
await getImageStyle(mediaFile.id, style);
|
|
10
|
+
if (style.srcset && mediaFile.width) {
|
|
11
|
+
const widths = style.srcset.filter((w) => w <= mediaFile.width);
|
|
12
|
+
for (const w of widths) {
|
|
13
|
+
await getImageStyle(mediaFile.id, {
|
|
14
|
+
...style,
|
|
15
|
+
name: `${style.name}_${w}w`,
|
|
16
|
+
width: w,
|
|
17
|
+
srcset: undefined,
|
|
18
|
+
sizes: undefined
|
|
19
|
+
});
|
|
22
20
|
}
|
|
23
21
|
}
|
|
24
|
-
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function generateDefaultStylesInBackground(mediaFile) {
|
|
25
|
+
generateDefaultStyles(mediaFile).catch((e) => console.warn('Background style generation failed:', e));
|
|
25
26
|
}
|
|
@@ -15,8 +15,18 @@ export async function generateImageStyle(mediaFileId, style) {
|
|
|
15
15
|
throw new Error('Media file not found');
|
|
16
16
|
}
|
|
17
17
|
const imageBuffer = await file.arrayBuffer();
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const buf = Buffer.from(imageBuffer);
|
|
19
|
+
// Read EXIF orientation before processing
|
|
20
|
+
const metadata = await sharp(buf).metadata();
|
|
21
|
+
// .rotate() applies EXIF orientation to pixels AND strips the tag from output.
|
|
22
|
+
// Prevents double-rotation in WebP/JPEG where EXIF orientation tag may persist.
|
|
23
|
+
let sharpInstance = sharp(buf).rotate();
|
|
24
|
+
// DB stores raw (pre-EXIF) dimensions. After .rotate(), pixels are oriented,
|
|
25
|
+
// so we need oriented dimensions for crop calculation.
|
|
26
|
+
const needsSwap = metadata.orientation != null && metadata.orientation >= 5;
|
|
27
|
+
const imgWidth = needsSwap ? metadata.height : metadata.width;
|
|
28
|
+
const imgHeight = needsSwap ? metadata.width : metadata.height;
|
|
29
|
+
const width = style.width ?? imgWidth ?? mediaFile.width ?? undefined;
|
|
20
30
|
const height = style.height ?? undefined;
|
|
21
31
|
if (
|
|
22
32
|
// Focal point crop
|
|
@@ -25,9 +35,9 @@ export async function generateImageStyle(mediaFileId, style) {
|
|
|
25
35
|
height &&
|
|
26
36
|
mediaFile.focalX != null &&
|
|
27
37
|
mediaFile.focalY != null &&
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const region = calculateFocalCropRegion(
|
|
38
|
+
imgWidth &&
|
|
39
|
+
imgHeight) {
|
|
40
|
+
const region = calculateFocalCropRegion(imgWidth, imgHeight, mediaFile.focalX, mediaFile.focalY, width, height);
|
|
31
41
|
sharpInstance = sharpInstance
|
|
32
42
|
.extract(region)
|
|
33
43
|
.resize(width, height);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sanitizeRichText(dirty: string): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
+
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
3
|
+
const tag = node.tagName.toLowerCase();
|
|
4
|
+
if (data.attrName === 'src' && !['img', 'video'].includes(tag)) {
|
|
5
|
+
data.keepAttr = false;
|
|
6
|
+
}
|
|
7
|
+
if (data.attrName === 'style') {
|
|
8
|
+
const value = node.getAttribute('style') || '';
|
|
9
|
+
const match = value.match(/text-align\s*:\s*(left|center|right|justify)/);
|
|
10
|
+
node.setAttribute('style', match ? `text-align: ${match[1]}` : '');
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
export function sanitizeRichText(dirty) {
|
|
14
|
+
return DOMPurify.sanitize(dirty, {
|
|
15
|
+
ALLOWED_TAGS: [
|
|
16
|
+
'p',
|
|
17
|
+
'h1',
|
|
18
|
+
'h2',
|
|
19
|
+
'h3',
|
|
20
|
+
'h4',
|
|
21
|
+
'h5',
|
|
22
|
+
'h6',
|
|
23
|
+
'strong',
|
|
24
|
+
'em',
|
|
25
|
+
's',
|
|
26
|
+
'code',
|
|
27
|
+
'ul',
|
|
28
|
+
'ol',
|
|
29
|
+
'li',
|
|
30
|
+
'blockquote',
|
|
31
|
+
'br',
|
|
32
|
+
'hr',
|
|
33
|
+
'a',
|
|
34
|
+
'img',
|
|
35
|
+
'table',
|
|
36
|
+
'thead',
|
|
37
|
+
'tbody',
|
|
38
|
+
'tr',
|
|
39
|
+
'th',
|
|
40
|
+
'td',
|
|
41
|
+
'mark',
|
|
42
|
+
'u',
|
|
43
|
+
'pre',
|
|
44
|
+
'video',
|
|
45
|
+
'figure',
|
|
46
|
+
'figcaption'
|
|
47
|
+
],
|
|
48
|
+
ALLOWED_ATTR: [
|
|
49
|
+
'href',
|
|
50
|
+
'target',
|
|
51
|
+
'rel',
|
|
52
|
+
'src',
|
|
53
|
+
'alt',
|
|
54
|
+
'title',
|
|
55
|
+
'class',
|
|
56
|
+
'colspan',
|
|
57
|
+
'rowspan',
|
|
58
|
+
'colwidth',
|
|
59
|
+
'style',
|
|
60
|
+
'poster',
|
|
61
|
+
'data-media-id',
|
|
62
|
+
'controls',
|
|
63
|
+
'width',
|
|
64
|
+
'height'
|
|
65
|
+
]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
const blurUrl = $derived(
|
|
25
25
|
data && isProperImageObject(data) ? data.blurDataUrl : null
|
|
26
26
|
);
|
|
27
|
+
|
|
28
|
+
let loaded = $state(false);
|
|
29
|
+
|
|
30
|
+
// Reset loaded when image source changes
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (data && isProperImageObject(data)) data.data?.url;
|
|
33
|
+
loaded = false;
|
|
34
|
+
});
|
|
27
35
|
</script>
|
|
28
36
|
|
|
29
37
|
<style>
|
|
@@ -52,8 +60,9 @@
|
|
|
52
60
|
alt={data.data.alt}
|
|
53
61
|
width={data.data.width}
|
|
54
62
|
height={data.data.height}
|
|
55
|
-
class="{className} {blurUrl ? 'includio-blur-placeholder' : ''}"
|
|
56
|
-
style={blurUrl ? `background-image:url(${blurUrl})` : undefined}
|
|
63
|
+
class="{className} {blurUrl && !loaded ? 'includio-blur-placeholder' : ''}"
|
|
64
|
+
style={blurUrl && !loaded ? `background-image:url(${blurUrl})` : undefined}
|
|
65
|
+
onload={() => (loaded = true)}
|
|
57
66
|
{loading}
|
|
58
67
|
{...restProps}
|
|
59
68
|
/>
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type {
|
|
1
|
+
<script lang="ts" generics="T extends { data: PopulatedEntryData }">
|
|
2
|
+
import type { PopulatedEntryData } from '../../types/entries.js';
|
|
3
3
|
import { onMount, type Snippet } from 'svelte';
|
|
4
4
|
|
|
5
5
|
type Props = {
|
|
6
|
-
entry:
|
|
7
|
-
child?: Snippet<[{ entry:
|
|
6
|
+
entry: T;
|
|
7
|
+
child?: Snippet<[{ entry: T }]>;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
let { entry, child }: Props = $props();
|
|
11
11
|
|
|
12
|
-
let previewData:
|
|
12
|
+
let previewData: T = $state(entry);
|
|
13
13
|
let hybridModeEnabled = $state(false);
|
|
14
14
|
let highlightedPath = $state<string | null>(null);
|
|
15
|
+
let parentOrigin: string | null = null;
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Checks if a value looks like a UUID reference
|
|
@@ -53,18 +54,18 @@
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
* Checks if a value is a simple resolved reference (has id
|
|
57
|
-
* e.g., { id: "...", url: "...", width: 800
|
|
57
|
+
* Checks if a value is a simple resolved reference (has id UUID at top level)
|
|
58
|
+
* e.g., { id: "...", slug: "...", type: "collection" } or { id: "...", url: "...", width: 800 }
|
|
58
59
|
*/
|
|
59
60
|
function isResolvedReference(
|
|
60
61
|
value: unknown
|
|
61
|
-
): value is { id: string;
|
|
62
|
+
): value is { id: string; [key: string]: unknown } {
|
|
62
63
|
return (
|
|
63
64
|
typeof value === 'object' &&
|
|
64
65
|
value !== null &&
|
|
65
66
|
'id' in value &&
|
|
66
|
-
'
|
|
67
|
-
|
|
67
|
+
typeof (value as Record<string, unknown>).id === 'string' &&
|
|
68
|
+
isUUID((value as Record<string, unknown>).id)
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -232,12 +233,19 @@
|
|
|
232
233
|
|
|
233
234
|
onMount(() => {
|
|
234
235
|
function handleMessage(e: MessageEvent) {
|
|
236
|
+
// Accept first message from any origin (handshake), then lock to that origin
|
|
237
|
+
if (!parentOrigin) {
|
|
238
|
+
parentOrigin = e.origin;
|
|
239
|
+
} else if (e.origin !== parentOrigin) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
235
243
|
if (e.data.type === 'preview-update') {
|
|
236
244
|
// Merge form data with current preview data, preserving resolved references
|
|
237
245
|
previewData = {
|
|
238
246
|
...previewData,
|
|
239
|
-
data: deepMergeWithReferences(previewData.data, e.data.data)
|
|
240
|
-
};
|
|
247
|
+
data: deepMergeWithReferences(previewData.data as PopulatedEntryData, e.data.data)
|
|
248
|
+
} as T;
|
|
241
249
|
}
|
|
242
250
|
|
|
243
251
|
if (e.data.type === 'hybrid-mode-enable') {
|
|
@@ -257,7 +265,7 @@
|
|
|
257
265
|
document.addEventListener('click', handleLinkClick, true);
|
|
258
266
|
|
|
259
267
|
// Signal to parent that preview is ready to receive data
|
|
260
|
-
window.parent.postMessage({ type: 'preview-ready' }, '*');
|
|
268
|
+
window.parent.postMessage({ type: 'preview-ready' }, parentOrigin || '*');
|
|
261
269
|
|
|
262
270
|
return () => {
|
|
263
271
|
window.removeEventListener('message', handleMessage);
|
|
@@ -319,7 +327,7 @@
|
|
|
319
327
|
if (path) {
|
|
320
328
|
// Normalize to dot notation (hero[0].data → hero.0.data)
|
|
321
329
|
const normalized = path.replace(/\[(\d+)\]/g, '.$1');
|
|
322
|
-
window.parent.postMessage({ type: 'hybrid-focus', path: normalized }, '*');
|
|
330
|
+
window.parent.postMessage({ type: 'hybrid-focus', path: normalized }, parentOrigin || '*');
|
|
323
331
|
}
|
|
324
332
|
}
|
|
325
333
|
|
|
@@ -1,11 +1,41 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PopulatedEntryData } from '../../types/entries.js';
|
|
2
2
|
import { type Snippet } from 'svelte';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
declare function $$render<T extends {
|
|
4
|
+
data: PopulatedEntryData;
|
|
5
|
+
}>(): {
|
|
6
|
+
props: {
|
|
7
|
+
entry: T;
|
|
8
|
+
child?: Snippet<[{
|
|
9
|
+
entry: T;
|
|
10
|
+
}]>;
|
|
11
|
+
};
|
|
12
|
+
exports: {};
|
|
13
|
+
bindings: "";
|
|
14
|
+
slots: {};
|
|
15
|
+
events: {};
|
|
8
16
|
};
|
|
9
|
-
declare
|
|
10
|
-
|
|
17
|
+
declare class __sveltets_Render<T extends {
|
|
18
|
+
data: PopulatedEntryData;
|
|
19
|
+
}> {
|
|
20
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
21
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
22
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
23
|
+
bindings(): "";
|
|
24
|
+
exports(): {};
|
|
25
|
+
}
|
|
26
|
+
interface $$IsomorphicComponent {
|
|
27
|
+
new <T extends {
|
|
28
|
+
data: PopulatedEntryData;
|
|
29
|
+
}>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
30
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
31
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
32
|
+
<T extends {
|
|
33
|
+
data: PopulatedEntryData;
|
|
34
|
+
}>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
35
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
36
|
+
}
|
|
37
|
+
declare const Preview: $$IsomorphicComponent;
|
|
38
|
+
type Preview<T extends {
|
|
39
|
+
data: PopulatedEntryData;
|
|
40
|
+
}> = InstanceType<typeof Preview<T>>;
|
|
11
41
|
export default Preview;
|
|
@@ -5,3 +5,4 @@ export { default as HybridTarget } from './components/hybrid-target.svelte';
|
|
|
5
5
|
export { default as Image } from './components/image.svelte';
|
|
6
6
|
export { default as Video } from './components/video.svelte';
|
|
7
7
|
export { default as Media } from './components/media.svelte';
|
|
8
|
+
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -5,3 +5,4 @@ export { default as HybridTarget } from './components/hybrid-target.svelte';
|
|
|
5
5
|
export { default as Image } from './components/image.svelte';
|
|
6
6
|
export { default as Video } from './components/video.svelte';
|
|
7
7
|
export { default as Media } from './components/media.svelte';
|
|
8
|
+
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a link value to a URL string.
|
|
3
|
+
* Handles: string, { url: string }, { url: Record<string, string> } (UrlFieldData), null/undefined.
|
|
4
|
+
*/
|
|
5
|
+
export declare function getLink(link: string | {
|
|
6
|
+
url: string | Record<string, string>;
|
|
7
|
+
} | null | undefined, language?: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a link value to a URL string.
|
|
3
|
+
* Handles: string, { url: string }, { url: Record<string, string> } (UrlFieldData), null/undefined.
|
|
4
|
+
*/
|
|
5
|
+
export function getLink(link, language) {
|
|
6
|
+
if (!link)
|
|
7
|
+
return '';
|
|
8
|
+
if (typeof link === 'object' && 'url' in link) {
|
|
9
|
+
const url = link.url;
|
|
10
|
+
if (typeof url === 'object') {
|
|
11
|
+
// UrlFieldData — localized Record<string, string>
|
|
12
|
+
if (language && url[language])
|
|
13
|
+
return normalizeLink(url[language]);
|
|
14
|
+
// Fallback to first available locale
|
|
15
|
+
const first = Object.values(url)[0];
|
|
16
|
+
return first ? normalizeLink(first) : '';
|
|
17
|
+
}
|
|
18
|
+
return normalizeLink(url);
|
|
19
|
+
}
|
|
20
|
+
if (typeof link !== 'string')
|
|
21
|
+
return '';
|
|
22
|
+
return normalizeLink(link);
|
|
23
|
+
}
|
|
24
|
+
function normalizeLink(link) {
|
|
25
|
+
if (link.startsWith('#') ||
|
|
26
|
+
link.startsWith('http://') ||
|
|
27
|
+
link.startsWith('https://') ||
|
|
28
|
+
link.startsWith('/')) {
|
|
29
|
+
return link;
|
|
30
|
+
}
|
|
31
|
+
return `/${link}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ImageFieldData, VideoFieldData, MediaFieldData } from '../../types/fields.js';
|
|
2
|
+
export declare function isImageFieldData(media: MediaFieldData): media is ImageFieldData;
|
|
3
|
+
export declare function isVideoFieldData(media: MediaFieldData): media is VideoFieldData;
|