create-smoodly-app 0.0.4 → 0.0.6

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 (52) hide show
  1. package/README.md +64 -10
  2. package/dist/index.js +66 -9
  3. package/package.json +1 -1
  4. package/templates/next/README.md +6 -5
  5. package/templates/next/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -1
  6. package/templates/next/app/(smoodly)/admin/layout.tsx +1 -1
  7. package/templates/next/app/[...path]/page.tsx +15 -0
  8. package/templates/next/app/page.tsx +5 -42
  9. package/templates/next/components/pages/Article.tsx +15 -0
  10. package/templates/next/components/pages/Home.tsx +35 -0
  11. package/templates/next/components/pages/Page.tsx +40 -0
  12. package/templates/next/components/sections/ArticleList.tsx +1 -1
  13. package/templates/next/package.json +2 -2
  14. package/templates/next/smoodly/admin.tsx +6 -0
  15. package/templates/next/smoodly/collections.ts +26 -0
  16. package/templates/next/smoodly/config.ts +27 -0
  17. package/templates/next/smoodly/ops.ts +11 -0
  18. package/templates/next/smoodly/sections.ts +8 -0
  19. package/templates/next/smoodly/server.ts +72 -0
  20. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +92 -5
  21. package/templates/next/tsconfig.json +5 -0
  22. package/templates/next-intl/README.md +40 -0
  23. package/templates/next-intl/_gitignore +5 -0
  24. package/templates/next-intl/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
  25. package/templates/next-intl/app/(smoodly)/admin/layout.tsx +15 -0
  26. package/templates/next-intl/app/[locale]/[...path]/page.tsx +18 -0
  27. package/templates/next-intl/app/[locale]/layout.tsx +21 -0
  28. package/templates/next-intl/app/[locale]/page.tsx +15 -0
  29. package/templates/next-intl/components/pages/Article.tsx +15 -0
  30. package/templates/next-intl/components/pages/Home.tsx +35 -0
  31. package/templates/next-intl/components/pages/Page.tsx +40 -0
  32. package/templates/next-intl/components/sections/ArticleList.tsx +31 -0
  33. package/templates/next-intl/components/sections/CtaBanner.tsx +35 -0
  34. package/templates/next-intl/components/sections/Hero.tsx +24 -0
  35. package/templates/next-intl/i18n/navigation.ts +6 -0
  36. package/templates/next-intl/i18n/request.ts +17 -0
  37. package/templates/next-intl/i18n/routing.ts +17 -0
  38. package/templates/next-intl/next.config.ts +8 -0
  39. package/templates/next-intl/package.json +25 -0
  40. package/templates/next-intl/proxy.ts +11 -0
  41. package/templates/{next/lib → next-intl}/smoodly/admin.tsx +2 -2
  42. package/templates/{next → next-intl/smoodly}/collections.ts +1 -0
  43. package/templates/next-intl/smoodly/config.ts +31 -0
  44. package/templates/{next/lib → next-intl}/smoodly/ops.ts +3 -3
  45. package/templates/next-intl/smoodly/sections.ts +8 -0
  46. package/templates/next-intl/smoodly/server.ts +73 -0
  47. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +427 -0
  48. package/templates/next-intl/tsconfig.json +41 -0
  49. package/templates/next/app/[slug]/page.tsx +0 -42
  50. package/templates/next/lib/smoodly/index.ts +0 -64
  51. package/templates/next/sections.ts +0 -8
  52. package/templates/next/smoodly.config.ts +0 -19
@@ -0,0 +1,72 @@
1
+ // Server-only glue: the client, both stores, the site layer and the
2
+ // renderer, constructed lazily so `next build` doesn't need env vars at
3
+ // import time. The env shape is the package's (SMOODLY_*); whether the
4
+ // secret is a service-role key or a cloud write key is invisible here.
5
+ import {
6
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex,
7
+ } from "smoodly";
8
+ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
9
+ import { createPageRenderer, type PageRegistration } from "smoodly/next";
10
+ import type { SupabaseClient } from "@supabase/supabase-js";
11
+ import { collections, config, sections } from "./config";
12
+
13
+ let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
14
+
15
+ function bound() {
16
+ if (cached) return cached;
17
+ const env = smoodlyEnv();
18
+ const db = createSmoodlyClient(env);
19
+ // ONE path index for both stores: pages and entries share the URL space.
20
+ const paths = new SupabasePathIndex(db);
21
+ const locales = config.locales;
22
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales });
23
+ const pages = new SupabasePageStore(db, {
24
+ sections, entries, paths, locales,
25
+ homePageSlug: config.homePageSlug,
26
+ tree: config.pages?.tree,
27
+ });
28
+ const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
29
+ cached = { db, pages, entries, auth };
30
+ return cached;
31
+ }
32
+
33
+ export function stores() {
34
+ const { pages, entries } = bound();
35
+ return { pages, entries };
36
+ }
37
+
38
+ /** The server-side client (seed script, one-off admin tasks). */
39
+ export const client = () => bound().db;
40
+
41
+ /** The admin gate: session verified against the auth project, editors row read with the write key. */
42
+ export const adminAuth = () => bound().auth;
43
+
44
+ /** What the browser needs to sign in — public values only. */
45
+ export function publicAuth(): AuthConfig {
46
+ const env = smoodlyEnv();
47
+ return { url: env.authUrl, key: env.authKey };
48
+ }
49
+
50
+ /** Locale-relative CMS path → site URL: the one seam for the renderer
51
+ * (metadata alternates, the `page` prop's links) and the admin (canvas,
52
+ * View live). The identity for a single-language site; with next-intl it
53
+ * becomes a locale-prefixing lookup. */
54
+ export const href = (path: string, _locale: string) => path;
55
+
56
+ /** The site layer: (locale, path) → page or entry, and the content API
57
+ * (site.getPagesTree for menus, site.getEntries for lists). */
58
+ export const site = createSmoodlySite({
59
+ stores,
60
+ collections,
61
+ sections,
62
+ locales: config.locales,
63
+ homePageSlug: config.homePageSlug,
64
+ });
65
+
66
+ /** The two route files call these. */
67
+ export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
68
+ site,
69
+ sections,
70
+ pages: (config.registry.pages ?? []) as PageRegistration[],
71
+ href,
72
+ });
@@ -11,18 +11,37 @@ $$;
11
11
  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
+ "parent_id" uuid references "pages" ("id") on delete restrict,
14
15
  "slug" text not null,
15
- "template" text not null,
16
+ "title" text not null,
17
+ "i18n" jsonb,
18
+ "template" text,
16
19
  "status" text not null default 'draft',
17
20
  "sort" numeric,
18
21
  "locales" text[],
19
22
  "draft_version_id" uuid,
20
23
  "published_version_id" uuid,
21
24
  "created_at" timestamptz not null default now(),
22
- "updated_at" timestamptz not null default now(),
23
- unique ("space_id", "slug")
25
+ "updated_at" timestamptz not null default now()
24
26
  );
25
27
 
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
+
34
+ create index if not exists "pages_parent_idx"
35
+ on "pages" ("space_id", "parent_id", "sort");
36
+
37
+ -- Sibling reorder for pages: same shape as smoodly_reorder for entries.
38
+ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void language sql as $$
39
+ update "pages" pg set "sort" = u.ord
40
+ from unnest(p_ids) with ordinality as u(id, ord)
41
+ where pg."id" = u.id
42
+ and pg."space_id" = "smoodly_current_space"();
43
+ $$;
44
+
26
45
  create table if not exists "page_versions" (
27
46
  "id" uuid primary key default gen_random_uuid(),
28
47
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
@@ -52,6 +71,7 @@ create table if not exists "entries" (
52
71
  "id" uuid primary key default gen_random_uuid(),
53
72
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
54
73
  "collection" text not null,
74
+ "parent_id" uuid references "entries" ("id") on delete restrict,
55
75
  "fields" jsonb not null default '{}'::jsonb,
56
76
  "i18n" jsonb,
57
77
  "status" text not null default 'draft',
@@ -61,8 +81,22 @@ create table if not exists "entries" (
61
81
  "updated_at" timestamptz not null default now()
62
82
  );
63
83
 
64
- create unique index if not exists "entries_slug_key"
65
- on "entries" ("space_id", "collection", ("fields"->>'slug'));
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' <> '');
66
100
 
67
101
  create index if not exists "entries_collection_status_idx"
68
102
  on "entries" ("space_id", "collection", "status");
@@ -70,6 +104,9 @@ create index if not exists "entries_collection_status_idx"
70
104
  create index if not exists "entries_collection_sort_idx"
71
105
  on "entries" ("space_id", "collection", "sort");
72
106
 
107
+ create index if not exists "entries_parent_idx"
108
+ on "entries" ("space_id", "collection", "parent_id");
109
+
73
110
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
74
111
  -- statement. Deliberately does not touch updated_at — a drag must not
75
112
  -- make the whole collection look freshly edited. Scoped to the current
@@ -95,6 +132,38 @@ create table if not exists "refs" (
95
132
  primary key ("source_kind", "source_id", "source_field", "target_id")
96
133
  );
97
134
 
135
+ -- The URL index (spec 2026-09-05 §4): the refs counterpart for paths.
136
+ -- Derived — the stores rewrite a target's rows on every create, rename,
137
+ -- move and delete — so one primary-key hit resolves any URL in any locale
138
+ -- and no page or entry can share a URL with another in any locale.
139
+ -- Rows exist for drafts too (the editor needs them); the read policy
140
+ -- admits a row only when its target is published.
141
+ create table if not exists "paths" (
142
+ "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
143
+ "locale" text not null,
144
+ "path" text not null,
145
+ "kind" text not null check ("kind" in ('page', 'entry')),
146
+ "target_id" uuid not null,
147
+ primary key ("space_id", "locale", "path")
148
+ );
149
+
150
+ create index if not exists "paths_target_idx"
151
+ on "paths" ("space_id", "kind", "target_id");
152
+
153
+ -- Replace the targets' rows with the given rows in ONE call: a SQL
154
+ -- function runs in the caller's transaction, so a primary-key collision
155
+ -- rolls the whole rewrite back. Inserted rows get their space from the
156
+ -- a_set_space trigger (the claim, or the default space).
157
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql as $$
158
+ delete from "paths" p
159
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid)
160
+ where p."kind" = t.kind and p."target_id" = t.id
161
+ and p."space_id" = "smoodly_current_space"();
162
+ insert into "paths" ("locale", "path", "kind", "target_id")
163
+ select r.locale, r.path, r.kind, r.id
164
+ from jsonb_to_recordset(p_rows) as r(locale text, path text, kind text, id uuid);
165
+ $$;
166
+
98
167
  -- RLS on every core table. The service role bypasses it (self-host
99
168
  -- adapters untouched). The policy set that follows (space layer) admits
100
169
  -- a key to its own space: everything for a write key, published rows for
@@ -108,6 +177,7 @@ alter table "saved_sections" enable row level security;
108
177
  alter table "global_sections" enable row level security;
109
178
  alter table "entries" enable row level security;
110
179
  alter table "refs" enable row level security;
180
+ alter table "paths" enable row level security;
111
181
 
112
182
  -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
113
183
  -- for everyone with ONE definition. Self-host fills them by hand or
@@ -234,6 +304,10 @@ drop trigger if exists "smoodly_a_set_space" on "refs";
234
304
  create trigger "smoodly_a_set_space" before insert on "refs"
235
305
  for each row execute function "smoodly_set_space"();
236
306
 
307
+ drop trigger if exists "smoodly_a_set_space" on "paths";
308
+ create trigger "smoodly_a_set_space" before insert on "paths"
309
+ for each row execute function "smoodly_set_space"();
310
+
237
311
  drop trigger if exists "smoodly_a_set_space" on "editors";
238
312
  create trigger "smoodly_a_set_space" before insert on "editors"
239
313
  for each row execute function "smoodly_set_space"();
@@ -262,6 +336,10 @@ drop trigger if exists "smoodly_b_row_cap" on "refs";
262
336
  create trigger "smoodly_b_row_cap" before insert on "refs"
263
337
  for each row execute function "smoodly_enforce_row_cap"();
264
338
 
339
+ drop trigger if exists "smoodly_b_row_cap" on "paths";
340
+ create trigger "smoodly_b_row_cap" before insert on "paths"
341
+ for each row execute function "smoodly_enforce_row_cap"();
342
+
265
343
  drop policy if exists "smoodly_write" on "pages";
266
344
  create policy "smoodly_write" on "pages" for all
267
345
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -308,6 +386,15 @@ create policy "smoodly_write" on "refs" for all
308
386
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
309
387
  with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
310
388
 
389
+ drop policy if exists "smoodly_write" on "paths";
390
+ create policy "smoodly_write" on "paths" for all
391
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
392
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
393
+
394
+ drop policy if exists "smoodly_read" on "paths";
395
+ 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'))));
397
+
311
398
  drop policy if exists "smoodly_write" on "editors";
312
399
  create policy "smoodly_write" on "editors" for all
313
400
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -17,6 +17,11 @@
17
17
  "isolatedModules": true,
18
18
  "jsx": "react-jsx",
19
19
  "incremental": true,
20
+ "paths": {
21
+ "@/*": [
22
+ "./*"
23
+ ]
24
+ },
20
25
  "plugins": [
21
26
  {
22
27
  "name": "next"
@@ -0,0 +1,40 @@
1
+ # Smoodly site
2
+
3
+ Created by `create-smoodly-app`. Content lives in Supabase; the admin is
4
+ mounted at `/admin` from the `smoodly` package.
5
+
6
+ npm run dev # http://localhost:3000 — /admin is the editor
7
+
8
+ ## Where things are
9
+
10
+ - `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
11
+ - `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
12
+ - `app/[locale]/layout.tsx` — the site's root layout (`<html lang>`, next-intl's provider); the admin has its own under `app/(smoodly)/admin/`.
13
+ - `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded).
14
+ - `components/pages/` — the page shapes and entry pages: schema + view, one file each.
15
+ - `components/sections/` — the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
16
+ - `smoodly/collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
17
+ - `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).
18
+ - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
19
+
20
+ Translated URLs: a page's slug and title are edited per locale in the
21
+ admin's page settings; a collection's base segment per locale is its
22
+ `path` in `smoodly/collections.ts`. UI strings of your own go through
23
+ next-intl's messages — add `messages` in `i18n/request.ts`:
24
+ https://next-intl.dev/docs/usage/configuration
25
+
26
+ ## Editors
27
+
28
+ Anyone who may open `/admin` is a Supabase Auth user with a row in the
29
+ `editors` table. To add one: create the user in Supabase Studio
30
+ (Authentication → Users), then in the SQL editor:
31
+
32
+ insert into editors (user_id, role) values ('<the user id>', 'editor');
33
+
34
+ ## Hosted Supabase
35
+
36
+ supabase init # if the CLI has not been set up here yet
37
+ supabase link # pick the project
38
+ supabase db push # applies supabase/migrations/
39
+
40
+ Then put the project's URL and keys in `.env.local`.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .next/
3
+ .env.local
4
+ next-env.d.ts
5
+ *.tsbuildinfo
@@ -0,0 +1 @@
1
+ export { AdminPage as default } from "@/smoodly/admin";
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import { AdminLayout } from "@/smoodly/admin";
3
+
4
+ // The admin's own root layout: the site's is app/[locale]/layout.tsx and
5
+ // the admin sits outside the locale segment (the proxy skips /admin), so
6
+ // Next needs a second <html> here.
7
+ export default function AdminRootLayout({ children }: { children: ReactNode }) {
8
+ return (
9
+ <html lang="en">
10
+ <body>
11
+ <AdminLayout>{children}</AdminLayout>
12
+ </body>
13
+ </html>
14
+ );
15
+ }
@@ -0,0 +1,18 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type Params = Promise<{ locale: string; path: string[] }>;
4
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
5
+
6
+ // Smoodly: everything else — pages at any depth and collection entries
7
+ // under their `path`, with translated segments per locale. Add
8
+ // app/[locale]/<segment>/page.tsx beside this file and it wins for that
9
+ // exact path; deeper paths still resolve here.
10
+ export async function generateMetadata({ params }: { params: Params }) {
11
+ const { locale, path } = await params;
12
+ return smoodlyMetadata(path, { locale });
13
+ }
14
+
15
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
16
+ const { locale, path } = await params;
17
+ return renderSmoodlyPath(path, { locale, searchParams });
18
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import { notFound } from "next/navigation";
3
+ import { hasLocale, NextIntlClientProvider } from "next-intl";
4
+ import { routing } from "@/i18n/routing";
5
+
6
+ export const metadata = { title: "Smoodly site" };
7
+
8
+ // The site's root layout. The admin has its own under app/(smoodly)/admin —
9
+ // two root layouts, a full page load between them, which is fine: they
10
+ // are separate apps in spirit.
11
+ export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
12
+ const { locale } = await params;
13
+ if (!hasLocale(routing.locales, locale)) notFound();
14
+ return (
15
+ <html lang={locale}>
16
+ <body>
17
+ <NextIntlClientProvider>{children}</NextIntlClientProvider>
18
+ </body>
19
+ </html>
20
+ );
21
+ }
@@ -0,0 +1,15 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type Params = Promise<{ locale: string }>;
4
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
5
+
6
+ // Smoodly: the home page, in the locale next-intl's proxy resolved. Editing
7
+ // this file replaces or wraps the CMS home; the shapes it can render live
8
+ // in components/pages/.
9
+ export async function generateMetadata({ params }: { params: Params }) {
10
+ return smoodlyMetadata([], { locale: (await params).locale });
11
+ }
12
+
13
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
14
+ return renderSmoodlyPath([], { locale: (await params).locale, searchParams });
15
+ }
@@ -0,0 +1,15 @@
1
+ import { smoodly } from "smoodly";
2
+ import { articles } from "@/smoodly/collections";
3
+
4
+ // The entry page for `articles`, served at the collection's `path`
5
+ // (/posts/<slug>). Fields only; refs arrive resolved (author is a person).
6
+ export const ArticlePage = smoodly.entryPage(articles, ({ entry, path }) => {
7
+ const a = entry.fields as { title: string; excerpt?: string; author?: { name: string; role?: string } | null };
8
+ return (
9
+ <article data-path={path}>
10
+ <h1>{a.title}</h1>
11
+ {a.author && <p>By {a.author.name}{a.author.role ? `, ${a.author.role}` : ""}</p>}
12
+ {a.excerpt && <p>{a.excerpt}</p>}
13
+ </article>
14
+ );
15
+ });
@@ -0,0 +1,35 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { Hero } from "../sections/Hero";
3
+ import { ArticleList } from "../sections/ArticleList";
4
+ import { CtaBanner } from "../sections/CtaBanner";
5
+
6
+ // A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
7
+ // `slug` makes it fixed — one record the editor can neither delete nor
8
+ // duplicate — and the config's homePageSlug maps that record to "/".
9
+ export const homeSchema = smoodly.schema.page({
10
+ name: "home",
11
+ title: "Home",
12
+ slug: "home",
13
+ zones: {
14
+ hero: z.sections([Hero]).max(1),
15
+ body: z.freeform({ sections: [ArticleList] }),
16
+ // Locked: fields only. One CtaBanner, always there, never movable.
17
+ prefooter: z.locked(CtaBanner),
18
+ },
19
+ meta: { seo: true },
20
+ });
21
+
22
+ function HomeView({ zones }: Props<typeof homeSchema>) {
23
+ return (
24
+ <>
25
+ <Zone of={zones.hero} />
26
+ <Zone of={zones.body} />
27
+ <Zone of={zones.prefooter} />
28
+ <footer>
29
+ <p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
30
+ </footer>
31
+ </>
32
+ );
33
+ }
34
+
35
+ export const HomePage = smoodly.page(homeSchema, HomeView);
@@ -0,0 +1,40 @@
1
+ import Link from "next/link";
2
+ import { smoodly, z, Zone, type Props } from "smoodly";
3
+ import { Hero } from "../sections/Hero";
4
+ import { ArticleList } from "../sections/ArticleList";
5
+ import { CtaBanner } from "../sections/CtaBanner";
6
+
7
+ // Open registration: editors create as many of these as they like, at
8
+ // any depth the config allows. `page` is where this one sits in the tree.
9
+ export const pageSchema = smoodly.schema.page({
10
+ name: "page",
11
+ title: "Page",
12
+ zones: {
13
+ hero: z.sections([Hero]).max(1),
14
+ body: z.freeform({ sections: [ArticleList] }),
15
+ prefooter: z.locked(CtaBanner),
16
+ },
17
+ meta: { seo: true },
18
+ });
19
+
20
+ function PageView({ zones, page }: Props<typeof pageSchema>) {
21
+ return (
22
+ <>
23
+ {page.ancestors.length > 0 && (
24
+ // `url` is the ancestor's site URL through the app's href seam
25
+ // (/fi/yritys under next-intl); `path` would be the bare CMS path.
26
+ <nav aria-label="Breadcrumb">
27
+ {page.ancestors.map((a) => (
28
+ <span key={a.path}>{a.hasPage ? <Link href={a.url}>{a.title}</Link> : a.title} / </span>
29
+ ))}
30
+ {page.title}
31
+ </nav>
32
+ )}
33
+ <Zone of={zones.hero} />
34
+ <Zone of={zones.body} />
35
+ <Zone of={zones.prefooter} />
36
+ </>
37
+ );
38
+ }
39
+
40
+ export const StandardPage = smoodly.page(pageSchema, PageView);
@@ -0,0 +1,31 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+ import { articles } from "@/smoodly/collections";
3
+
4
+ export const articleListSchema = smoodly.schema.section({
5
+ name: "articleList",
6
+ title: "Article list",
7
+ fields: {
8
+ title: f.text(),
9
+ // .resolve("author") — hydrate each article AND follow its author ref
10
+ // one level (article → person), all in resolveTree's batched pass.
11
+ items: f.refList(() => articles).resolve("author"),
12
+ },
13
+ });
14
+
15
+ function ArticleListView({ title, items, styles }: Props<typeof articleListSchema>) {
16
+ return (
17
+ <smoodly.Section styles={styles}>
18
+ <h2>{title}</h2>
19
+ <ul>
20
+ {/* an item demoted/deleted after publish resolves to null — skip it */}
21
+ {items.filter(Boolean).map((article, i) => (
22
+ <li key={i}>
23
+ <strong>{article.title}</strong> — {article.author?.name ?? "unknown author"}
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ </smoodly.Section>
28
+ );
29
+ }
30
+
31
+ export const ArticleList = smoodly.section(articleListSchema, ArticleListView);
@@ -0,0 +1,35 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // The locked-zone section: every page renders exactly one of these in its
4
+ // `prefooter` zone. Editors change the words, never the structure — no
5
+ // add, remove, move or duplicate. The editor materializes the node from
6
+ // `sample` the first time it opens a page whose prefooter is empty.
7
+ export const ctaBannerSchema = smoodly.schema.section({
8
+ name: "ctaBanner",
9
+ title: "CTA banner",
10
+ description: "The closing call to action under every page.",
11
+ fields: {
12
+ heading: f.text(),
13
+ buttonLabel: f.text(),
14
+ buttonHref: f.text(),
15
+ },
16
+ styles: {
17
+ tone: f.select(["light", "dark"]).default("dark"),
18
+ },
19
+ sample: {
20
+ heading: "Ready to try Smoodly?",
21
+ buttonLabel: "Read the docs",
22
+ buttonHref: "/docs/intro",
23
+ },
24
+ });
25
+
26
+ function CtaBannerView({ heading, buttonLabel, buttonHref, styles }: Props<typeof ctaBannerSchema>) {
27
+ return (
28
+ <smoodly.Section styles={styles}>
29
+ <h2>{heading}</h2>
30
+ <a href={buttonHref}>{buttonLabel}</a>
31
+ </smoodly.Section>
32
+ );
33
+ }
34
+
35
+ export const CtaBanner = smoodly.section(ctaBannerSchema, CtaBannerView);
@@ -0,0 +1,24 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ export const heroSchema = smoodly.schema.section({
4
+ name: "hero",
5
+ title: "Hero",
6
+ fields: {
7
+ heading: f.text(),
8
+ kicker: f.text().optional(),
9
+ },
10
+ styles: {
11
+ tone: f.select(["light", "dark"]).default("light"),
12
+ },
13
+ });
14
+
15
+ function HeroView({ heading, kicker, styles }: Props<typeof heroSchema>) {
16
+ return (
17
+ <smoodly.Section styles={styles}>
18
+ {kicker ? <p>{kicker}</p> : null}
19
+ <h1>{heading}</h1>
20
+ </smoodly.Section>
21
+ );
22
+ }
23
+
24
+ export const Hero = smoodly.section(heroSchema, HeroView);
@@ -0,0 +1,6 @@
1
+ import { createNavigation } from "next-intl/navigation";
2
+ import { routing } from "./routing";
3
+
4
+ // Locale-aware Link/redirect/router, and getPathname — the function
5
+ // smoodly/server.ts binds as `href`.
6
+ export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
@@ -0,0 +1,17 @@
1
+ import { locale as rootLocale } from "next/root-params";
2
+ import { hasLocale } from "next-intl";
3
+ import { getRequestConfig } from "next-intl/server";
4
+ import { routing } from "./routing";
5
+
6
+ // Runs once per request that touches next-intl. An unknown or absent
7
+ // locale (the admin lives outside app/[locale]) falls back to the default
8
+ // rather than 404ing. The site has no UI strings of its own yet, but
9
+ // NextIntlClientProvider wants a messages object all the same — fill it
10
+ // in when it does: https://next-intl.dev/docs/usage/configuration
11
+ export default getRequestConfig(async ({ locale }) => {
12
+ const requested = locale ?? (await rootLocale());
13
+ return {
14
+ locale: hasLocale(routing.locales, requested) ? requested : routing.defaultLocale,
15
+ messages: {},
16
+ };
17
+ });
@@ -0,0 +1,17 @@
1
+ import { defineRouting } from "next-intl/routing";
2
+
3
+ // The one place the locale set is declared. smoodly/config.ts imports it
4
+ // (the proxy runs in the edge runtime and cannot import the config, so it
5
+ // could not go the other way). `alternateLinks: false`: next-intl would
6
+ // derive hreflang from URL patterns, which is wrong once slugs translate —
7
+ // smoodlyMetadata emits alternates from the CMS paths instead.
8
+ // `localeDetection: false` for the same reason — a redirect that keeps the
9
+ // English segments under `/fi/` lands on a 404; the visitor picks a
10
+ // language through the site's own switcher.
11
+ export const routing = defineRouting({
12
+ locales: ["en", "fi"],
13
+ defaultLocale: "en",
14
+ localePrefix: "as-needed",
15
+ alternateLinks: false,
16
+ localeDetection: false,
17
+ });
@@ -0,0 +1,8 @@
1
+ import type { NextConfig } from "next";
2
+ import createNextIntlPlugin from "next-intl/plugin";
3
+
4
+ const withNextIntl = createNextIntlPlugin();
5
+
6
+ const config: NextConfig = {};
7
+
8
+ export default withNextIntl(config);
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "smoodly-site",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@supabase/supabase-js": "^2.113.0",
13
+ "next": "^16.3.4",
14
+ "react": "^19",
15
+ "react-dom": "^19",
16
+ "smoodly": "0.0.6",
17
+ "next-intl": "^4.14.2"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^26.4.1",
21
+ "@types/react": "^19",
22
+ "@types/react-dom": "^19.2.5",
23
+ "typescript": "^5"
24
+ }
25
+ }