smoodly 0.0.10 → 0.0.12
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 +108 -8
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/forms/CropEditor.d.ts +10 -0
- package/dist/admin/forms/CropEditor.js +61 -0
- package/dist/admin/forms/FieldWidget.js +4 -4
- package/dist/admin/forms/MediaGrid.d.ts +11 -0
- package/dist/admin/forms/MediaGrid.js +16 -0
- package/dist/admin/forms/MediaPicker.d.ts +8 -0
- package/dist/admin/forms/MediaPicker.js +44 -0
- package/dist/admin/forms/MediaWidget.d.ts +9 -0
- package/dist/admin/forms/MediaWidget.js +83 -0
- package/dist/admin/forms/assets-context.d.ts +19 -0
- package/dist/admin/forms/assets-context.js +35 -0
- package/dist/admin/forms/crop-editor.d.ts +13 -0
- package/dist/admin/forms/crop-editor.js +72 -0
- package/dist/admin/forms/measure.d.ts +2 -0
- package/dist/admin/forms/measure.js +39 -0
- package/dist/admin/forms/upload.d.ts +17 -0
- package/dist/admin/forms/upload.js +33 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +83 -2
- package/dist/admin/ops.d.ts +27 -0
- package/dist/admin/serialize.d.ts +5 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +0 -6
- package/dist/admin/shell/AdminApp.js +10 -17
- package/dist/admin/shell/IconRail.d.ts +7 -0
- package/dist/admin/shell/IconRail.js +73 -0
- package/dist/admin/shell/MediaScreen.d.ts +8 -0
- package/dist/admin/shell/MediaScreen.js +86 -0
- package/dist/admin/shell/media-list.d.ts +8 -0
- package/dist/admin/shell/media-list.js +14 -0
- package/dist/admin/ui/Modal.d.ts +8 -0
- package/dist/admin/ui/Modal.js +15 -0
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +3 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +107 -24
- package/dist/admin/validate.js +6 -0
- package/dist/asset-store.d.ts +86 -0
- package/dist/asset-store.js +141 -0
- package/dist/collections.js +2 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -5
- package/dist/fields.d.ts +8 -10
- package/dist/fields.js +14 -1
- package/dist/image/SmoodlyImage.d.ts +18 -0
- package/dist/image/SmoodlyImage.js +16 -0
- package/dist/image/SmoodlyVideo.d.ts +7 -0
- package/dist/image/SmoodlyVideo.js +5 -0
- package/dist/image/crop-math.d.ts +14 -0
- package/dist/image/crop-math.js +38 -0
- package/dist/image/index.d.ts +6 -0
- package/dist/image/index.js +5 -0
- package/dist/image/loaders.d.ts +15 -0
- package/dist/image/loaders.js +5 -0
- package/dist/image/next-config.d.ts +15 -0
- package/dist/image/next-config.js +27 -0
- package/dist/image-dimensions.d.ts +4 -0
- package/dist/image-dimensions.js +25 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/media.d.ts +108 -0
- package/dist/media.js +129 -0
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +25 -4
- package/dist/resolve.d.ts +19 -4
- package/dist/resolve.js +55 -2
- package/dist/site.d.ts +2 -0
- package/dist/site.js +21 -3
- package/dist/sql-space.d.ts +8 -1
- package/dist/sql-space.js +28 -0
- package/dist/sql.js +30 -0
- package/dist/store.js +3 -0
- package/dist/supabase-asset-store.d.ts +21 -0
- package/dist/supabase-asset-store.js +170 -0
- package/package.json +9 -1
package/dist/config.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
|
-
|
|
2
|
+
import { DEFAULT_MEDIA } from "./media.js";
|
|
3
|
+
/** `shared`: the placement node type and an admin route; `asset`: the refs
|
|
4
|
+
* target of every media edge; `media`: the admin's Media screen route. */
|
|
5
|
+
export const RESERVED_NAMES = ["shared", "asset", "media"];
|
|
6
|
+
const isReserved = (name) => RESERVED_NAMES.includes(name);
|
|
3
7
|
function assertUniqueNames(config) {
|
|
4
8
|
const seen = new Set();
|
|
5
9
|
const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
|
|
@@ -11,8 +15,8 @@ function assertUniqueNames(config) {
|
|
|
11
15
|
...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
|
|
12
16
|
];
|
|
13
17
|
for (const name of all) {
|
|
14
|
-
if (name
|
|
15
|
-
throw new Error(`smoodly: "${
|
|
18
|
+
if (isReserved(name))
|
|
19
|
+
throw new Error(`smoodly: "${name}" is a reserved name.`);
|
|
16
20
|
if (seen.has(name))
|
|
17
21
|
throw new Error(`smoodly: duplicate registered name "${name}".`);
|
|
18
22
|
seen.add(name);
|
|
@@ -29,8 +33,8 @@ function assertUniqueNames(config) {
|
|
|
29
33
|
const sharedSeen = new Set();
|
|
30
34
|
for (const item of config.registry.shared ?? []) {
|
|
31
35
|
const ownSection = item.name === item.section && sectionNames.has(item.name);
|
|
32
|
-
if (item.name
|
|
33
|
-
throw new Error(`smoodly: "${
|
|
36
|
+
if (isReserved(item.name))
|
|
37
|
+
throw new Error(`smoodly: "${item.name}" is a reserved name.`);
|
|
34
38
|
if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
|
|
35
39
|
throw new Error(`smoodly: duplicate registered name "${item.name}".`);
|
|
36
40
|
}
|
|
@@ -143,10 +147,18 @@ export function defineConfig(config) {
|
|
|
143
147
|
}
|
|
144
148
|
const standard = Object.fromEntries(Object.entries(config.styles?.sections ?? {}).map(([k, b]) => [k, b.descriptor]));
|
|
145
149
|
const byName = new Map((config.registry.sections ?? []).map((s) => [s.schema.name, s.schema]));
|
|
150
|
+
const media = {
|
|
151
|
+
maxSize: config.media?.maxSize ?? DEFAULT_MEDIA.maxSize,
|
|
152
|
+
accept: config.media?.accept ? [...config.media.accept] : [...DEFAULT_MEDIA.accept],
|
|
153
|
+
};
|
|
154
|
+
if (!Number.isInteger(media.maxSize) || media.maxSize <= 0) {
|
|
155
|
+
throw new Error("smoodly: media.maxSize must be a positive integer of bytes.");
|
|
156
|
+
}
|
|
146
157
|
return {
|
|
147
158
|
...config,
|
|
148
159
|
homePageSlug,
|
|
149
160
|
pageDepth: depth,
|
|
161
|
+
media,
|
|
150
162
|
resolvedStyles(sectionName) {
|
|
151
163
|
const schema = byName.get(sectionName);
|
|
152
164
|
if (!schema)
|
package/dist/fields.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Toolset } from "./toolset.ts";
|
|
2
|
+
import { type ResolvedFile, type ResolvedImage, type ResolvedVideo } from "./media.ts";
|
|
2
3
|
export type Descriptor = {
|
|
3
4
|
type: string;
|
|
4
5
|
[key: string]: unknown;
|
|
@@ -49,16 +50,13 @@ export declare const f: {
|
|
|
49
50
|
}) => FieldBuilder<string, {}>;
|
|
50
51
|
date: () => FieldBuilder<string, {}>;
|
|
51
52
|
number: () => FieldBuilder<number, {}>;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
file: () => FieldBuilder<{
|
|
60
|
-
url: string;
|
|
61
|
-
}, {}>;
|
|
53
|
+
/** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
|
|
54
|
+
* constraint, so the component knows its box before the image loads. */
|
|
55
|
+
image: (opts?: {
|
|
56
|
+
aspect?: string | string[];
|
|
57
|
+
}) => FieldBuilder<ResolvedImage, {}>;
|
|
58
|
+
video: () => FieldBuilder<ResolvedVideo, {}>;
|
|
59
|
+
file: () => FieldBuilder<ResolvedFile, {}>;
|
|
62
60
|
richtext: (opts?: {
|
|
63
61
|
toolset?: Toolset;
|
|
64
62
|
}) => FieldBuilder<unknown, {}>;
|
package/dist/fields.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// f.text().max(40) is plain data; validation logic lives server/admin-side,
|
|
3
3
|
// keyed by descriptor type. Chaining is immutable.
|
|
4
4
|
import { DEFAULT_TOOLSET } from "./toolset.js";
|
|
5
|
+
import { parseAspect } from "./media.js";
|
|
5
6
|
function builder(descriptor) {
|
|
6
7
|
const chain = (patch) => builder({ ...descriptor, ...patch });
|
|
7
8
|
return {
|
|
@@ -56,7 +57,19 @@ export const f = {
|
|
|
56
57
|
slug: (opts) => builder({ type: "slug", ...(opts?.from ? { from: opts.from } : {}) }),
|
|
57
58
|
date: () => builder({ type: "date" }),
|
|
58
59
|
number: () => builder({ type: "number" }),
|
|
59
|
-
|
|
60
|
+
/** `aspect`: one `"w:h"` or several the crop tool may lock to — a per-use
|
|
61
|
+
* constraint, so the component knows its box before the image loads. */
|
|
62
|
+
image: (opts) => {
|
|
63
|
+
const aspect = opts?.aspect;
|
|
64
|
+
if (aspect !== undefined) {
|
|
65
|
+
const list = Array.isArray(aspect) ? aspect : [aspect];
|
|
66
|
+
if (list.length === 0)
|
|
67
|
+
throw new Error('smoodly: f.image aspect must name at least one "w:h" ratio.');
|
|
68
|
+
for (const a of list)
|
|
69
|
+
parseAspect(a);
|
|
70
|
+
}
|
|
71
|
+
return builder({ type: "image", ...(aspect !== undefined ? { aspect } : {}) });
|
|
72
|
+
},
|
|
60
73
|
video: () => builder({ type: "video" }),
|
|
61
74
|
file: () => builder({ type: "file" }),
|
|
62
75
|
richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
2
|
+
import type { ResolvedImage } from "../media.ts";
|
|
3
|
+
import type { ImageLoader } from "./loaders.ts";
|
|
4
|
+
export type SmoodlyImageProps = {
|
|
5
|
+
image: ResolvedImage;
|
|
6
|
+
/** Overrides the value's aspect for this placement (`"21:9"`). */
|
|
7
|
+
aspect?: string;
|
|
8
|
+
sizes?: string;
|
|
9
|
+
priority?: boolean;
|
|
10
|
+
loading?: "eager" | "lazy";
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
/** Overrides the resolved alt (rarely right — the editor owns it). */
|
|
14
|
+
alt?: string;
|
|
15
|
+
loader?: ImageLoader;
|
|
16
|
+
unoptimized?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function SmoodlyImage({ image, aspect, sizes, priority, loading, className, style, alt, loader, unoptimized }: SmoodlyImageProps): ReactElement;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// <SmoodlyImage>: a box at the aspect (declared, chosen or natural), the
|
|
3
|
+
// image inside positioned by crop and hotspot, dimensions set so nothing
|
|
4
|
+
// shifts. Wraps next/image; the only file besides SmoodlyVideo that a
|
|
5
|
+
// site imports from the media layer. Server-safe — no "use client".
|
|
6
|
+
import Image from "next/image";
|
|
7
|
+
import { boxAspectOf, frameFor } from "./crop-math.js";
|
|
8
|
+
export function SmoodlyImage({ image, aspect, sizes, priority, loading, className, style, alt, loader, unoptimized }) {
|
|
9
|
+
const box = boxAspectOf(image, aspect);
|
|
10
|
+
const frame = loader?.handlesCrop ? { left: 0, top: 0, width: 100, height: 100 } : frameFor(image, box);
|
|
11
|
+
const svg = image.mime === "image/svg+xml";
|
|
12
|
+
return (_jsx("span", { className: className, style: { display: "block", position: "relative", overflow: "hidden", aspectRatio: String(box), ...style }, children: _jsx(Image, { src: image.url, alt: alt ?? image.alt, width: image.width, height: image.height, sizes: sizes, priority: priority, loading: loading, unoptimized: unoptimized ?? svg, ...(loader ? { loader: ({ width, quality }) => loader.url(image, { width, quality }) } : {}), style: {
|
|
13
|
+
position: "absolute", left: `${frame.left}%`, top: `${frame.top}%`,
|
|
14
|
+
width: `${frame.width}%`, height: `${frame.height}%`, maxWidth: "none",
|
|
15
|
+
} }) }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReactElement, VideoHTMLAttributes } from "react";
|
|
2
|
+
import type { ResolvedVideo } from "../media.ts";
|
|
3
|
+
export type SmoodlyVideoProps = {
|
|
4
|
+
video: ResolvedVideo;
|
|
5
|
+
} & Omit<VideoHTMLAttributes<HTMLVideoElement>, "src" | "poster" | "width" | "height">;
|
|
6
|
+
/** A <video> over the bucket's CDN with its poster and size. No transcoding (spec §9). */
|
|
7
|
+
export declare function SmoodlyVideo({ video, ...rest }: SmoodlyVideoProps): ReactElement;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/** A <video> over the bucket's CDN with its poster and size. No transcoding (spec §9). */
|
|
3
|
+
export function SmoodlyVideo({ video, ...rest }) {
|
|
4
|
+
return (_jsx("video", { src: video.url, poster: video.poster?.url, width: video.width, height: video.height, playsInline: true, ...rest }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Crop, type Hotspot, type ResolvedImage } from "../media.ts";
|
|
2
|
+
export type Frame = {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function boxAspectOf(image: ResolvedImage, override?: string): number;
|
|
9
|
+
export declare function frameFor(image: {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
crop?: Crop;
|
|
13
|
+
hotspot?: Hotspot;
|
|
14
|
+
}, boxAspect: number): Frame;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The render-side frame: where the image sits inside its box so the crop
|
|
2
|
+
// covers it and the hotspot stays as central as the crop allows. Box
|
|
3
|
+
// units: height 1, width = the box's aspect. Percentages out, so the
|
|
4
|
+
// component never needs the box's pixel size (spec 2026-09-08 media §9).
|
|
5
|
+
import { CENTER_HOTSPOT, FULL_CROP, clamp01, cropAspect, parseAspect } from "../media.js";
|
|
6
|
+
export function boxAspectOf(image, override) {
|
|
7
|
+
if (override)
|
|
8
|
+
return parseAspect(override);
|
|
9
|
+
if (image.aspect)
|
|
10
|
+
return parseAspect(image.aspect);
|
|
11
|
+
const imageAspect = image.width / image.height;
|
|
12
|
+
return image.crop ? cropAspect(image.crop, imageAspect) : imageAspect;
|
|
13
|
+
}
|
|
14
|
+
export function frameFor(image, boxAspect) {
|
|
15
|
+
const crop = image.crop ?? FULL_CROP;
|
|
16
|
+
const hotspot = image.hotspot ?? CENTER_HOTSPOT;
|
|
17
|
+
const imageAspect = image.width / image.height;
|
|
18
|
+
const ca = cropAspect(crop, imageAspect);
|
|
19
|
+
// Scale the image so the crop covers the box: fit the crop's height when
|
|
20
|
+
// the crop is wider than the box, its width when it is taller.
|
|
21
|
+
let w, h;
|
|
22
|
+
if (ca >= boxAspect) {
|
|
23
|
+
h = 1 / crop.height;
|
|
24
|
+
w = h * imageAspect;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
w = boxAspect / crop.width;
|
|
28
|
+
h = w / imageAspect;
|
|
29
|
+
}
|
|
30
|
+
const cropW = crop.width * w, cropH = crop.height * h;
|
|
31
|
+
const hx = clamp01((hotspot.x - crop.left) / crop.width);
|
|
32
|
+
const hy = clamp01((hotspot.y - crop.top) / crop.height);
|
|
33
|
+
const winLeft = Math.min(Math.max(hx * cropW - boxAspect / 2, 0), Math.max(cropW - boxAspect, 0));
|
|
34
|
+
const winTop = Math.min(Math.max(hy * cropH - 0.5, 0), Math.max(cropH - 1, 0));
|
|
35
|
+
const left = -(crop.left * w) - winLeft;
|
|
36
|
+
const top = -(crop.top * h) - winTop;
|
|
37
|
+
return { left: (left / boxAspect) * 100, top: top * 100, width: (w / boxAspect) * 100, height: h * 100 };
|
|
38
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SmoodlyImage, type SmoodlyImageProps } from "./SmoodlyImage.tsx";
|
|
2
|
+
export { SmoodlyVideo, type SmoodlyVideoProps } from "./SmoodlyVideo.tsx";
|
|
3
|
+
export { imageUrl, nextLoader, type ImageLoader, type ImageUrlOptions } from "./loaders.ts";
|
|
4
|
+
export { frameFor, boxAspectOf, type Frame } from "./crop-math.ts";
|
|
5
|
+
export { smoodlyImages, isPrivateHost, type RemotePattern, type SmoodlyImagesConfig } from "./next-config.ts";
|
|
6
|
+
export type { ResolvedImage, ResolvedVideo, ResolvedFile, Crop, Hotspot } from "../media.ts";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SmoodlyImage } from "./SmoodlyImage.js";
|
|
2
|
+
export { SmoodlyVideo } from "./SmoodlyVideo.js";
|
|
3
|
+
export { imageUrl, nextLoader } from "./loaders.js";
|
|
4
|
+
export { frameFor, boxAspectOf } from "./crop-math.js";
|
|
5
|
+
export { smoodlyImages, isPrivateHost } from "./next-config.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedImage } from "../media.ts";
|
|
2
|
+
export type ImageUrlOptions = {
|
|
3
|
+
width?: number;
|
|
4
|
+
quality?: number;
|
|
5
|
+
format?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ImageLoader = {
|
|
8
|
+
name: string;
|
|
9
|
+
/** True when the URL already applies crop and hotspot. */
|
|
10
|
+
handlesCrop: boolean;
|
|
11
|
+
url(image: ResolvedImage, opts: ImageUrlOptions): string;
|
|
12
|
+
};
|
|
13
|
+
export declare const nextLoader: ImageLoader;
|
|
14
|
+
/** The escape hatch: a URL for a width, through the loader. */
|
|
15
|
+
export declare function imageUrl(image: ResolvedImage, opts?: ImageUrlOptions, loader?: ImageLoader): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type RemotePattern = {
|
|
2
|
+
protocol: "http" | "https";
|
|
3
|
+
hostname: string;
|
|
4
|
+
port?: string;
|
|
5
|
+
pathname: string;
|
|
6
|
+
};
|
|
7
|
+
export type SmoodlyImagesConfig = {
|
|
8
|
+
remotePatterns: RemotePattern[];
|
|
9
|
+
dangerouslyAllowLocalIP?: true;
|
|
10
|
+
};
|
|
11
|
+
/** `localhost`, loopback and the RFC 1918 ranges — the hosts a local
|
|
12
|
+
* Supabase stack answers on. A public hostname that happens to resolve
|
|
13
|
+
* privately (a Docker alias) is not detected; set the flag by hand then. */
|
|
14
|
+
export declare function isPrivateHost(hostname: string): boolean;
|
|
15
|
+
export declare function smoodlyImages(url?: string | undefined): SmoodlyImagesConfig;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** `localhost`, loopback and the RFC 1918 ranges — the hosts a local
|
|
2
|
+
* Supabase stack answers on. A public hostname that happens to resolve
|
|
3
|
+
* privately (a Docker alias) is not detected; set the flag by hand then. */
|
|
4
|
+
export function isPrivateHost(hostname) {
|
|
5
|
+
const h = hostname.replace(/^\[|\]$/g, "");
|
|
6
|
+
if (h === "localhost" || h.endsWith(".localhost") || h === "::1")
|
|
7
|
+
return true;
|
|
8
|
+
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h);
|
|
9
|
+
if (!m)
|
|
10
|
+
return false;
|
|
11
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
12
|
+
return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
|
|
13
|
+
}
|
|
14
|
+
export function smoodlyImages(url = process.env.SMOODLY_URL) {
|
|
15
|
+
if (!url) {
|
|
16
|
+
console.warn("smoodly: SMOODLY_URL is not set — next/image will refuse Supabase media URLs until it is.");
|
|
17
|
+
return { remotePatterns: [] };
|
|
18
|
+
}
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
const pattern = {
|
|
21
|
+
protocol: u.protocol === "http:" ? "http" : "https",
|
|
22
|
+
hostname: u.hostname,
|
|
23
|
+
...(u.port ? { port: u.port } : {}),
|
|
24
|
+
pathname: "/storage/v1/object/public/media/**",
|
|
25
|
+
};
|
|
26
|
+
return { remotePatterns: [pattern], ...(isPrivateHost(u.hostname) ? { dangerouslyAllowLocalIP: true } : {}) };
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Header-only image dimensions for the two formats the starter ships
|
|
2
|
+
// (JPEG, PNG). The seed and AssetStore.create use it server-side, where
|
|
3
|
+
// there is no <img> to measure with and no sharp to depend on. Null for
|
|
4
|
+
// anything else — the row then carries no dimensions.
|
|
5
|
+
export function imageDimensions(bytes) {
|
|
6
|
+
if (bytes.length >= 24 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
7
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
8
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
9
|
+
}
|
|
10
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
11
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
12
|
+
let i = 2;
|
|
13
|
+
while (i + 9 < bytes.length) {
|
|
14
|
+
if (bytes[i] !== 0xff)
|
|
15
|
+
return null;
|
|
16
|
+
const marker = bytes[i + 1];
|
|
17
|
+
// SOF0..SOF15 except DHT (C4), JPG (C8) and DAC (CC) carry the frame size
|
|
18
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
19
|
+
return { height: view.getUint16(i + 5), width: view.getUint16(i + 7) };
|
|
20
|
+
}
|
|
21
|
+
i += 2 + view.getUint16(i + 2);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,11 +12,17 @@ export type { PageSchema } from "./page.ts";
|
|
|
12
12
|
export { fixedSlug } from "./page.ts";
|
|
13
13
|
export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
|
|
14
14
|
export { f } from "./fields.ts";
|
|
15
|
+
export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.ts";
|
|
16
|
+
export type { Crop, Hotspot, AssetKind, ImageValue, VideoValue, FileValue, AssetMeta, AssetRecord, ResolvedImage, ResolvedVideo, ResolvedFile, } from "./media.ts";
|
|
15
17
|
export { z } from "./zones.ts";
|
|
16
18
|
export { defineConfig } from "./config.ts";
|
|
17
19
|
export { collectionSQL, coreTablesSQL } from "./sql.ts";
|
|
18
20
|
export { MemoryPageStore } from "./store.ts";
|
|
21
|
+
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.ts";
|
|
22
|
+
export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
|
|
23
|
+
export { imageDimensions } from "./image-dimensions.ts";
|
|
19
24
|
export { SupabasePageStore } from "./supabase-store.ts";
|
|
25
|
+
export { SupabaseAssetStore } from "./supabase-asset-store.ts";
|
|
20
26
|
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
|
21
27
|
export type { RefEdge, SectionLike } from "./refs.ts";
|
|
22
28
|
export { MemoryRefIndex } from "./ref-index.ts";
|
|
@@ -29,8 +35,8 @@ export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, Li
|
|
|
29
35
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
|
|
30
36
|
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.ts";
|
|
31
37
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
32
|
-
export { resolveTree, resolveFields } from "./resolve.ts";
|
|
33
|
-
export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
|
|
38
|
+
export { resolveTree, resolveFields, resolveMedia } from "./resolve.ts";
|
|
39
|
+
export type { EntryFetcher, ResolveFieldsOptions, AssetFetcher } from "./resolve.ts";
|
|
34
40
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.ts";
|
|
35
41
|
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
|
|
36
42
|
export { fixedPathFor } from "./localize.ts";
|
package/dist/index.js
CHANGED
|
@@ -10,11 +10,15 @@ export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
|
|
|
10
10
|
export { RichText } from "./richtext-render.js";
|
|
11
11
|
export { fixedSlug } from "./page.js";
|
|
12
12
|
export { f } from "./fields.js";
|
|
13
|
+
export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.js";
|
|
13
14
|
export { z } from "./zones.js";
|
|
14
15
|
export { defineConfig } from "./config.js";
|
|
15
16
|
export { collectionSQL, coreTablesSQL } from "./sql.js";
|
|
16
17
|
export { MemoryPageStore } from "./store.js";
|
|
18
|
+
export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
|
|
19
|
+
export { imageDimensions } from "./image-dimensions.js";
|
|
17
20
|
export { SupabasePageStore } from "./supabase-store.js";
|
|
21
|
+
export { SupabaseAssetStore } from "./supabase-asset-store.js";
|
|
18
22
|
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
19
23
|
export { MemoryRefIndex } from "./ref-index.js";
|
|
20
24
|
export { MemoryPathIndex } from "./path-index.js";
|
|
@@ -23,7 +27,7 @@ export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
|
23
27
|
export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
24
28
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
|
|
25
29
|
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.js";
|
|
26
|
-
export { resolveTree, resolveFields } from "./resolve.js";
|
|
30
|
+
export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
|
|
27
31
|
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
28
32
|
export { fixedPathFor } from "./localize.js";
|
|
29
33
|
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
package/dist/media.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
|
+
export type Crop = {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
};
|
|
8
|
+
export type Hotspot = {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
};
|
|
12
|
+
export type AssetKind = "image" | "video" | "file";
|
|
13
|
+
export type ImageValue = {
|
|
14
|
+
asset: string;
|
|
15
|
+
alt?: string;
|
|
16
|
+
crop?: Crop;
|
|
17
|
+
hotspot?: Hotspot;
|
|
18
|
+
aspect?: string;
|
|
19
|
+
};
|
|
20
|
+
export type VideoValue = {
|
|
21
|
+
asset: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
};
|
|
24
|
+
export type FileValue = {
|
|
25
|
+
asset: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
};
|
|
28
|
+
export type AssetMeta = {
|
|
29
|
+
title?: string | null;
|
|
30
|
+
alt?: string | null;
|
|
31
|
+
caption?: string | null;
|
|
32
|
+
credit?: string | null;
|
|
33
|
+
};
|
|
34
|
+
export type AssetRecord = {
|
|
35
|
+
id: string;
|
|
36
|
+
kind: AssetKind;
|
|
37
|
+
status: "pending" | "ready";
|
|
38
|
+
/** The object path in the bucket: `<space_id>/<id>.<ext>`. */
|
|
39
|
+
path: string;
|
|
40
|
+
mime: string;
|
|
41
|
+
size: number;
|
|
42
|
+
width: number | null;
|
|
43
|
+
height: number | null;
|
|
44
|
+
duration: number | null;
|
|
45
|
+
posterId: string | null;
|
|
46
|
+
filename: string;
|
|
47
|
+
title: string | null;
|
|
48
|
+
alt: string | null;
|
|
49
|
+
caption: string | null;
|
|
50
|
+
credit: string | null;
|
|
51
|
+
/** Derived by the store from the bucket's public URL, never stored. */
|
|
52
|
+
url: string;
|
|
53
|
+
createdAt: number;
|
|
54
|
+
updatedAt: number;
|
|
55
|
+
};
|
|
56
|
+
export type ResolvedImage = {
|
|
57
|
+
url: string;
|
|
58
|
+
width: number;
|
|
59
|
+
height: number;
|
|
60
|
+
mime: string;
|
|
61
|
+
alt: string;
|
|
62
|
+
crop?: Crop;
|
|
63
|
+
hotspot?: Hotspot;
|
|
64
|
+
aspect?: string;
|
|
65
|
+
};
|
|
66
|
+
export type ResolvedVideo = {
|
|
67
|
+
url: string;
|
|
68
|
+
width?: number;
|
|
69
|
+
height?: number;
|
|
70
|
+
duration?: number;
|
|
71
|
+
mime: string;
|
|
72
|
+
poster?: ResolvedImage;
|
|
73
|
+
title?: string;
|
|
74
|
+
};
|
|
75
|
+
export type ResolvedFile = {
|
|
76
|
+
url: string;
|
|
77
|
+
mime: string;
|
|
78
|
+
size: number;
|
|
79
|
+
filename: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
};
|
|
82
|
+
/** The refs target collection of every asset edge — a reserved registered name. */
|
|
83
|
+
export declare const ASSET_COLLECTION = "asset";
|
|
84
|
+
export declare const MEDIA_TYPES: ReadonlySet<string>;
|
|
85
|
+
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
86
|
+
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
87
|
+
* and next/image may keep one for as long as they like. */
|
|
88
|
+
export declare const MEDIA_CACHE_SECONDS = 31536000;
|
|
89
|
+
export declare const DEFAULT_MEDIA: {
|
|
90
|
+
readonly maxSize: number;
|
|
91
|
+
readonly accept: readonly ["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif", "image/svg+xml", "video/mp4", "video/webm", "application/pdf"];
|
|
92
|
+
};
|
|
93
|
+
export declare const FULL_CROP: Crop;
|
|
94
|
+
export declare const CENTER_HOTSPOT: Hotspot;
|
|
95
|
+
export declare const clamp01: (n: number) => number;
|
|
96
|
+
/** `"16:9"` → 16/9. Throws on anything else, so a typo fails at registration. */
|
|
97
|
+
export declare function parseAspect(s: string): number;
|
|
98
|
+
/** The crop's own pixel ratio, given the image's. */
|
|
99
|
+
export declare const cropAspect: (crop: Crop, imageAspect: number) => number;
|
|
100
|
+
export declare const isMediaValue: (v: unknown) => v is {
|
|
101
|
+
asset: string;
|
|
102
|
+
};
|
|
103
|
+
/** Every asset id a value set references, walking lists and objects —
|
|
104
|
+
* unlike refs, media may sit inside a container (spec §8). */
|
|
105
|
+
export declare function collectAssetIds(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, out: Set<string>): void;
|
|
106
|
+
/** Replace every media value in place with its resolved object — or delete
|
|
107
|
+
* it when the asset is missing or pending, so `image?.url` guards hold. */
|
|
108
|
+
export declare function applyAssets(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, assets: Map<string, AssetRecord>): void;
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** The refs target collection of every asset edge — a reserved registered name. */
|
|
2
|
+
export const ASSET_COLLECTION = "asset";
|
|
3
|
+
export const MEDIA_TYPES = new Set(["image", "video", "file"]);
|
|
4
|
+
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
5
|
+
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
6
|
+
* and next/image may keep one for as long as they like. */
|
|
7
|
+
export const MEDIA_CACHE_SECONDS = 31536000;
|
|
8
|
+
export const DEFAULT_MEDIA = {
|
|
9
|
+
maxSize: 25 * 1024 * 1024,
|
|
10
|
+
accept: [
|
|
11
|
+
"image/jpeg", "image/png", "image/webp", "image/avif", "image/gif", "image/svg+xml",
|
|
12
|
+
"video/mp4", "video/webm", "application/pdf",
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
export const FULL_CROP = { left: 0, top: 0, width: 1, height: 1 };
|
|
16
|
+
export const CENTER_HOTSPOT = { x: 0.5, y: 0.5 };
|
|
17
|
+
export const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
18
|
+
const ASPECT_RE = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/;
|
|
19
|
+
/** `"16:9"` → 16/9. Throws on anything else, so a typo fails at registration. */
|
|
20
|
+
export function parseAspect(s) {
|
|
21
|
+
const m = ASPECT_RE.exec(s);
|
|
22
|
+
const w = m ? Number(m[1]) : 0;
|
|
23
|
+
const h = m ? Number(m[2]) : 0;
|
|
24
|
+
if (!m || w <= 0 || h <= 0)
|
|
25
|
+
throw new Error(`smoodly: "${s}" is not an aspect — use "w:h", like "16:9".`);
|
|
26
|
+
return w / h;
|
|
27
|
+
}
|
|
28
|
+
/** The crop's own pixel ratio, given the image's. */
|
|
29
|
+
export const cropAspect = (crop, imageAspect) => (crop.width * imageAspect) / crop.height;
|
|
30
|
+
export const isMediaValue = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && typeof v.asset === "string";
|
|
31
|
+
/** Every asset id a value set references, walking lists and objects —
|
|
32
|
+
* unlike refs, media may sit inside a container (spec §8). */
|
|
33
|
+
export function collectAssetIds(values, descriptors, out) {
|
|
34
|
+
if (!values)
|
|
35
|
+
return;
|
|
36
|
+
for (const [key, d] of Object.entries(descriptors)) {
|
|
37
|
+
const value = values[key];
|
|
38
|
+
if (value == null)
|
|
39
|
+
continue;
|
|
40
|
+
if (MEDIA_TYPES.has(d.type)) {
|
|
41
|
+
if (isMediaValue(value))
|
|
42
|
+
out.add(value.asset);
|
|
43
|
+
}
|
|
44
|
+
else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
|
|
45
|
+
collectAssetIds(value, d.fields, out);
|
|
46
|
+
}
|
|
47
|
+
else if (d.type === "list" && Array.isArray(value)) {
|
|
48
|
+
const item = d.item;
|
|
49
|
+
for (const v of value) {
|
|
50
|
+
if (MEDIA_TYPES.has(item.type)) {
|
|
51
|
+
if (isMediaValue(v))
|
|
52
|
+
out.add(v.asset);
|
|
53
|
+
}
|
|
54
|
+
else if (item.type === "object" && v && typeof v === "object") {
|
|
55
|
+
collectAssetIds(v, item.fields, out);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function resolvedImage(asset, value, d) {
|
|
62
|
+
if (asset.width === null || asset.height === null)
|
|
63
|
+
return undefined;
|
|
64
|
+
const declared = d.aspect;
|
|
65
|
+
const aspect = value.aspect ?? (typeof declared === "string" ? declared : undefined);
|
|
66
|
+
return {
|
|
67
|
+
url: asset.url, width: asset.width, height: asset.height, mime: asset.mime,
|
|
68
|
+
alt: value.alt ?? asset.alt ?? "",
|
|
69
|
+
...(value.crop ? { crop: value.crop } : {}),
|
|
70
|
+
...(value.hotspot ? { hotspot: value.hotspot } : {}),
|
|
71
|
+
...(aspect ? { aspect } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function resolveOne(value, d, assets) {
|
|
75
|
+
if (!isMediaValue(value))
|
|
76
|
+
return undefined;
|
|
77
|
+
const asset = assets.get(value.asset);
|
|
78
|
+
if (!asset || asset.status !== "ready")
|
|
79
|
+
return undefined;
|
|
80
|
+
if (d.type === "image")
|
|
81
|
+
return resolvedImage(asset, value, d);
|
|
82
|
+
if (d.type === "video") {
|
|
83
|
+
const v = value;
|
|
84
|
+
const poster = asset.posterId ? assets.get(asset.posterId) : undefined;
|
|
85
|
+
const posterResolved = poster ? resolvedImage(poster, { asset: poster.id }, { type: "image" }) : undefined;
|
|
86
|
+
return {
|
|
87
|
+
url: asset.url, mime: asset.mime,
|
|
88
|
+
...(asset.width !== null ? { width: asset.width } : {}),
|
|
89
|
+
...(asset.height !== null ? { height: asset.height } : {}),
|
|
90
|
+
...(asset.duration !== null ? { duration: asset.duration } : {}),
|
|
91
|
+
...(v.title ? { title: v.title } : {}),
|
|
92
|
+
...(posterResolved ? { poster: posterResolved } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const f = value;
|
|
96
|
+
return { url: asset.url, mime: asset.mime, size: asset.size, filename: asset.filename, ...(f.title ? { title: f.title } : {}) };
|
|
97
|
+
}
|
|
98
|
+
/** Replace every media value in place with its resolved object — or delete
|
|
99
|
+
* it when the asset is missing or pending, so `image?.url` guards hold. */
|
|
100
|
+
export function applyAssets(values, descriptors, assets) {
|
|
101
|
+
if (!values)
|
|
102
|
+
return;
|
|
103
|
+
for (const [key, d] of Object.entries(descriptors)) {
|
|
104
|
+
const value = values[key];
|
|
105
|
+
if (value == null)
|
|
106
|
+
continue;
|
|
107
|
+
if (MEDIA_TYPES.has(d.type)) {
|
|
108
|
+
const resolved = resolveOne(value, d, assets);
|
|
109
|
+
if (resolved === undefined)
|
|
110
|
+
delete values[key];
|
|
111
|
+
else
|
|
112
|
+
values[key] = resolved;
|
|
113
|
+
}
|
|
114
|
+
else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
|
|
115
|
+
applyAssets(value, d.fields, assets);
|
|
116
|
+
}
|
|
117
|
+
else if (d.type === "list" && Array.isArray(value)) {
|
|
118
|
+
const item = d.item;
|
|
119
|
+
if (MEDIA_TYPES.has(item.type)) {
|
|
120
|
+
values[key] = value.map((v) => resolveOne(v, item, assets)).filter((v) => v !== undefined);
|
|
121
|
+
}
|
|
122
|
+
else if (item.type === "object") {
|
|
123
|
+
for (const v of value)
|
|
124
|
+
if (v && typeof v === "object")
|
|
125
|
+
applyAssets(v, item.fields, assets);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/refs.d.ts
CHANGED
|
@@ -11,10 +11,12 @@ export declare function collectEntryRefs(fields: Record<string, unknown>, schema
|
|
|
11
11
|
export type SectionLike = {
|
|
12
12
|
name: string;
|
|
13
13
|
fields: Record<string, Descriptor>;
|
|
14
|
+
styles?: Record<string, Descriptor>;
|
|
14
15
|
} | {
|
|
15
16
|
schema: {
|
|
16
17
|
name: string;
|
|
17
18
|
fields: Record<string, Descriptor>;
|
|
19
|
+
styles?: Record<string, Descriptor>;
|
|
18
20
|
};
|
|
19
21
|
};
|
|
20
22
|
export declare function collectTreeRefs(tree: PageTree, sections: SectionLike[]): RefEdge[];
|