create-smoodly-app 0.0.33 → 0.0.35

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 (55) hide show
  1. package/dist/index.js +21 -6
  2. package/package.json +1 -1
  3. package/templates/blank/app/(site)/layout.tsx +3 -1
  4. package/templates/blank/app/api/preview/route.ts +3 -0
  5. package/templates/blank/package.json +1 -1
  6. package/templates/blank/scripts/seed.ts +1 -1
  7. package/templates/blank/smoodly/server.ts +7 -3
  8. package/templates/blank/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  9. package/templates/blank/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  10. package/templates/blank/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929135623_smoodly_views.sql} +1 -1
  11. package/templates/blank-intl/app/[locale]/layout.tsx +3 -1
  12. package/templates/blank-intl/app/api/preview/route.ts +3 -0
  13. package/templates/blank-intl/package.json +1 -1
  14. package/templates/blank-intl/scripts/seed.ts +1 -1
  15. package/templates/blank-intl/smoodly/server.ts +7 -3
  16. package/templates/blank-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  17. package/templates/blank-intl/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  18. package/templates/blank-intl/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929135623_smoodly_views.sql} +1 -1
  19. package/templates/blank-intl.tsconfig.json +0 -3
  20. package/templates/blank.tsconfig.json +0 -3
  21. package/templates/lorem/README.md +3 -2
  22. package/templates/lorem/app/(site)/layout.tsx +3 -1
  23. package/templates/lorem/app/api/preview/route.ts +3 -0
  24. package/templates/lorem/app/globals.css +9 -0
  25. package/templates/lorem/components/news.ts +14 -0
  26. package/templates/lorem/components/pages/News.tsx +48 -0
  27. package/templates/lorem/components/pages/NewsPost.tsx +28 -0
  28. package/templates/lorem/messages/en.json +2 -1
  29. package/templates/lorem/package.json +1 -1
  30. package/templates/lorem/scripts/seed.ts +45 -8
  31. package/templates/lorem/smoodly/collections.ts +21 -1
  32. package/templates/lorem/smoodly/config.ts +3 -1
  33. package/templates/lorem/smoodly/server.ts +7 -3
  34. package/templates/lorem/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  35. package/templates/lorem/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  36. package/templates/lorem/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929135623_smoodly_views.sql} +22 -1
  37. package/templates/lorem-intl/README.md +3 -2
  38. package/templates/lorem-intl/app/[locale]/layout.tsx +3 -1
  39. package/templates/lorem-intl/app/api/preview/route.ts +3 -0
  40. package/templates/lorem-intl/app/globals.css +9 -0
  41. package/templates/lorem-intl/components/news.ts +14 -0
  42. package/templates/lorem-intl/components/pages/News.tsx +48 -0
  43. package/templates/lorem-intl/components/pages/NewsPost.tsx +28 -0
  44. package/templates/lorem-intl/messages/en.json +2 -1
  45. package/templates/lorem-intl/messages/fi.json +2 -1
  46. package/templates/lorem-intl/package.json +1 -1
  47. package/templates/lorem-intl/scripts/seed.ts +45 -8
  48. package/templates/lorem-intl/smoodly/collections.ts +21 -1
  49. package/templates/lorem-intl/smoodly/config.ts +3 -1
  50. package/templates/lorem-intl/smoodly/server.ts +7 -3
  51. package/templates/lorem-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  52. package/templates/lorem-intl/supabase/migrations/20260929135523_smoodly_v6.sql +200 -0
  53. package/templates/lorem-intl/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929135623_smoodly_views.sql} +22 -1
  54. package/templates/lorem-intl.tsconfig.json +0 -3
  55. package/templates/lorem.tsconfig.json +0 -3
@@ -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";
@@ -1,5 +1,5 @@
1
1
  -- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
2
- -- (2026-09-26T15:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
2
+ -- (2026-09-29T13:56:23.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 posts sha256:2e648b887b8e7146
@@ -10,9 +10,6 @@
10
10
  "smoodly": [
11
11
  "../../smoodly/src/index.ts"
12
12
  ],
13
- "smoodly/bridge": [
14
- "../../smoodly/src/admin/next/bridge.tsx"
15
- ],
16
13
  "smoodly/image/config": [
17
14
  "../../smoodly/src/image/next-config.ts"
18
15
  ],
@@ -10,9 +10,6 @@
10
10
  "smoodly": [
11
11
  "../../smoodly/src/index.ts"
12
12
  ],
13
- "smoodly/bridge": [
14
- "../../smoodly/src/admin/next/bridge.tsx"
15
- ],
16
13
  "smoodly/image/config": [
17
14
  "../../smoodly/src/image/next-config.ts"
18
15
  ],
@@ -11,13 +11,13 @@ for an agent (or a person) building the site.
11
11
  - `app/(site)/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
12
12
  - `app/(site)/[...path]/page.tsx` — every other page and every collection entry, at any depth.
13
13
  - `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the footer from global content; the admin has its own under `app/(smoodly)/admin/`.
14
- - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
14
+ - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page), `News` and `NewsPost` (a collection at the nested address `company/news`, its index and entry page).
15
15
  - `components/PageFrame.tsx` — the header and `<main>` every page view renders (a layout cannot know which page is on screen); with more than one locale, the language links from the page's `locales` map — the same page in the other language, or its home when there is no translation.
16
16
  - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
17
17
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
18
18
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
19
19
  - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
20
- - `smoodly/collections.ts` — structured content (people, articles). Add a collection, run `npx smoodly migrate`, apply the file it writes.
20
+ - `smoodly/collections.ts` — structured content (people, articles, news). Add a collection, run `npx smoodly migrate`, apply the file it writes.
21
21
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
22
22
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
23
23
 
@@ -28,6 +28,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
28
28
  - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
29
29
  - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
30
30
  - **Company / About** — a path and a page under it (the breadcrumb), the same body sections, plus people.
31
+ - **News** — a collection at a nested address, `company/news`: code owns the whole address, so its index page and posts stay put when an editor renames Company, and nobody can create a page there.
31
32
  - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
32
33
  - **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
33
34
 
@@ -8,7 +8,9 @@ import "../globals.css";
8
8
  const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
9
9
  const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400", "500", "600"], variable: "--font-sans" });
10
10
 
11
- export const metadata = { title: "Lorem Co." };
11
+ // metadataBase makes Smoodly's hreflang alternates and social image URLs
12
+ // absolute; SITE_URL is the deployed address (spec 2026-09-29 pre-install fixes §6).
13
+ export const metadata = { metadataBase: new URL(process.env.SITE_URL || "http://localhost:3000"), title: "Lorem Co." };
12
14
 
13
15
  // The site's root layout, in its own route group so the admin's layout
14
16
  // under app/(smoodly)/admin is a second root layout beside it rather than
@@ -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";
@@ -188,6 +188,15 @@ h3.card__title { font-size: 24px; }
188
188
 
189
189
  .articles-index { padding-bottom: 96px; }
190
190
 
191
+ .news-index { padding-bottom: 96px; }
192
+ .news-list { list-style: none; margin: 0; padding: 0; max-width: 760px; display: flex; flex-direction: column; }
193
+ .news-list__item { display: flex; flex-direction: column; gap: 8px; padding-block: 28px; border-top: 1px solid var(--line); }
194
+ .news-list__item:last-child { border-bottom: 1px solid var(--line); }
195
+ .news-list__date { font-size: 13px; color: var(--muted); }
196
+ .news-list__title { font-family: var(--serif); font-weight: 500; font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; color: inherit; text-wrap: balance; }
197
+ .news-list__title:hover { color: var(--accent); }
198
+ .news-list__excerpt { font-size: 15px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
199
+
191
200
  .article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
192
201
  .article__back { font-size: 14px; color: var(--muted); }
193
202
  .article__head { max-width: 760px; display: flex; flex-direction: column; gap: 20px; }
@@ -0,0 +1,14 @@
1
+ // The news index's URL, for the post page's back link, from the
2
+ // collection's own nested path (smoodly/collections.ts).
3
+ import { news } from "@/smoodly/collections";
4
+
5
+ /** The collection's address in a locale: its per-locale path, or the
6
+ * first declared one for a locale the map does not name. */
7
+ function address(locale: string): string {
8
+ const p = news.path;
9
+ if (typeof p === "string") return p;
10
+ return p?.[locale] ?? Object.values(p ?? {})[0] ?? "company/news";
11
+ }
12
+
13
+ export type NewsFields = { title: string; slug: string; date: string; excerpt?: string; body?: unknown };
14
+ export const newsIndexPath = (locale: string) => `/${address(locale)}`;
@@ -0,0 +1,48 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { PageHero } from "../sections/PageHero";
3
+ import { PageFrame } from "../PageFrame";
4
+ import { formatDate } from "../articles";
5
+ import type { NewsFields } from "../news";
6
+ import { ui } from "../strings";
7
+ import { q } from "@/smoodly/server";
8
+
9
+ // The News index: a fixed page on the news collection's own nested
10
+ // address (smoodly/collections.ts), so it is the collection's index page.
11
+ // Code owns /company/news whole: the Company page above it is an
12
+ // editor's, and renaming it leaves the news where it is. The editor owns
13
+ // the hero; the list below reads the posts through `q`, newest first.
14
+ export const newsSchema = smoodly.schema.page({
15
+ name: "newsIndex",
16
+ title: "News",
17
+ slug: "company/news",
18
+ zones: {
19
+ hero: z.locked(PageHero),
20
+ },
21
+ meta: { seo: true },
22
+ });
23
+
24
+ async function NewsView({ zones, page }: Props<typeof newsSchema>) {
25
+ const { locale, url } = await ui();
26
+ const items = await q().getEntries("news", { locale });
27
+ return (
28
+ <PageFrame locale={page.locale} locales={page.locales}>
29
+ <Zone of={zones.hero} />
30
+ <section className="container news-index">
31
+ <ul className="news-list">
32
+ {items.map(({ entry, path }) => {
33
+ const n = entry.fields as NewsFields;
34
+ return (
35
+ <li key={entry.id} className="news-list__item">
36
+ <time className="news-list__date" dateTime={n.date}>{formatDate(n.date, locale)}</time>
37
+ <a className="news-list__title" href={url(path ?? "#")}>{n.title}</a>
38
+ {n.excerpt && <p className="news-list__excerpt">{n.excerpt}</p>}
39
+ </li>
40
+ );
41
+ })}
42
+ </ul>
43
+ </section>
44
+ </PageFrame>
45
+ );
46
+ }
47
+
48
+ export const NewsPage = smoodly.page(newsSchema, NewsView);
@@ -0,0 +1,28 @@
1
+ import { smoodly, type EntryPageProps } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
3
+ import { news } from "@/smoodly/collections";
4
+ import { PageFrame } from "../PageFrame";
5
+ import { formatDate } from "../articles";
6
+ import { newsIndexPath, type NewsFields } from "../news";
7
+ import { ui } from "../strings";
8
+
9
+ // The entry page for `news`, at /company/news/<slug> and
10
+ // /fi/yritys/uutiset/<slug>: the collection's nested path, then the slug.
11
+ async function NewsPostView({ entry, locale, locales }: EntryPageProps) {
12
+ const { t, url } = await ui();
13
+ const n = entry.fields as NewsFields;
14
+ return (
15
+ <PageFrame locale={locale} locales={locales}>
16
+ <article className="container article">
17
+ <a className="article__back" href={url(newsIndexPath(locale))}>← {t("allNews")}</a>
18
+ <div className="article__head">
19
+ <h1 className="article__title">{n.title}</h1>
20
+ {n.date && <p className="article__byline">{formatDate(n.date, locale)}</p>}
21
+ </div>
22
+ <div className="article__body prose prose--lead"><RichText doc={n.body} /></div>
23
+ </article>
24
+ </PageFrame>
25
+ );
26
+ }
27
+
28
+ export const NewsPostPage = smoodly.entryPage(news, NewsPostView);
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "allArticles": "All articles",
3
3
  "relatedArticles": "Related articles",
4
- "minRead": "{minutes} min read"
4
+ "minRead": "{minutes} min read",
5
+ "allNews": "All news"
5
6
  }
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.33"
17
+ "smoodly": "0.0.35"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -16,13 +16,13 @@
16
16
  // page exists.
17
17
  import { readFile } from "node:fs/promises";
18
18
  import next from "@next/env";
19
- import { imageDimensions, type PageTree } from "smoodly";
19
+ import { imageDimensions, lastPart, type PageTree } from "smoodly";
20
20
  const { loadEnvConfig } = next;
21
21
 
22
22
  loadEnvConfig(process.cwd());
23
23
 
24
24
  const { config } = await import("../smoodly/config");
25
- const { articles } = await import("../smoodly/collections");
25
+ const { articles, news } = await import("../smoodly/collections");
26
26
  const { href, stores } = await import("../smoodly/server");
27
27
  const { pages, entries, assets } = stores();
28
28
 
@@ -79,10 +79,16 @@ const articlesSegment = (locale: string) => {
79
79
  const declared = articles.path;
80
80
  return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "posts");
81
81
  };
82
+ /** The news collection's nested address in a locale (`company/news`), from its own `path`. */
83
+ const newsAddress = (locale: string) => {
84
+ const declared = news.path;
85
+ return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "company/news");
86
+ };
82
87
  /** Site URLs for the starter's own pages in a locale. */
83
88
  const paths = (locale: string) => ({
84
89
  home: href("/", locale),
85
90
  articles: href(`/${articlesSegment(locale)}`, locale),
91
+ news: href(`/${newsAddress(locale)}`, locale),
86
92
  about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
87
93
  });
88
94
 
@@ -110,12 +116,14 @@ type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; m
110
116
  * its own (spec 2026-09-06); the Finnish page is copied from the
111
117
  * English draft, then overwritten with the Finnish tree. */
112
118
  async function seedPage(
113
- input: { slug: string; title: string; template: string | null; parentId?: string; fi?: { slug?: string; title: string } },
119
+ input: { slug: string; title: string; template: string | null; parentId?: string; fixed?: string; fi?: { slug?: string; title: string } },
114
120
  trees?: { en: Tree; fi?: Tree },
115
121
  ) {
116
122
  const page = await pages.createPage({
117
123
  locale: EN, slug: input.slug, title: input.title, template: input.template,
118
124
  ...(input.parentId ? { parentId: input.parentId } : {}),
125
+ // A code-owned record names its owner (spec 2026-09-29 nested paths §3).
126
+ ...(input.fixed ? { fixed: input.fixed } : {}),
119
127
  });
120
128
  if (trees) {
121
129
  await pages.saveDraft(page.id, EN, trees.en as unknown as PageTree);
@@ -173,6 +181,23 @@ const article = await seedEntry("articles", {
173
181
  tags: ["Näkemyksiä", "Muotoilu"],
174
182
  });
175
183
 
184
+ // Two news posts at the collection's nested address (/company/news/<slug>,
185
+ // /fi/yritys/uutiset/<slug>): Latin copy, a slug per locale.
186
+ await seedEntry("news", {
187
+ title: "Lorem ipsum dolor sit amet nova",
188
+ slug: "lorem-news",
189
+ date: "2026-09-20",
190
+ excerpt: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
191
+ body: doc(p("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")),
192
+ }, { slug: "lorem-uutinen" });
193
+ await seedEntry("news", {
194
+ title: "Ipsum dolor sit amet consectetur",
195
+ slug: "ipsum-update",
196
+ date: "2026-09-27",
197
+ excerpt: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
198
+ body: doc(p("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.")),
199
+ }, { slug: "ipsum-paivitys" });
200
+
176
201
  // --- global items ---------------------------------------------------
177
202
 
178
203
  // No Home item: the brand in the header is the way home.
@@ -190,13 +215,13 @@ await seedEntry("header",
190
215
 
191
216
  await seedEntry("footer", {
192
217
  tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
193
- company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }] },
218
+ company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }, { label: "News", href: paths(EN).news }] },
194
219
  legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }] },
195
220
  social: { title: "Social", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
196
221
  copyright: "© 2026 Lorem Co. Built with Smoodly.",
197
222
  }, FI ? {
198
223
  tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
199
- company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }] },
224
+ company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }, { label: "Uutiset", href: paths(FI).news }] },
200
225
  legal: { title: "Lakiasiat", links: [{ label: "Tietosuoja", href: "#" }, { label: "Ehdot", href: "#" }] },
201
226
  social: { title: "Some", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
202
227
  } : undefined);
@@ -293,7 +318,7 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
293
318
  });
294
319
 
295
320
  await seedPage(
296
- { slug: "home", title: "Home", template: "home", fi: { title: "Etusivu" } },
321
+ { slug: "home", title: "Home", template: "home", fixed: "page:home", fi: { title: "Etusivu" } },
297
322
  {
298
323
  en: homeTree(EN, { kicker: "Lorem ipsum dolor", buttons: ["About us", "Read articles"], link: "About Lorem Co. →", favourites: "From the journal" }),
299
324
  fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
@@ -307,7 +332,7 @@ const articlesHero = (heading: string, text: string): Tree => ({
307
332
  zones: { hero: [{ id: "node-articles-hero", type: "pageHero", fields: { heading, text } }] },
308
333
  });
309
334
  await seedPage(
310
- { slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
335
+ { slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fixed: "collection:articles", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
311
336
  {
312
337
  en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
313
338
  fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
@@ -319,6 +344,18 @@ await seedPage(
319
344
  // both locales with its own slug and title.
320
345
  const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
321
346
 
347
+ // The News index: the fixed page on the news collection's nested address.
348
+ // Code owns the whole address, so it is a root record holding the last
349
+ // part as its slug (spec 2026-09-29 nested paths §3) — it shows under
350
+ // Company in URL only; renaming Company never moves it.
351
+ await seedPage(
352
+ { slug: lastPart(newsAddress(EN)), title: "News", template: "newsIndex", fixed: "collection:news", fi: { slug: lastPart(newsAddress(FI ?? EN)), title: "Uutiset" } },
353
+ {
354
+ en: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "News", text: "Lorem ipsum dolor sit amet — what is new at Lorem Co." } }] } },
355
+ fi: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "Uutiset", text: "Lorem ipsum dolor sit amet — mitä Lorem Co:lle kuuluu." } }] } },
356
+ },
357
+ );
358
+
322
359
  const aboutTree = (t: { kicker: string; people: string; description: string }): Tree => ({
323
360
  fields: { showInNavigation: true },
324
361
  meta: { description: t.description },
@@ -379,5 +416,5 @@ await seedPage(
379
416
  );
380
417
 
381
418
  console.log(
382
- `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
419
+ `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, News (${newsAddress(EN)}${FI ? ` / ${newsAddress(FI)}` : ""}) with two posts, four people, header, footer, contact and quote — all published.`,
383
420
  );
@@ -38,4 +38,24 @@ export const articles = smoodly.collection({
38
38
  },
39
39
  });
40
40
 
41
- export const collections = [people, articles];
41
+ // News lives under the company: a nested address, owned by code whole
42
+ // (spec 2026-09-29 nested paths). The Company page at /company is an
43
+ // editor's page; renaming it never moves the news. The News fixed page
44
+ // (components/pages/News.tsx) claims the same address, which makes it
45
+ // the collection's index page.
46
+ export const news = smoodly.collection({
47
+ name: "news",
48
+ title: "News",
49
+ titleField: "title",
50
+ path: "company/news",
51
+ order: { by: "date", direction: "desc" },
52
+ fields: {
53
+ title: f.text().localized(),
54
+ date: f.date(),
55
+ slug: f.slug({ from: "title" }),
56
+ excerpt: f.text().localized(),
57
+ body: f.richtext({ toolset: article }).localized(),
58
+ },
59
+ });
60
+
61
+ export const collections = [people, articles, news];
@@ -9,6 +9,8 @@ import { HomePage } from "@/components/pages/Home";
9
9
  import { ArticlesPage } from "@/components/pages/Articles";
10
10
  import { StandardPage } from "@/components/pages/Page";
11
11
  import { ArticlePage } from "@/components/pages/Article";
12
+ import { NewsPage } from "@/components/pages/News";
13
+ import { NewsPostPage } from "@/components/pages/NewsPost";
12
14
 
13
15
  export { collections, sections, globals, elements };
14
16
 
@@ -22,7 +24,7 @@ export const config = defineConfig({
22
24
  elements,
23
25
  collections,
24
26
  globals,
25
- pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
27
+ pages: [HomePage, ArticlesPage, StandardPage, ArticlePage, NewsPage, NewsPostPage],
26
28
  },
27
29
  locales: { default: "en", supported: ["en"] },
28
30
  // Editors pick dates and times in this zone; the views format with it (an IANA name, never an offset).
@@ -32,7 +32,8 @@ function bound(): Bound {
32
32
  const env = smoodlyEnv();
33
33
  const db = createSmoodlyClient(env);
34
34
  // ONE path index for both stores: pages and entries share the URL space.
35
- const paths = new SupabasePathIndex(db);
35
+ // The index reads the code-owned addresses from the config (spec 2026-09-29 anchored paths §2).
36
+ const paths = new SupabasePathIndex(db, config.anchors);
36
37
  const locales = config.locales;
37
38
  const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
38
39
  const pages = new SupabasePageStore(db, {
@@ -40,6 +41,8 @@ function bound(): Bound {
40
41
  // The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
41
42
  custom: config.custom,
42
43
  homePageSlug: config.homePageSlug,
44
+ // Code-owned addresses by owner: a fixed page's or a collection's path (spec 2026-09-29 nested paths §3).
45
+ addresses: config.addresses,
43
46
  tree: config.pages?.tree,
44
47
  });
45
48
  // The media library: rows in `assets`, bytes in the public `media` bucket.
@@ -84,8 +87,9 @@ export function publicAuth(): AuthConfig {
84
87
  return { url: env.authUrl, key: env.authKey };
85
88
  }
86
89
 
87
- /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5). */
88
- export function publicContent(): ContentConfig {
90
+ /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
91
+ * The space is asked of the database once per process. */
92
+ export function publicContent(): Promise<ContentConfig> {
89
93
  return publicContentConfig(smoodlyEnv());
90
94
  }
91
95