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/refs.js
CHANGED
|
@@ -5,12 +5,32 @@
|
|
|
5
5
|
// every save; `refs` answers only the REVERSE question (who depends on
|
|
6
6
|
// this?) — forward resolution stays resolveTree's job.
|
|
7
7
|
import { sharedLink } from "./shared.js";
|
|
8
|
+
import { ASSET_COLLECTION, isMediaValue, MEDIA_TYPES } from "./media.js";
|
|
8
9
|
function targetOf(d) {
|
|
9
10
|
const t = d.target?.();
|
|
10
11
|
if (!t?.name)
|
|
11
12
|
throw new Error("smoodly: ref descriptor has no resolvable target.");
|
|
12
13
|
return t.name;
|
|
13
14
|
}
|
|
15
|
+
/** Media edges walk into containers (spec 2026-09-08 media §8); `field`
|
|
16
|
+
* stays the top-level key so the edge is addressable like a ref. */
|
|
17
|
+
function collectMedia(field, value, d, out) {
|
|
18
|
+
if (value == null)
|
|
19
|
+
return;
|
|
20
|
+
if (MEDIA_TYPES.has(d.type)) {
|
|
21
|
+
if (isMediaValue(value))
|
|
22
|
+
out.push({ field, targetCollection: ASSET_COLLECTION, targetId: value.asset });
|
|
23
|
+
}
|
|
24
|
+
else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
|
|
25
|
+
for (const [k, sub] of Object.entries(d.fields)) {
|
|
26
|
+
collectMedia(field, value[k], sub, out);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (d.type === "list" && Array.isArray(value)) {
|
|
30
|
+
for (const v of value)
|
|
31
|
+
collectMedia(field, v, d.item, out);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
14
34
|
function collectFrom(values, descriptors, out) {
|
|
15
35
|
for (const [field, d] of Object.entries(descriptors)) {
|
|
16
36
|
const value = values[field];
|
|
@@ -25,10 +45,9 @@ function collectFrom(values, descriptors, out) {
|
|
|
25
45
|
out.push({ field, targetCollection: targetOf(d), targetId: id });
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
|
-
else
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
continue;
|
|
48
|
+
else {
|
|
49
|
+
// Refs are top-level only (f.list/f.object refuse them); media is not.
|
|
50
|
+
collectMedia(field, value, d, out);
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
}
|
|
@@ -56,6 +75,8 @@ export function collectTreeRefs(tree, sections) {
|
|
|
56
75
|
if (!schema)
|
|
57
76
|
continue; // fail-soft, same as renderPage
|
|
58
77
|
collectFrom(node.fields, schema.fields, out);
|
|
78
|
+
if (schema.styles && node.styles)
|
|
79
|
+
collectFrom(node.styles, schema.styles, out);
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
return out;
|
package/dist/resolve.d.ts
CHANGED
|
@@ -2,29 +2,44 @@ import type { Descriptor } from "./fields.ts";
|
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { PageTree } from "./render.tsx";
|
|
4
4
|
import { type SharedSchema } from "./shared.ts";
|
|
5
|
+
import { type AssetRecord } from "./media.ts";
|
|
5
6
|
export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
|
|
7
|
+
export type AssetFetcher = (ids: string[]) => Promise<Record<string, AssetRecord>>;
|
|
6
8
|
type SchemaLike = {
|
|
7
9
|
name: string;
|
|
8
10
|
fields: Record<string, Descriptor>;
|
|
11
|
+
styles?: Record<string, Descriptor>;
|
|
12
|
+
};
|
|
13
|
+
/** One value set whose media resolves in place: a node's fields or styles,
|
|
14
|
+
* an entry's fields, or an entry hydrated through a ref. */
|
|
15
|
+
export type MediaSet = {
|
|
16
|
+
values: Record<string, unknown> | undefined;
|
|
17
|
+
descriptors: Record<string, Descriptor>;
|
|
9
18
|
};
|
|
10
19
|
type ResolveOptions = {
|
|
11
|
-
sections: ({
|
|
12
|
-
name: string;
|
|
13
|
-
fields: Record<string, Descriptor>;
|
|
14
|
-
} | {
|
|
20
|
+
sections: (SchemaLike | {
|
|
15
21
|
schema: SchemaLike;
|
|
16
22
|
})[];
|
|
17
23
|
collections: CollectionSchema<any>[];
|
|
18
24
|
fetch: EntryFetcher;
|
|
25
|
+
/** Media (spec 2026-09-08 §9): one batched asset fetch per page, values become resolved objects. */
|
|
26
|
+
assets?: AssetFetcher;
|
|
19
27
|
/** The registered shared items a placement node may name (spec 2026-09-07 §4). */
|
|
20
28
|
shared?: SharedSchema[];
|
|
21
29
|
/** Optional safety valve on chain length; unlimited by default. */
|
|
22
30
|
maxDepth?: number;
|
|
23
31
|
};
|
|
24
32
|
export declare function resolveTree(tree: PageTree, options: ResolveOptions): Promise<PageTree>;
|
|
33
|
+
/** One value set's media, resolved in place. Exported for the site layer's shared-item styles. */
|
|
34
|
+
export declare function resolveMedia(values: Record<string, unknown>, descriptors: Record<string, Descriptor>, fetch: AssetFetcher): Promise<void>;
|
|
35
|
+
/** Many value sets, one asset round (plus one for posters): the tree's
|
|
36
|
+
* nodes and every entry hydrated through a ref, or a list page's items.
|
|
37
|
+
* Exported for the site layer's content API. */
|
|
38
|
+
export declare function resolveMediaSets(sets: MediaSet[], fetch: AssetFetcher): Promise<void>;
|
|
25
39
|
export type ResolveFieldsOptions = {
|
|
26
40
|
collections: CollectionSchema<any>[];
|
|
27
41
|
fetch: EntryFetcher;
|
|
42
|
+
assets?: AssetFetcher;
|
|
28
43
|
maxDepth?: number;
|
|
29
44
|
};
|
|
30
45
|
/** One entry's fields, refs hydrated — the entry page's counterpart of
|
package/dist/resolve.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// chain gets a shallow copy (refs as ids), so traversal terminates by
|
|
8
8
|
// construction and the output stays JSON-serializable.
|
|
9
9
|
import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
|
|
10
|
+
import { applyAssets, collectAssetIds } from "./media.js";
|
|
10
11
|
function targetOf(d) {
|
|
11
12
|
const t = d.target?.();
|
|
12
13
|
if (!t?.name)
|
|
@@ -38,9 +39,51 @@ export async function resolveTree(tree, options) {
|
|
|
38
39
|
collectTasks(node.fields, schema.fields, tasks);
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
|
-
await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
42
|
+
const hydrated = await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
|
|
43
|
+
if (options.assets) {
|
|
44
|
+
const sets = [];
|
|
45
|
+
for (const node of Object.values(resolved.zones ?? {}).flat()) {
|
|
46
|
+
const schema = sectionSchemas.get(node.type);
|
|
47
|
+
if (!schema)
|
|
48
|
+
continue;
|
|
49
|
+
sets.push({ values: node.fields, descriptors: schema.fields });
|
|
50
|
+
sets.push({ values: node.styles, descriptors: schema.styles ?? {} });
|
|
51
|
+
}
|
|
52
|
+
await resolveMediaSets([...sets, ...hydrated], options.assets);
|
|
53
|
+
}
|
|
42
54
|
return resolved;
|
|
43
55
|
}
|
|
56
|
+
/** Every wanted asset, then the posters those videos name — two rounds at most. */
|
|
57
|
+
async function fetchAssets(fetch, ids) {
|
|
58
|
+
const out = new Map();
|
|
59
|
+
if (ids.size === 0)
|
|
60
|
+
return out;
|
|
61
|
+
for (const [id, a] of Object.entries(await fetch([...ids])))
|
|
62
|
+
out.set(id, a);
|
|
63
|
+
const posters = new Set();
|
|
64
|
+
for (const a of out.values())
|
|
65
|
+
if (a.posterId && !out.has(a.posterId))
|
|
66
|
+
posters.add(a.posterId);
|
|
67
|
+
if (posters.size > 0)
|
|
68
|
+
for (const [id, a] of Object.entries(await fetch([...posters])))
|
|
69
|
+
out.set(id, a);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** One value set's media, resolved in place. Exported for the site layer's shared-item styles. */
|
|
73
|
+
export async function resolveMedia(values, descriptors, fetch) {
|
|
74
|
+
await resolveMediaSets([{ values, descriptors }], fetch);
|
|
75
|
+
}
|
|
76
|
+
/** Many value sets, one asset round (plus one for posters): the tree's
|
|
77
|
+
* nodes and every entry hydrated through a ref, or a list page's items.
|
|
78
|
+
* Exported for the site layer's content API. */
|
|
79
|
+
export async function resolveMediaSets(sets, fetch) {
|
|
80
|
+
const ids = new Set();
|
|
81
|
+
for (const { values, descriptors } of sets)
|
|
82
|
+
collectAssetIds(values, descriptors, ids);
|
|
83
|
+
const assets = await fetchAssets(fetch, ids);
|
|
84
|
+
for (const { values, descriptors } of sets)
|
|
85
|
+
applyAssets(values, descriptors, assets);
|
|
86
|
+
}
|
|
44
87
|
/** Placements first (spec 2026-09-07 §4): fetch every linked item in one
|
|
45
88
|
* call per item, rewrite each node into the item's section node — the
|
|
46
89
|
* placement's id stays, so the canvas selects it — and drop what the
|
|
@@ -83,13 +126,19 @@ export async function resolveFields(fields, descriptors, options) {
|
|
|
83
126
|
const resolved = structuredClone(fields);
|
|
84
127
|
const tasks = [];
|
|
85
128
|
collectTasks(resolved, descriptors, tasks);
|
|
86
|
-
await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
|
|
129
|
+
const hydrated = await runResolution(tasks, { ...options, maxDepth: options.maxDepth ?? Infinity });
|
|
130
|
+
if (options.assets)
|
|
131
|
+
await resolveMediaSets([{ values: resolved, descriptors }, ...hydrated], options.assets);
|
|
87
132
|
return resolved;
|
|
88
133
|
}
|
|
134
|
+
/** Hydrates every task's refs in place and returns each hydrated entry
|
|
135
|
+
* copy with its collection's descriptors — the media pass walks those
|
|
136
|
+
* too, so a card reached through a ref carries its image URL. */
|
|
89
137
|
async function runResolution(seeds, options) {
|
|
90
138
|
const collectionSchemas = new Map(options.collections.map((c) => [c.name, c]));
|
|
91
139
|
// every entry is fetched once; cycle cuts reuse cached ancestors
|
|
92
140
|
const cache = new Map();
|
|
141
|
+
const hydrated = [];
|
|
93
142
|
let tasks = seeds;
|
|
94
143
|
for (let round = 0; round < options.maxDepth && tasks.length > 0; round++) {
|
|
95
144
|
// batch: one fetch per collection with unique uncached ids
|
|
@@ -118,6 +167,9 @@ async function runResolution(seeds, options) {
|
|
|
118
167
|
if (!raw)
|
|
119
168
|
return null;
|
|
120
169
|
const entry = structuredClone(raw);
|
|
170
|
+
const schema = collectionSchemas.get(task.collection);
|
|
171
|
+
if (schema)
|
|
172
|
+
hydrated.push({ values: entry, descriptors: schema.fields });
|
|
121
173
|
const pathKey = `${task.collection}:${id}`;
|
|
122
174
|
// cycle: this entry is its own ancestor — shallow copy, no recursion
|
|
123
175
|
if (task.ancestors.includes(pathKey))
|
|
@@ -162,6 +214,7 @@ async function runResolution(seeds, options) {
|
|
|
162
214
|
}
|
|
163
215
|
tasks = next;
|
|
164
216
|
}
|
|
217
|
+
return hydrated;
|
|
165
218
|
}
|
|
166
219
|
function collectTasks(values, descriptors, out) {
|
|
167
220
|
for (const [key, d] of Object.entries(descriptors)) {
|
package/dist/site.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AssetStore } from "./asset-store.ts";
|
|
1
2
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
3
|
import { type EntryRecord, type EntryStore } from "./entry-store.ts";
|
|
3
4
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
@@ -9,6 +10,7 @@ export type SiteOptions = {
|
|
|
9
10
|
stores: () => {
|
|
10
11
|
pages: PageStore;
|
|
11
12
|
entries: EntryStore;
|
|
13
|
+
assets?: AssetStore;
|
|
12
14
|
};
|
|
13
15
|
collections: CollectionSchema<any>[];
|
|
14
16
|
sections: SectionLike[];
|
package/dist/site.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { entryFieldsIn, schemaOf } from "./entry-store.js";
|
|
2
2
|
import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
|
|
3
|
-
import { resolveFields, resolveTree } from "./resolve.js";
|
|
3
|
+
import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
|
|
4
4
|
import { STYLES_KEY } from "./shared.js";
|
|
5
5
|
/** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
|
|
6
6
|
* bound to the render locale; the default keeps CMS paths as URLs. */
|
|
@@ -24,6 +24,15 @@ export function createSmoodlySite(options) {
|
|
|
24
24
|
* locale — published ones (a versioned collection's snapshot) on the
|
|
25
25
|
* public path, the live rows in draft mode. */
|
|
26
26
|
const fetcher = (draft, locale) => (collection, ids) => options.stores().entries.getMany(collection, ids, locale, draft ? undefined : { status: "published" });
|
|
27
|
+
/** The media fetcher (spec 2026-09-08 §9) — absent when the site runs without an asset store. */
|
|
28
|
+
const assetFetcher = () => {
|
|
29
|
+
const assets = options.stores().assets;
|
|
30
|
+
return assets ? (ids) => assets.getMany(ids) : undefined;
|
|
31
|
+
};
|
|
32
|
+
const sectionStyles = (name) => {
|
|
33
|
+
const s = options.sections.find((x) => ("schema" in x ? x.schema : x).name === name);
|
|
34
|
+
return (s && ("schema" in s ? s.schema : s).styles) ?? {};
|
|
35
|
+
};
|
|
27
36
|
/** Visible to the public in a locale: present there, and a folder or a
|
|
28
37
|
* published page. Draft mode sees every present node. Presence is the
|
|
29
38
|
* locale row — nothing falls back (spec 2026-09-06 §3). */
|
|
@@ -57,6 +66,7 @@ export function createSmoodlySite(options) {
|
|
|
57
66
|
collections: options.collections,
|
|
58
67
|
shared: options.shared,
|
|
59
68
|
fetch: fetcher(draft, locale),
|
|
69
|
+
assets: assetFetcher(),
|
|
60
70
|
});
|
|
61
71
|
const all = await pages.listPages();
|
|
62
72
|
const index = new Map(all.map((n) => [n.id, n]));
|
|
@@ -97,7 +107,7 @@ export function createSmoodlySite(options) {
|
|
|
97
107
|
: (await entries.getMany(schema.name, [id], locale, { status: "published" }))[id];
|
|
98
108
|
if (!raw)
|
|
99
109
|
return null;
|
|
100
|
-
const fields = await resolveFields(raw, schema.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
|
|
110
|
+
const fields = await resolveFields(raw, schema.fields, { collections: options.collections, fetch: fetcher(draft, locale), assets: assetFetcher() });
|
|
101
111
|
// Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
|
|
102
112
|
const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
|
|
103
113
|
return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
|
|
@@ -127,6 +137,11 @@ export function createSmoodlySite(options) {
|
|
|
127
137
|
const all = await entries.list(collection, { order: o.order });
|
|
128
138
|
const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
|
|
129
139
|
const published = await entries.getMany(collection, wanted.map((e) => e.id), locale, { status: "published" });
|
|
140
|
+
// Refs stay ids on the content API; media resolves (one asset round for
|
|
141
|
+
// the whole list), because a list page's cards need their image URLs.
|
|
142
|
+
const fetchAssets = assetFetcher();
|
|
143
|
+
if (fetchAssets)
|
|
144
|
+
await resolveMediaSets(Object.values(published).map((values) => ({ values, descriptors: schema.fields })), fetchAssets);
|
|
130
145
|
return entryItems(schema, wanted, all, locale, published);
|
|
131
146
|
}
|
|
132
147
|
const api = {
|
|
@@ -192,10 +207,13 @@ export function createSmoodlySite(options) {
|
|
|
192
207
|
: (await entries.getMany(item.name, [record.id], locale, { status: "published" }))[record.id];
|
|
193
208
|
if (!raw)
|
|
194
209
|
return null;
|
|
195
|
-
const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale) });
|
|
210
|
+
const fields = await resolveFields(raw, item.fields, { collections: options.collections, fetch: fetcher(draft, locale), assets: assetFetcher() });
|
|
196
211
|
if (!item.section)
|
|
197
212
|
return fields;
|
|
198
213
|
const { [STYLES_KEY]: styles, ...rest } = fields;
|
|
214
|
+
const fetch = assetFetcher();
|
|
215
|
+
if (fetch && styles && typeof styles === "object")
|
|
216
|
+
await resolveMedia(styles, sectionStyles(item.section), fetch);
|
|
199
217
|
return { ...rest, styles: styles !== null && typeof styles === "object" ? styles : {} };
|
|
200
218
|
},
|
|
201
219
|
};
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
export declare const DEFAULT_SPACE_ID = "00000000-0000-0000-0000-000000000000";
|
|
5
5
|
export declare const DEFAULT_SPACE = "'00000000-0000-0000-0000-000000000000'";
|
|
6
6
|
export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0000-0000-0000-000000000000'";
|
|
7
|
-
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths"];
|
|
7
|
+
export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths", "assets"];
|
|
8
8
|
export type ContentTable = (typeof CONTENT_TABLES)[number];
|
|
9
9
|
export declare function currentSpaceSQL(): string;
|
|
10
10
|
export declare function spaceLayerSQL(): string;
|
|
11
|
+
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
|
12
|
+
* §12). Public read: the CDN and next/image fetch without tokens. Writes:
|
|
13
|
+
* a write key under its own space's prefix only; the service role
|
|
14
|
+
* bypasses; signed uploads are minted server-side with that same key.
|
|
15
|
+
* Schema-qualified calls: storage policies do not run with public on
|
|
16
|
+
* the search path. Last in the migration, after the functions exist. */
|
|
17
|
+
export declare function storageSQL(): string;
|
package/dist/sql-space.js
CHANGED
|
@@ -17,6 +17,7 @@ export const CONTENT_TABLES = [
|
|
|
17
17
|
"entry_versions",
|
|
18
18
|
"refs",
|
|
19
19
|
"paths",
|
|
20
|
+
"assets",
|
|
20
21
|
];
|
|
21
22
|
export function currentSpaceSQL() {
|
|
22
23
|
return `
|
|
@@ -41,6 +42,7 @@ const PUBLISHED_READ = {
|
|
|
41
42
|
entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
|
|
42
43
|
refs: null,
|
|
43
44
|
editors: null,
|
|
45
|
+
assets: `"status" = 'ready'`,
|
|
44
46
|
paths: `(("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')))`,
|
|
45
47
|
};
|
|
46
48
|
/** Tables whose existing indexes do not lead with space_id. */
|
|
@@ -177,5 +179,31 @@ $$;`.trim());
|
|
|
177
179
|
` using (${predicate});`);
|
|
178
180
|
}
|
|
179
181
|
}
|
|
182
|
+
statements.push(storageSQL());
|
|
180
183
|
return statements.join("\n\n");
|
|
181
184
|
}
|
|
185
|
+
/** The media bucket and its object policies (spec 2026-09-08 media §3,
|
|
186
|
+
* §12). Public read: the CDN and next/image fetch without tokens. Writes:
|
|
187
|
+
* a write key under its own space's prefix only; the service role
|
|
188
|
+
* bypasses; signed uploads are minted server-side with that same key.
|
|
189
|
+
* Schema-qualified calls: storage policies do not run with public on
|
|
190
|
+
* the search path. Last in the migration, after the functions exist. */
|
|
191
|
+
export function storageSQL() {
|
|
192
|
+
const write = `"bucket_id" = 'media'` +
|
|
193
|
+
` and (select public."smoodly_key_kind"()) = 'write'` +
|
|
194
|
+
` and not (select public."smoodly_space_key_revoked"())` +
|
|
195
|
+
` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
|
|
196
|
+
return `
|
|
197
|
+
insert into storage.buckets ("id", "name", "public", "file_size_limit")
|
|
198
|
+
values ('media', 'media', true, 104857600)
|
|
199
|
+
on conflict ("id") do update set "public" = excluded."public", "file_size_limit" = excluded."file_size_limit";
|
|
200
|
+
|
|
201
|
+
drop policy if exists "smoodly_media_read" on storage.objects;
|
|
202
|
+
create policy "smoodly_media_read" on storage.objects for select
|
|
203
|
+
using ("bucket_id" = 'media');
|
|
204
|
+
|
|
205
|
+
drop policy if exists "smoodly_media_write" on storage.objects;
|
|
206
|
+
create policy "smoodly_media_write" on storage.objects for all
|
|
207
|
+
using (${write})
|
|
208
|
+
with check (${write});`.trim();
|
|
209
|
+
}
|
package/dist/sql.js
CHANGED
|
@@ -233,6 +233,35 @@ create table if not exists "paths" (
|
|
|
233
233
|
create index if not exists "paths_target_idx"
|
|
234
234
|
on "paths" ("space_id", "kind", "target_id");
|
|
235
235
|
|
|
236
|
+
-- Media (spec 2026-09-08): one row per upload, bytes in the "media"
|
|
237
|
+
-- bucket at "path". Immediate — never draft-gated, never versioned —
|
|
238
|
+
-- so there is no locale row and no version table. status 'pending' is
|
|
239
|
+
-- a row whose signed upload has not been confirmed; list never shows it.
|
|
240
|
+
create table if not exists "assets" (
|
|
241
|
+
"id" uuid primary key default gen_random_uuid(),
|
|
242
|
+
${SPACE_COL},
|
|
243
|
+
"kind" text not null check ("kind" in ('image', 'video', 'file')),
|
|
244
|
+
"status" text not null default 'pending' check ("status" in ('pending', 'ready')),
|
|
245
|
+
"path" text not null,
|
|
246
|
+
"mime" text not null,
|
|
247
|
+
"size" bigint not null,
|
|
248
|
+
"width" integer,
|
|
249
|
+
"height" integer,
|
|
250
|
+
"duration" numeric,
|
|
251
|
+
"poster_id" uuid references "assets" ("id") on delete set null,
|
|
252
|
+
"filename" text not null,
|
|
253
|
+
"title" text,
|
|
254
|
+
"alt" text,
|
|
255
|
+
"caption" text,
|
|
256
|
+
"credit" text,
|
|
257
|
+
"created_by" text,
|
|
258
|
+
"created_at" timestamptz not null default now(),
|
|
259
|
+
"updated_at" timestamptz not null default now()
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
create index if not exists "assets_space_created_idx"
|
|
263
|
+
on "assets" ("space_id", "status", "created_at" desc);
|
|
264
|
+
|
|
236
265
|
-- Replace the targets' rows with the given rows in ONE call: a SQL
|
|
237
266
|
-- function runs in the caller's transaction, so a primary-key collision
|
|
238
267
|
-- rolls the whole rewrite back. Inserted rows get their space from the
|
|
@@ -262,6 +291,7 @@ alter table "entry_locales" enable row level security;
|
|
|
262
291
|
alter table "entry_versions" enable row level security;
|
|
263
292
|
alter table "refs" enable row level security;
|
|
264
293
|
alter table "paths" enable row level security;
|
|
294
|
+
alter table "assets" enable row level security;
|
|
265
295
|
`.trim(),
|
|
266
296
|
spaceLayerSQL(),
|
|
267
297
|
].join("\n\n");
|
package/dist/store.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// locale row owns its slug, title, publish state and version pointers,
|
|
10
10
|
// and each locale has its own history. Nothing falls back to another
|
|
11
11
|
// locale — presence IS the row.
|
|
12
|
+
import { ASSET_COLLECTION } from "./media.js";
|
|
12
13
|
import { collectTreeRefs } from "./refs.js";
|
|
13
14
|
import { MemoryPathIndex } from "./path-index.js";
|
|
14
15
|
import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
|
|
@@ -62,6 +63,8 @@ export function pageRefEdges(trees, sections) {
|
|
|
62
63
|
export async function assertPublishable(label, tree, sections, entries, locale) {
|
|
63
64
|
const targets = new Map();
|
|
64
65
|
for (const edge of collectTreeRefs(tree, sections)) {
|
|
66
|
+
if (edge.targetCollection === ASSET_COLLECTION)
|
|
67
|
+
continue; // assets are immediate, never draft-gated
|
|
65
68
|
if (!targets.has(edge.targetId))
|
|
66
69
|
targets.set(edge.targetId, { collection: edge.targetCollection, field: edge.field });
|
|
67
70
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { type AssetPage, type AssetQuery, type AssetStore, type BeginUploadInput, type CreateAssetInput, type FinishInput, type UploadTicket } from "./asset-store.ts";
|
|
3
|
+
import { type AssetMeta, type AssetRecord } from "./media.ts";
|
|
4
|
+
export declare class SupabaseAssetStore implements AssetStore {
|
|
5
|
+
private db;
|
|
6
|
+
constructor(db: SupabaseClient);
|
|
7
|
+
private fail;
|
|
8
|
+
private bucket;
|
|
9
|
+
private toRecord;
|
|
10
|
+
/** The space this client writes into — the claim, or the default. */
|
|
11
|
+
private space;
|
|
12
|
+
private insert;
|
|
13
|
+
beginUpload(input: BeginUploadInput): Promise<UploadTicket>;
|
|
14
|
+
finishUpload(id: string, dims: FinishInput): Promise<AssetRecord>;
|
|
15
|
+
create(input: CreateAssetInput): Promise<AssetRecord>;
|
|
16
|
+
get(id: string): Promise<AssetRecord | null>;
|
|
17
|
+
getMany(ids: string[]): Promise<Record<string, AssetRecord>>;
|
|
18
|
+
list(query?: AssetQuery): Promise<AssetPage>;
|
|
19
|
+
update(id: string, meta: AssetMeta): Promise<AssetRecord>;
|
|
20
|
+
delete(id: string): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { assetErrors, kindOf, objectPath, withDownload, PAGE_SIZE, } from "./asset-store.js";
|
|
2
|
+
import { ASSET_COLLECTION, MEDIA_CACHE_SECONDS } from "./media.js";
|
|
3
|
+
const BUCKET = "media";
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
const SELECT = "id, kind, status, path, mime, size, width, height, duration, poster_id, filename, title, alt, caption, credit, created_at, updated_at";
|
|
6
|
+
export class SupabaseAssetStore {
|
|
7
|
+
constructor(db) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
}
|
|
10
|
+
fail(message) {
|
|
11
|
+
throw new Error(`smoodly: ${message}`);
|
|
12
|
+
}
|
|
13
|
+
bucket() {
|
|
14
|
+
return this.db.storage.from(BUCKET);
|
|
15
|
+
}
|
|
16
|
+
toRecord(row) {
|
|
17
|
+
return {
|
|
18
|
+
id: row.id, kind: row.kind, status: row.status, path: row.path, mime: row.mime, size: Number(row.size),
|
|
19
|
+
width: row.width, height: row.height, duration: row.duration === null ? null : Number(row.duration),
|
|
20
|
+
posterId: row.poster_id, filename: row.filename, title: row.title, alt: row.alt, caption: row.caption, credit: row.credit,
|
|
21
|
+
url: withDownload(this.bucket().getPublicUrl(row.path).data.publicUrl, row.mime),
|
|
22
|
+
createdAt: Date.parse(row.created_at), updatedAt: Date.parse(row.updated_at),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** The space this client writes into — the claim, or the default. */
|
|
26
|
+
async space() {
|
|
27
|
+
const res = await this.db.rpc("smoodly_current_space");
|
|
28
|
+
if (res.error)
|
|
29
|
+
this.fail(res.error.message);
|
|
30
|
+
return String(res.data);
|
|
31
|
+
}
|
|
32
|
+
async insert(input, status, extra = {}) {
|
|
33
|
+
const id = crypto.randomUUID();
|
|
34
|
+
const path = objectPath(await this.space(), id, input.filename, input.mime);
|
|
35
|
+
const res = await this.db
|
|
36
|
+
.from("assets")
|
|
37
|
+
.insert({
|
|
38
|
+
id, kind: kindOf(input.mime), status, path, mime: input.mime, size: input.size, filename: input.filename,
|
|
39
|
+
...(input.createdBy ? { created_by: input.createdBy } : {}), ...extra,
|
|
40
|
+
})
|
|
41
|
+
.select(SELECT)
|
|
42
|
+
.single();
|
|
43
|
+
if (res.error)
|
|
44
|
+
this.fail(res.error.message);
|
|
45
|
+
return this.toRecord(res.data);
|
|
46
|
+
}
|
|
47
|
+
async beginUpload(input) {
|
|
48
|
+
const row = await this.insert(input, "pending");
|
|
49
|
+
const signed = await this.bucket().createSignedUploadUrl(row.path);
|
|
50
|
+
if (signed.error)
|
|
51
|
+
this.fail(signed.error.message);
|
|
52
|
+
return { id: row.id, uploadUrl: signed.data.signedUrl, token: signed.data.token };
|
|
53
|
+
}
|
|
54
|
+
async finishUpload(id, dims) {
|
|
55
|
+
const current = await this.get(id);
|
|
56
|
+
if (!current)
|
|
57
|
+
throw assetErrors.notFound(id);
|
|
58
|
+
if (current.status !== "pending")
|
|
59
|
+
throw assetErrors.notPending(id);
|
|
60
|
+
const res = await this.db
|
|
61
|
+
.from("assets")
|
|
62
|
+
.update({
|
|
63
|
+
status: "ready", width: dims.width ?? null, height: dims.height ?? null, duration: dims.duration ?? null,
|
|
64
|
+
poster_id: dims.posterId ?? null, updated_at: new Date().toISOString(),
|
|
65
|
+
})
|
|
66
|
+
.eq("id", id)
|
|
67
|
+
.select(SELECT)
|
|
68
|
+
.single();
|
|
69
|
+
if (res.error)
|
|
70
|
+
this.fail(res.error.message);
|
|
71
|
+
return this.toRecord(res.data);
|
|
72
|
+
}
|
|
73
|
+
async create(input) {
|
|
74
|
+
const row = await this.insert(input, "ready", {
|
|
75
|
+
width: input.width ?? null, height: input.height ?? null, duration: input.duration ?? null,
|
|
76
|
+
poster_id: input.posterId ?? null, title: input.title ?? null, alt: input.alt ?? null,
|
|
77
|
+
caption: input.caption ?? null, credit: input.credit ?? null,
|
|
78
|
+
});
|
|
79
|
+
const up = await this.bucket().upload(row.path, input.bytes, { contentType: input.mime, upsert: false, cacheControl: String(MEDIA_CACHE_SECONDS) });
|
|
80
|
+
if (up.error) {
|
|
81
|
+
await this.db.from("assets").delete().eq("id", row.id);
|
|
82
|
+
this.fail(up.error.message);
|
|
83
|
+
}
|
|
84
|
+
return row;
|
|
85
|
+
}
|
|
86
|
+
async get(id) {
|
|
87
|
+
if (!UUID_RE.test(id))
|
|
88
|
+
return null;
|
|
89
|
+
const res = await this.db.from("assets").select(SELECT).eq("id", id).maybeSingle();
|
|
90
|
+
if (res.error)
|
|
91
|
+
this.fail(res.error.message);
|
|
92
|
+
return res.data ? this.toRecord(res.data) : null;
|
|
93
|
+
}
|
|
94
|
+
async getMany(ids) {
|
|
95
|
+
const wanted = ids.filter((id) => UUID_RE.test(id));
|
|
96
|
+
if (wanted.length === 0)
|
|
97
|
+
return {};
|
|
98
|
+
const res = await this.db.from("assets").select(SELECT).in("id", wanted).eq("status", "ready");
|
|
99
|
+
if (res.error)
|
|
100
|
+
this.fail(res.error.message);
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const row of res.data)
|
|
103
|
+
out[row.id] = this.toRecord(row);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
async list(query = {}) {
|
|
107
|
+
const limit = query.limit ?? PAGE_SIZE;
|
|
108
|
+
let q = this.db.from("assets").select(SELECT).eq("status", "ready").order("created_at", { ascending: false }).limit(limit + 1);
|
|
109
|
+
if (query.kind)
|
|
110
|
+
q = q.eq("kind", query.kind);
|
|
111
|
+
if (query.cursor)
|
|
112
|
+
q = q.lt("created_at", query.cursor);
|
|
113
|
+
if (query.search) {
|
|
114
|
+
// PostgREST's or() splits on commas; the pattern must not carry one.
|
|
115
|
+
const term = `%${query.search.replace(/[,%]/g, " ").trim()}%`;
|
|
116
|
+
q = q.or(`title.ilike.${term},filename.ilike.${term},alt.ilike.${term}`);
|
|
117
|
+
}
|
|
118
|
+
const res = await q;
|
|
119
|
+
if (res.error)
|
|
120
|
+
this.fail(res.error.message);
|
|
121
|
+
const rows = res.data;
|
|
122
|
+
const page = rows.slice(0, limit);
|
|
123
|
+
return {
|
|
124
|
+
items: page.map((r) => this.toRecord(r)),
|
|
125
|
+
nextCursor: rows.length > limit ? page[page.length - 1].created_at : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async update(id, meta) {
|
|
129
|
+
if (!UUID_RE.test(id))
|
|
130
|
+
throw assetErrors.notFound(id);
|
|
131
|
+
const patch = { updated_at: new Date().toISOString() };
|
|
132
|
+
for (const key of ["title", "alt", "caption", "credit"])
|
|
133
|
+
if (meta[key] !== undefined)
|
|
134
|
+
patch[key] = meta[key];
|
|
135
|
+
const res = await this.db.from("assets").update(patch).eq("id", id).select(SELECT).maybeSingle();
|
|
136
|
+
if (res.error)
|
|
137
|
+
this.fail(res.error.message);
|
|
138
|
+
if (!res.data)
|
|
139
|
+
throw assetErrors.notFound(id);
|
|
140
|
+
return this.toRecord(res.data);
|
|
141
|
+
}
|
|
142
|
+
async delete(id) {
|
|
143
|
+
const row = await this.get(id);
|
|
144
|
+
if (!row)
|
|
145
|
+
return;
|
|
146
|
+
const usage = await this.db.from("refs").select("source_id").eq("target_collection", ASSET_COLLECTION).eq("target_id", id);
|
|
147
|
+
if (usage.error)
|
|
148
|
+
this.fail(usage.error.message);
|
|
149
|
+
if (usage.data.length > 0)
|
|
150
|
+
throw assetErrors.referenced(id, usage.data.length);
|
|
151
|
+
const paths = [row.path];
|
|
152
|
+
if (row.posterId) {
|
|
153
|
+
const poster = await this.get(row.posterId);
|
|
154
|
+
if (poster)
|
|
155
|
+
paths.push(poster.path);
|
|
156
|
+
}
|
|
157
|
+
// The row first (its poster_id reference goes with it), then the poster row, then the objects.
|
|
158
|
+
const gone = await this.db.from("assets").delete().eq("id", id);
|
|
159
|
+
if (gone.error)
|
|
160
|
+
this.fail(gone.error.message);
|
|
161
|
+
if (row.posterId) {
|
|
162
|
+
const posterGone = await this.db.from("assets").delete().eq("id", row.posterId);
|
|
163
|
+
if (posterGone.error)
|
|
164
|
+
this.fail(posterGone.error.message);
|
|
165
|
+
}
|
|
166
|
+
const removed = await this.bucket().remove(paths);
|
|
167
|
+
if (removed.error)
|
|
168
|
+
this.fail(removed.error.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smoodly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
"types": "./dist/next/index.d.ts",
|
|
21
21
|
"default": "./dist/next/index.js"
|
|
22
22
|
},
|
|
23
|
+
"./image": {
|
|
24
|
+
"types": "./dist/image/index.d.ts",
|
|
25
|
+
"default": "./dist/image/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./image/config": {
|
|
28
|
+
"types": "./dist/image/next-config.d.ts",
|
|
29
|
+
"default": "./dist/image/next-config.js"
|
|
30
|
+
},
|
|
23
31
|
"./admin": {
|
|
24
32
|
"types": "./dist/admin/index.d.ts",
|
|
25
33
|
"default": "./dist/admin/index.js"
|