smoodly 0.0.9 → 0.0.11

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 (125) hide show
  1. package/README.md +167 -18
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  4. package/dist/admin/editor/CanvasOverlay.js +39 -0
  5. package/dist/admin/editor/EditorView.js +56 -237
  6. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  7. package/dist/admin/editor/InsertPicker.js +18 -0
  8. package/dist/admin/editor/NoticePane.d.ts +4 -0
  9. package/dist/admin/editor/NoticePane.js +4 -0
  10. package/dist/admin/editor/Outline.d.ts +18 -0
  11. package/dist/admin/editor/Outline.js +12 -0
  12. package/dist/admin/editor/locale-url.d.ts +12 -0
  13. package/dist/admin/editor/locale-url.js +32 -0
  14. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  15. package/dist/admin/editor/overlay-geometry.js +27 -0
  16. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  17. package/dist/admin/editor/useCanvasBridge.js +53 -0
  18. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  19. package/dist/admin/editor/usePageLocale.js +88 -0
  20. package/dist/admin/forms/CropEditor.d.ts +10 -0
  21. package/dist/admin/forms/CropEditor.js +61 -0
  22. package/dist/admin/forms/FieldWidget.js +8 -26
  23. package/dist/admin/forms/MediaGrid.d.ts +11 -0
  24. package/dist/admin/forms/MediaGrid.js +16 -0
  25. package/dist/admin/forms/MediaPicker.d.ts +8 -0
  26. package/dist/admin/forms/MediaPicker.js +44 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +9 -0
  28. package/dist/admin/forms/MediaWidget.js +83 -0
  29. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  30. package/dist/admin/forms/RichtextToolbar.js +37 -0
  31. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  32. package/dist/admin/forms/RichtextWidget.js +123 -0
  33. package/dist/admin/forms/assets-context.d.ts +19 -0
  34. package/dist/admin/forms/assets-context.js +35 -0
  35. package/dist/admin/forms/crop-editor.d.ts +13 -0
  36. package/dist/admin/forms/crop-editor.js +72 -0
  37. package/dist/admin/forms/measure.d.ts +2 -0
  38. package/dist/admin/forms/measure.js +39 -0
  39. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  40. package/dist/admin/forms/richtext-extensions.js +74 -0
  41. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  42. package/dist/admin/forms/richtext-paste.js +59 -0
  43. package/dist/admin/forms/upload.d.ts +17 -0
  44. package/dist/admin/forms/upload.js +33 -0
  45. package/dist/admin/index.d.ts +1 -2
  46. package/dist/admin/index.js +0 -1
  47. package/dist/admin/next/create-admin.d.ts +3 -7
  48. package/dist/admin/next/create-admin.js +14 -5
  49. package/dist/admin/next/op-handler.d.ts +3 -0
  50. package/dist/admin/next/op-handler.js +1 -0
  51. package/dist/admin/ops-impl.d.ts +3 -0
  52. package/dist/admin/ops-impl.js +83 -2
  53. package/dist/admin/ops.d.ts +27 -0
  54. package/dist/admin/richtext-doc.d.ts +7 -0
  55. package/dist/admin/richtext-doc.js +18 -0
  56. package/dist/admin/serialize.d.ts +5 -0
  57. package/dist/admin/serialize.js +1 -0
  58. package/dist/admin/shell/AdminApp.d.ts +1 -2
  59. package/dist/admin/shell/AdminApp.js +18 -19
  60. package/dist/admin/shell/EntriesList.js +3 -2
  61. package/dist/admin/shell/EntryForm.js +8 -24
  62. package/dist/admin/shell/IconRail.d.ts +7 -0
  63. package/dist/admin/shell/IconRail.js +73 -0
  64. package/dist/admin/shell/ListView.js +3 -1
  65. package/dist/admin/shell/MediaScreen.d.ts +8 -0
  66. package/dist/admin/shell/MediaScreen.js +86 -0
  67. package/dist/admin/shell/PagesList.js +3 -2
  68. package/dist/admin/shell/RouteLink.d.ts +4 -0
  69. package/dist/admin/shell/RouteLink.js +12 -0
  70. package/dist/admin/shell/SharedForm.js +3 -21
  71. package/dist/admin/shell/media-list.d.ts +8 -0
  72. package/dist/admin/shell/media-list.js +14 -0
  73. package/dist/admin/shell/router.d.ts +37 -0
  74. package/dist/admin/shell/router.js +84 -0
  75. package/dist/admin/shell/session.js +3 -1
  76. package/dist/admin/ui/Modal.d.ts +8 -0
  77. package/dist/admin/ui/Modal.js +15 -0
  78. package/dist/admin/ui/primitives.d.ts +3 -2
  79. package/dist/admin/ui/primitives.js +8 -6
  80. package/dist/admin/ui/theme.d.ts +1 -1
  81. package/dist/admin/ui/theme.js +133 -24
  82. package/dist/admin/validate.js +16 -0
  83. package/dist/asset-store.d.ts +86 -0
  84. package/dist/asset-store.js +141 -0
  85. package/dist/collections.d.ts +0 -14
  86. package/dist/collections.js +2 -10
  87. package/dist/config.d.ts +12 -0
  88. package/dist/config.js +17 -5
  89. package/dist/fields.d.ts +10 -11
  90. package/dist/fields.js +16 -2
  91. package/dist/image/SmoodlyImage.d.ts +18 -0
  92. package/dist/image/SmoodlyImage.js +16 -0
  93. package/dist/image/SmoodlyVideo.d.ts +7 -0
  94. package/dist/image/SmoodlyVideo.js +5 -0
  95. package/dist/image/crop-math.d.ts +14 -0
  96. package/dist/image/crop-math.js +38 -0
  97. package/dist/image/index.d.ts +6 -0
  98. package/dist/image/index.js +5 -0
  99. package/dist/image/loaders.d.ts +15 -0
  100. package/dist/image/loaders.js +5 -0
  101. package/dist/image/next-config.d.ts +15 -0
  102. package/dist/image/next-config.js +27 -0
  103. package/dist/image-dimensions.d.ts +4 -0
  104. package/dist/image-dimensions.js +25 -0
  105. package/dist/index.d.ts +12 -4
  106. package/dist/index.js +8 -2
  107. package/dist/media.d.ts +108 -0
  108. package/dist/media.js +129 -0
  109. package/dist/refs.d.ts +2 -0
  110. package/dist/refs.js +25 -4
  111. package/dist/resolve.d.ts +19 -4
  112. package/dist/resolve.js +55 -2
  113. package/dist/site.d.ts +2 -0
  114. package/dist/site.js +21 -3
  115. package/dist/sql-space.d.ts +8 -1
  116. package/dist/sql-space.js +28 -0
  117. package/dist/sql.js +30 -0
  118. package/dist/store.js +3 -0
  119. package/dist/supabase-asset-store.d.ts +21 -0
  120. package/dist/supabase-asset-store.js +170 -0
  121. package/dist/toolset.d.ts +30 -0
  122. package/dist/toolset.js +49 -0
  123. package/package.json +15 -1
  124. package/dist/admin/richtext.d.ts +0 -16
  125. package/dist/admin/richtext.js +0 -98
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
  };
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ export declare const TOOLSET_MARKS: readonly ["bold", "italic", "link"];
2
+ export declare const TOOLSET_NODES: readonly ["h1", "h2", "h3", "h4", "h5", "h6", "bulletList", "orderedList", "blockquote"];
3
+ export type ToolsetMark = (typeof TOOLSET_MARKS)[number];
4
+ export type ToolsetNode = (typeof TOOLSET_NODES)[number];
5
+ export type Toolset = {
6
+ marks?: ToolsetMark[];
7
+ nodes?: ToolsetNode[];
8
+ elements?: string[];
9
+ };
10
+ /** What a richtext field gets when it declares nothing. `h1` is left out:
11
+ * the section or page shape owns the page's first heading. */
12
+ export declare const DEFAULT_TOOLSET: Toolset;
13
+ export declare function toolset(input: {
14
+ marks?: readonly ToolsetMark[];
15
+ nodes?: readonly ToolsetNode[];
16
+ elements?: {
17
+ schema: {
18
+ name: string;
19
+ };
20
+ }[];
21
+ }): Toolset;
22
+ /** The resolved toolset the widget reads: which marks and nodes an editor
23
+ * may CREATE. The schema itself is always the full one (spec §2). */
24
+ export type Allowed = {
25
+ marks: ReadonlySet<ToolsetMark>;
26
+ nodes: ReadonlySet<ToolsetNode>;
27
+ /** The allowed heading levels, ascending. */
28
+ headingLevels: number[];
29
+ };
30
+ export declare function allowedOf(input: unknown): Allowed;
@@ -0,0 +1,49 @@
1
+ // Toolsets are plain shared values, not registrations (DESIGN.md §3): a
2
+ // richtext field imports one by value and it travels inside the field
3
+ // descriptor to the admin. The vocabulary is typed and checked at
4
+ // registration; a field without a toolset carries the default. `Allowed`
5
+ // is the resolved form the widget reads (spec 2026-09-08 §1).
6
+ export const TOOLSET_MARKS = ["bold", "italic", "link"];
7
+ export const TOOLSET_NODES = ["h1", "h2", "h3", "h4", "h5", "h6", "bulletList", "orderedList", "blockquote"];
8
+ /** What a richtext field gets when it declares nothing. `h1` is left out:
9
+ * the section or page shape owns the page's first heading. */
10
+ export const DEFAULT_TOOLSET = {
11
+ marks: ["bold", "italic", "link"],
12
+ nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote"],
13
+ };
14
+ const isMark = (s) => TOOLSET_MARKS.includes(s);
15
+ const isNode = (s) => TOOLSET_NODES.includes(s);
16
+ export function toolset(input) {
17
+ const out = {};
18
+ if (input.marks) {
19
+ for (const m of input.marks) {
20
+ if (!isMark(m))
21
+ throw new Error(`smoodly: toolset mark "${m}" is not one of ${TOOLSET_MARKS.join(", ")}.`);
22
+ }
23
+ out.marks = [...input.marks];
24
+ }
25
+ if (input.nodes) {
26
+ for (const n of input.nodes) {
27
+ if (!isNode(n))
28
+ throw new Error(`smoodly: toolset node "${n}" is not one of ${TOOLSET_NODES.join(", ")}.`);
29
+ }
30
+ out.nodes = [...input.nodes];
31
+ }
32
+ if (input.elements)
33
+ out.elements = input.elements.map((e) => e.schema.name);
34
+ return out;
35
+ }
36
+ const HEADING = /^h([1-6])$/;
37
+ export function allowedOf(input) {
38
+ // A missing or malformed toolset is the default; a declared one is exact,
39
+ // so a group it leaves out allows nothing (a marks-only toolset has no headings).
40
+ const t = input && typeof input === "object" ? input : DEFAULT_TOOLSET;
41
+ const marks = Array.isArray(t.marks) ? t.marks.filter((m) => typeof m === "string" && isMark(m)) : [];
42
+ const nodes = Array.isArray(t.nodes) ? t.nodes.filter((n) => typeof n === "string" && isNode(n)) : [];
43
+ const headingLevels = nodes
44
+ .map((n) => HEADING.exec(n)?.[1])
45
+ .filter((l) => l !== undefined)
46
+ .map(Number)
47
+ .sort((a, b) => a - b);
48
+ return { marks: new Set(marks), nodes: new Set(nodes), headingLevels };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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,10 @@
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
+ },
23
27
  "./admin": {
24
28
  "types": "./dist/admin/index.d.ts",
25
29
  "default": "./dist/admin/index.js"
@@ -65,6 +69,16 @@
65
69
  },
66
70
  "dependencies": {
67
71
  "@supabase/supabase-js": "^2.113.0",
72
+ "@tiptap/core": "3.31.3",
73
+ "@tiptap/extension-blockquote": "3.31.3",
74
+ "@tiptap/extension-bold": "3.31.3",
75
+ "@tiptap/extension-heading": "3.31.3",
76
+ "@tiptap/extension-italic": "3.31.3",
77
+ "@tiptap/extension-link": "3.31.3",
78
+ "@tiptap/extension-list": "3.31.3",
79
+ "@tiptap/pm": "3.31.3",
80
+ "@tiptap/react": "3.31.3",
81
+ "@tiptap/starter-kit": "3.31.3",
68
82
  "lucide-react": "^1.41.0"
69
83
  }
70
84
  }
@@ -1,16 +0,0 @@
1
- export type RichtextNode = {
2
- type: string;
3
- attrs?: Record<string, unknown>;
4
- content?: RichtextNode[];
5
- text?: string;
6
- marks?: {
7
- type: string;
8
- attrs?: Record<string, unknown>;
9
- }[];
10
- };
11
- export type RichtextDoc = {
12
- type: "doc";
13
- content: RichtextNode[];
14
- };
15
- export declare function plainToRichtext(text: string): RichtextDoc;
16
- export declare function richtextToPlain(doc: unknown): string;