create-smoodly-app 0.0.4 → 0.0.5

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 +41 -9
  2. package/dist/index.js +63 -7
  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
package/README.md CHANGED
@@ -9,7 +9,11 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
9
9
  ## What it asks
10
10
 
11
11
  1. Project name (also the positional argument).
12
- 2. Where content lives: a **local Supabase stack** (`supabase init` +
12
+ 2. **Multilingual?** No (default) scaffolds one language. Yes asks for the
13
+ locales, comma-separated, the first being the default (`en, fi`), and
14
+ scaffolds the next-intl layout: the site under `app/[locale]/`, `i18n/`,
15
+ `proxy.ts`, translated URLs per locale. Flag: `--locales en,fi`.
16
+ 3. Where content lives: a **local Supabase stack** (`supabase init` +
13
17
  `supabase start` inside the app; needs the Supabase CLI and Docker; when
14
18
  another project holds the default `543xx` ports the new one moves to the
15
19
  next free block, `553xx` and so on, and says so) or a
@@ -19,25 +23,29 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
19
23
  pasted without `https://`; anything that is not an http(s) URL is refused
20
24
  before any step runs. A custom domain has no ref, so the link is not
21
25
  offered and the two commands are printed instead.
22
- 3. The first editor: pick an existing Auth user or create one (email +
26
+ 4. The first editor: pick an existing Auth user or create one (email +
23
27
  password). The `editors` row is written with role `owner`.
24
28
  If `@` does not appear when you type it (macOS, `@` on Option+2 with the
25
29
  terminal's "Option as Meta" setting on), paste the address or pass
26
30
  `--editor-email`.
27
31
 
28
- Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--url`,
29
- `--publishable-key`, `--secret-key`, `--editor-email` (password from
32
+ Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--locales`,
33
+ `--url`, `--publishable-key`, `--secret-key`, `--editor-email` (password from
30
34
  `SMOODLY_EDITOR_PASSWORD`), `--skip-link`, `--yes`. `--help` lists them.
31
35
  A hidden `--smoodly-dep <spec>` overrides the `smoodly` dependency written into
32
36
  `package.json`; CI points it at the packed tarball.
33
37
 
34
38
  ## How it is built
35
39
 
36
- `templates/next/` is generated by `scripts/build-template.ts` from
37
- `examples/site` — an allow-list of copied files plus a generated
38
- `package.json` (with `smoodly` pinned to this package's version), `tsconfig`,
39
- `next.config`, README and the one migration (`coreTablesSQL` + the starter
40
- collections' views). It is git-ignored and rebuilt by `npm run build`.
40
+ `templates/next/` and `templates/next-intl/` are both generated by
41
+ `scripts/build-template.ts` from `examples/site` — the multilingual layout
42
+ plus a generated `package.json` (with `smoodly` pinned to this package's
43
+ version), `tsconfig`, `next.config`, README and the one migration
44
+ (`coreTablesSQL` + the starter collections' views). The four single-language
45
+ route files live in `variants/single/`, and two exact-line transforms turn
46
+ the example's glue (locale config, `href`) into the single-language one; a
47
+ drifted example fails the build rather than silently shipping stale copies.
48
+ Both templates are git-ignored and rebuilt by `npm run build`.
41
49
 
42
50
  `src/plan.ts` turns answers into steps; `src/run.ts` executes them with
43
51
  injected `exec`, fs and Supabase client so the sequence and every fallback
@@ -47,6 +55,30 @@ The CLI has no runtime dependency on `smoodly`.
47
55
  npm test # unit tests (template build, planner, runner, editor)
48
56
  npm run build # template + esbuild bundle → dist/index.js
49
57
 
58
+ Walked 2026-09-06: both templates scaffolded against the repo's local stack
59
+ in hosted mode, `tsc`, `next build` and `/admin` 200 for each; the
60
+ multilingual one with `--locales en,de`.
61
+
62
+ ## Follow-ups (logged 2026-09-06)
63
+
64
+ - **A non-interactive run without `--locales` needs `--yes`.** The
65
+ "Multilingual site?" prompt is asked whenever `--locales` is absent, and
66
+ with no TTY (a CI step, stdin at EOF) the CLI exits 13 having created
67
+ nothing — "Detected unsettled top-level await". CI's hosted-mode e2e step
68
+ hit this; both e2e steps now pass a flag that settles the question
69
+ (`--yes` for single-language, `--locales en,de` for the multilingual one).
70
+ A prompt that treated EOF as its default value would need no flag.
71
+ - **The next-intl template inherits next-intl's `localeDetection`
72
+ default**, which redirects an unprefixed URL to the visitor's preferred
73
+ locale *keeping the source locale's slugs* — on a site with translated
74
+ slugs that redirect lands on a 404 (`/company/about-us` →
75
+ `/fi/company/about-us` for a Finnish browser). Recorded in
76
+ `examples/site/README.md` under "Multilingual (2026-09-06)". Fixed the
77
+ same day: `i18n/routing.ts` in the example — and so in the generated
78
+ `templates/next-intl` — sets `localeDetection: false`, so the English
79
+ URL stays English for a Finnish browser and the visitor picks a
80
+ language through the site's own switcher.
81
+
50
82
  ## Follow-ups (logged 2026-09-05)
51
83
 
52
84
  - Text prompts drop `@` when the terminal sends Option as Meta (macOS,
package/dist/index.js CHANGED
@@ -7,6 +7,16 @@ import { fileURLToPath } from "node:url";
7
7
 
8
8
  // src/args.ts
9
9
  import { parseArgs } from "node:util";
10
+ function parseLocales(input) {
11
+ const locales = input.split(",").map((s) => s.trim()).filter(Boolean);
12
+ if (locales.length < 2) throw new Error("--locales needs at least two, comma-separated (e.g. en,fi); a single-language site needs none.");
13
+ for (const l of locales) {
14
+ if (!/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(l)) throw new Error(`--locales: "${l}" is not a locale tag (en, fi, sv-SE, pt-BR).`);
15
+ }
16
+ const dup = locales.find((l, i) => locales.indexOf(l) !== i);
17
+ if (dup) throw new Error(`--locales lists "${dup}" twice.`);
18
+ return locales;
19
+ }
10
20
  function parseFlags(argv) {
11
21
  const { values, positionals } = parseArgs({
12
22
  args: argv,
@@ -18,6 +28,7 @@ function parseFlags(argv) {
18
28
  "secret-key": { type: "string" },
19
29
  "editor-email": { type: "string" },
20
30
  "smoodly-dep": { type: "string" },
31
+ locales: { type: "string" },
21
32
  "skip-link": { type: "boolean", default: false },
22
33
  yes: { type: "boolean", short: "y", default: false },
23
34
  help: { type: "boolean", short: "h", default: false }
@@ -38,7 +49,8 @@ function parseFlags(argv) {
38
49
  skipLink: values["skip-link"] ?? false,
39
50
  yes: values.yes ?? false,
40
51
  help: values.help ?? false,
41
- smoodlyDep: values["smoodly-dep"]
52
+ smoodlyDep: values["smoodly-dep"],
53
+ locales: values.locales === void 0 ? void 0 : parseLocales(values.locales)
42
54
  };
43
55
  }
44
56
  var HELP = `create-smoodly-app \u2014 a fresh Next.js site with Smoodly installed
@@ -47,6 +59,7 @@ Usage: npx create-smoodly-app [name] [options]
47
59
 
48
60
  Options:
49
61
  --supabase local|hosted Where content lives (asked when omitted; local needs the Supabase CLI)
62
+ --locales <a,b,\u2026> Multilingual: the locales, first is the default (scaffolds next-intl; omitted = one language)
50
63
  --url <url> Hosted: the Supabase project URL
51
64
  --publishable-key <key> Hosted: the anon / publishable key
52
65
  --secret-key <key> Hosted: the service-role / secret key
@@ -188,6 +201,8 @@ async function isEmptyOrMissing(dir) {
188
201
  throw e;
189
202
  }
190
203
  }
204
+ var ROUTING_LOCALES = ' locales: ["en", "fi"],';
205
+ var ROUTING_DEFAULT = ' defaultLocale: "en",';
191
206
  async function scaffold(opts) {
192
207
  await cp(opts.templateDir, opts.dir, { recursive: true });
193
208
  await rename(join(opts.dir, "_gitignore"), join(opts.dir, ".gitignore"));
@@ -196,6 +211,17 @@ async function scaffold(opts) {
196
211
  pkg.name = opts.name;
197
212
  if (opts.smoodlyDep) pkg.dependencies.smoodly = opts.smoodlyDep;
198
213
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
214
+ if (opts.locales) {
215
+ const routingPath = join(opts.dir, "i18n/routing.ts");
216
+ const routing = await readFile(routingPath, "utf8");
217
+ if (!routing.includes(ROUTING_LOCALES) || !routing.includes(ROUTING_DEFAULT)) {
218
+ throw new Error("i18n/routing.ts in the template has drifted; expected the lines\n" + ROUTING_LOCALES + "\n" + ROUTING_DEFAULT);
219
+ }
220
+ await writeFile(
221
+ routingPath,
222
+ routing.replace(ROUTING_LOCALES, ` locales: [${opts.locales.map((l) => JSON.stringify(l)).join(", ")}],`).replace(ROUTING_DEFAULT, ` defaultLocale: ${JSON.stringify(opts.locales[0])},`)
223
+ );
224
+ }
199
225
  }
200
226
 
201
227
  // src/prompts.ts
@@ -219,6 +245,19 @@ async function resolveAnswers(flags2, ctx) {
219
245
  if (invalid) bail(invalid);
220
246
  const dir = resolve(ctx.cwd, name);
221
247
  if (!await isEmptyOrMissing(dir)) bail(`${dir} exists and is not empty.`);
248
+ const locales = flags2.locales ?? (flags2.yes ? void 0 : await answer(p.confirm({ message: "Multilingual site? (next-intl, translated URLs)", initialValue: false })) ? await answer(
249
+ p.text({
250
+ message: "Locales, comma-separated, the first is the default",
251
+ placeholder: "en, fi",
252
+ validate: (v) => {
253
+ try {
254
+ parseLocales(v ?? "");
255
+ } catch (e) {
256
+ return e instanceof Error ? e.message : String(e);
257
+ }
258
+ }
259
+ })
260
+ ).then(parseLocales) : void 0);
222
261
  const supabase = flags2.supabase ?? (flags2.yes ? "local" : await answer(
223
262
  p.select({
224
263
  message: "Where should content live?",
@@ -230,7 +269,7 @@ async function resolveAnswers(flags2, ctx) {
230
269
  ));
231
270
  if (supabase === "local") {
232
271
  if (!ctx.hasSupabaseCli) bail("Local mode needs the Supabase CLI: https://supabase.com/docs/guides/cli (brew install supabase/tap/supabase)");
233
- return { name, dir, supabase, hasSupabaseCli: true, smoodlyDep: flags2.smoodlyDep };
272
+ return { name, dir, supabase, hasSupabaseCli: true, smoodlyDep: flags2.smoodlyDep, locales };
234
273
  }
235
274
  const badUrl = "Supabase project URL must be an http(s) URL, like https://abcd.supabase.co";
236
275
  const typedUrl = flags2.url ?? await answer(
@@ -249,7 +288,7 @@ async function resolveAnswers(flags2, ctx) {
249
288
  p.log.warn(`No project ref in ${url}; skipping supabase link. Run \`supabase link --project-ref <project-ref>\` and \`supabase db push\` yourself.`);
250
289
  }
251
290
  const linkAndPush = !ctx.hasSupabaseCli ? false : flags2.skipLink || !ref ? false : flags2.yes ? true : await answer(p.confirm({ message: `Link this app to the project and push the migration now? (supabase link --project-ref ${ref}, supabase db push)` }));
252
- return { name, dir, supabase, hasSupabaseCli: ctx.hasSupabaseCli, hosted: { keys: { url, publishableKey, secretKey }, linkAndPush }, smoodlyDep: flags2.smoodlyDep };
291
+ return { name, dir, supabase, hasSupabaseCli: ctx.hasSupabaseCli, hosted: { keys: { url, publishableKey, secretKey }, linkAndPush }, smoodlyDep: flags2.smoodlyDep, locales };
253
292
  }
254
293
  function askEditorWith(flags2, env = process.env) {
255
294
  return async (existing) => {
@@ -354,7 +393,13 @@ async function run(answers2, deps) {
354
393
  try {
355
394
  switch (step) {
356
395
  case "copyTemplate":
357
- await deps.fs.scaffold({ templateDir: deps.templateDir, dir: cwd, name: answers2.name, smoodlyDep: answers2.smoodlyDep });
396
+ await deps.fs.scaffold({
397
+ templateDir: answers2.locales ? deps.templateDirs.intl : deps.templateDirs.single,
398
+ dir: cwd,
399
+ name: answers2.name,
400
+ smoodlyDep: answers2.smoodlyDep,
401
+ locales: answers2.locales
402
+ });
358
403
  break;
359
404
  case "install":
360
405
  deps.log("Installing dependencies\u2026");
@@ -452,7 +497,10 @@ function linkCommand(answers2) {
452
497
  }
453
498
 
454
499
  // src/index.ts
455
- var TEMPLATE_DIR = fileURLToPath(new URL("../templates/next/", import.meta.url));
500
+ var TEMPLATE_DIRS = {
501
+ single: fileURLToPath(new URL("../templates/next/", import.meta.url)),
502
+ intl: fileURLToPath(new URL("../templates/next-intl/", import.meta.url))
503
+ };
456
504
  var flags;
457
505
  try {
458
506
  flags = parseFlags(process.argv.slice(2));
@@ -473,7 +521,7 @@ try {
473
521
  answers = await resolveAnswers(flags, { hasSupabaseCli, cwd: process.cwd() });
474
522
  outcome = await run(answers, {
475
523
  exec,
476
- templateDir: TEMPLATE_DIR,
524
+ templateDirs: TEMPLATE_DIRS,
477
525
  editorClient: supabaseEditorClient,
478
526
  askEditor: askEditorWith(flags),
479
527
  log: (line) => p2.log.step(line),
@@ -496,7 +544,15 @@ if (!outcome.ok) {
496
544
  process.exit(1);
497
545
  }
498
546
  p2.note(
499
- [`cd ${answers.name}`, "npm run dev", "", "Site: http://localhost:3000", "Admin: http://localhost:3000/admin", `Editor: ${outcome.editor.email ?? outcome.editor.id}`].join("\n"),
547
+ [
548
+ `cd ${answers.name}`,
549
+ "npm run dev",
550
+ "",
551
+ "Site: http://localhost:3000",
552
+ "Admin: http://localhost:3000/admin",
553
+ `Editor: ${outcome.editor.email ?? outcome.editor.id}`,
554
+ ...answers.locales ? [`Locales: ${answers.locales.join(", ")} (i18n/routing.ts)`] : []
555
+ ].join("\n"),
500
556
  "Ready"
501
557
  );
502
558
  p2.outro("Happy editing.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,11 +7,12 @@ mounted at `/admin` from the `smoodly` package.
7
7
 
8
8
  ## Where things are
9
9
 
10
- - `app/page.tsx` — the home page: its zones and JSX, registered as the fixed slug `home`.
11
- - `app/[slug]/page.tsx` — every other page editors create.
12
- - `components/sections/` — the building blocks editors compose with. Add one, list it in `sections.ts`.
13
- - `collections.ts`structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
14
- - `lib/smoodly/`the wiring: stores, the admin ops action, the admin mount.
10
+ - `app/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
11
+ - `app/[...path]/page.tsx` — every other page and every collection entry, at any depth.
12
+ - `components/pages/` — the page shapes and entry pages: schema + view, one file each.
13
+ - `components/sections/`the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
14
+ - `smoodly/collections.ts`structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
15
+ - `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).
15
16
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
16
17
 
17
18
  ## Editors
@@ -1 +1 @@
1
- export { AdminPage as default } from "../../../../lib/smoodly/admin";
1
+ export { AdminPage as default } from "@/smoodly/admin";
@@ -1 +1 @@
1
- export { AdminLayout as default } from "../../../lib/smoodly/admin";
1
+ export { AdminLayout as default } from "@/smoodly/admin";
@@ -0,0 +1,15 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type Params = Promise<{ 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`. Add app/<segment>/page.tsx beside this file and it
8
+ // wins for that exact path; deeper paths still resolve here.
9
+ export async function generateMetadata({ params }: { params: Params }) {
10
+ return smoodlyMetadata((await params).path);
11
+ }
12
+
13
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
14
+ return renderSmoodlyPath((await params).path, { searchParams });
15
+ }
@@ -1,48 +1,11 @@
1
- import { smoodly, z, Zone, type Props } from "smoodly";
2
- import { Hero } from "../components/sections/Hero";
3
- import { ArticleList } from "../components/sections/ArticleList";
4
- import { CtaBanner } from "../components/sections/CtaBanner";
5
- import { renderSmoodlyPage, smoodlyMetadata } from "../lib/smoodly";
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
6
2
 
7
3
  type SearchParams = Promise<Record<string, string | string[] | undefined>>;
8
4
 
9
- // The simple strategy: this file IS the page. Zones, JSX and the route
10
- // live together; `slug` makes it a singleton the editor cannot delete or
11
- // duplicate. See docs/DESIGN.md §3.
12
- export const homeSchema = smoodly.schema.page({
13
- name: "home",
14
- title: "Home",
15
- slug: "home",
16
- zones: {
17
- hero: z.sections([Hero]).max(1),
18
- body: z.freeform({ sections: [ArticleList] }),
19
- // Locked: fields only. One CtaBanner, always there, never movable.
20
- prefooter: z.locked(CtaBanner),
21
- },
22
- meta: { seo: true },
23
- });
24
-
25
- function HomeView({ zones }: Props<typeof homeSchema>) {
26
- return (
27
- <>
28
- <Zone of={zones.hero} />
29
- <Zone of={zones.body} />
30
- <Zone of={zones.prefooter} />
31
- <footer>
32
- <p>Hard-coded footer — owned by the developer, invisible to the editor.</p>
33
- </footer>
34
- </>
35
- );
36
- }
37
-
38
- export const HomePage = smoodly.page(homeSchema, HomeView);
39
-
40
- // No `dynamic = "force-dynamic"` needed: renderSmoodlyPage and
41
- // smoodlyMetadata wait for a request themselves, so `next build` never
42
- // prerenders this route against the database.
43
-
44
- export const generateMetadata = () => smoodlyMetadata(HomePage);
5
+ // Smoodly: the home page. Editing this file replaces or wraps the CMS
6
+ // home; the shapes it can render live in components/pages/.
7
+ export const generateMetadata = () => smoodlyMetadata([]);
45
8
 
46
9
  export default async function Page({ searchParams }: { searchParams: SearchParams }) {
47
- return renderSmoodlyPage(HomePage, { searchParams });
10
+ return renderSmoodlyPath([], { searchParams });
48
11
  }
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import { f, smoodly, type Props } from "smoodly";
2
- import { articles } from "../../collections";
2
+ import { articles } from "@/smoodly/collections";
3
3
 
4
4
  export const articleListSchema = smoodly.schema.section({
5
5
  name: "articleList",
@@ -9,11 +9,11 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
+ "@supabase/supabase-js": "^2.113.0",
12
13
  "next": "^16.3.4",
13
14
  "react": "^19",
14
15
  "react-dom": "^19",
15
- "@supabase/supabase-js": "^2.113.0",
16
- "smoodly": "0.0.4"
16
+ "smoodly": "0.0.5"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^26.4.1",
@@ -0,0 +1,6 @@
1
+ import { createSmoodlyAdmin } from "smoodly/admin/next";
2
+ import { config } from "./config";
3
+ import { smoodlyOp } from "./ops";
4
+ import { publicAuth } from "./server";
5
+
6
+ export const { AdminPage, AdminLayout } = createSmoodlyAdmin({ config, op: smoodlyOp, auth: publicAuth });
@@ -0,0 +1,26 @@
1
+ import { f, smoodly } from "smoodly";
2
+
3
+ export const people = smoodly.collection({
4
+ name: "people",
5
+ title: "People",
6
+ titleField: "name",
7
+ fields: {
8
+ name: f.text(),
9
+ role: f.text().optional(),
10
+ },
11
+ });
12
+
13
+ export const articles = smoodly.collection({
14
+ name: "articles",
15
+ title: "Articles",
16
+ titleField: "title",
17
+ path: "posts",
18
+ fields: {
19
+ title: f.text(),
20
+ slug: f.slug({ from: "title" }),
21
+ excerpt: f.text().optional(),
22
+ author: f.ref(() => people),
23
+ },
24
+ });
25
+
26
+ export const collections = [people, articles];
@@ -0,0 +1,27 @@
1
+ import { defineConfig, f } from "smoodly";
2
+ import { collections } from "./collections";
3
+ import { sections } from "./sections";
4
+ import { HomePage } from "@/components/pages/Home";
5
+ import { StandardPage } from "@/components/pages/Page";
6
+ import { ArticlePage } from "@/components/pages/Article";
7
+
8
+ export { collections, sections };
9
+
10
+ // The registry and the plain settings, one file. Runtime-free: nothing
11
+ // here imports a route file or Next, so a script, the CLI's template
12
+ // build and (later) the MCP server can load it outside the app.
13
+ export const config = defineConfig({
14
+ registry: {
15
+ sections,
16
+ collections,
17
+ pages: [HomePage, StandardPage, ArticlePage],
18
+ },
19
+ locales: { default: "en", supported: ["en"] },
20
+ // Smoodly's own default, spelled out: the record the site root serves.
21
+ homePageSlug: "home",
22
+ // Nesting on: an editor can put pages three deep.
23
+ pages: { tree: { depth: 3 } },
24
+ styles: {
25
+ sections: { spacing: f.select(["sm", "md", "lg"]).default("md") },
26
+ },
27
+ });
@@ -0,0 +1,11 @@
1
+ "use server";
2
+ // The app's entire write surface for the admin: ONE exported action,
3
+ // gated by the editors table. ('use server' modules may only export
4
+ // async functions — hence the shape.)
5
+ import { createAdminOpHandler, type AdminOpCall } from "smoodly/admin/next";
6
+ import { config } from "./config";
7
+ import { adminAuth, href, stores } from "./server";
8
+
9
+ export async function smoodlyOp(call: AdminOpCall) {
10
+ return createAdminOpHandler({ config, ...stores(), auth: adminAuth(), href })(call);
11
+ }
@@ -0,0 +1,8 @@
1
+ // The site's section registrations, in one list. config.ts puts it
2
+ // in the registry and re-exports it, so the config, the stores (which walk
3
+ // trees against these schemas) and the renderer all share one array.
4
+ import { Hero } from "@/components/sections/Hero";
5
+ import { ArticleList } from "@/components/sections/ArticleList";
6
+ import { CtaBanner } from "@/components/sections/CtaBanner";
7
+
8
+ export const sections = [Hero, ArticleList, CtaBanner];
@@ -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
+ });