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.
Files changed (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/DOCS.md +4540 -0
  3. package/README.md +37 -42
  4. package/ROADMAP.md +13 -0
  5. package/dist/admin/client/account/preferences-section.svelte +1 -0
  6. package/dist/admin/client/account/profile-section.svelte +4 -1
  7. package/dist/admin/client/account/security-section.svelte +2 -2
  8. package/dist/admin/client/entry/entry-form.svelte +1 -1
  9. package/dist/admin/client/users/users-page.svelte +9 -9
  10. package/dist/admin/components/fields/blocks-field.svelte +1 -1
  11. package/dist/admin/components/fields/media-field.svelte +1 -0
  12. package/dist/admin/components/layout/layout-renderer.svelte +2 -1
  13. package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
  14. package/dist/admin/components/media/bulk-action-bar.svelte +2 -0
  15. package/dist/admin/components/media/file/file-details.svelte +1 -0
  16. package/dist/admin/components/media/focal-point-input.svelte +3 -2
  17. package/dist/admin/components/media/tag-sidebar.svelte +1 -0
  18. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +2 -1
  19. package/dist/components/ui/dropdown-menu/index.d.ts +17 -0
  20. package/dist/components/ui/spinner/spinner.svelte +2 -2
  21. package/dist/components/ui/spinner/spinner.svelte.d.ts +4 -0
  22. package/dist/core/server/fields/utils/imageStyles.js +5 -4
  23. package/dist/core/server/media/styles/sharp/generateImageStyle.js +27 -0
  24. package/dist/db-postgres/index.js +4 -4
  25. package/dist/db-postgres/schema/imageStyle.d.ts +17 -0
  26. package/dist/db-postgres/schema/imageStyle.js +3 -2
  27. package/dist/demo/seed.js +0 -1
  28. package/dist/sveltekit/components/cms-provider.svelte +17 -0
  29. package/dist/sveltekit/components/cms-provider.svelte.d.ts +12 -0
  30. package/dist/sveltekit/components/structured-content.svelte +1 -0
  31. package/dist/sveltekit/components/video-context.d.ts +2 -0
  32. package/dist/sveltekit/components/video-context.js +8 -0
  33. package/dist/sveltekit/components/video.svelte +12 -4
  34. package/dist/sveltekit/index.d.ts +2 -0
  35. package/dist/sveltekit/index.js +2 -0
  36. package/dist/sveltekit/server/handle.js +9 -0
  37. package/dist/sveltekit/server/index.d.ts +1 -0
  38. package/dist/sveltekit/server/index.js +1 -0
  39. package/dist/sveltekit/server/layout.d.ts +4 -0
  40. package/dist/sveltekit/server/layout.js +5 -0
  41. package/dist/types/cms-context.d.ts +3 -0
  42. package/dist/types/cms-context.js +1 -0
  43. package/dist/types/fields.d.ts +1 -0
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +1 -0
  46. package/dist/updates/0.14.1/index.d.ts +2 -0
  47. package/dist/updates/0.14.1/index.js +15 -0
  48. package/dist/updates/0.14.2/index.d.ts +2 -0
  49. package/dist/updates/0.14.2/index.js +18 -0
  50. package/dist/updates/index.js +3 -1
  51. package/package.json +8 -2
package/README.md CHANGED
@@ -1,58 +1,53 @@
1
- # Svelte library
1
+ # Includio CMS
2
2
 
3
- Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
3
+ A headless CMS built for SvelteKit. Type-safe, extensible, with a modern admin interface.
4
4
 
5
- Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
5
+ ## Features
6
6
 
7
- ## Creating a project
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
- If you're seeing this, you've probably already done this step. Congrats!
20
+ ## Quick Start
10
21
 
11
22
  ```bash
12
- # create a new project in the current directory
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
- Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
31
-
32
- ## Building
33
-
34
- To build your library:
35
-
36
- ```bash
37
- npm run package
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
- To create a production version of your showcase app:
41
-
42
- ```bash
43
- npm run build
44
- ```
39
+ See the [full documentation](/docs) for installation, configuration, and usage.
45
40
 
46
- You can preview the production build with `npm run preview`.
41
+ ## Links
47
42
 
48
- > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
43
+ - [Full Documentation](DOCS.md)
44
+ - [Changelog](CHANGELOG.md)
45
+ - [Roadmap](ROADMAP.md)
49
46
 
50
- ## Publishing
47
+ ## For AI Assistants
51
48
 
52
- Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
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
- To publish your library to [npm](https://www.npmjs.com):
51
+ ## License
55
52
 
56
- ```bash
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 -->
@@ -21,6 +21,7 @@
21
21
  </Card.Header>
22
22
  <Card.Content>
23
23
  <div class="flex flex-col gap-1">
24
+ <!-- svelte-ignore a11y_label_has_associated_control -->
24
25
  <label class="text-sm font-medium">{lang.language.select}</label>
25
26
  <Select.Root
26
27
  type="single"
@@ -17,7 +17,8 @@
17
17
  const interfaceLanguage = useInterfaceLanguage();
18
18
  const lang = $derived(accountLang[interfaceLanguage.current]);
19
19
 
20
- const schema = $derived(createProfileSchema(lang.errors));
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
- const schema = $derived(createPasswordSchema(lang.errors));
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
- <svelte:component this={getSortIcon('name')} class="users-sort-icon size-3.5" />
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
- <svelte:component this={getSortIcon('role')} class="users-sort-icon size-3.5" />
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
- <svelte:component this={getSortIcon('createdAt')} class="users-sort-icon size-3.5" />
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(),
@@ -114,6 +114,7 @@
114
114
 
115
115
  {#snippet videoPreview(file: MediaFile)}
116
116
  <div class="w-full h-full">
117
+ tt<!-- svelte-ignore a11y_media_has_caption -->
117
118
  <video
118
119
  controls
119
120
  poster={file.posterUrl || file.thumbnailUrl || undefined}
@@ -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
- <svelte:self
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>
@@ -272,6 +272,7 @@
272
272
  </div>
273
273
  {:else}
274
274
  <div class="w-full overflow-hidden rounded-lg shadow-sm">
275
+ tttttt<!-- svelte-ignore a11y_media_has_caption -->
275
276
  <video
276
277
  controls
277
278
  poster={file.posterUrl || file.thumbnailUrl || undefined}
@@ -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="application"
154
+ role="group"
154
155
  tabindex="0"
155
156
  aria-label={t.ariaLabel}
156
- aria-valuetext={t.ariaValueText(xPercent, yPercent)}
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}
@@ -271,6 +271,7 @@
271
271
  )}
272
272
  style="background-color: {color}"
273
273
  onclick={() => handleColorChange(tag, color)}
274
+ aria-label="Set color to {color}"
274
275
  ></button>
275
276
  {/each}
276
277
  </div>
@@ -104,7 +104,8 @@
104
104
  return {};
105
105
  }
106
106
 
107
- const allFields = blockDef?.fields ?? [];
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 { ComponentProps } from "svelte";
4
+ import type { HTMLAttributes } from "svelte/elements";
5
5
 
6
- let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
6
+ let { class: className, ...restProps }: HTMLAttributes<SVGElement> = $props();
7
7
  </script>
8
8
 
9
9
  <Loader2Icon
@@ -0,0 +1,4 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ declare const Spinner: import("svelte").Component<HTMLAttributes<SVGElement>, {}, "">;
3
+ type Spinner = ReturnType<typeof Spinner>;
4
+ export default Spinner;
@@ -1,4 +1,4 @@
1
- import { getImageStyleIfExists } from '../../media/styles/operations/getImageStyle.js';
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 rawStyles = [...(isProcessableImage(val) ? defaultStyles : []), ...(field.styles || [])];
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 getImageStyleIfExists(val.id, style);
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 getImageStyleIfExists(val.id, variantStyle);
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
@@ -1,5 +1,4 @@
1
1
  import { getCMS } from '../core/cms.js';
2
- import { v4 as uuid } from 'uuid';
3
2
  const blogPosts = [
4
3
  {
5
4
  title: 'Getting Started with Includio CMS',
@@ -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;
@@ -171,6 +171,7 @@
171
171
  {@render videoSnippet(node.attrs ?? {})}
172
172
  {:else}
173
173
  {@const attrs = node.attrs ?? {}}
174
+ <!-- svelte-ignore a11y_media_has_caption -->
174
175
  <video
175
176
  controls
176
177
  poster={attrs.poster as string ?? undefined}
@@ -0,0 +1,2 @@
1
+ export declare function setPreferMp4(value: boolean): void;
2
+ export declare function getPreferMp4(): boolean;
@@ -0,0 +1,8 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol('video-prefer-mp4');
3
+ export function setPreferMp4(value) {
4
+ setContext(KEY, value);
5
+ }
6
+ export function getPreferMp4() {
7
+ return !!getContext(KEY);
8
+ }
@@ -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: webm first (better compression), mp4 second
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 (a.mimeType === 'video/webm' && b.mimeType !== 'video/webm') return -1;
27
- if (a.mimeType !== 'video/webm' && b.mimeType === 'video/webm') return 1;
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';
@@ -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';