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
package/README.md CHANGED
@@ -9,8 +9,15 @@ 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` +
13
- `supabase start` inside the app; needs the Supabase CLI and Docker; when
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` +
17
+ `supabase start` inside the app, with the services Smoodly never uses
18
+ excluded — storage, image proxy, edge functions, analytics, realtime,
19
+ the pooler — so the stack stays small beside any other you run; needs
20
+ the Supabase CLI and Docker; when
14
21
  another project holds the default `543xx` ports the new one moves to the
15
22
  next free block, `553xx` and so on, and says so) or a
16
23
  **hosted project** (URL, publishable key, secret key; then an offer to
@@ -19,25 +26,29 @@ sign in. Spec: `docs/superpowers/specs/2026-09-04-create-smoodly-app-design.md`.
19
26
  pasted without `https://`; anything that is not an http(s) URL is refused
20
27
  before any step runs. A custom domain has no ref, so the link is not
21
28
  offered and the two commands are printed instead.
22
- 3. The first editor: pick an existing Auth user or create one (email +
29
+ 4. The first editor: pick an existing Auth user or create one (email +
23
30
  password). The `editors` row is written with role `owner`.
24
31
  If `@` does not appear when you type it (macOS, `@` on Option+2 with the
25
32
  terminal's "Option as Meta" setting on), paste the address or pass
26
33
  `--editor-email`.
27
34
 
28
- Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--url`,
29
- `--publishable-key`, `--secret-key`, `--editor-email` (password from
35
+ Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--locales`,
36
+ `--url`, `--publishable-key`, `--secret-key`, `--editor-email` (password from
30
37
  `SMOODLY_EDITOR_PASSWORD`), `--skip-link`, `--yes`. `--help` lists them.
31
38
  A hidden `--smoodly-dep <spec>` overrides the `smoodly` dependency written into
32
39
  `package.json`; CI points it at the packed tarball.
33
40
 
34
41
  ## How it is built
35
42
 
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`.
43
+ `templates/next/` and `templates/next-intl/` are both generated by
44
+ `scripts/build-template.ts` from `examples/site` — the multilingual layout
45
+ plus a generated `package.json` (with `smoodly` pinned to this package's
46
+ version), `tsconfig`, `next.config`, README and the one migration
47
+ (`coreTablesSQL` + the starter collections' views). The four single-language
48
+ route files live in `variants/single/`, and two exact-line transforms turn
49
+ the example's glue (locale config, `href`) into the single-language one; a
50
+ drifted example fails the build rather than silently shipping stale copies.
51
+ Both templates are git-ignored and rebuilt by `npm run build`.
41
52
 
42
53
  `src/plan.ts` turns answers into steps; `src/run.ts` executes them with
43
54
  injected `exec`, fs and Supabase client so the sequence and every fallback
@@ -47,6 +58,49 @@ The CLI has no runtime dependency on `smoodly`.
47
58
  npm test # unit tests (template build, planner, runner, editor)
48
59
  npm run build # template + esbuild bundle → dist/index.js
49
60
 
61
+ Walked 2026-09-06: both templates scaffolded against the repo's local stack
62
+ in hosted mode, `tsc`, `next build` and `/admin` 200 for each; the
63
+ multilingual one with `--locales en,de`.
64
+
65
+ ## Follow-ups (logged 2026-09-06)
66
+
67
+ - Local mode on a fresh multilingual install (0.0.5, 2026-09-06) failed at
68
+ `supabase start` with `supabase_storage_<app> container is not ready:
69
+ unhealthy` — the storage container logged `Started Successfully` and the
70
+ CLI still stopped the stack. Five local stacks (57 containers) were
71
+ sharing 7.8 GB of Docker memory at the time, the same pressure that
72
+ made the repo's live suites flake on 2026-09-05. Smoodly never uses
73
+ storage, so the CLI could start the app's stack with
74
+ `supabase start -x storage-api,imgproxy,edge-runtime,logflare,vector,realtime`
75
+ (the container names `supabase start --help` lists; CI already excludes
76
+ most of these for the repo stack), and the "Finish by hand" text could
77
+ mention `supabase stop` on other projects. Retried the same day with
78
+ the other four stacks stopped (`supabase stop --project-id <name>`):
79
+ `npx create-smoodly-app@0.0.5 --supabase local --locales en,fi --yes`
80
+ came up with every container healthy, storage included, and the app
81
+ built and served `/admin` 200, `/` and `/fi` 404 — so the cause is
82
+ memory pressure, not the CLI. Fixed the same day: `run.ts` starts the
83
+ app's stack with `-x storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor`
84
+ (`LOCAL_START_ARGS`), and the "Finish by hand" text carries the same
85
+ flags. Kept: db, kong, auth, PostgREST, Studio and pg-meta, mailpit.
86
+ - **A non-interactive run without `--locales` needs `--yes`.** The
87
+ "Multilingual site?" prompt is asked whenever `--locales` is absent, and
88
+ with no TTY (a CI step, stdin at EOF) the CLI exits 13 having created
89
+ nothing — "Detected unsettled top-level await". CI's hosted-mode e2e step
90
+ hit this; both e2e steps now pass a flag that settles the question
91
+ (`--yes` for single-language, `--locales en,de` for the multilingual one).
92
+ A prompt that treated EOF as its default value would need no flag.
93
+ - **The next-intl template inherits next-intl's `localeDetection`
94
+ default**, which redirects an unprefixed URL to the visitor's preferred
95
+ locale *keeping the source locale's slugs* — on a site with translated
96
+ slugs that redirect lands on a 404 (`/company/about-us` →
97
+ `/fi/company/about-us` for a Finnish browser). Recorded in
98
+ `examples/site/README.md` under "Multilingual (2026-09-06)". Fixed the
99
+ same day: `i18n/routing.ts` in the example — and so in the generated
100
+ `templates/next-intl` — sets `localeDetection: false`, so the English
101
+ URL stays English for a Finnish browser and the visitor picks a
102
+ language through the site's own switcher.
103
+
50
104
  ## Follow-ups (logged 2026-09-05)
51
105
 
52
106
  - 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) => {
@@ -339,6 +378,7 @@ function parseStatusEnv(output) {
339
378
  }
340
379
 
341
380
  // src/run.ts
381
+ var LOCAL_START_ARGS = ["start", "-x", "storage-api,imgproxy,edge-runtime,logflare,vector,realtime,supavisor"];
342
382
  async function run(answers2, deps) {
343
383
  if (answers2.supabase === "hosted" && !answers2.hosted) {
344
384
  throw new Error("hosted answers need the project's keys");
@@ -354,7 +394,13 @@ async function run(answers2, deps) {
354
394
  try {
355
395
  switch (step) {
356
396
  case "copyTemplate":
357
- await deps.fs.scaffold({ templateDir: deps.templateDir, dir: cwd, name: answers2.name, smoodlyDep: answers2.smoodlyDep });
397
+ await deps.fs.scaffold({
398
+ templateDir: answers2.locales ? deps.templateDirs.intl : deps.templateDirs.single,
399
+ dir: cwd,
400
+ name: answers2.name,
401
+ smoodlyDep: answers2.smoodlyDep,
402
+ locales: answers2.locales
403
+ });
358
404
  break;
359
405
  case "install":
360
406
  deps.log("Installing dependencies\u2026");
@@ -375,7 +421,7 @@ async function run(answers2, deps) {
375
421
  await deps.fs.writeFile(configPath, shiftBlock(toml, base));
376
422
  deps.log(`Ports ${DEFAULT_BASE / 100}xx are busy (another Supabase project running?), using ${base / 100}xx.`);
377
423
  }
378
- await sh("supabase", ["start"]);
424
+ await sh("supabase", LOCAL_START_ARGS);
379
425
  ctx.keys = parseStatusEnv((await sh("supabase", ["status", "-o", "env"], { capture: true })).stdout);
380
426
  break;
381
427
  }
@@ -421,7 +467,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
421
467
  ].join("\n");
422
468
  switch (step) {
423
469
  case "supabaseLocal":
424
- return [`cd ${answers2.name} && supabase init && supabase start`, "", envLines, "", editorLines].join("\n");
470
+ return [`cd ${answers2.name} && supabase init && supabase ${LOCAL_START_ARGS.join(" ")}`, "", envLines, "", editorLines].join("\n");
425
471
  case "supabaseInit": {
426
472
  const commands = answers2.hosted?.linkAndPush ? [`cd ${answers2.name} && supabase init && ${linkCommand(answers2)} && supabase db push`] : [`cd ${answers2.name} && supabase init`, "", `When you are ready: ${linkCommand(answers2)} && supabase db push`];
427
473
  return [...commands, "", envLines, "", editorLines].join("\n");
@@ -452,7 +498,10 @@ function linkCommand(answers2) {
452
498
  }
453
499
 
454
500
  // src/index.ts
455
- var TEMPLATE_DIR = fileURLToPath(new URL("../templates/next/", import.meta.url));
501
+ var TEMPLATE_DIRS = {
502
+ single: fileURLToPath(new URL("../templates/next/", import.meta.url)),
503
+ intl: fileURLToPath(new URL("../templates/next-intl/", import.meta.url))
504
+ };
456
505
  var flags;
457
506
  try {
458
507
  flags = parseFlags(process.argv.slice(2));
@@ -473,7 +522,7 @@ try {
473
522
  answers = await resolveAnswers(flags, { hasSupabaseCli, cwd: process.cwd() });
474
523
  outcome = await run(answers, {
475
524
  exec,
476
- templateDir: TEMPLATE_DIR,
525
+ templateDirs: TEMPLATE_DIRS,
477
526
  editorClient: supabaseEditorClient,
478
527
  askEditor: askEditorWith(flags),
479
528
  log: (line) => p2.log.step(line),
@@ -496,7 +545,15 @@ if (!outcome.ok) {
496
545
  process.exit(1);
497
546
  }
498
547
  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"),
548
+ [
549
+ `cd ${answers.name}`,
550
+ "npm run dev",
551
+ "",
552
+ "Site: http://localhost:3000",
553
+ "Admin: http://localhost:3000/admin",
554
+ `Editor: ${outcome.editor.email ?? outcome.editor.id}`,
555
+ ...answers.locales ? [`Locales: ${answers.locales.join(", ")} (i18n/routing.ts)`] : []
556
+ ].join("\n"),
500
557
  "Ready"
501
558
  );
502
559
  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.6",
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.6"
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];