create-smoodly-app 0.0.34 → 0.0.36

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 (71) hide show
  1. package/dist/index.js +4 -0
  2. package/package.json +1 -1
  3. package/templates/blank/app/(site)/layout.tsx +4 -2
  4. package/templates/blank/app/api/preview/route.ts +3 -0
  5. package/templates/blank/components/pages/Home.tsx +1 -1
  6. package/templates/blank/components/pages/Page.tsx +1 -1
  7. package/templates/blank/components/pages/Post.tsx +5 -7
  8. package/templates/blank/package.json +1 -1
  9. package/templates/blank/smoodly/admin.tsx +2 -2
  10. package/templates/blank/smoodly/server.ts +3 -3
  11. package/templates/blank/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  12. package/templates/blank/supabase/migrations/20260930040956_smoodly_v7.sql +84 -0
  13. package/templates/blank/supabase/migrations/{20260929120100_smoodly_views.sql → 20260930041056_smoodly_views.sql} +3 -6
  14. package/templates/blank-intl/app/[locale]/layout.tsx +4 -2
  15. package/templates/blank-intl/app/api/preview/route.ts +3 -0
  16. package/templates/blank-intl/components/pages/Home.tsx +1 -1
  17. package/templates/blank-intl/components/pages/Page.tsx +1 -1
  18. package/templates/blank-intl/components/pages/Post.tsx +5 -7
  19. package/templates/blank-intl/package.json +1 -1
  20. package/templates/blank-intl/smoodly/admin.tsx +2 -2
  21. package/templates/blank-intl/smoodly/server.ts +3 -3
  22. package/templates/blank-intl/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  23. package/templates/blank-intl/supabase/migrations/20260930040956_smoodly_v7.sql +84 -0
  24. package/templates/blank-intl/supabase/migrations/{20260929120100_smoodly_views.sql → 20260930041056_smoodly_views.sql} +3 -6
  25. package/templates/blank-intl.tsconfig.json +0 -3
  26. package/templates/blank.tsconfig.json +0 -3
  27. package/templates/lorem/app/(site)/layout.tsx +5 -3
  28. package/templates/lorem/app/api/preview/route.ts +3 -0
  29. package/templates/lorem/components/PageFrame.tsx +1 -1
  30. package/templates/lorem/components/articles.ts +0 -1
  31. package/templates/lorem/components/news.ts +0 -1
  32. package/templates/lorem/components/pages/Article.tsx +32 -14
  33. package/templates/lorem/components/pages/Articles.tsx +4 -6
  34. package/templates/lorem/components/pages/Home.tsx +1 -1
  35. package/templates/lorem/components/pages/News.tsx +8 -12
  36. package/templates/lorem/components/pages/NewsPost.tsx +6 -6
  37. package/templates/lorem/components/pages/Page.tsx +1 -1
  38. package/templates/lorem/components/sections/FavouriteArticles.tsx +2 -3
  39. package/templates/lorem/components/sections/People.tsx +1 -1
  40. package/templates/lorem/messages/en.json +1 -0
  41. package/templates/lorem/package.json +1 -1
  42. package/templates/lorem/smoodly/admin.tsx +2 -2
  43. package/templates/lorem/smoodly/collections.ts +2 -0
  44. package/templates/lorem/smoodly/server.ts +3 -3
  45. package/templates/lorem/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  46. package/templates/lorem/supabase/migrations/20260930040956_smoodly_v7.sql +84 -0
  47. package/templates/lorem/supabase/migrations/{20260929120100_smoodly_views.sql → 20260930041056_smoodly_views.sql} +4 -10
  48. package/templates/lorem-intl/app/[locale]/layout.tsx +5 -3
  49. package/templates/lorem-intl/app/api/preview/route.ts +3 -0
  50. package/templates/lorem-intl/components/PageFrame.tsx +1 -1
  51. package/templates/lorem-intl/components/articles.ts +0 -1
  52. package/templates/lorem-intl/components/news.ts +0 -1
  53. package/templates/lorem-intl/components/pages/Article.tsx +32 -14
  54. package/templates/lorem-intl/components/pages/Articles.tsx +4 -6
  55. package/templates/lorem-intl/components/pages/Home.tsx +1 -1
  56. package/templates/lorem-intl/components/pages/News.tsx +8 -12
  57. package/templates/lorem-intl/components/pages/NewsPost.tsx +6 -6
  58. package/templates/lorem-intl/components/pages/Page.tsx +1 -1
  59. package/templates/lorem-intl/components/sections/FavouriteArticles.tsx +2 -3
  60. package/templates/lorem-intl/components/sections/People.tsx +1 -1
  61. package/templates/lorem-intl/messages/en.json +1 -0
  62. package/templates/lorem-intl/messages/fi.json +1 -0
  63. package/templates/lorem-intl/package.json +1 -1
  64. package/templates/lorem-intl/smoodly/admin.tsx +2 -2
  65. package/templates/lorem-intl/smoodly/collections.ts +2 -0
  66. package/templates/lorem-intl/smoodly/server.ts +3 -3
  67. package/templates/lorem-intl/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  68. package/templates/lorem-intl/supabase/migrations/20260930040956_smoodly_v7.sql +84 -0
  69. package/templates/lorem-intl/supabase/migrations/{20260929120100_smoodly_views.sql → 20260930041056_smoodly_views.sql} +4 -10
  70. package/templates/lorem-intl.tsconfig.json +0 -3
  71. package/templates/lorem.tsconfig.json +0 -3
@@ -22,7 +22,7 @@ function PeopleView({ heading, people: persons, styles }: Props<typeof peopleSch
22
22
  <div className="container">
23
23
  <h2 className="h2" {...m.field("heading")}>{heading}</h2>
24
24
  <div className="people__grid" {...m.field("people")}>
25
- {(persons ?? []).filter(Boolean).map((person, i) => (
25
+ {(persons ?? []).filter((ref) => ref !== null).map(({ fields: person }, i) => (
26
26
  <div key={i} className="person">
27
27
  <span className="person__mark" aria-hidden="true">{person.name?.charAt(0)}</span>
28
28
  <span className="person__name">{person.name}</span>
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "allArticles": "All articles",
3
3
  "relatedArticles": "Related articles",
4
+ "moreFromAuthor": "More from {name}",
4
5
  "minRead": "{minutes} min read",
5
6
  "allNews": "All news"
6
7
  }
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.34"
17
+ "smoodly": "0.0.36"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -1,6 +1,6 @@
1
1
  import { createSmoodlyAdmin } from "smoodly/admin/next";
2
2
  import { config } from "./config";
3
3
  import { smoodlyOp } from "./ops";
4
- import { publicAuth, publicContent } from "./server";
4
+ import { publicAuth, presence } from "./server";
5
5
 
6
- export const { AdminPage, AdminLayout } = createSmoodlyAdmin({ config, op: smoodlyOp, auth: publicAuth, content: publicContent });
6
+ export const { AdminPage, AdminLayout } = createSmoodlyAdmin({ config, op: smoodlyOp, auth: publicAuth, presence });
@@ -25,6 +25,7 @@ export const articles = smoodly.collection({
25
25
  titleField: "title",
26
26
  path: "posts",
27
27
  order: { by: "date", direction: "desc" },
28
+ seo: { description: "excerpt", image: "image" },
28
29
  fields: {
29
30
  title: f.text().localized(),
30
31
  image: f.image({ aspect: "3:2" }),
@@ -49,6 +50,7 @@ export const news = smoodly.collection({
49
50
  titleField: "title",
50
51
  path: "company/news",
51
52
  order: { by: "date", direction: "desc" },
53
+ seo: { description: "excerpt" },
52
54
  fields: {
53
55
  title: f.text().localized(),
54
56
  date: f.date(),
@@ -9,7 +9,7 @@
9
9
  // admin route enters it from config.ts — so nothing here may read the
10
10
  // config at module scope.
11
11
  import {
12
- createSmoodlyClient, createSmoodlySite, publicContentConfig, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type ContentConfig, type SmoodlySite,
12
+ createSmoodlyClient, createSmoodlySite, presenceConfig, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabaseMemberStore, SupabasePageStore, SupabasePathIndex, type PresenceConfig, type SmoodlySite,
13
13
  } from "smoodly";
14
14
  // The admin gate from `smoodly/admin`, which holds no client code: the site
15
15
  // routes import this file, and `smoodly/admin/next` would put the admin
@@ -89,8 +89,8 @@ export function publicAuth(): AuthConfig {
89
89
 
90
90
  /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
91
91
  * The space is asked of the database once per process. */
92
- export function publicContent(): Promise<ContentConfig> {
93
- return publicContentConfig(smoodlyEnv());
92
+ export function presence(): Promise<PresenceConfig> {
93
+ return presenceConfig(smoodlyEnv());
94
94
  }
95
95
 
96
96
  /** Locale-relative CMS path → site URL: the one seam for the renderer
@@ -0,0 +1,200 @@
1
+ -- Smoodly core schema, version 6 (docs/superpowers/specs/2026-09-29-pre-install-fixes-design.md §3).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 5: one permissive policy per table and action (the
5
+ -- write key reads through smoodly_read; smoodly_write splits into insert,
6
+ -- update and delete where a public read exists; the media pair the same),
7
+ -- indexes on the foreign keys without one, and no anon call of the row cap.
8
+ -- Access is unchanged.
9
+
10
+ set client_min_messages = warning;
11
+
12
+ create index if not exists "page_locales_draft_version_idx" on "page_locales" ("draft_version_id");
13
+ create index if not exists "entry_locales_draft_version_idx" on "entry_locales" ("draft_version_id");
14
+ create index if not exists "page_locales_published_version_idx" on "page_locales" ("published_version_id");
15
+ create index if not exists "entry_locales_published_version_idx" on "entry_locales" ("published_version_id");
16
+ create index if not exists "pages_parent_id_idx" on "pages" ("parent_id");
17
+ create index if not exists "entries_parent_id_idx" on "entries" ("parent_id");
18
+ create index if not exists "assets_poster_idx" on "assets" ("poster_id");
19
+
20
+ drop policy if exists "smoodly_write" on "pages";
21
+
22
+ drop policy if exists "smoodly_insert" on "pages";
23
+ create policy "smoodly_insert" on "pages" for insert
24
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
25
+
26
+ drop policy if exists "smoodly_update" on "pages";
27
+ create policy "smoodly_update" on "pages" for update
28
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
29
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
30
+
31
+ drop policy if exists "smoodly_delete" on "pages";
32
+ create policy "smoodly_delete" on "pages" for delete
33
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
34
+
35
+ drop policy if exists "smoodly_read" on "pages";
36
+ create policy "smoodly_read" on "pages" for select
37
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')));
38
+
39
+ drop policy if exists "smoodly_write" on "page_locales";
40
+
41
+ drop policy if exists "smoodly_insert" on "page_locales";
42
+ create policy "smoodly_insert" on "page_locales" for insert
43
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
44
+
45
+ drop policy if exists "smoodly_update" on "page_locales";
46
+ create policy "smoodly_update" on "page_locales" for update
47
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
48
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
49
+
50
+ drop policy if exists "smoodly_delete" on "page_locales";
51
+ create policy "smoodly_delete" on "page_locales" for delete
52
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
53
+
54
+ drop policy if exists "smoodly_read" on "page_locales";
55
+ create policy "smoodly_read" on "page_locales" for select
56
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or "status" = 'published'));
57
+
58
+ drop policy if exists "smoodly_write" on "page_versions";
59
+
60
+ drop policy if exists "smoodly_insert" on "page_versions";
61
+ create policy "smoodly_insert" on "page_versions" for insert
62
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
63
+
64
+ drop policy if exists "smoodly_update" on "page_versions";
65
+ create policy "smoodly_update" on "page_versions" for update
66
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
67
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
68
+
69
+ drop policy if exists "smoodly_delete" on "page_versions";
70
+ create policy "smoodly_delete" on "page_versions" for delete
71
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
72
+
73
+ drop policy if exists "smoodly_read" on "page_versions";
74
+ create policy "smoodly_read" on "page_versions" for select
75
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id")));
76
+
77
+ drop policy if exists "smoodly_write" on "entries";
78
+
79
+ drop policy if exists "smoodly_insert" on "entries";
80
+ create policy "smoodly_insert" on "entries" for insert
81
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
82
+
83
+ drop policy if exists "smoodly_update" on "entries";
84
+ create policy "smoodly_update" on "entries" for update
85
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
86
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
87
+
88
+ drop policy if exists "smoodly_delete" on "entries";
89
+ create policy "smoodly_delete" on "entries" for delete
90
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
91
+
92
+ drop policy if exists "smoodly_read" on "entries";
93
+ create policy "smoodly_read" on "entries" for select
94
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published')));
95
+
96
+ drop policy if exists "smoodly_write" on "entry_locales";
97
+
98
+ drop policy if exists "smoodly_insert" on "entry_locales";
99
+ create policy "smoodly_insert" on "entry_locales" for insert
100
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
101
+
102
+ drop policy if exists "smoodly_update" on "entry_locales";
103
+ create policy "smoodly_update" on "entry_locales" for update
104
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
105
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
106
+
107
+ drop policy if exists "smoodly_delete" on "entry_locales";
108
+ create policy "smoodly_delete" on "entry_locales" for delete
109
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
110
+
111
+ drop policy if exists "smoodly_read" on "entry_locales";
112
+ create policy "smoodly_read" on "entry_locales" for select
113
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or "status" = 'published'));
114
+
115
+ drop policy if exists "smoodly_write" on "entry_versions";
116
+
117
+ drop policy if exists "smoodly_insert" on "entry_versions";
118
+ create policy "smoodly_insert" on "entry_versions" for insert
119
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
120
+
121
+ drop policy if exists "smoodly_update" on "entry_versions";
122
+ create policy "smoodly_update" on "entry_versions" for update
123
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
124
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
125
+
126
+ drop policy if exists "smoodly_delete" on "entry_versions";
127
+ create policy "smoodly_delete" on "entry_versions" for delete
128
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
129
+
130
+ drop policy if exists "smoodly_read" on "entry_versions";
131
+ create policy "smoodly_read" on "entry_versions" for select
132
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")));
133
+
134
+ drop policy if exists "smoodly_write" on "refs";
135
+ create policy "smoodly_write" on "refs" for all
136
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
137
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
138
+
139
+ drop policy if exists "smoodly_write" on "paths";
140
+
141
+ drop policy if exists "smoodly_insert" on "paths";
142
+ create policy "smoodly_insert" on "paths" for insert
143
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
144
+
145
+ drop policy if exists "smoodly_update" on "paths";
146
+ create policy "smoodly_update" on "paths" for update
147
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
148
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
149
+
150
+ drop policy if exists "smoodly_delete" on "paths";
151
+ create policy "smoodly_delete" on "paths" for delete
152
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
153
+
154
+ drop policy if exists "smoodly_read" on "paths";
155
+ create policy "smoodly_read" on "paths" for select
156
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or (("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')))));
157
+
158
+ drop policy if exists "smoodly_write" on "assets";
159
+
160
+ drop policy if exists "smoodly_insert" on "assets";
161
+ create policy "smoodly_insert" on "assets" for insert
162
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
163
+
164
+ drop policy if exists "smoodly_update" on "assets";
165
+ create policy "smoodly_update" on "assets" for update
166
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
167
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
168
+
169
+ drop policy if exists "smoodly_delete" on "assets";
170
+ create policy "smoodly_delete" on "assets" for delete
171
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
172
+
173
+ drop policy if exists "smoodly_read" on "assets";
174
+ create policy "smoodly_read" on "assets" for select
175
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and ((select "smoodly_key_kind"()) = 'write' or "status" = 'ready'));
176
+
177
+ drop policy if exists "smoodly_write" on "members";
178
+ create policy "smoodly_write" on "members" for all
179
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
180
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
181
+
182
+ drop policy if exists "smoodly_media_write" on storage.objects;
183
+
184
+ drop policy if exists "smoodly_media_insert" on storage.objects;
185
+ create policy "smoodly_media_insert" on storage.objects for insert
186
+ with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
187
+
188
+ drop policy if exists "smoodly_media_update" on storage.objects;
189
+ create policy "smoodly_media_update" on storage.objects for update
190
+ using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text)
191
+ with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
192
+
193
+ drop policy if exists "smoodly_media_delete" on storage.objects;
194
+ create policy "smoodly_media_delete" on storage.objects for delete
195
+ using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
196
+
197
+ revoke execute on function "smoodly_space_row_cap"() from public, anon;
198
+
199
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '6')
200
+ on conflict ("key") do update set "value" = excluded."value";
@@ -0,0 +1,84 @@
1
+ -- Smoodly core schema, version 7 (docs/superpowers/specs/2026-09-29-content-api-contract-design.md §5).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 6: smoodly_list_entries, which filters, orders and
5
+ -- limits a collection over the snapshot the reader is shown, and the read
6
+ -- policies of every content table admit a write key only (§8).
7
+
8
+ -- The shown snapshot of one collection's entries in a locale, filtered,
9
+ -- ordered and limited in one query (spec 2026-09-29 content API contract
10
+ -- §5): the published snapshot, or the live merge in preview. Invoker
11
+ -- rights, so RLS decides what a key sees; a preview read needs the
12
+ -- service role or a write key, anything else reads published.
13
+ create or replace function "smoodly_list_entries"(
14
+ p_collection text, p_locale text, p_draft boolean, p_where jsonb, p_order jsonb,
15
+ p_limit integer, p_parent uuid, p_has_parent boolean, p_ids uuid[]
16
+ ) returns table ("id" uuid, "fields" jsonb) language sql stable set search_path = public as $$
17
+ with mode as (
18
+ select p_draft and (current_user = 'service_role' or "smoodly_key_kind"() = 'write') as draft
19
+ ), shown as (
20
+ select e."id", e."sort", e."created_at", l."updated_at",
21
+ case when (select draft from mode) then e."fields" || l."fields" || jsonb_build_object('slug', l."slug")
22
+ else coalesce(v."fields", e."fields" || l."fields" || jsonb_build_object('slug', l."slug")) end as "fields"
23
+ from "entries" e
24
+ join "entry_locales" l on l."entry_id" = e."id" and l."locale" = p_locale
25
+ left join "entry_versions" v on v."id" = l."published_version_id"
26
+ where e."collection" = p_collection
27
+ and ((select draft from mode) or l."status" = 'published')
28
+ and (not p_has_parent or e."parent_id" is not distinct from p_parent)
29
+ and (p_ids is null or e."id" = any(p_ids))
30
+ )
31
+ select s."id", s."fields" from shown s
32
+ where not exists (
33
+ select 1 from jsonb_each(coalesce(p_where, '{}'::jsonb)) w
34
+ where not (case when w.value ? 'in'
35
+ then (s."fields" ->> w.key) = any (select jsonb_array_elements_text(w.value -> 'in'))
36
+ else s."fields" @> jsonb_build_object(w.key, w.value -> 'contains') end))
37
+ order by
38
+ case when p_order ->> 'by' = 'manual' then s."sort" end asc nulls last,
39
+ case when p_order ->> 'direction' = 'asc' then
40
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
41
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end asc nulls last,
42
+ case when p_order ->> 'direction' = 'desc' then
43
+ case p_order ->> 'by' when 'createdAt' then to_jsonb(s."created_at") when 'updatedAt' then to_jsonb(s."updated_at")
44
+ when 'manual' then null else nullif(nullif(s."fields" -> (p_order ->> 'by'), 'null'::jsonb), '""'::jsonb) end end desc nulls last,
45
+ s."created_at" asc, s."id" asc
46
+ limit p_limit;
47
+ $$;
48
+
49
+ -- No public content reads (spec 2026-09-29 content API contract §8): the
50
+ -- publishable key and a read key read nothing; a write key reads its own space.
51
+ drop policy if exists "smoodly_read" on "pages";
52
+ create policy "smoodly_read" on "pages" for select
53
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
54
+
55
+ drop policy if exists "smoodly_read" on "page_locales";
56
+ create policy "smoodly_read" on "page_locales" for select
57
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
58
+
59
+ drop policy if exists "smoodly_read" on "page_versions";
60
+ create policy "smoodly_read" on "page_versions" for select
61
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
62
+
63
+ drop policy if exists "smoodly_read" on "entries";
64
+ create policy "smoodly_read" on "entries" for select
65
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
66
+
67
+ drop policy if exists "smoodly_read" on "entry_locales";
68
+ create policy "smoodly_read" on "entry_locales" for select
69
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
70
+
71
+ drop policy if exists "smoodly_read" on "entry_versions";
72
+ create policy "smoodly_read" on "entry_versions" for select
73
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
74
+
75
+ drop policy if exists "smoodly_read" on "paths";
76
+ create policy "smoodly_read" on "paths" for select
77
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
78
+
79
+ drop policy if exists "smoodly_read" on "assets";
80
+ create policy "smoodly_read" on "assets" for select
81
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
82
+
83
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '7')
84
+ on conflict ("key") do update set "value" = excluded."value";
@@ -1,5 +1,5 @@
1
- -- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
2
- -- (2026-09-29T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
1
+ -- Smoodly: read views for this app's collections, written by `npx smoodly migrate`
2
+ -- (2026-09-30T04:10:56.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
3
3
  -- Never edit a written file: change the collection and run migrate again.
4
4
 
5
5
  -- smoodly:collection people sha256:929798ebb4fa2bb6
@@ -18,7 +18,7 @@ join "entry_locales" l on l."entry_id" = e."id"
18
18
  left join "entry_versions" v on v."id" = l."published_version_id"
19
19
  where e."collection" = 'people' and l."status" = 'published';
20
20
 
21
- -- smoodly:collection articles sha256:f0257bf8fe462954
21
+ -- smoodly:collection articles sha256:dcbe071d70e13943
22
22
  create or replace view "articles" with (security_invoker = true) as
23
23
  select
24
24
  e."id",
@@ -39,10 +39,7 @@ join "entry_locales" l on l."entry_id" = e."id"
39
39
  left join "entry_versions" v on v."id" = l."published_version_id"
40
40
  where e."collection" = 'articles' and l."status" = 'published';
41
41
 
42
- create index if not exists "entries_articles_date_idx"
43
- on "entries" (("fields"->'date')) where "collection" = 'articles';
44
-
45
- -- smoodly:collection news sha256:ed50b7a480ac4120
42
+ -- smoodly:collection news sha256:03a94ad5ba35ffe4
46
43
  create or replace view "news" with (security_invoker = true) as
47
44
  select
48
45
  e."id",
@@ -59,6 +56,3 @@ from "entries" e
59
56
  join "entry_locales" l on l."entry_id" = e."id"
60
57
  left join "entry_versions" v on v."id" = l."published_version_id"
61
58
  where e."collection" = 'news' and l."status" = 'published';
62
-
63
- create index if not exists "entries_news_date_idx"
64
- on "entries" (("fields"->'date')) where "collection" = 'news';
@@ -11,7 +11,9 @@ import "../globals.css";
11
11
  const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
12
12
  const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400", "500", "600"], variable: "--font-sans" });
13
13
 
14
- export const metadata = { title: "Lorem Co." };
14
+ // metadataBase makes Smoodly's hreflang alternates and social image URLs
15
+ // absolute; SITE_URL is the deployed address (spec 2026-09-29 pre-install fixes §6).
16
+ export const metadata = { metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"), title: "Lorem Co." };
15
17
 
16
18
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
17
19
  // two root layouts, a full page load between them, which is fine: they
@@ -24,7 +26,7 @@ export const metadata = { title: "Lorem Co." };
24
26
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
25
27
  const { locale } = await params;
26
28
  if (!hasLocale(routing.locales, locale)) notFound();
27
- const [footerProps, contactFields] = await Promise.all([
29
+ const [footer, contact] = await Promise.all([
28
30
  q().getGlobal("footer", { locale }),
29
31
  q().getGlobal("contact", { locale }),
30
32
  ]);
@@ -33,7 +35,7 @@ export default async function LocaleLayout({ children, params }: { children: Rea
33
35
  <body>
34
36
  <NextIntlClientProvider>
35
37
  {children}
36
- {footerProps && <FooterView {...footerProps} contact={contactFields} />}
38
+ {footer && <FooterView {...footer.fields} settings={footer.settings} styles={footer.styles} contact={contact?.fields} />}
37
39
  </NextIntlClientProvider>
38
40
  </body>
39
41
  </html>
@@ -0,0 +1,3 @@
1
+ // Draft mode on (with SMOODLY_PREVIEW_SECRET) or off (the preview pill's
2
+ // Exit). The logic lives in the package.
3
+ export { previewRoute as GET } from "smoodly/next";
@@ -24,7 +24,7 @@ export async function PageFrame({ locale, locales, children }: {
24
24
  : undefined;
25
25
  return (
26
26
  <>
27
- {header && <HeaderView {...header} home={href("/", locale)} locales={links} />}
27
+ {header && <HeaderView {...header.fields} settings={header.settings} styles={header.styles} home={href("/", locale)} locales={links} />}
28
28
  <main>{children}</main>
29
29
  </>
30
30
  );
@@ -23,7 +23,6 @@ function segment(locale: string): string {
23
23
  }
24
24
 
25
25
  export const articlesIndexPath = (locale: string) => `/${segment(locale)}`;
26
- export const articlePath = (slug: string, locale: string) => `/${segment(locale)}/${slug}`;
27
26
 
28
27
  export const formatDate = (iso: string, locale: string) =>
29
28
  new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(iso));
@@ -10,5 +10,4 @@ function address(locale: string): string {
10
10
  return p?.[locale] ?? Object.values(p ?? {})[0] ?? "company/news";
11
11
  }
12
12
 
13
- export type NewsFields = { title: string; slug: string; date: string; excerpt?: string; body?: unknown };
14
13
  export const newsIndexPath = (locale: string) => `/${address(locale)}`;
@@ -1,11 +1,11 @@
1
- import { smoodly, type EntryPageProps } from "smoodly";
1
+ import { smoodly } from "smoodly";
2
2
  import { RichText } from "@/smoodly/richtext";
3
3
  import { SmoodlyImage } from "smoodly/image";
4
4
  import { articles } from "@/smoodly/collections";
5
5
  import { q } from "@/smoodly/server";
6
6
  import { ArticleCard } from "../ArticleCard";
7
7
  import { PageFrame } from "../PageFrame";
8
- import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
8
+ import { articlesIndexPath, formatDate, readingMinutes } from "../articles";
9
9
  import { ui } from "../strings";
10
10
 
11
11
  // The entry page for `articles`, served at the collection's `path`
@@ -13,13 +13,19 @@ import { ui } from "../strings";
13
13
  // resolved (author is a person). The body is richtext rendered by the
14
14
  // package's RichText; the read time is words / 200. "Related articles"
15
15
  // is the newest other published articles, fetched here, and absent when
16
- // there are none. The render locale and the alternates arrive as props
16
+ // there are none. The render locale and the alternates ride on the entry
17
17
  // (spec 2026-09-15); PageFrame renders the header from them.
18
- async function ArticleView({ entry, locale, locales }: EntryPageProps) {
18
+ export const ArticlePage = smoodly.entryPage(articles, async function ArticleView({ entry }) {
19
+ const { locale, locales } = entry;
19
20
  const { t, url } = await ui();
20
- const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
21
+ const a = entry.fields;
21
22
  // Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
22
- const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
23
+ const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.id !== entry.id).slice(0, 3);
24
+ // The author's other articles: `where` filters on the shared `author` field in the store, and reads the published rows.
25
+ const authorId = a.author?.id;
26
+ const more = authorId
27
+ ? (await q().getEntries("articles", { locale, where: { author: authorId } })).filter((r) => r.id !== entry.id)
28
+ : [];
23
29
  const index = url(articlesIndexPath(locale));
24
30
  return (
25
31
  <PageFrame locale={locale} locales={locales}>
@@ -35,8 +41,8 @@ async function ArticleView({ entry, locale, locales }: EntryPageProps) {
35
41
  <p className="article__byline">
36
42
  <span className="article__avatar" aria-hidden="true" />
37
43
  <span>
38
- {a.author?.name && <strong>{a.author.name}</strong>}
39
- {a.author?.name && " · "}
44
+ {a.author?.fields.name && <strong>{a.author.fields.name}</strong>}
45
+ {a.author?.fields.name && " · "}
40
46
  {a.date && <>{formatDate(a.date, locale)} · </>}
41
47
  {t("minRead", { minutes: readingMinutes(a.body) })}
42
48
  </span>
@@ -45,6 +51,20 @@ async function ArticleView({ entry, locale, locales }: EntryPageProps) {
45
51
  {a.image && <SmoodlyImage image={a.image} aspect="21:9" className="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
46
52
  <div className="article__body prose prose--lead"><RichText doc={a.body} /></div>
47
53
  </article>
54
+ {more.length > 0 && (
55
+ <section className="related" data-testid="more-by-author">
56
+ <div className="container">
57
+ <div className="related__head">
58
+ <h2 className="h2">{t("moreFromAuthor", { name: a.author?.fields.name ?? "" })}</h2>
59
+ </div>
60
+ <div className="card-grid card-grid--3">
61
+ {more.map((r) => (
62
+ <ArticleCard key={r.id} article={r.fields} url={url(r.path!)} locale={locale} compact />
63
+ ))}
64
+ </div>
65
+ </div>
66
+ </section>
67
+ )}
48
68
  {related.length > 0 && (
49
69
  <section className="related">
50
70
  <div className="container">
@@ -53,16 +73,14 @@ async function ArticleView({ entry, locale, locales }: EntryPageProps) {
53
73
  <a className="link-accent" href={index}>{t("allArticles")} →</a>
54
74
  </div>
55
75
  <div className="card-grid card-grid--3">
56
- {related.map(({ entry: r, path }) => {
57
- const fields = r.fields as ArticleFields;
58
- return <ArticleCard key={r.id} article={fields} url={url(path ?? articlePath(fields.slug, locale))} locale={locale} compact />;
59
- })}
76
+ {related.map((r) => (
77
+ <ArticleCard key={r.id} article={r.fields} url={url(r.path!)} locale={locale} compact />
78
+ ))}
60
79
  </div>
61
80
  </div>
62
81
  </section>
63
82
  )}
64
83
  </PageFrame>
65
84
  );
66
- }
85
+ });
67
86
 
68
- export const ArticlePage = smoodly.entryPage(articles, ArticleView);
@@ -2,7 +2,6 @@ import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { PageHero } from "../sections/PageHero";
3
3
  import { ArticleCard } from "../ArticleCard";
4
4
  import { PageFrame } from "../PageFrame";
5
- import { articlePath, type ArticleFields } from "../articles";
6
5
  import { ui } from "../strings";
7
6
  import { q } from "@/smoodly/server";
8
7
 
@@ -21,7 +20,7 @@ export const articlesSchema = smoodly.schema.page({
21
20
  zones: {
22
21
  hero: z.locked(PageHero),
23
22
  },
24
- meta: { seo: true },
23
+ seo: true,
25
24
  });
26
25
 
27
26
  async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
@@ -32,10 +31,9 @@ async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
32
31
  <Zone of={zones.hero} />
33
32
  <section className="container articles-index">
34
33
  <div className="card-grid">
35
- {items.map(({ entry, path }) => {
36
- const a = entry.fields as ArticleFields;
37
- return <ArticleCard key={entry.id} article={a} url={url(path ?? articlePath(a.slug, locale))} locale={locale} />;
38
- })}
34
+ {items.map((entry) => (
35
+ <ArticleCard key={entry.id} article={entry.fields} url={url(entry.path!)} locale={locale} />
36
+ ))}
39
37
  </div>
40
38
  </section>
41
39
  </PageFrame>
@@ -23,7 +23,7 @@ export const homeSchema = smoodly.schema.page({
23
23
  hero: z.locked(Hero),
24
24
  body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote], custom: true }),
25
25
  },
26
- meta: { seo: true },
26
+ seo: true,
27
27
  });
28
28
 
29
29
  function HomeView({ zones, page }: Props<typeof homeSchema>) {