smoodly 0.0.45 → 0.0.46

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 (240) hide show
  1. package/README.md +3 -3
  2. package/dist/addresses.d.ts +6 -7
  3. package/dist/addresses.js +6 -4
  4. package/dist/admin/PublicSans-LICENSE.txt +93 -0
  5. package/dist/admin/auth-config.d.ts +6 -0
  6. package/dist/admin/auth-config.js +1 -0
  7. package/dist/admin/browser/fetch-op.d.ts +10 -0
  8. package/dist/admin/browser/fetch-op.js +44 -0
  9. package/dist/admin/browser/main.d.ts +1 -0
  10. package/dist/admin/browser/main.js +21 -0
  11. package/dist/admin/custom-section.d.ts +1 -1
  12. package/dist/admin/drift.d.ts +1 -1
  13. package/dist/admin/editor/EditorView.js +14 -14
  14. package/dist/admin/editor/autosave.js +1 -1
  15. package/dist/admin/editor/canvas-address.d.ts +2 -0
  16. package/dist/admin/editor/canvas-address.js +8 -0
  17. package/dist/admin/editor/overlay-geometry.d.ts +1 -1
  18. package/dist/admin/editor/protocol.d.ts +18 -4
  19. package/dist/admin/editor/protocol.js +15 -1
  20. package/dist/admin/editor/useCanvasBridge.d.ts +1 -1
  21. package/dist/admin/editor/useCanvasBridge.js +5 -6
  22. package/dist/admin/editor/useInlineSession.js +4 -2
  23. package/dist/admin/files.d.ts +11 -0
  24. package/dist/admin/files.generated.js +2 -0
  25. package/dist/admin/files.js +8 -0
  26. package/dist/admin/forms/useRefOptions.d.ts +2 -2
  27. package/dist/admin/forms/useRefOptions.js +2 -2
  28. package/dist/admin/forms/useRichtextEditor.js +4 -4
  29. package/dist/admin/handler.d.ts +40 -0
  30. package/dist/admin/handler.js +151 -0
  31. package/dist/admin/index.d.ts +1 -1
  32. package/dist/admin/ops-impl.d.ts +7 -1
  33. package/dist/admin/ops-impl.js +110 -33
  34. package/dist/admin/ops.d.ts +3 -2
  35. package/dist/admin/page-data.d.ts +21 -0
  36. package/dist/admin/page-data.js +4 -0
  37. package/dist/admin/page.d.ts +6 -0
  38. package/dist/admin/page.js +35 -0
  39. package/dist/admin/serialize.js +7 -7
  40. package/dist/admin/shell/AdminApp.js +2 -2
  41. package/dist/admin/shell/EntryForm.js +1 -1
  42. package/dist/admin/shell/router.d.ts +3 -16
  43. package/dist/admin/shell/router.js +3 -39
  44. package/dist/admin/shell/session.d.ts +2 -5
  45. package/dist/admin/supabase-auth.js +1 -1
  46. package/dist/admin/tree-ops.d.ts +1 -1
  47. package/dist/admin/ui/fonts.d.ts +12 -0
  48. package/dist/admin/ui/fonts.js +22 -0
  49. package/dist/admin/ui/theme.d.ts +13 -4
  50. package/dist/admin/ui/theme.js +28 -157
  51. package/dist/assemble.d.ts +60 -0
  52. package/dist/assemble.js +359 -0
  53. package/dist/astro/cache.d.ts +34 -0
  54. package/dist/astro/cache.js +69 -0
  55. package/dist/astro/components/Custom.astro +21 -0
  56. package/dist/astro/components/Guarded.astro +24 -0
  57. package/dist/astro/components/Head.astro +26 -0
  58. package/dist/astro/components/Image.astro +20 -0
  59. package/dist/astro/components/RichText.astro +23 -0
  60. package/dist/astro/components/Section.astro +20 -0
  61. package/dist/astro/components/Video.astro +5 -0
  62. package/dist/astro/components/Zone.astro +19 -0
  63. package/dist/astro/components.d.ts +6 -0
  64. package/dist/astro/components.js +7 -0
  65. package/dist/astro/content-loaders.d.ts +40 -0
  66. package/dist/astro/content-loaders.js +100 -0
  67. package/dist/astro/content.d.ts +9 -0
  68. package/dist/astro/content.js +17 -0
  69. package/dist/astro/custom.d.ts +1 -0
  70. package/dist/astro/custom.js +3 -0
  71. package/dist/astro/defaults.d.ts +20 -0
  72. package/dist/astro/defaults.js +21 -0
  73. package/dist/astro/env-files.d.ts +1 -0
  74. package/dist/astro/env-files.js +21 -0
  75. package/dist/astro/head.d.ts +13 -0
  76. package/dist/astro/head.js +19 -0
  77. package/dist/astro/image.d.ts +27 -0
  78. package/dist/astro/image.js +29 -0
  79. package/dist/astro/index.d.ts +43 -0
  80. package/dist/astro/index.js +111 -0
  81. package/dist/astro/locale-paths.d.ts +30 -0
  82. package/dist/astro/locale-paths.js +38 -0
  83. package/dist/astro/middleware.d.ts +1 -0
  84. package/dist/astro/middleware.js +83 -0
  85. package/dist/astro/preview-document.d.ts +1 -0
  86. package/dist/astro/preview-document.js +18 -0
  87. package/dist/astro/preview.d.ts +10 -0
  88. package/dist/astro/preview.js +72 -0
  89. package/dist/astro/richtext.d.ts +15 -0
  90. package/dist/astro/richtext.js +78 -0
  91. package/dist/astro/routes/admin.d.ts +3 -0
  92. package/dist/astro/routes/admin.js +41 -0
  93. package/dist/astro/routes/page.astro +17 -0
  94. package/dist/astro/routes/preview.astro +11 -0
  95. package/dist/astro/runtime.d.ts +32 -0
  96. package/dist/astro/runtime.js +46 -0
  97. package/dist/astro/scope.d.ts +45 -0
  98. package/dist/astro/scope.js +103 -0
  99. package/dist/astro/server.d.ts +14 -0
  100. package/dist/astro/server.js +45 -0
  101. package/dist/astro/shell.d.ts +67 -0
  102. package/dist/astro/shell.js +63 -0
  103. package/dist/astro/style.d.ts +1 -0
  104. package/dist/astro/style.js +11 -0
  105. package/dist/astro/views-check.d.ts +3 -0
  106. package/dist/astro/views-check.js +19 -0
  107. package/dist/astro/zone.d.ts +39 -0
  108. package/dist/astro/zone.js +33 -0
  109. package/dist/bind.d.ts +19 -0
  110. package/dist/bind.js +34 -0
  111. package/dist/bridge/bridge.d.ts +9 -0
  112. package/dist/bridge/bridge.js +107 -0
  113. package/dist/bridge/entry.d.ts +1 -0
  114. package/dist/bridge/entry.js +5 -0
  115. package/dist/bridge/preview-pill.d.ts +18 -0
  116. package/dist/bridge/preview-pill.js +115 -0
  117. package/dist/cli/import.d.ts +3 -0
  118. package/dist/cli/import.js +10 -1
  119. package/dist/cli/load-collections.js +4 -3
  120. package/dist/cli/load-config.d.ts +4 -0
  121. package/dist/cli/load-config.js +26 -0
  122. package/dist/cli/main.d.ts +5 -1
  123. package/dist/cli/main.js +24 -6
  124. package/dist/cli/migrate.d.ts +3 -0
  125. package/dist/cli/migrate.js +8 -1
  126. package/dist/cli/rebuild.d.ts +23 -0
  127. package/dist/cli/rebuild.js +22 -0
  128. package/dist/cli/space-io.d.ts +15 -1
  129. package/dist/cli/space-io.js +14 -0
  130. package/dist/client.js +5 -0
  131. package/dist/config.d.ts +28 -20
  132. package/dist/config.js +71 -75
  133. package/dist/content/content-api.d.ts +1 -1
  134. package/dist/content/node.d.ts +2 -3
  135. package/dist/content/node.js +2 -1
  136. package/dist/crash-message.d.ts +1 -0
  137. package/dist/crash-message.js +8 -0
  138. package/dist/css.d.ts +4 -0
  139. package/dist/css.js +5 -2
  140. package/dist/custom/CustomSection.d.ts +1 -1
  141. package/dist/custom/CustomSection.js +4 -8
  142. package/dist/custom/css.js +2 -2
  143. package/dist/custom/index.d.ts +6 -10
  144. package/dist/custom/index.js +1 -2
  145. package/dist/custom/layout.d.ts +5 -0
  146. package/dist/custom/layout.js +14 -0
  147. package/dist/entry-page.d.ts +3 -18
  148. package/dist/env.js +1 -1
  149. package/dist/global-id.js +2 -2
  150. package/dist/global.d.ts +5 -2
  151. package/dist/global.js +8 -5
  152. package/dist/image/next-config.d.ts +3 -0
  153. package/dist/image/next-config.js +12 -7
  154. package/dist/image/remote-pattern.d.ts +1 -0
  155. package/dist/image/remote-pattern.js +5 -0
  156. package/dist/index.d.ts +15 -17
  157. package/dist/index.js +10 -19
  158. package/dist/marks.d.ts +7 -0
  159. package/dist/marks.js +7 -0
  160. package/dist/next/admin-route.d.ts +7 -0
  161. package/dist/next/admin-route.js +28 -0
  162. package/dist/next/bridge.d.ts +1 -0
  163. package/dist/next/bridge.js +26 -0
  164. package/dist/next/canvas-boundary.d.ts +1 -1
  165. package/dist/next/canvas-slot.d.ts +1 -0
  166. package/dist/next/canvas-slot.js +4 -3
  167. package/dist/next/index.d.ts +2 -0
  168. package/dist/next/index.js +1 -0
  169. package/dist/next/page-renderer.d.ts +5 -1
  170. package/dist/next/page-renderer.js +19 -33
  171. package/dist/next/preview-pill-define.d.ts +1 -0
  172. package/dist/next/preview-pill-define.js +10 -0
  173. package/dist/next/preview-pill.d.ts +0 -3
  174. package/dist/next/preview-pill.js +7 -50
  175. package/dist/next/queries.d.ts +3 -28
  176. package/dist/next/queries.js +3 -90
  177. package/dist/next/reads.d.ts +2 -0
  178. package/dist/next/reads.js +18 -0
  179. package/dist/page-context.d.ts +89 -0
  180. package/dist/page-context.js +8 -0
  181. package/dist/pairing.d.ts +2 -22
  182. package/dist/project.d.ts +69 -0
  183. package/dist/project.js +210 -0
  184. package/dist/published.d.ts +96 -0
  185. package/dist/published.js +125 -0
  186. package/dist/react.d.ts +61 -0
  187. package/dist/react.js +17 -0
  188. package/dist/read-shapes.d.ts +1 -1
  189. package/dist/reads.d.ts +80 -0
  190. package/dist/reads.js +142 -0
  191. package/dist/refs.d.ts +1 -1
  192. package/dist/registration.d.ts +8 -0
  193. package/dist/registration.js +17 -0
  194. package/dist/render.d.ts +5 -83
  195. package/dist/render.js +5 -10
  196. package/dist/resolve.d.ts +1 -1
  197. package/dist/richtext/index.d.ts +6 -2
  198. package/dist/richtext/index.js +3 -2
  199. package/dist/richtext-href.d.ts +4 -0
  200. package/dist/richtext-href.js +17 -0
  201. package/dist/richtext-render.js +1 -14
  202. package/dist/schema-version.d.ts +1 -1
  203. package/dist/schema-version.js +1 -1
  204. package/dist/section-crash.d.ts +2 -2
  205. package/dist/section-crash.js +2 -7
  206. package/dist/section-wrapper.d.ts +3 -10
  207. package/dist/section-wrapper.js +3 -2
  208. package/dist/site.d.ts +38 -14
  209. package/dist/site.js +159 -286
  210. package/dist/smd-key.d.ts +9 -0
  211. package/dist/smd-key.js +1 -0
  212. package/dist/sql-space.d.ts +4 -0
  213. package/dist/sql-space.js +10 -5
  214. package/dist/sql.d.ts +2 -0
  215. package/dist/sql.js +117 -0
  216. package/dist/store.d.ts +1 -1
  217. package/dist/supabase-published-store.d.ts +35 -0
  218. package/dist/supabase-published-store.js +215 -0
  219. package/dist/supabase-store.d.ts +1 -1
  220. package/dist/toolset.d.ts +2 -4
  221. package/dist/toolset.js +6 -4
  222. package/dist/version.d.ts +1 -1
  223. package/dist/version.js +1 -1
  224. package/dist/view-props.d.ts +40 -0
  225. package/dist/view-props.js +1 -0
  226. package/dist/views.d.ts +36 -0
  227. package/dist/views.js +52 -0
  228. package/dist/zones.d.ts +7 -7
  229. package/dist/zones.js +7 -6
  230. package/migrations/20261007120000_smoodly_v8.sql +154 -0
  231. package/migrations/20261007200000_smoodly_v9.sql +30 -0
  232. package/package.json +46 -19
  233. package/dist/admin/next/bridge.d.ts +0 -2
  234. package/dist/admin/next/bridge.js +0 -129
  235. package/dist/admin/next/create-admin.d.ts +0 -19
  236. package/dist/admin/next/create-admin.js +0 -44
  237. package/dist/admin/next/index.d.ts +0 -8
  238. package/dist/admin/next/index.js +0 -8
  239. package/dist/admin/next/op-handler.d.ts +0 -18
  240. package/dist/admin/next/op-handler.js +0 -31
package/dist/sql.js CHANGED
@@ -120,6 +120,122 @@ create or replace function "smoodly_list_entries"(
120
120
  limit p_limit;
121
121
  $$;`.trim();
122
122
  }
123
+ /** The delivery model (spec 2026-10-07 §1): two derived tables, a list function over the raw snapshot, a routes view. */
124
+ export function publishedTablesSQL() {
125
+ return `
126
+ -- The delivery model (spec 2026-10-07): Publish writes the finished document of a page, an entry
127
+ -- or a global here; a live read is one lookup. Derived and rebuildable from the editing tables.
128
+ create table if not exists "published_pages" (
129
+ ${SPACE_COL},
130
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
131
+ "locale" text not null,
132
+ "parent_id" uuid,
133
+ "sort" numeric,
134
+ "path" text,
135
+ "title" text not null,
136
+ "has_page" boolean not null,
137
+ "link_fields" jsonb not null default '{}'::jsonb,
138
+ "document" jsonb,
139
+ "tags" text[] not null default '{}',
140
+ "model" text not null,
141
+ "stale" boolean not null default false,
142
+ "published_at" timestamptz not null default now(),
143
+ primary key ("page_id", "locale")
144
+ );
145
+ create unique index if not exists "published_pages_address_idx"
146
+ on "published_pages" ("space_id", "locale", "path") where "path" is not null;
147
+ create index if not exists "published_pages_tree_idx"
148
+ on "published_pages" ("space_id", "locale", "parent_id", "sort");
149
+
150
+ create table if not exists "published_entries" (
151
+ ${SPACE_COL},
152
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
153
+ "locale" text not null,
154
+ "collection" text not null,
155
+ "parent_id" uuid,
156
+ "sort" numeric,
157
+ "slug" text,
158
+ "path" text,
159
+ "created_at" timestamptz not null,
160
+ "updated_at" timestamptz not null,
161
+ "fields" jsonb not null,
162
+ "document" jsonb not null,
163
+ "tags" text[] not null default '{}',
164
+ "model" text not null,
165
+ "stale" boolean not null default false,
166
+ "published_at" timestamptz not null default now(),
167
+ primary key ("entry_id", "locale")
168
+ );
169
+ create index if not exists "published_entries_list_idx"
170
+ on "published_entries" ("space_id", "collection", "locale");
171
+ create unique index if not exists "published_entries_address_idx"
172
+ on "published_entries" ("space_id", "locale", "path") where "path" is not null;
173
+ create index if not exists "published_entries_fields_idx"
174
+ on "published_entries" using gin ("fields");
175
+
176
+ alter table "published_pages" enable row level security;
177
+ alter table "published_entries" enable row level security;
178
+
179
+ -- smoodly_list_entries without the draft branch, over the delivery table (spec 2026-10-07 §3):
180
+ -- \`where\` and \`order\` read the raw published snapshot, the row answers with its document.
181
+ -- Invoker rights, so RLS decides what a key sees.
182
+ create or replace function "smoodly_list_published"(
183
+ p_collection text, p_locale text, p_where jsonb, p_order jsonb,
184
+ p_limit integer, p_parent uuid, p_has_parent boolean, p_ids uuid[]
185
+ ) returns table ("id" uuid, "document" jsonb, "model" text, "stale" boolean) language sql stable set search_path = public as $$
186
+ with shown as (
187
+ select p."entry_id" as "id", p."sort", p."created_at", p."updated_at", p."fields", p."document", p."model", p."stale"
188
+ from "published_entries" p
189
+ where p."collection" = p_collection and p."locale" = p_locale
190
+ and (not p_has_parent or p."parent_id" is not distinct from p_parent)
191
+ and (p_ids is null or p."entry_id" = any(p_ids))
192
+ )
193
+ select s."id", s."document", s."model", s."stale" from shown s
194
+ where not exists (
195
+ select 1 from jsonb_each(coalesce(p_where, '{}'::jsonb)) w
196
+ where not (case when w.value ? 'in'
197
+ then (s."fields" ->> w.key) = any (select jsonb_array_elements_text(w.value -> 'in'))
198
+ else s."fields" @> jsonb_build_object(w.key, w.value -> 'contains') end))
199
+ order by
200
+ case when p_order ->> 'by' = 'manual' then s."sort" end asc nulls last,
201
+ case when p_order ->> 'direction' = 'asc' then
202
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
203
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end asc nulls last,
204
+ case when p_order ->> 'direction' = 'desc' then
205
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
206
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end desc nulls last,
207
+ s."created_at" asc, s."id" asc
208
+ limit p_limit;
209
+ $$;
210
+
211
+ -- One lookup for any address (spec 2026-10-07 §3): a page or an entry, under the caller's own policies.
212
+ create or replace view "published_routes" with (security_invoker = true) as
213
+ select "space_id", "locale", "path", 'page' as "kind", "page_id" as "id", "document", "model", "stale", "tags", null::text as "collection"
214
+ from "published_pages" where "path" is not null and "has_page"
215
+ union all
216
+ select "space_id", "locale", "path", 'entry' as "kind", "entry_id" as "id", "document", "model", "stale", "tags", "collection"
217
+ from "published_entries" where "path" is not null;
218
+
219
+ -- The address and the locale's global items in one call (schema v9; register 2026-10-07: a miss was two
220
+ -- rounds, the page then the layout's globals). The route row first, kind 'page' or 'entry'; then one row per
221
+ -- named global, kind 'global', the collection its name. A miss answers nothing: the globals ride with a page
222
+ -- only. Invoker rights, so RLS decides what a key sees.
223
+ create or replace function "smoodly_lookup_address"(p_locale text, p_path text, p_globals text[])
224
+ returns table ("kind" text, "id" uuid, "collection" text, "document" jsonb, "model" text, "stale" boolean, "tags" text[])
225
+ language sql stable set search_path = public as $$
226
+ with route as (
227
+ select r."kind", r."id", r."collection", r."document", r."model", r."stale", r."tags"
228
+ from "published_routes" r where r."locale" = p_locale and r."path" = p_path limit 1
229
+ )
230
+ select * from route
231
+ union all
232
+ select 'global' as "kind", e."entry_id", e."collection", e."document", e."model", e."stale", e."tags"
233
+ from "published_entries" e
234
+ where e."locale" = p_locale and e."collection" = any(coalesce(p_globals, '{}'::text[]))
235
+ and exists (select 1 from route);
236
+ $$;
237
+ `.trim();
238
+ }
123
239
  export function coreTablesSQL() {
124
240
  return [
125
241
  // The trigger/policy blocks below drop-before-create so the migration is
@@ -413,6 +529,7 @@ alter table "paths" enable row level security;
413
529
  alter table "assets" enable row level security;
414
530
  alter table "smoodly_meta" enable row level security;
415
531
  `.trim(),
532
+ publishedTablesSQL(),
416
533
  spaceLayerSQL(),
417
534
  listEntriesSQL(),
418
535
  grantsSQL(),
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PageTree } from "./render.tsx";
1
+ import type { PageTree } from "./page-context.ts";
2
2
  import type { EntryStore } from "./entry-store.ts";
3
3
  import type { Addresses } from "./addresses.ts";
4
4
  import type { Anchors } from "./anchors.ts";
@@ -0,0 +1,35 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import type { CollectionSchema } from "./collections.ts";
3
+ import type { PublishedEntryRow, PublishedKey, PublishedPageRow, PublishedQuery, PublishedRoute, PublishedStore, PublishedTreeRow } from "./published.ts";
4
+ export declare class SupabasePublishedStore implements PublishedStore {
5
+ private db;
6
+ private schemas;
7
+ private spaceId;
8
+ /** `collections` encode a `where` on an array field as containment, as SupabaseEntryStore.query does. */
9
+ constructor(db: SupabaseClient, collections?: CollectionSchema<any>[]);
10
+ private fail;
11
+ putPages(rows: PublishedPageRow[]): Promise<void>;
12
+ putEntries(rows: PublishedEntryRow[]): Promise<void>;
13
+ /** The space this client writes in (the service role's is the default): a path delete must not reach past it,
14
+ * since the service role passes every policy. Cached for the life of the client; a failed lookup is not. */
15
+ private space;
16
+ /** The unique (space, locale, path) index holds one row per address: a row of another id at an incoming address
17
+ * (a stale row a capped rename did not reach) is deleted before the upsert. Not atomic with it: rows are derived,
18
+ * and a read heals what a failure between the two leaves. One delete per locale in the chunk. */
19
+ private vacate;
20
+ /** One request per kind and locale filter: the keys without a locale go in one in(). */
21
+ private eachGroup;
22
+ remove(keys: PublishedKey[]): Promise<void>;
23
+ markStale(keys: PublishedKey[]): Promise<void>;
24
+ /** A route row is for serving, not for the tree: a page route carries the
25
+ * document, model, stale flag and tags; its tree columns are empty, and an
26
+ * entry route's `fields` and times likewise. */
27
+ byAddress(locale: string, path: string, globals?: readonly string[]): Promise<PublishedRoute | null>;
28
+ private route;
29
+ page(id: string, locale: string): Promise<PublishedPageRow | null>;
30
+ entry(id: string, locale: string): Promise<PublishedEntryRow | null>;
31
+ /** A list row carries the document, model and stale flag; the raw `fields`
32
+ * and the tree columns stay in the table (a list never needs them). */
33
+ entries(collection: string, q: PublishedQuery): Promise<PublishedEntryRow[]>;
34
+ tree(locale: string): Promise<PublishedTreeRow[]>;
35
+ }
@@ -0,0 +1,215 @@
1
+ // The Supabase PublishedStore (spec 2026-10-07 delivery model §1, §3): rows in
2
+ // published_pages and published_entries, one address lookup through the
3
+ // published_routes view, lists through smoodly_list_published. Every read is
4
+ // one request; the space is the key's, through the policies as everywhere.
5
+ import { readCurrentSpace } from "./current-space.js";
6
+ import { ARRAY_FIELD_TYPES } from "./entry-store.js";
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const CHUNK = 100;
9
+ const TREE_COLUMNS = "page_id, parent_id, sort, path, title, has_page, link_fields, model, stale";
10
+ const num = (v) => (v === null ? null : Number(v));
11
+ const iso = (ms) => new Date(ms).toISOString();
12
+ const fromPage = (r) => ({
13
+ id: r.page_id, locale: r.locale, parentId: r.parent_id, sort: num(r.sort), path: r.path, title: r.title, hasPage: r.has_page,
14
+ linkFields: r.link_fields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, publishedAt: Date.parse(r.published_at),
15
+ });
16
+ const toPage = (r) => ({
17
+ page_id: r.id, locale: r.locale, parent_id: r.parentId, sort: r.sort, path: r.path, title: r.title, has_page: r.hasPage,
18
+ link_fields: r.linkFields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, published_at: iso(r.publishedAt),
19
+ });
20
+ const fromEntry = (r) => ({
21
+ id: r.entry_id, locale: r.locale, collection: r.collection, parentId: r.parent_id, sort: num(r.sort), slug: r.slug, path: r.path,
22
+ createdAt: Date.parse(r.created_at), updatedAt: Date.parse(r.updated_at), fields: r.fields, document: r.document, tags: r.tags,
23
+ model: r.model, stale: r.stale, publishedAt: Date.parse(r.published_at),
24
+ });
25
+ const toEntry = (r) => ({
26
+ entry_id: r.id, locale: r.locale, collection: r.collection, parent_id: r.parentId, sort: r.sort, slug: r.slug, path: r.path,
27
+ created_at: iso(r.createdAt), updated_at: iso(r.updatedAt), fields: r.fields, document: r.document, tags: r.tags,
28
+ model: r.model, stale: r.stale, published_at: iso(r.publishedAt),
29
+ });
30
+ export class SupabasePublishedStore {
31
+ /** `collections` encode a `where` on an array field as containment, as SupabaseEntryStore.query does. */
32
+ constructor(db, collections = []) {
33
+ this.db = db;
34
+ this.schemas = new Map(collections.map((c) => [c.name, c]));
35
+ }
36
+ fail(message) {
37
+ throw new Error(`smoodly: published store: ${message}`);
38
+ }
39
+ async putPages(rows) {
40
+ for (let i = 0; i < rows.length; i += CHUNK) {
41
+ const chunk = rows.slice(i, i + CHUNK);
42
+ await this.vacate("published_pages", "page_id", chunk);
43
+ const res = await this.db.from("published_pages").upsert(chunk.map(toPage), { onConflict: "page_id,locale" });
44
+ if (res.error)
45
+ this.fail(res.error.message);
46
+ }
47
+ }
48
+ async putEntries(rows) {
49
+ for (let i = 0; i < rows.length; i += CHUNK) {
50
+ const chunk = rows.slice(i, i + CHUNK);
51
+ await this.vacate("published_entries", "entry_id", chunk);
52
+ const res = await this.db.from("published_entries").upsert(chunk.map(toEntry), { onConflict: "entry_id,locale" });
53
+ if (res.error)
54
+ this.fail(res.error.message);
55
+ }
56
+ }
57
+ /** The space this client writes in (the service role's is the default): a path delete must not reach past it,
58
+ * since the service role passes every policy. Cached for the life of the client; a failed lookup is not. */
59
+ space() {
60
+ if (!this.spaceId) {
61
+ this.spaceId = readCurrentSpace(this.db).catch((e) => {
62
+ this.spaceId = undefined;
63
+ throw e;
64
+ });
65
+ }
66
+ return this.spaceId;
67
+ }
68
+ /** The unique (space, locale, path) index holds one row per address: a row of another id at an incoming address
69
+ * (a stale row a capped rename did not reach) is deleted before the upsert. Not atomic with it: rows are derived,
70
+ * and a read heals what a failure between the two leaves. One delete per locale in the chunk. */
71
+ async vacate(table, column, rows) {
72
+ const byLocale = new Map();
73
+ for (const r of rows) {
74
+ // An id the table cannot hold (no UUID) vacates nothing: an empty `not in ()` is no filter PostgREST accepts.
75
+ if (r.path === null || !UUID_RE.test(r.id))
76
+ continue;
77
+ if (!byLocale.has(r.locale))
78
+ byLocale.set(r.locale, { paths: [], ids: [] });
79
+ byLocale.get(r.locale).paths.push(r.path);
80
+ byLocale.get(r.locale).ids.push(r.id);
81
+ }
82
+ if (byLocale.size === 0)
83
+ return;
84
+ const space = await this.space();
85
+ for (const [locale, { paths, ids }] of byLocale) {
86
+ const res = await this.db.from(table).delete()
87
+ .eq("space_id", space).eq("locale", locale).in("path", paths)
88
+ .not(column, "in", `(${ids.join(",")})`);
89
+ if (res.error)
90
+ this.fail(res.error.message);
91
+ }
92
+ }
93
+ /** One request per kind and locale filter: the keys without a locale go in one in(). */
94
+ async eachGroup(keys, act) {
95
+ const groups = new Map();
96
+ for (const k of keys) {
97
+ if (!UUID_RE.test(k.id))
98
+ continue;
99
+ const g = `${k.kind}:${k.locale ?? ""}`;
100
+ if (!groups.has(g))
101
+ groups.set(g, { kind: k.kind, locale: k.locale, ids: [] });
102
+ groups.get(g).ids.push(k.id);
103
+ }
104
+ for (const { kind, locale, ids } of groups.values()) {
105
+ const [table, column] = kind === "page" ? ["published_pages", "page_id"] : ["published_entries", "entry_id"];
106
+ for (let i = 0; i < ids.length; i += CHUNK)
107
+ await act(table, column, ids.slice(i, i + CHUNK), locale);
108
+ }
109
+ }
110
+ async remove(keys) {
111
+ await this.eachGroup(keys, async (table, column, ids, locale) => {
112
+ let q = this.db.from(table).delete().in(column, ids);
113
+ if (locale !== undefined)
114
+ q = q.eq("locale", locale);
115
+ const res = await q;
116
+ if (res.error)
117
+ this.fail(res.error.message);
118
+ });
119
+ }
120
+ async markStale(keys) {
121
+ await this.eachGroup(keys, async (table, column, ids, locale) => {
122
+ let q = this.db.from(table).update({ stale: true }).in(column, ids);
123
+ if (locale !== undefined)
124
+ q = q.eq("locale", locale);
125
+ const res = await q;
126
+ if (res.error)
127
+ this.fail(res.error.message);
128
+ });
129
+ }
130
+ /** A route row is for serving, not for the tree: a page route carries the
131
+ * document, model, stale flag and tags; its tree columns are empty, and an
132
+ * entry route's `fields` and times likewise. */
133
+ async byAddress(locale, path, globals) {
134
+ if (globals) {
135
+ // One call (schema v9): the route row, then the locale's rows of the named global items.
136
+ const res = await this.db.rpc("smoodly_lookup_address", { p_locale: locale, p_path: path, p_globals: [...globals] });
137
+ if (res.error)
138
+ this.fail(res.error.message);
139
+ const rows = res.data;
140
+ const first = rows.find((r) => r.kind !== "global");
141
+ if (!first)
142
+ return null;
143
+ const route = this.route({ ...first, kind: first.kind, locale, path });
144
+ route.globals = rows.filter((r) => r.kind === "global").map((r) => ({
145
+ id: r.id, locale, collection: r.collection, parentId: null, sort: null, slug: null, path: null, createdAt: 0, updatedAt: 0,
146
+ fields: {}, document: r.document, tags: r.tags, model: r.model, stale: r.stale, publishedAt: 0,
147
+ }));
148
+ return route;
149
+ }
150
+ const res = await this.db.from("published_routes").select("kind, id, locale, path, document, model, stale, tags, collection")
151
+ .eq("locale", locale).eq("path", path).limit(1).maybeSingle();
152
+ if (res.error)
153
+ this.fail(res.error.message);
154
+ const r = res.data;
155
+ return r ? this.route(r) : null;
156
+ }
157
+ route(r) {
158
+ const served = { id: r.id, locale: r.locale, parentId: null, sort: null, path: r.path, tags: r.tags, model: r.model, stale: r.stale, publishedAt: 0 };
159
+ return r.kind === "page"
160
+ ? { kind: "page", row: { ...served, title: "", hasPage: true, linkFields: {}, document: r.document } }
161
+ : { kind: "entry", row: { ...served, collection: r.collection, slug: null, createdAt: 0, updatedAt: 0, fields: {}, document: r.document } };
162
+ }
163
+ async page(id, locale) {
164
+ if (!UUID_RE.test(id))
165
+ return null;
166
+ const res = await this.db.from("published_pages").select("*").eq("page_id", id).eq("locale", locale).maybeSingle();
167
+ if (res.error)
168
+ this.fail(res.error.message);
169
+ return res.data ? fromPage(res.data) : null;
170
+ }
171
+ async entry(id, locale) {
172
+ if (!UUID_RE.test(id))
173
+ return null;
174
+ const res = await this.db.from("published_entries").select("*").eq("entry_id", id).eq("locale", locale).maybeSingle();
175
+ if (res.error)
176
+ this.fail(res.error.message);
177
+ return res.data ? fromEntry(res.data) : null;
178
+ }
179
+ /** A list row carries the document, model and stale flag; the raw `fields`
180
+ * and the tree columns stay in the table (a list never needs them). */
181
+ async entries(collection, q) {
182
+ const schema = this.schemas.get(collection);
183
+ const where = {};
184
+ for (const [key, want] of Object.entries(q.where ?? {})) {
185
+ const inArray = ARRAY_FIELD_TYPES.has(schema?.fields[key]?.type ?? "");
186
+ where[key] = Array.isArray(want) ? { in: want.map(String) } : { contains: inArray ? [want] : want };
187
+ }
188
+ const res = await this.db.rpc("smoodly_list_published", {
189
+ p_collection: collection,
190
+ p_locale: q.locale,
191
+ p_where: where,
192
+ p_order: q.order === undefined ? null : q.order === "manual" ? { by: "manual" } : q.order,
193
+ p_limit: q.limit ?? null,
194
+ p_parent: q.parent ?? null,
195
+ p_has_parent: q.parent !== undefined,
196
+ p_ids: q.ids ? q.ids.filter((id) => UUID_RE.test(id)) : null,
197
+ });
198
+ if (res.error)
199
+ this.fail(res.error.message);
200
+ return res.data.map((r) => ({
201
+ id: r.id, locale: q.locale, collection, parentId: null, sort: null, slug: null, path: null, createdAt: 0, updatedAt: 0,
202
+ fields: {}, document: r.document, tags: [], model: r.model, stale: r.stale, publishedAt: 0,
203
+ }));
204
+ }
205
+ async tree(locale) {
206
+ const res = await this.db.from("published_pages").select(TREE_COLUMNS).eq("locale", locale)
207
+ .order("sort", { ascending: true, nullsFirst: false }).order("published_at").order("page_id");
208
+ if (res.error)
209
+ this.fail(res.error.message);
210
+ return res.data.map((r) => ({
211
+ id: r.page_id, parentId: r.parent_id, sort: num(r.sort), path: r.path, title: r.title, hasPage: r.has_page, linkFields: r.link_fields,
212
+ model: r.model, stale: r.stale,
213
+ }));
214
+ }
215
+ }
@@ -1,6 +1,6 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import { type AddLocaleOptions, type CreatePageInput, type PageRecord, type PageStore, type PageStoreOptions, type PageVersion, type PageScan } from "./store.ts";
3
- import type { PageTree } from "./render.tsx";
3
+ import type { PageTree } from "./page-context.ts";
4
4
  import type { PathRow } from "./path-index.ts";
5
5
  import { type DraftToken } from "./draft-token.ts";
6
6
  import { type PathTarget } from "./paths.ts";
package/dist/toolset.d.ts CHANGED
@@ -15,10 +15,8 @@ export declare function toolset(input: {
15
15
  nodes?: readonly ToolsetNode[];
16
16
  /** Registered Elements embeddable in this toolset, by value (spec 2026-09-08 block nodes §1). */
17
17
  elements?: {
18
- schema: {
19
- name: string;
20
- contexts?: readonly string[];
21
- };
18
+ name: string;
19
+ contexts: readonly string[];
22
20
  }[];
23
21
  }): Toolset;
24
22
  /** The resolved toolset the widget reads: which marks and nodes an editor
package/dist/toolset.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { schemaOnly } from "./registration.js";
1
2
  // Toolsets are plain shared values, not registrations (DESIGN.md §3): a
2
3
  // richtext field imports one by value and it travels inside the field
3
4
  // descriptor to the admin. The vocabulary is typed and checked at
@@ -30,12 +31,13 @@ export function toolset(input) {
30
31
  out.nodes = [...input.nodes];
31
32
  }
32
33
  if (input.elements) {
33
- for (const e of input.elements) {
34
- if (!(e.schema.contexts ?? []).includes("richtext")) {
35
- throw new Error(`smoodly: element "${e.schema.name}" is not registered for the richtext context — add "richtext" to its contexts.`);
34
+ const schemas = input.elements.map((e) => schemaOnly(e));
35
+ for (const e of schemas) {
36
+ if (!(e.contexts ?? []).includes("richtext")) {
37
+ throw new Error(`smoodly: element "${e.name}" is not registered for the richtext context — add "richtext" to its contexts.`);
36
38
  }
37
39
  }
38
- out.elements = input.elements.map((e) => e.schema.name);
40
+ out.elements = schemas.map((e) => e.name);
39
41
  }
40
42
  return out;
41
43
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.45";
1
+ export declare const SMOODLY_VERSION = "0.0.46";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.45";
2
+ export const SMOODLY_VERSION = "0.0.46";
@@ -0,0 +1,40 @@
1
+ import type { CollectionSchema } from "./collections.ts";
2
+ import type { BuilderMap, Materialize, Values } from "./schema.ts";
3
+ import type { StyleProps } from "./styles.ts";
4
+ import type { EntryItem } from "./read-shapes.ts";
5
+ import type { PageContext, ZoneContent } from "./page-context.ts";
6
+ /** What the view receives. A page view gets its zones and where it sits
7
+ * in the tree; a section/element view gets field values flat, settings
8
+ * complete (defaults filled) and Smoodly's styles steps as stored (Default
9
+ * stores nothing — spec 2026-09-14 §2). The page branch keys on `kind` — an
10
+ * optional-only check would match section schemas too, since a type
11
+ * lacking `__zones` still extends `{ __zones?: X }`. */
12
+ export type Props<S> = S extends {
13
+ kind: "page";
14
+ __zones?: infer Z extends Record<string, unknown>;
15
+ __fields?: infer F extends BuilderMap;
16
+ } ? {
17
+ zones: Record<keyof Z & string, ZoneContent>;
18
+ page: PageContext<Values<F>>;
19
+ } : S extends {
20
+ __f?: infer F extends BuilderMap;
21
+ __s?: infer St extends BuilderMap;
22
+ } ? Values<F> & {
23
+ settings: Values<St>;
24
+ styles: StyleProps;
25
+ } : never;
26
+ export type EntryPageSchema = {
27
+ kind: "entryPage";
28
+ name: string;
29
+ collection: string;
30
+ };
31
+ /** What an entry page renders: the entry as a read returns it — its path, locale and
32
+ * locale alternates ride on it (spec 2026-09-29 content API contract §3). */
33
+ export type EntryPageProps<F = Record<string, unknown>> = {
34
+ entry: EntryItem<F>;
35
+ };
36
+ /** A collection's fields as a read returns them (refs resolved, media as images): an Astro entry page's
37
+ * `entry: EntryItem<EntryFields<typeof articles>>`, what React's `entryPage(articles, View)` infers. */
38
+ export type EntryFields<C extends CollectionSchema<any>> = {
39
+ [K in keyof NonNullable<C["__entry"]>]: Materialize<NonNullable<C["__entry"]>[K]>;
40
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import type { ComponentType } from "react";
2
+ import type { ResolvedConfig } from "./config.ts";
3
+ /** A paired section or element: a component carrying its schema. */
4
+ type ViewOf = ComponentType<any> & {
5
+ schema: {
6
+ name: string;
7
+ };
8
+ };
9
+ /** A paired page type or entry page. */
10
+ type PageViewOf = ((props: any) => unknown) & {
11
+ schema: {
12
+ kind: string;
13
+ name: string;
14
+ collection?: string;
15
+ };
16
+ };
17
+ export type ViewsInput<S extends ViewOf = ViewOf, E extends ViewOf = ViewOf, P extends PageViewOf = PageViewOf> = {
18
+ sections: S[];
19
+ elements?: E[];
20
+ /** Page types and entry pages alike: everything that renders a URL. */
21
+ pages: P[];
22
+ /** The project's bound RichText, for the custom section's columns (spec 2026-09-14 §6). */
23
+ RichText?: ComponentType<{
24
+ doc: unknown;
25
+ }>;
26
+ };
27
+ export type Views<S extends ViewOf = ViewOf, E extends ViewOf = ViewOf, P extends PageViewOf = PageViewOf> = {
28
+ sections: S[];
29
+ elements: E[];
30
+ pages: P[];
31
+ RichText?: ComponentType<{
32
+ doc: unknown;
33
+ }>;
34
+ };
35
+ export declare function defineViews<S extends ViewOf, E extends ViewOf, P extends PageViewOf>(config: ResolvedConfig, views: ViewsInput<S, E, P>): Views<S, E, P>;
36
+ export {};
package/dist/views.js ADDED
@@ -0,0 +1,52 @@
1
+ function match(what, key, schemas, views) {
2
+ const registered = new Map(schemas.map((s) => [s.name, s]));
3
+ const viewed = new Set();
4
+ for (const v of views) {
5
+ const schema = registered.get(v.schema.name);
6
+ if (!schema)
7
+ throw new Error(`smoodly: the view for ${what} "${v.schema.name}" has no schema in the config — add its schema to registry.${key}.`);
8
+ if (schema !== v.schema) {
9
+ throw new Error(`smoodly: the view for ${what} "${v.schema.name}" is paired with a different schema object than the one in the config — pair it with the registered schema.`);
10
+ }
11
+ viewed.add(v.schema.name);
12
+ }
13
+ for (const s of schemas) {
14
+ if (!viewed.has(s.name))
15
+ throw new Error(`smoodly: ${what} "${s.name}" is registered in the config but has no view — add its component to defineViews({ ${key} }).`);
16
+ }
17
+ }
18
+ export function defineViews(config, views) {
19
+ const elements = views.elements ?? [];
20
+ match("section", "sections", config.registry.sections, views.sections);
21
+ match("element", "elements", config.registry.elements, elements);
22
+ match("page type", "pages", config.registry.pages, views.pages.filter((p) => p.schema.kind === "page"));
23
+ const collections = new Map(config.registry.collections.map((c) => [c.name, c]));
24
+ const rendered = new Set();
25
+ for (const p of views.pages) {
26
+ if (p.schema.kind !== "entryPage")
27
+ continue;
28
+ const name = p.schema.collection;
29
+ if (!collections.has(name))
30
+ throw new Error(`smoodly: an entry page renders collection "${name}", which is not in registry.collections.`);
31
+ if (rendered.has(name))
32
+ throw new Error(`smoodly: two entry pages render collection "${name}" — keep one in defineViews({ pages }).`);
33
+ rendered.add(name);
34
+ }
35
+ for (const c of config.registry.collections) {
36
+ if (c.path !== undefined && !rendered.has(c.name)) {
37
+ throw new Error(`smoodly: collection "${c.name}" declares a path but no entry page — add smoodly.entryPage(${c.name}, View) to defineViews({ pages }).`);
38
+ }
39
+ }
40
+ // A bound RichText (createRichText) carries the element components it renders. One it lacks would
41
+ // vanish from every body without a word, so it is named here (final review of the registration split).
42
+ const bound = views.RichText?.elements;
43
+ if (Array.isArray(bound)) {
44
+ const rendered = new Set(bound.map((e) => e?.schema?.name));
45
+ for (const e of config.registry.elements) {
46
+ if ((e.contexts ?? []).includes("richtext") && !rendered.has(e.name)) {
47
+ throw new Error(`smoodly: element "${e.name}" has a view in defineViews({ elements }), but the bound RichText does not render it — add its component to createRichText({ elements }) in smoodly/richtext.tsx.`);
48
+ }
49
+ }
50
+ }
51
+ return { sections: views.sections, elements, pages: views.pages, ...(views.RichText ? { RichText: views.RichText } : {}) };
52
+ }
package/dist/zones.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { GlobalSchema } from "./global.ts";
2
- type WithSchema = {
3
- schema: {
4
- name: string;
5
- };
2
+ /** A section schema as a zone needs it. `kind` keeps a component out at compile time: a function has a `name` too. */
3
+ type Named = {
4
+ kind: string;
5
+ name: string;
6
6
  };
7
7
  export type ZonePolicy = {
8
8
  kind: "sections";
@@ -22,16 +22,16 @@ export type ZonePolicy = {
22
22
  global?: string;
23
23
  };
24
24
  export declare const z: {
25
- sections(components: WithSchema[]): {
25
+ sections(schemas: Named[]): {
26
26
  kind: "sections";
27
27
  allow: string[];
28
28
  } & {
29
29
  max(n: number): ZonePolicy;
30
30
  };
31
31
  freeform(opts: {
32
- sections: WithSchema[] | "*";
32
+ sections: Named[] | "*";
33
33
  custom?: boolean;
34
34
  }): ZonePolicy;
35
- locked(target: WithSchema | GlobalSchema): ZonePolicy;
35
+ locked(target: Named | GlobalSchema): ZonePolicy;
36
36
  };
37
37
  export {};
package/dist/zones.js CHANGED
@@ -1,10 +1,11 @@
1
- // Zone policies — the freedom dial. Policies reference paired components
2
- // directly; only their registered names are stored (plain data).
1
+ // Zone policies — the freedom dial. Policies reference schemas directly;
2
+ // only their registered names are stored (plain data).
3
+ import { schemaOnly } from "./registration.js";
3
4
  const isGlobal = (target) => "kind" in target && target.kind === "global";
4
- const names = (components) => components.map((c) => c.schema.name);
5
+ const names = (schemas) => schemas.map((s) => schemaOnly(s).name);
5
6
  export const z = {
6
- sections(components) {
7
- const allow = names(components);
7
+ sections(schemas) {
8
+ const allow = names(schemas);
8
9
  return {
9
10
  kind: "sections",
10
11
  allow,
@@ -24,6 +25,6 @@ export const z = {
24
25
  throw new Error(`smoodly: global item "${target.name}" has no section — a locked zone needs a visual item.`);
25
26
  return { kind: "locked", component: target.section, global: target.name };
26
27
  }
27
- return { kind: "locked", component: target.schema.name };
28
+ return { kind: "locked", component: schemaOnly(target).name };
28
29
  },
29
30
  };