create-smoodly-app 0.0.6 → 0.0.8

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.
@@ -12,28 +12,34 @@ create table if not exists "pages" (
12
12
  "id" uuid primary key default gen_random_uuid(),
13
13
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
14
14
  "parent_id" uuid references "pages" ("id") on delete restrict,
15
- "slug" text not null,
16
- "title" text not null,
17
- "i18n" jsonb,
18
15
  "template" text,
19
- "status" text not null default 'draft',
20
16
  "sort" numeric,
21
- "locales" text[],
22
- "draft_version_id" uuid,
23
- "published_version_id" uuid,
24
17
  "created_at" timestamptz not null default now(),
25
18
  "updated_at" timestamptz not null default now()
26
19
  );
27
20
 
28
- -- The tree is the URL (spec 2026-09-05 §1): a node stores its own segment
29
- -- and its parent. Siblings are unique per segment; NULLS NOT DISTINCT so
30
- -- two root nodes cannot share one either. template null = folder.
31
- create unique index if not exists "pages_sibling_slug_key"
32
- on "pages" ("space_id", "parent_id", "slug") nulls not distinct;
33
-
21
+ -- The tree is the URL (spec 2026-09-05 §1): a node stores its parent and
22
+ -- its position; template null = folder. Everything a locale owns — the
23
+ -- node's own segment, title, publish state and version pointers — lives
24
+ -- in page_locales, one row per locale the page exists in (spec 2026-09-06
25
+ -- §2). There is no sibling-slug index: URL uniqueness per locale is the
26
+ -- primary key of "paths", which the stores write inside the same operation.
34
27
  create index if not exists "pages_parent_idx"
35
28
  on "pages" ("space_id", "parent_id", "sort");
36
29
 
30
+ create table if not exists "page_locales" (
31
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
32
+ "page_id" uuid not null references "pages" ("id") on delete cascade,
33
+ "locale" text not null,
34
+ "slug" text not null,
35
+ "title" text not null,
36
+ "status" text not null default 'draft',
37
+ "draft_version_id" uuid,
38
+ "published_version_id" uuid,
39
+ "updated_at" timestamptz not null default now(),
40
+ primary key ("page_id", "locale")
41
+ );
42
+
37
43
  -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
38
44
  create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
39
45
  update "pages" pg set "sort" = u.ord
@@ -42,30 +48,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
42
48
  and pg."space_id" = "smoodly_current_space"();
43
49
  $$;
44
50
 
51
+ -- A version belongs to ONE locale of one page: each locale has its own
52
+ -- draft, its own publish and its own history.
45
53
  create table if not exists "page_versions" (
46
54
  "id" uuid primary key default gen_random_uuid(),
47
55
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
48
56
  "page_id" uuid not null references "pages" ("id") on delete cascade,
57
+ "locale" text not null,
49
58
  "tree" jsonb not null,
50
59
  "created_by" text,
51
60
  "created_at" timestamptz not null default now()
52
61
  );
53
62
 
54
- create table if not exists "saved_sections" (
55
- "id" uuid primary key default gen_random_uuid(),
56
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
57
- "title" text not null,
58
- "fragment" jsonb not null,
59
- "created_at" timestamptz not null default now()
60
- );
61
-
62
- create table if not exists "global_sections" (
63
- "id" uuid primary key default gen_random_uuid(),
64
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
65
- "type" text not null,
66
- "content" jsonb not null,
67
- "updated_at" timestamptz not null default now()
68
- );
63
+ create index if not exists "page_versions_page_locale_idx"
64
+ on "page_versions" ("page_id", "locale", "created_at");
69
65
 
70
66
  create table if not exists "entries" (
71
67
  "id" uuid primary key default gen_random_uuid(),
@@ -73,40 +69,58 @@ create table if not exists "entries" (
73
69
  "collection" text not null,
74
70
  "parent_id" uuid references "entries" ("id") on delete restrict,
75
71
  "fields" jsonb not null default '{}'::jsonb,
76
- "i18n" jsonb,
77
- "status" text not null default 'draft',
78
72
  "sort" numeric,
79
- "locales" text[],
80
73
  "created_at" timestamptz not null default now(),
81
74
  "updated_at" timestamptz not null default now()
82
75
  );
83
76
 
84
- -- Entry slugs are unique among SIBLINGS, like pages: one rule for every
85
- -- node in the tree, and the URL is the tree. NULLS NOT DISTINCT so two
86
- -- ROOT entries (parent_id null) cannot share a slug either. The partial
87
- -- predicate keeps absent, null, empty or non-string slugs out of the
88
- -- index entirely: with NULLS NOT DISTINCT a null slug would otherwise
89
- -- collide with the next null slug, and a collection with no slug field
90
- -- at all could hold only one entry per parent. jsonb_typeof(...) =
91
- -- 'string' is required because ->> renders ANY JSON scalar as text —
92
- -- a number, boolean or null slug would otherwise print as non-empty
93
- -- text and claim the index; only a non-empty string slug claims a URL,
94
- -- absent, null, empty or non-string slugs never conflict — the same
95
- -- rule as slugOf in paths.ts. A flat collection (every entry a root)
96
- -- therefore behaves exactly as per-collection uniqueness did.
97
- create unique index if not exists "entries_sibling_slug_key"
98
- on "entries" ("space_id", "collection", "parent_id", ("fields"->>'slug')) nulls not distinct
99
- where (jsonb_typeof("fields"->'slug') = 'string' and "fields"->>'slug' <> '');
100
-
101
- create index if not exists "entries_collection_status_idx"
102
- on "entries" ("space_id", "collection", "status");
103
-
77
+ -- The node holds identity, tree position and the SHARED fields — every
78
+ -- field without .localized() (spec 2026-09-06 §7): a Person's photo is
79
+ -- the same in every language. Everything a locale owns — its segment,
80
+ -- publish state, the .localized() fields and the version pointers —
81
+ -- lives in entry_locales, one row per locale the entry exists in. There
82
+ -- is no sibling-slug index any more: URL uniqueness per locale is the
83
+ -- primary key of "paths", which the stores write inside the same
84
+ -- operation, and both adapters pre-check siblings per locale in code.
104
85
  create index if not exists "entries_collection_sort_idx"
105
86
  on "entries" ("space_id", "collection", "sort");
106
87
 
107
88
  create index if not exists "entries_parent_idx"
108
89
  on "entries" ("space_id", "collection", "parent_id");
109
90
 
91
+ create table if not exists "entry_locales" (
92
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
93
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
94
+ "locale" text not null,
95
+ "slug" text,
96
+ "status" text not null default 'draft',
97
+ "fields" jsonb not null default '{}'::jsonb,
98
+ "draft_version_id" uuid,
99
+ "published_version_id" uuid,
100
+ "updated_at" timestamptz not null default now(),
101
+ primary key ("entry_id", "locale")
102
+ );
103
+
104
+ create index if not exists "entry_locales_status_idx"
105
+ on "entry_locales" ("space_id", "locale", "status");
106
+
107
+ -- A version belongs to ONE locale of one entry and stores the MERGED
108
+ -- fields (shared + localized + slug) as they were at that save, so
109
+ -- restoring an old Finnish version restores the shared fields too.
110
+ -- Rows exist only for collections registered with `versions: true`.
111
+ create table if not exists "entry_versions" (
112
+ "id" uuid primary key default gen_random_uuid(),
113
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
114
+ "entry_id" uuid not null references "entries" ("id") on delete cascade,
115
+ "locale" text not null,
116
+ "fields" jsonb not null,
117
+ "created_by" text,
118
+ "created_at" timestamptz not null default now()
119
+ );
120
+
121
+ create index if not exists "entry_versions_entry_locale_idx"
122
+ on "entry_versions" ("entry_id", "locale", "created_at");
123
+
110
124
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
111
125
  -- statement. Deliberately does not touch updated_at — a drag must not
112
126
  -- make the whole collection look freshly edited. Scoped to the current
@@ -172,10 +186,11 @@ $$;
172
186
  -- (collectionSQL) run with security_invoker = true, so they are subject
173
187
  -- to this same smoodly_read policy rather than their owner's privileges.
174
188
  alter table "pages" enable row level security;
189
+ alter table "page_locales" enable row level security;
175
190
  alter table "page_versions" enable row level security;
176
- alter table "saved_sections" enable row level security;
177
- alter table "global_sections" enable row level security;
178
191
  alter table "entries" enable row level security;
192
+ alter table "entry_locales" enable row level security;
193
+ alter table "entry_versions" enable row level security;
179
194
  alter table "refs" enable row level security;
180
195
  alter table "paths" enable row level security;
181
196
 
@@ -272,11 +287,13 @@ begin
272
287
  end;
273
288
  $$;
274
289
 
290
+ create index if not exists "page_locales_space_idx" on "page_locales" ("space_id");
291
+
275
292
  create index if not exists "page_versions_space_idx" on "page_versions" ("space_id");
276
293
 
277
- create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
294
+ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_id");
278
295
 
279
- create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
296
+ create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
280
297
 
281
298
  create index if not exists "refs_space_idx" on "refs" ("space_id");
282
299
 
@@ -284,20 +301,24 @@ drop trigger if exists "smoodly_a_set_space" on "pages";
284
301
  create trigger "smoodly_a_set_space" before insert on "pages"
285
302
  for each row execute function "smoodly_set_space"();
286
303
 
304
+ drop trigger if exists "smoodly_a_set_space" on "page_locales";
305
+ create trigger "smoodly_a_set_space" before insert on "page_locales"
306
+ for each row execute function "smoodly_set_space"();
307
+
287
308
  drop trigger if exists "smoodly_a_set_space" on "page_versions";
288
309
  create trigger "smoodly_a_set_space" before insert on "page_versions"
289
310
  for each row execute function "smoodly_set_space"();
290
311
 
291
- drop trigger if exists "smoodly_a_set_space" on "saved_sections";
292
- create trigger "smoodly_a_set_space" before insert on "saved_sections"
312
+ drop trigger if exists "smoodly_a_set_space" on "entries";
313
+ create trigger "smoodly_a_set_space" before insert on "entries"
293
314
  for each row execute function "smoodly_set_space"();
294
315
 
295
- drop trigger if exists "smoodly_a_set_space" on "global_sections";
296
- create trigger "smoodly_a_set_space" before insert on "global_sections"
316
+ drop trigger if exists "smoodly_a_set_space" on "entry_locales";
317
+ create trigger "smoodly_a_set_space" before insert on "entry_locales"
297
318
  for each row execute function "smoodly_set_space"();
298
319
 
299
- drop trigger if exists "smoodly_a_set_space" on "entries";
300
- create trigger "smoodly_a_set_space" before insert on "entries"
320
+ drop trigger if exists "smoodly_a_set_space" on "entry_versions";
321
+ create trigger "smoodly_a_set_space" before insert on "entry_versions"
301
322
  for each row execute function "smoodly_set_space"();
302
323
 
303
324
  drop trigger if exists "smoodly_a_set_space" on "refs";
@@ -316,20 +337,24 @@ drop trigger if exists "smoodly_b_row_cap" on "pages";
316
337
  create trigger "smoodly_b_row_cap" before insert on "pages"
317
338
  for each row execute function "smoodly_enforce_row_cap"();
318
339
 
340
+ drop trigger if exists "smoodly_b_row_cap" on "page_locales";
341
+ create trigger "smoodly_b_row_cap" before insert on "page_locales"
342
+ for each row execute function "smoodly_enforce_row_cap"();
343
+
319
344
  drop trigger if exists "smoodly_b_row_cap" on "page_versions";
320
345
  create trigger "smoodly_b_row_cap" before insert on "page_versions"
321
346
  for each row execute function "smoodly_enforce_row_cap"();
322
347
 
323
- drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
324
- create trigger "smoodly_b_row_cap" before insert on "saved_sections"
348
+ drop trigger if exists "smoodly_b_row_cap" on "entries";
349
+ create trigger "smoodly_b_row_cap" before insert on "entries"
325
350
  for each row execute function "smoodly_enforce_row_cap"();
326
351
 
327
- drop trigger if exists "smoodly_b_row_cap" on "global_sections";
328
- create trigger "smoodly_b_row_cap" before insert on "global_sections"
352
+ drop trigger if exists "smoodly_b_row_cap" on "entry_locales";
353
+ create trigger "smoodly_b_row_cap" before insert on "entry_locales"
329
354
  for each row execute function "smoodly_enforce_row_cap"();
330
355
 
331
- drop trigger if exists "smoodly_b_row_cap" on "entries";
332
- create trigger "smoodly_b_row_cap" before insert on "entries"
356
+ drop trigger if exists "smoodly_b_row_cap" on "entry_versions";
357
+ create trigger "smoodly_b_row_cap" before insert on "entry_versions"
333
358
  for each row execute function "smoodly_enforce_row_cap"();
334
359
 
335
360
  drop trigger if exists "smoodly_b_row_cap" on "refs";
@@ -347,6 +372,15 @@ create policy "smoodly_write" on "pages" for all
347
372
 
348
373
  drop policy if exists "smoodly_read" on "pages";
349
374
  create policy "smoodly_read" on "pages" for select
375
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published'));
376
+
377
+ drop policy if exists "smoodly_write" on "page_locales";
378
+ create policy "smoodly_write" on "page_locales" for all
379
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
380
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
381
+
382
+ drop policy if exists "smoodly_read" on "page_locales";
383
+ create policy "smoodly_read" on "page_locales" for select
350
384
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
351
385
 
352
386
  drop policy if exists "smoodly_write" on "page_versions";
@@ -356,30 +390,34 @@ create policy "smoodly_write" on "page_versions" for all
356
390
 
357
391
  drop policy if exists "smoodly_read" on "page_versions";
358
392
  create policy "smoodly_read" on "page_versions" for select
359
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "pages" p where p."published_version_id" = "page_versions"."id"));
393
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
360
394
 
361
- drop policy if exists "smoodly_write" on "saved_sections";
362
- create policy "smoodly_write" on "saved_sections" for all
395
+ drop policy if exists "smoodly_write" on "entries";
396
+ create policy "smoodly_write" on "entries" for all
363
397
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
364
398
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
365
399
 
366
- drop policy if exists "smoodly_write" on "global_sections";
367
- create policy "smoodly_write" on "global_sections" for all
400
+ drop policy if exists "smoodly_read" on "entries";
401
+ create policy "smoodly_read" on "entries" for select
402
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."entry_id" = "entries"."id" and l."status" = 'published'));
403
+
404
+ drop policy if exists "smoodly_write" on "entry_locales";
405
+ create policy "smoodly_write" on "entry_locales" for all
368
406
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
369
407
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
370
408
 
371
- drop policy if exists "smoodly_read" on "global_sections";
372
- create policy "smoodly_read" on "global_sections" for select
373
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
409
+ drop policy if exists "smoodly_read" on "entry_locales";
410
+ create policy "smoodly_read" on "entry_locales" for select
411
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
374
412
 
375
- drop policy if exists "smoodly_write" on "entries";
376
- create policy "smoodly_write" on "entries" for all
413
+ drop policy if exists "smoodly_write" on "entry_versions";
414
+ create policy "smoodly_write" on "entry_versions" for all
377
415
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
378
416
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
379
417
 
380
- drop policy if exists "smoodly_read" on "entries";
381
- create policy "smoodly_read" on "entries" for select
382
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and "status" = 'published');
418
+ drop policy if exists "smoodly_read" on "entry_versions";
419
+ create policy "smoodly_read" on "entry_versions" for select
420
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id"));
383
421
 
384
422
  drop policy if exists "smoodly_write" on "refs";
385
423
  create policy "smoodly_write" on "refs" for all
@@ -393,7 +431,7 @@ create policy "smoodly_write" on "paths" for all
393
431
 
394
432
  drop policy if exists "smoodly_read" on "paths";
395
433
  create policy "smoodly_read" on "paths" for select
396
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("kind" = 'page' and exists (select 1 from "pages" p where p."id" = "paths"."target_id" and p."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entries" e where e."id" = "paths"."target_id" and e."status" = 'published'))));
434
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (("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'))));
397
435
 
398
436
  drop policy if exists "smoodly_write" on "editors";
399
437
  create policy "smoodly_write" on "editors" for all
@@ -402,26 +440,30 @@ create policy "smoodly_write" on "editors" for all
402
440
 
403
441
  create or replace view "people" with (security_invoker = true) as
404
442
  select
405
- "id",
406
- "sort",
407
- "locales",
408
- "created_at",
409
- "updated_at",
410
- "fields"->>'name' as "name",
411
- "fields"->>'role' as "role"
412
- from "entries"
413
- where "collection" = 'people' and "status" = 'published';
443
+ e."id",
444
+ l."locale",
445
+ l."slug",
446
+ e."sort",
447
+ e."created_at",
448
+ l."updated_at",
449
+ e."fields"->>'name' as "name",
450
+ l."fields"->>'role' as "role"
451
+ from "entries" e
452
+ join "entry_locales" l on l."entry_id" = e."id"
453
+ where e."collection" = 'people' and l."status" = 'published';
414
454
 
415
455
  create or replace view "articles" with (security_invoker = true) as
416
456
  select
417
- "id",
418
- "sort",
419
- "locales",
420
- "created_at",
421
- "updated_at",
422
- "fields"->>'title' as "title",
423
- "fields"->>'slug' as "slug",
424
- "fields"->>'excerpt' as "excerpt",
425
- "fields"->>'author' as "author"
426
- from "entries"
427
- where "collection" = 'articles' and "status" = 'published';
457
+ e."id",
458
+ l."locale",
459
+ l."slug",
460
+ e."sort",
461
+ e."created_at",
462
+ l."updated_at",
463
+ v."fields"->>'title' as "title",
464
+ v."fields"->>'excerpt' as "excerpt",
465
+ v."fields"->>'author' as "author"
466
+ from "entries" e
467
+ join "entry_locales" l on l."entry_id" = e."id"
468
+ join "entry_versions" v on v."id" = l."published_version_id"
469
+ where e."collection" = 'articles' and l."status" = 'published';
@@ -2,19 +2,30 @@ import type { ReactNode } from "react";
2
2
  import { notFound } from "next/navigation";
3
3
  import { hasLocale, NextIntlClientProvider } from "next-intl";
4
4
  import { routing } from "@/i18n/routing";
5
+ import { Footer } from "@/components/sections/Footer";
6
+ import { contact, footer } from "@/smoodly/shared";
7
+ import { getSmoodlyShared } from "@/smoodly/server";
5
8
 
6
9
  export const metadata = { title: "Smoodly site" };
7
10
 
8
11
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
9
12
  // two root layouts, a full page load between them, which is fine: they
10
- // are separate apps in spirit.
13
+ // are separate apps in spirit. The footer and the contact block are
14
+ // shared content: fetched here, cached per locale under their tag,
15
+ // null until published in this locale.
11
16
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
12
17
  const { locale } = await params;
13
18
  if (!hasLocale(routing.locales, locale)) notFound();
19
+ const [footerProps, contactFields] = await Promise.all([
20
+ getSmoodlyShared(footer, { locale }),
21
+ getSmoodlyShared(contact, { locale }),
22
+ ]);
14
23
  return (
15
24
  <html lang={locale}>
16
25
  <body>
17
26
  <NextIntlClientProvider>{children}</NextIntlClientProvider>
27
+ {contactFields && <address>{contactFields.email} · {contactFields.address}</address>}
28
+ {footerProps && <Footer {...footerProps} />}
18
29
  </body>
19
30
  </html>
20
31
  );
@@ -2,6 +2,7 @@ import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Hero } from "../sections/Hero";
3
3
  import { ArticleList } from "../sections/ArticleList";
4
4
  import { CtaBanner } from "../sections/CtaBanner";
5
+ import { Testimonials } from "../sections/Testimonials";
5
6
 
6
7
  // A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
7
8
  // `slug` makes it fixed — one record the editor can neither delete nor
@@ -12,7 +13,7 @@ export const homeSchema = smoodly.schema.page({
12
13
  slug: "home",
13
14
  zones: {
14
15
  hero: z.sections([Hero]).max(1),
15
- body: z.freeform({ sections: [ArticleList] }),
16
+ body: z.freeform({ sections: [ArticleList, Testimonials] }),
16
17
  // Locked: fields only. One CtaBanner, always there, never movable.
17
18
  prefooter: z.locked(CtaBanner),
18
19
  },
@@ -3,6 +3,7 @@ import { smoodly, z, Zone, type Props } from "smoodly";
3
3
  import { Hero } from "../sections/Hero";
4
4
  import { ArticleList } from "../sections/ArticleList";
5
5
  import { CtaBanner } from "../sections/CtaBanner";
6
+ import { Testimonials } from "../sections/Testimonials";
6
7
 
7
8
  // Open registration: editors create as many of these as they like, at
8
9
  // any depth the config allows. `page` is where this one sits in the tree.
@@ -11,7 +12,7 @@ export const pageSchema = smoodly.schema.page({
11
12
  title: "Page",
12
13
  zones: {
13
14
  hero: z.sections([Hero]).max(1),
14
- body: z.freeform({ sections: [ArticleList] }),
15
+ body: z.freeform({ sections: [ArticleList, Testimonials] }),
15
16
  prefooter: z.locked(CtaBanner),
16
17
  },
17
18
  meta: { seo: true },
@@ -0,0 +1,33 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // The site footer — a VISUAL shared item (smoodly/shared.ts): registered
4
+ // like any section, stored once, rendered by the locale layout through
5
+ // getSmoodlyShared, never placed in a page tree. The tagline is per
6
+ // language; the copyright line is the same everywhere.
7
+ export const footerSchema = smoodly.schema.section({
8
+ name: "footer",
9
+ title: "Footer",
10
+ description: "The site-wide footer.",
11
+ fields: {
12
+ tagline: f.text().localized(),
13
+ copyright: f.text(),
14
+ },
15
+ styles: {
16
+ tone: f.select(["light", "dark"]).default("dark"),
17
+ },
18
+ sample: {
19
+ tagline: "Made with Smoodly",
20
+ copyright: "© Laniakea Studio",
21
+ },
22
+ });
23
+
24
+ function FooterView({ tagline, copyright, styles }: Props<typeof footerSchema>) {
25
+ return (
26
+ <smoodly.Section as="footer" styles={styles}>
27
+ <p>{tagline}</p>
28
+ <small>{copyright}</small>
29
+ </smoodly.Section>
30
+ );
31
+ }
32
+
33
+ export const Footer = smoodly.section(footerSchema, FooterView);
@@ -0,0 +1,30 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // A testimonials band — placed on pages from the "+" picker's Shared
4
+ // group as a LINKED node (spec 2026-09-07 §4): one row, edited under
5
+ // Shared content, rendered wherever it is placed.
6
+ export const testimonialsSchema = smoodly.schema.section({
7
+ name: "testimonials",
8
+ title: "Testimonials",
9
+ description: "What people say about us.",
10
+ fields: {
11
+ heading: f.text().localized(),
12
+ quotes: f.richtext().localized(),
13
+ },
14
+ styles: {
15
+ tone: f.select(["light", "dark"]).default("light"),
16
+ },
17
+ sample: {
18
+ heading: "Kind words",
19
+ },
20
+ });
21
+
22
+ function TestimonialsView({ heading, styles }: Props<typeof testimonialsSchema>) {
23
+ return (
24
+ <smoodly.Section styles={styles}>
25
+ <h2>{heading}</h2>
26
+ </smoodly.Section>
27
+ );
28
+ }
29
+
30
+ export const Testimonials = smoodly.section(testimonialsSchema, TestimonialsView);
@@ -13,7 +13,7 @@
13
13
  "next": "^16.3.4",
14
14
  "react": "^19",
15
15
  "react-dom": "^19",
16
- "smoodly": "0.0.6",
16
+ "smoodly": "0.0.8",
17
17
  "next-intl": "^4.14.2"
18
18
  },
19
19
  "devDependencies": {
@@ -1,24 +1,31 @@
1
1
  import { f, smoodly } from "smoodly";
2
2
 
3
+ // A person's name is the same in every language; the role is translated.
4
+ // Shared fields live on the entry, .localized() ones on its locale rows
5
+ // (spec 2026-09-06 §7).
3
6
  export const people = smoodly.collection({
4
7
  name: "people",
5
8
  title: "People",
6
9
  titleField: "name",
7
10
  fields: {
8
11
  name: f.text(),
9
- role: f.text().optional(),
12
+ role: f.text().optional().localized(),
10
13
  },
11
14
  });
12
15
 
16
+ // Articles keep a version history per locale (`versions: true`): every
17
+ // save is a snapshot, publish moves a pointer, and the published site
18
+ // keeps serving the published snapshot while a draft moves on.
13
19
  export const articles = smoodly.collection({
14
20
  name: "articles",
15
21
  title: "Articles",
16
22
  titleField: "title",
17
23
  path: "posts",
24
+ versions: true,
18
25
  fields: {
19
- title: f.text(),
26
+ title: f.text().localized(),
20
27
  slug: f.slug({ from: "title" }),
21
- excerpt: f.text().optional(),
28
+ excerpt: f.text().optional().localized(),
22
29
  author: f.ref(() => people),
23
30
  },
24
31
  });
@@ -2,11 +2,12 @@ import { defineConfig, f } from "smoodly";
2
2
  import { routing } from "@/i18n/routing";
3
3
  import { collections } from "./collections";
4
4
  import { sections } from "./sections";
5
+ import { shared } from "./shared";
5
6
  import { HomePage } from "@/components/pages/Home";
6
7
  import { StandardPage } from "@/components/pages/Page";
7
8
  import { ArticlePage } from "@/components/pages/Article";
8
9
 
9
- export { collections, sections };
10
+ export { collections, sections, shared };
10
11
 
11
12
  // The registry and the plain settings, one file. Runtime-free: nothing
12
13
  // here imports a route file or Next, so a script, the CLI's template
@@ -18,6 +19,7 @@ export const config = defineConfig({
18
19
  registry: {
19
20
  sections,
20
21
  collections,
22
+ shared,
21
23
  pages: [HomePage, StandardPage, ArticlePage],
22
24
  },
23
25
  locales: { default: routing.defaultLocale, supported: [...routing.locales] },
@@ -4,5 +4,7 @@
4
4
  import { Hero } from "@/components/sections/Hero";
5
5
  import { ArticleList } from "@/components/sections/ArticleList";
6
6
  import { CtaBanner } from "@/components/sections/CtaBanner";
7
+ import { Footer } from "@/components/sections/Footer";
8
+ import { Testimonials } from "@/components/sections/Testimonials";
7
9
 
8
- export const sections = [Hero, ArticleList, CtaBanner];
10
+ export const sections = [Hero, ArticleList, CtaBanner, Footer, Testimonials];
@@ -9,7 +9,7 @@ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admi
9
9
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
10
10
  import type { SupabaseClient } from "@supabase/supabase-js";
11
11
  import { getPathname } from "@/i18n/navigation";
12
- import { collections, config, sections } from "./config";
12
+ import { collections, config, sections, shared } from "./config";
13
13
 
14
14
  let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
15
15
 
@@ -20,7 +20,7 @@ function bound() {
20
20
  // ONE path index for both stores: pages and entries share the URL space.
21
21
  const paths = new SupabasePathIndex(db);
22
22
  const locales = config.locales;
23
- const entries = new SupabaseEntryStore(db, collections, { paths, locales });
23
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
24
24
  const pages = new SupabasePageStore(db, {
25
25
  sections, entries, paths, locales,
26
26
  homePageSlug: config.homePageSlug,
@@ -59,13 +59,14 @@ export const href = (path: string, locale: string) => getPathname({ locale, href
59
59
  export const site = createSmoodlySite({
60
60
  stores,
61
61
  collections,
62
+ shared,
62
63
  sections,
63
64
  locales: config.locales,
64
65
  homePageSlug: config.homePageSlug,
65
66
  });
66
67
 
67
68
  /** The two route files call these. */
68
- export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
69
+ export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata, getSmoodlyShared } = createPageRenderer({
69
70
  site,
70
71
  sections,
71
72
  pages: (config.registry.pages ?? []) as PageRegistration[],