includio-cms 0.14.0 → 0.14.2
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 +33 -0
- package/DOCS.md +4540 -0
- package/README.md +37 -42
- package/ROADMAP.md +13 -0
- package/dist/admin/client/account/preferences-section.svelte +1 -0
- package/dist/admin/client/account/profile-section.svelte +4 -1
- package/dist/admin/client/account/security-section.svelte +2 -2
- package/dist/admin/client/entry/entry-form.svelte +1 -1
- package/dist/admin/client/users/users-page.svelte +9 -9
- package/dist/admin/components/fields/blocks-field.svelte +1 -1
- package/dist/admin/components/fields/media-field.svelte +1 -0
- package/dist/admin/components/layout/layout-renderer.svelte +2 -1
- package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
- package/dist/admin/components/media/bulk-action-bar.svelte +2 -0
- package/dist/admin/components/media/file/file-details.svelte +1 -0
- package/dist/admin/components/media/focal-point-input.svelte +3 -2
- package/dist/admin/components/media/tag-sidebar.svelte +1 -0
- package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +2 -1
- package/dist/components/ui/dropdown-menu/index.d.ts +17 -0
- package/dist/components/ui/spinner/spinner.svelte +2 -2
- package/dist/components/ui/spinner/spinner.svelte.d.ts +4 -0
- package/dist/core/server/fields/utils/imageStyles.js +5 -4
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +27 -0
- package/dist/db-postgres/index.js +4 -4
- package/dist/db-postgres/schema/imageStyle.d.ts +17 -0
- package/dist/db-postgres/schema/imageStyle.js +3 -2
- package/dist/demo/seed.js +0 -1
- package/dist/sveltekit/components/cms-provider.svelte +17 -0
- package/dist/sveltekit/components/cms-provider.svelte.d.ts +12 -0
- package/dist/sveltekit/components/structured-content.svelte +1 -0
- package/dist/sveltekit/components/video-context.d.ts +2 -0
- package/dist/sveltekit/components/video-context.js +8 -0
- package/dist/sveltekit/components/video.svelte +12 -4
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/server/handle.js +9 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/sveltekit/server/layout.d.ts +4 -0
- package/dist/sveltekit/server/layout.js +5 -0
- package/dist/types/cms-context.d.ts +3 -0
- package/dist/types/cms-context.js +1 -0
- package/dist/types/fields.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/updates/0.14.1/index.d.ts +2 -0
- package/dist/updates/0.14.1/index.js +15 -0
- package/dist/updates/0.14.2/index.d.ts +2 -0
- package/dist/updates/0.14.2/index.js +18 -0
- package/dist/updates/index.js +3 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,58 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Includio CMS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A headless CMS built for SvelteKit. Type-safe, extensible, with a modern admin interface.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Features
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **19 field types** — text, structured content, media, relations, blocks, SEO, and more
|
|
8
|
+
- **Structured content** — ProseMirror-based rich text with inline blocks, tables, media embeds
|
|
9
|
+
- **Media management** — image styles (Sharp), video transcoding (ffmpeg), focal points, blur placeholders
|
|
10
|
+
- **Per-language versioning** — independent draft/published/scheduled per language
|
|
11
|
+
- **Layout DSL** — organize admin editor with sections, columns, cards, accordions
|
|
12
|
+
- **Frontend components** — `<Image>`, `<Video>`, `<StructuredContent>`, `<Media>` for SvelteKit
|
|
13
|
+
- **REST API** — API key auth, CRUD endpoints, schema introspection
|
|
14
|
+
- **Entity API** — server-side programmatic CRUD for scripts, migrations, webhooks
|
|
15
|
+
- **Code generation** — TypeScript types and Zod schemas from your content schema
|
|
16
|
+
- **Pluggable adapters** — swap database, file storage, email, and AI providers
|
|
17
|
+
- **Plugin system** — lifecycle hooks and custom field types
|
|
18
|
+
- **Video transcoding** — auto-transcode to mp4/webm with background processing
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
## Quick Start
|
|
10
21
|
|
|
11
22
|
```bash
|
|
12
|
-
|
|
13
|
-
npx sv create
|
|
14
|
-
|
|
15
|
-
# create a new project in my-app
|
|
16
|
-
npx sv create my-app
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Developing
|
|
20
|
-
|
|
21
|
-
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
npm run dev
|
|
25
|
-
|
|
26
|
-
# or start the server and open the app in a new browser tab
|
|
27
|
-
npm run dev -- --open
|
|
23
|
+
pnpm add includio-cms
|
|
28
24
|
```
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
```typescript
|
|
27
|
+
import { defineConfig } from 'includio-cms/sveltekit';
|
|
28
|
+
import { pg } from 'includio-cms/db-postgres';
|
|
29
|
+
import { local } from 'includio-cms/files-local';
|
|
30
|
+
|
|
31
|
+
export default defineConfig({
|
|
32
|
+
languages: ['en'],
|
|
33
|
+
db: pg({ databaseUrl: process.env.DATABASE_URL }),
|
|
34
|
+
files: local(),
|
|
35
|
+
collections: [/* ... */],
|
|
36
|
+
});
|
|
38
37
|
```
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
npm run build
|
|
44
|
-
```
|
|
39
|
+
See the [full documentation](/docs) for installation, configuration, and usage.
|
|
45
40
|
|
|
46
|
-
|
|
41
|
+
## Links
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
- [Full Documentation](DOCS.md)
|
|
44
|
+
- [Changelog](CHANGELOG.md)
|
|
45
|
+
- [Roadmap](ROADMAP.md)
|
|
49
46
|
|
|
50
|
-
##
|
|
47
|
+
## For AI Assistants
|
|
51
48
|
|
|
52
|
-
|
|
49
|
+
Full documentation is in [`DOCS.md`](DOCS.md) (shipped with the npm package). When working on a project using includio-cms, read `node_modules/includio-cms/DOCS.md` for the complete API reference, available components, and migration guides.
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
## License
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
npm publish
|
|
58
|
-
```
|
|
53
|
+
MIT
|
package/ROADMAP.md
CHANGED
|
@@ -256,6 +256,19 @@
|
|
|
256
256
|
- [x] `[feature]` `[P2]` System info endpoint — CMS version, Node, PostgreSQL, ffmpeg, sharp, OS <!-- files: src/lib/admin/api/system-info.ts, src/lib/core/server/media/operations/getSystemInfo.ts -->
|
|
257
257
|
- [x] `[feature]` `[P2]` Disk usage endpoint — breakdown per category (originals, image styles, video styles, posters) <!-- files: src/lib/admin/api/system-info.ts, src/lib/core/server/media/operations/getDiskUsage.ts -->
|
|
258
258
|
|
|
259
|
+
## 0.14.1 — CMS context, Svelte 5 compat, docs
|
|
260
|
+
|
|
261
|
+
- [x] `[feature]` `[P1]` CMS context provider — Safari mp4 preference for video delivery <!-- files: src/lib/sveltekit/components/cms-provider.svelte, src/lib/sveltekit/components/video-context.ts, src/lib/types/cms-context.ts -->
|
|
262
|
+
- [x] `[fix]` `[P1]` Svelte 5 compatibility — a11y annotations, warning suppression across admin components
|
|
263
|
+
- [x] `[chore]` `[P1]` Documentation overhaul — new pages, README rewrite, DOCS.md compilation script <!-- files: scripts/compile-docs.ts, DOCS.md -->
|
|
264
|
+
- [x] `[chore]` `[P2]` Remove obsolete docs/ Obsidian config & stale docs
|
|
265
|
+
|
|
266
|
+
## 0.14.2 — Image styles: aspectRatio, skip defaults, lazy generation
|
|
267
|
+
|
|
268
|
+
- [x] `[feature]` `[P1]` aspectRatio option for image styles — crop to ratio without fixed dimensions <!-- files: src/lib/types/fields.ts, src/lib/core/server/media/styles/sharp/generateImageStyle.ts, src/lib/db-postgres/schema/imageStyle.ts -->
|
|
269
|
+
- [x] `[feature]` `[P1]` Skip default styles when field defines custom styles (prevents `<source>` conflicts in `<picture>`) <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
|
|
270
|
+
- [x] `[feature]` `[P2]` Lazy generation of custom field styles on first read <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
|
|
271
|
+
|
|
259
272
|
## 0.15.0 — SEO module
|
|
260
273
|
|
|
261
274
|
- [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
const interfaceLanguage = useInterfaceLanguage();
|
|
18
18
|
const lang = $derived(accountLang[interfaceLanguage.current]);
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// superForm is initialized once — no need for reactive schema
|
|
21
|
+
const schema = createProfileSchema(lang.errors);
|
|
21
22
|
|
|
22
23
|
$effect(() => {
|
|
23
24
|
const sessionName = $session.data?.user.name;
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
|
|
88
89
|
<!-- Email readonly -->
|
|
89
90
|
<div class="flex flex-col gap-1">
|
|
91
|
+
tttttt<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
90
92
|
<label class="text-sm font-medium">{lang.profile.email}</label>
|
|
91
93
|
<div class="acct-input-wrap">
|
|
92
94
|
<Input
|
|
@@ -117,6 +119,7 @@
|
|
|
117
119
|
|
|
118
120
|
<!-- Role badge -->
|
|
119
121
|
<div class="flex flex-col gap-1">
|
|
122
|
+
tttttt<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
120
123
|
<label class="text-sm font-medium">{lang.profile.role}</label>
|
|
121
124
|
<div>
|
|
122
125
|
<span
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
let showNew = $state(false);
|
|
20
20
|
let showConfirm = $state(false);
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// superForm is initialized once — no need for reactive schema
|
|
23
|
+
const schema = createPasswordSchema(lang.errors);
|
|
24
24
|
const passwordForm = superForm(defaults(zod4(schema)), {
|
|
25
25
|
validators: zod4Client(schema),
|
|
26
26
|
SPA: true,
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
</script>
|
|
97
97
|
|
|
98
98
|
{#if layoutNodes}
|
|
99
|
-
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
|
|
99
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions a11y_no_static_element_interactions -->
|
|
100
100
|
<div class="layout-entry-form" onclick={handleClick}>
|
|
101
101
|
<LayoutRenderer
|
|
102
102
|
nodes={layoutNodes}
|
|
@@ -396,34 +396,34 @@
|
|
|
396
396
|
aria-label={lang.selectAll}
|
|
397
397
|
/>
|
|
398
398
|
</Table.Head>
|
|
399
|
-
<Table.Head>
|
|
399
|
+
<Table.Head aria-sort={getAriaSort('name')}>
|
|
400
|
+
{@const SortIconName = getSortIcon('name')}
|
|
400
401
|
<button
|
|
401
402
|
class="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide transition-colors hover:text-primary"
|
|
402
403
|
onclick={() => toggleSort('name')}
|
|
403
|
-
aria-sort={getAriaSort('name')}
|
|
404
404
|
>
|
|
405
405
|
{lang.name}
|
|
406
|
-
<
|
|
406
|
+
<SortIconName class="users-sort-icon size-3.5" />
|
|
407
407
|
</button>
|
|
408
408
|
</Table.Head>
|
|
409
|
-
<Table.Head>
|
|
409
|
+
<Table.Head aria-sort={getAriaSort('role')}>
|
|
410
|
+
{@const SortIconRole = getSortIcon('role')}
|
|
410
411
|
<button
|
|
411
412
|
class="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide transition-colors hover:text-primary"
|
|
412
413
|
onclick={() => toggleSort('role')}
|
|
413
|
-
aria-sort={getAriaSort('role')}
|
|
414
414
|
>
|
|
415
415
|
{lang.role}
|
|
416
|
-
<
|
|
416
|
+
<SortIconRole class="users-sort-icon size-3.5" />
|
|
417
417
|
</button>
|
|
418
418
|
</Table.Head>
|
|
419
|
-
<Table.Head>
|
|
419
|
+
<Table.Head aria-sort={getAriaSort('createdAt')}>
|
|
420
|
+
{@const SortIconCreatedat = getSortIcon('createdAt')}
|
|
420
421
|
<button
|
|
421
422
|
class="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide transition-colors hover:text-primary"
|
|
422
423
|
onclick={() => toggleSort('createdAt')}
|
|
423
|
-
aria-sort={getAriaSort('createdAt')}
|
|
424
424
|
>
|
|
425
425
|
{lang.createdAt}
|
|
426
|
-
<
|
|
426
|
+
<SortIconCreatedat class="users-sort-icon size-3.5" />
|
|
427
427
|
</button>
|
|
428
428
|
</Table.Head>
|
|
429
429
|
<Table.Head class="w-[120px]">
|
|
@@ -327,7 +327,7 @@
|
|
|
327
327
|
<div class="flex grow items-center justify-between gap-4">
|
|
328
328
|
<div class="flex items-center gap-4">
|
|
329
329
|
{#if !isFixedLength || fixedCount > 1}
|
|
330
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
330
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
|
331
331
|
<div
|
|
332
332
|
use:draggable={{
|
|
333
333
|
container: index.toString(),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as Accordion from '../../../components/ui/accordion/index.js';
|
|
9
9
|
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
10
10
|
import { getLocalizedLabel } from '../../utils/collectionLabel.js';
|
|
11
|
+
import LayoutRenderer from './layout-renderer.svelte';
|
|
11
12
|
import { cn } from '../../../utils.js';
|
|
12
13
|
import {
|
|
13
14
|
resolveFieldByPath,
|
|
@@ -106,7 +107,7 @@
|
|
|
106
107
|
{/snippet}
|
|
107
108
|
|
|
108
109
|
{#snippet recurse(childNodes: LayoutNode[])}
|
|
109
|
-
<
|
|
110
|
+
<LayoutRenderer
|
|
110
111
|
nodes={childNodes}
|
|
111
112
|
{fields}
|
|
112
113
|
{form}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LayoutNode } from '../../../types/layout.js';
|
|
2
2
|
import type { Field } from '../../../types/fields.js';
|
|
3
3
|
import type { SuperForm } from 'sveltekit-superforms';
|
|
4
|
+
import LayoutRenderer from './layout-renderer.svelte';
|
|
4
5
|
type Props = {
|
|
5
6
|
nodes: LayoutNode[];
|
|
6
7
|
fields: Field[];
|
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
onUpdateTagColor(colorPickerTag.id, color);
|
|
182
182
|
colorPickerTagId = null;
|
|
183
183
|
}}
|
|
184
|
+
aria-label="Set color to {color}"
|
|
184
185
|
></button>
|
|
185
186
|
{/each}
|
|
186
187
|
</div>
|
|
@@ -208,6 +209,7 @@
|
|
|
208
209
|
e.preventDefault();
|
|
209
210
|
colorPickerTagId = tag.id;
|
|
210
211
|
}}
|
|
212
|
+
aria-label="Change tag color"
|
|
211
213
|
></button>
|
|
212
214
|
{:else}
|
|
213
215
|
<span class="mr-2 h-3 w-3 shrink-0 rounded-full" style="background-color: {tag.color}"></span>
|
|
@@ -148,12 +148,13 @@
|
|
|
148
148
|
</p>
|
|
149
149
|
|
|
150
150
|
<!-- Focal point area -->
|
|
151
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
|
151
152
|
<div
|
|
152
153
|
bind:this={container}
|
|
153
|
-
role="
|
|
154
|
+
role="group"
|
|
154
155
|
tabindex="0"
|
|
155
156
|
aria-label={t.ariaLabel}
|
|
156
|
-
aria-
|
|
157
|
+
aria-roledescription="focal point picker"
|
|
157
158
|
class="relative cursor-crosshair overflow-hidden rounded-lg border select-none focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
|
158
159
|
onpointerdown={onPointerDown}
|
|
159
160
|
onpointermove={onPointerMove}
|
|
@@ -104,7 +104,8 @@
|
|
|
104
104
|
return {};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
// blockDef won't change for this node instance — capture once from static source
|
|
108
|
+
const allFields = ((editor.extensionManager.extensions.find((e) => e.name === 'inlineBlock')?.options as { inlineBlocks: ObjectField[] })?.inlineBlocks ?? []).find((b) => b.slug === node.attrs.blockType)?.fields ?? [];
|
|
108
109
|
const standaloneForm = createStandaloneForm(normalizeBlockData(parseBlockData(node.attrs.blockData), allFields));
|
|
109
110
|
const formStore = standaloneForm.form;
|
|
110
111
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
|
2
|
+
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
|
3
|
+
import Content from "./dropdown-menu-content.svelte";
|
|
4
|
+
import Group from "./dropdown-menu-group.svelte";
|
|
5
|
+
import Item from "./dropdown-menu-item.svelte";
|
|
6
|
+
import Label from "./dropdown-menu-label.svelte";
|
|
7
|
+
import RadioGroup from "./dropdown-menu-radio-group.svelte";
|
|
8
|
+
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
|
9
|
+
import Separator from "./dropdown-menu-separator.svelte";
|
|
10
|
+
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
|
11
|
+
import Trigger from "./dropdown-menu-trigger.svelte";
|
|
12
|
+
import SubContent from "./dropdown-menu-sub-content.svelte";
|
|
13
|
+
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
|
14
|
+
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
|
15
|
+
declare const Sub: typeof DropdownMenuPrimitive.Sub;
|
|
16
|
+
declare const Root: typeof DropdownMenuPrimitive.Root;
|
|
17
|
+
export { CheckboxItem, Content, Root as DropdownMenu, CheckboxItem as DropdownMenuCheckboxItem, Content as DropdownMenuContent, Group as DropdownMenuGroup, Item as DropdownMenuItem, Label as DropdownMenuLabel, RadioGroup as DropdownMenuRadioGroup, RadioItem as DropdownMenuRadioItem, Separator as DropdownMenuSeparator, Shortcut as DropdownMenuShortcut, Sub as DropdownMenuSub, SubContent as DropdownMenuSubContent, SubTrigger as DropdownMenuSubTrigger, Trigger as DropdownMenuTrigger, GroupHeading as DropdownMenuGroupHeading, Group, GroupHeading, Item, Label, RadioGroup, RadioItem, Root, Separator, Shortcut, Sub, SubContent, SubTrigger, Trigger, };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from "../../../utils.js";
|
|
3
3
|
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
|
4
|
-
import type {
|
|
4
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
5
5
|
|
|
6
|
-
let { class: className, ...restProps }:
|
|
6
|
+
let { class: className, ...restProps }: HTMLAttributes<SVGElement> = $props();
|
|
7
7
|
</script>
|
|
8
8
|
|
|
9
9
|
<Loader2Icon
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getImageStyle } from '../../media/styles/operations/getImageStyle.js';
|
|
2
2
|
import { getCMS } from '../../../cms.js';
|
|
3
3
|
import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
|
|
4
4
|
export const defaultStyles = [
|
|
@@ -61,13 +61,14 @@ async function ensureBlurDataUrl(val) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
export async function getImageStyles(field, val) {
|
|
64
|
-
const
|
|
64
|
+
const hasCustomStyles = field.styles && field.styles.length > 0;
|
|
65
|
+
const rawStyles = [...(isProcessableImage(val) && !hasCustomStyles ? defaultStyles : []), ...(field.styles || [])];
|
|
65
66
|
const originalFormat = getOriginalFormat(val);
|
|
66
67
|
const stylesArr = expandStyleFormats(rawStyles, originalFormat);
|
|
67
68
|
const [styles, blurDataUrl] = await Promise.all([
|
|
68
69
|
Promise.all(stylesArr.map(async (style) => {
|
|
69
70
|
try {
|
|
70
|
-
const styleDbData = await
|
|
71
|
+
const styleDbData = await getImageStyle(val.id, style);
|
|
71
72
|
if (!styleDbData)
|
|
72
73
|
return null;
|
|
73
74
|
const result = {
|
|
@@ -88,7 +89,7 @@ export async function getImageStyles(field, val) {
|
|
|
88
89
|
srcset: undefined,
|
|
89
90
|
sizes: undefined
|
|
90
91
|
};
|
|
91
|
-
const variantData = await
|
|
92
|
+
const variantData = await getImageStyle(val.id, variantStyle);
|
|
92
93
|
if (!variantData)
|
|
93
94
|
return null;
|
|
94
95
|
return `${variantData.url} ${w}w`;
|
|
@@ -31,6 +31,33 @@ export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
|
|
|
31
31
|
const width = style.width ?? imgWidth ?? mediaFile.width ?? undefined;
|
|
32
32
|
const height = style.height ?? undefined;
|
|
33
33
|
if (
|
|
34
|
+
// Aspect ratio crop
|
|
35
|
+
style.crop &&
|
|
36
|
+
style.aspectRatio &&
|
|
37
|
+
imgWidth &&
|
|
38
|
+
imgHeight) {
|
|
39
|
+
const targetAspect = style.aspectRatio;
|
|
40
|
+
const imgAspect = imgWidth / imgHeight;
|
|
41
|
+
let cropW, cropH;
|
|
42
|
+
if (imgAspect > targetAspect) {
|
|
43
|
+
cropH = imgHeight;
|
|
44
|
+
cropW = Math.round(imgHeight * targetAspect);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
cropW = imgWidth;
|
|
48
|
+
cropH = Math.round(imgWidth / targetAspect);
|
|
49
|
+
}
|
|
50
|
+
const fX = mediaFile.focalX ?? 0.5;
|
|
51
|
+
const fY = mediaFile.focalY ?? 0.5;
|
|
52
|
+
const region = calculateFocalCropRegion(imgWidth, imgHeight, fX, fY, cropW, cropH);
|
|
53
|
+
sharpInstance = sharpInstance.extract(region);
|
|
54
|
+
if (style.width || style.height) {
|
|
55
|
+
const resizeW = style.width ?? undefined;
|
|
56
|
+
const resizeH = style.height ?? (resizeW ? Math.round(resizeW / style.aspectRatio) : undefined);
|
|
57
|
+
sharpInstance = sharpInstance.resize(resizeW, resizeH);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (
|
|
34
61
|
// Focal point crop
|
|
35
62
|
style.crop &&
|
|
36
63
|
width &&
|
|
@@ -584,7 +584,7 @@ export function pg(config) {
|
|
|
584
584
|
const [imageStyle] = await db
|
|
585
585
|
.select()
|
|
586
586
|
.from(schema.imageStylesTable)
|
|
587
|
-
.where(and(eq(schema.imageStylesTable.mediaFileId, mediaFileId), eq(schema.imageStylesTable.name, style.name), style.width ? eq(schema.imageStylesTable.width, style.width) : undefined, style.height ? eq(schema.imageStylesTable.height, style.height) : undefined, style.quality ? eq(schema.imageStylesTable.quality, style.quality) : undefined))
|
|
587
|
+
.where(and(eq(schema.imageStylesTable.mediaFileId, mediaFileId), eq(schema.imageStylesTable.name, style.name), style.width ? eq(schema.imageStylesTable.width, style.width) : undefined, style.height ? eq(schema.imageStylesTable.height, style.height) : undefined, style.quality ? eq(schema.imageStylesTable.quality, style.quality) : undefined, style.aspectRatio ? eq(schema.imageStylesTable.aspectRatio, style.aspectRatio) : undefined))
|
|
588
588
|
.limit(1);
|
|
589
589
|
if (!imageStyle)
|
|
590
590
|
return null;
|
|
@@ -597,9 +597,9 @@ export function pg(config) {
|
|
|
597
597
|
createImageStyle: async (mediaFileId, file, style) => {
|
|
598
598
|
const mimeType = file.mimeType ?? `image/${style.format}`;
|
|
599
599
|
const rows = await db.execute(sql `
|
|
600
|
-
INSERT INTO image_styles (id, media_file_id, name, url, width, height, crop, quality, media, mime_type)
|
|
601
|
-
VALUES (gen_random_uuid(), ${mediaFileId}, ${style.name}, ${file.url}, ${style.width ?? null}, ${style.height ?? null}, ${style.crop ?? false}, ${style.quality ?? null}, ${style.media ?? null}, ${mimeType})
|
|
602
|
-
ON CONFLICT (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0))
|
|
600
|
+
INSERT INTO image_styles (id, media_file_id, name, url, width, height, crop, quality, media, mime_type, aspect_ratio)
|
|
601
|
+
VALUES (gen_random_uuid(), ${mediaFileId}, ${style.name}, ${file.url}, ${style.width ?? null}, ${style.height ?? null}, ${style.crop ?? false}, ${style.quality ?? null}, ${style.media ?? null}, ${mimeType}, ${style.aspectRatio ?? null})
|
|
602
|
+
ON CONFLICT (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0), COALESCE(aspect_ratio, 0))
|
|
603
603
|
DO UPDATE SET url = EXCLUDED.url, mime_type = EXCLUDED.mime_type
|
|
604
604
|
RETURNING url, mime_type, media
|
|
605
605
|
`);
|
|
@@ -172,6 +172,23 @@ export declare const imageStylesTable: import("drizzle-orm/pg-core/table", { wit
|
|
|
172
172
|
identity: undefined;
|
|
173
173
|
generated: undefined;
|
|
174
174
|
}, {}, {}>;
|
|
175
|
+
aspectRatio: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
|
|
176
|
+
name: "aspect_ratio";
|
|
177
|
+
tableName: "image_styles";
|
|
178
|
+
dataType: "number";
|
|
179
|
+
columnType: "PgReal";
|
|
180
|
+
data: number;
|
|
181
|
+
driverParam: string | number;
|
|
182
|
+
notNull: false;
|
|
183
|
+
hasDefault: false;
|
|
184
|
+
isPrimaryKey: false;
|
|
185
|
+
isAutoincrement: false;
|
|
186
|
+
hasRuntimeDefault: false;
|
|
187
|
+
enumValues: undefined;
|
|
188
|
+
baseColumn: never;
|
|
189
|
+
identity: undefined;
|
|
190
|
+
generated: undefined;
|
|
191
|
+
}, {}, {}>;
|
|
175
192
|
};
|
|
176
193
|
dialect: "pg";
|
|
177
194
|
}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { boolean, integer, pgTable, text, uuid } from 'drizzle-orm/pg-core';
|
|
1
|
+
import { boolean, integer, pgTable, real, text, uuid } from 'drizzle-orm/pg-core';
|
|
2
2
|
export const imageStylesTable = pgTable('image_styles', {
|
|
3
3
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
4
4
|
mediaFileId: uuid('media_file_id').notNull(),
|
|
@@ -9,7 +9,8 @@ export const imageStylesTable = pgTable('image_styles', {
|
|
|
9
9
|
crop: boolean('crop').notNull().default(false),
|
|
10
10
|
quality: integer('quality'),
|
|
11
11
|
media: text('media'),
|
|
12
|
-
mimeType: text('mime_type').notNull()
|
|
12
|
+
mimeType: text('mime_type').notNull(),
|
|
13
|
+
aspectRatio: real('aspect_ratio')
|
|
13
14
|
});
|
|
14
15
|
// NOTE: unique index on (media_file_id, name, COALESCE(width,0), COALESCE(height,0), COALESCE(quality,0))
|
|
15
16
|
// is managed via SQL migration in src/lib/updates/0.5.8/index.ts (expression-based, not supported by Drizzle ORM)
|
package/dist/demo/seed.js
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { setPreferMp4 } from './video-context.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
data: { cmsContext?: { preferMp4?: boolean } };
|
|
7
|
+
children?: Snippet;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { data, children }: Props = $props();
|
|
11
|
+
|
|
12
|
+
if (data?.cmsContext?.preferMp4) setPreferMp4(true);
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
{#if children}
|
|
16
|
+
{@render children()}
|
|
17
|
+
{/if}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
data: {
|
|
4
|
+
cmsContext?: {
|
|
5
|
+
preferMp4?: boolean;
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
children?: Snippet;
|
|
9
|
+
}
|
|
10
|
+
declare const CmsProvider: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type CmsProvider = ReturnType<typeof CmsProvider>;
|
|
12
|
+
export default CmsProvider;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { VideoFieldData } from '../../types/fields.js';
|
|
3
3
|
import type { HTMLVideoAttributes } from 'svelte/elements';
|
|
4
4
|
import { isHybridEnabled } from './hybrid-context.js';
|
|
5
|
+
import { getPreferMp4 } from './video-context.js';
|
|
5
6
|
|
|
6
7
|
type Props = HTMLVideoAttributes & {
|
|
7
8
|
data: VideoFieldData;
|
|
@@ -16,15 +17,22 @@
|
|
|
16
17
|
}: Props = $props();
|
|
17
18
|
|
|
18
19
|
const hybrid = isHybridEnabled();
|
|
20
|
+
const preferMp4 = getPreferMp4();
|
|
19
21
|
|
|
20
|
-
// Collect transcoded sources sorted
|
|
22
|
+
// Collect transcoded sources sorted by preferred format
|
|
21
23
|
const sortedSources = $derived.by(() => {
|
|
22
24
|
if (!data?.styles) return [];
|
|
23
25
|
const done = Object.values(data.styles).filter((s) => s.status === 'done' && s.url);
|
|
24
|
-
// webm first, then mp4
|
|
25
26
|
return done.sort((a, b) => {
|
|
26
|
-
if (
|
|
27
|
-
|
|
27
|
+
if (preferMp4) {
|
|
28
|
+
// mp4 first (hardware-accelerated on Safari)
|
|
29
|
+
if (a.mimeType === 'video/mp4' && b.mimeType !== 'video/mp4') return -1;
|
|
30
|
+
if (a.mimeType !== 'video/mp4' && b.mimeType === 'video/mp4') return 1;
|
|
31
|
+
} else {
|
|
32
|
+
// webm first (better compression for supporting browsers)
|
|
33
|
+
if (a.mimeType === 'video/webm' && b.mimeType !== 'video/webm') return -1;
|
|
34
|
+
if (a.mimeType !== 'video/webm' && b.mimeType === 'video/webm') return 1;
|
|
35
|
+
}
|
|
28
36
|
return 0;
|
|
29
37
|
});
|
|
30
38
|
});
|
|
@@ -5,9 +5,11 @@ export { default as Preview } from './components/preview.svelte';
|
|
|
5
5
|
export { default as HybridTarget } from './components/hybrid-target.svelte';
|
|
6
6
|
export { default as Image } from './components/image.svelte';
|
|
7
7
|
export { default as Video } from './components/video.svelte';
|
|
8
|
+
export { default as CmsProvider } from './components/cms-provider.svelte';
|
|
8
9
|
export { default as Media } from './components/media.svelte';
|
|
9
10
|
export { default as StructuredContent } from './components/structured-content.svelte';
|
|
10
11
|
export { enableHybridEditing } from './components/hybrid-context.js';
|
|
12
|
+
export { setPreferMp4 } from './components/video-context.js';
|
|
11
13
|
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
|
12
14
|
export { structuredToHtml } from '../core/fields/structuredToHtml.js';
|
|
13
15
|
export { extractBlocks, extractInlineBlocks, extractText, extractMediaRefs } from '../core/server/fields/queryStructuredContent.js';
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -5,9 +5,11 @@ export { default as Preview } from './components/preview.svelte';
|
|
|
5
5
|
export { default as HybridTarget } from './components/hybrid-target.svelte';
|
|
6
6
|
export { default as Image } from './components/image.svelte';
|
|
7
7
|
export { default as Video } from './components/video.svelte';
|
|
8
|
+
export { default as CmsProvider } from './components/cms-provider.svelte';
|
|
8
9
|
export { default as Media } from './components/media.svelte';
|
|
9
10
|
export { default as StructuredContent } from './components/structured-content.svelte';
|
|
10
11
|
export { enableHybridEditing } from './components/hybrid-context.js';
|
|
12
|
+
export { setPreferMp4 } from './components/video-context.js';
|
|
11
13
|
export { getLink, isImageFieldData, isVideoFieldData } from './utils/index.js';
|
|
12
14
|
export { structuredToHtml } from '../core/fields/structuredToHtml.js';
|
|
13
15
|
export { extractBlocks, extractInlineBlocks, extractText, extractMediaRefs } from '../core/server/fields/queryStructuredContent.js';
|