create-smoodly-app 0.0.3 → 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 +52 -11
  2. package/dist/index.js +119 -34
  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,32 +9,43 @@ 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
16
20
  **hosted project** (URL, publishable key, secret key; then an offer to
17
21
  `supabase link --project-ref <ref>` + `supabase db push`, the ref taken
18
- from the URL so the CLI never opens its project picker).
19
- 3. The first editor: pick an existing Auth user or create one (email +
22
+ from the URL so the CLI never opens its project picker). The URL may be
23
+ pasted without `https://`; anything that is not an http(s) URL is refused
24
+ before any step runs. A custom domain has no ref, so the link is not
25
+ offered and the two commands are printed instead.
26
+ 4. The first editor: pick an existing Auth user or create one (email +
20
27
  password). The `editors` row is written with role `owner`.
21
28
  If `@` does not appear when you type it (macOS, `@` on Option+2 with the
22
29
  terminal's "Option as Meta" setting on), paste the address or pass
23
30
  `--editor-email`.
24
31
 
25
- Every prompt has a flag, so CI can run it: `--supabase local|hosted`, `--url`,
26
- `--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
27
34
  `SMOODLY_EDITOR_PASSWORD`), `--skip-link`, `--yes`. `--help` lists them.
28
35
  A hidden `--smoodly-dep <spec>` overrides the `smoodly` dependency written into
29
36
  `package.json`; CI points it at the packed tarball.
30
37
 
31
38
  ## How it is built
32
39
 
33
- `templates/next/` is generated by `scripts/build-template.ts` from
34
- `examples/site` — an allow-list of copied files plus a generated
35
- `package.json` (with `smoodly` pinned to this package's version), `tsconfig`,
36
- `next.config`, README and the one migration (`coreTablesSQL` + the starter
37
- 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`.
38
49
 
39
50
  `src/plan.ts` turns answers into steps; `src/run.ts` executes them with
40
51
  injected `exec`, fs and Supabase client so the sequence and every fallback
@@ -44,6 +55,30 @@ The CLI has no runtime dependency on `smoodly`.
44
55
  npm test # unit tests (template build, planner, runner, editor)
45
56
  npm run build # template + esbuild bundle → dist/index.js
46
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
+
47
82
  ## Follow-ups (logged 2026-09-05)
48
83
 
49
84
  - Text prompts drop `@` when the terminal sends Option as Meta (macOS,
@@ -84,7 +119,13 @@ The CLI has no runtime dependency on `smoodly`.
84
119
  project sorts first (seen 2026-09-05 during the hosted run, caught before
85
120
  the push). Fixed 2026-09-05: `projectRef` (src/env.ts) takes the ref from
86
121
  the typed URL's first host label and the link step and every fallback
87
- line pass it; a custom domain yields no ref and falls back to the picker.
122
+ line pass it. Seen again 2026-09-05 from the published 0.0.3: a URL typed
123
+ without `https://` gave no ref (the picker opened) and then failed
124
+ `ensureEditor` with supabase-js's "Invalid supabaseUrl". Fixed the same
125
+ day: `normalizeProjectUrl` (src/env.ts) adds the scheme and refuses
126
+ non-URLs at the prompt and for `--url`; a URL with no ref (custom domain)
127
+ never offers the link, and `run` throws rather than run a bare
128
+ `supabase link`.
88
129
  - The `--yes` flag defaults to local mode; with the Supabase CLI absent that
89
130
  stops with the install message rather than falling through to hosted.
90
131
  - Captured `exec` output is stdout only; stderr streams straight through, so
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
@@ -135,6 +148,41 @@ async function isPortFree(port) {
135
148
  import * as p from "@clack/prompts";
136
149
  import { resolve } from "node:path";
137
150
 
151
+ // src/env.ts
152
+ function renderEnv(keys) {
153
+ return [
154
+ "# Smoodly, self-host: the content project's values. SMOODLY_AUTH_URL and",
155
+ "# SMOODLY_AUTH_KEY default to these two \u2014 leave them unset.",
156
+ `SMOODLY_URL=${keys.url}`,
157
+ `SMOODLY_SECRET_KEY=${keys.secretKey}`,
158
+ `SMOODLY_PUBLISHABLE_KEY=${keys.publishableKey}`,
159
+ ""
160
+ ].join("\n");
161
+ }
162
+ function normalizeProjectUrl(input) {
163
+ const raw = input.trim();
164
+ if (!raw) return null;
165
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
166
+ try {
167
+ const url = new URL(withScheme);
168
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
169
+ if (!url.hostname) return null;
170
+ return url.origin;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ function projectRef(url) {
176
+ let host;
177
+ try {
178
+ host = new URL(url).hostname;
179
+ } catch {
180
+ return null;
181
+ }
182
+ const m = /^([a-z0-9]{20})\.supabase\.(co|in|red)$/.exec(host);
183
+ return m ? m[1] : null;
184
+ }
185
+
138
186
  // src/scaffold.ts
139
187
  import { cp, readdir, readFile, rename, writeFile } from "node:fs/promises";
140
188
  import { join } from "node:path";
@@ -153,6 +201,8 @@ async function isEmptyOrMissing(dir) {
153
201
  throw e;
154
202
  }
155
203
  }
204
+ var ROUTING_LOCALES = ' locales: ["en", "fi"],';
205
+ var ROUTING_DEFAULT = ' defaultLocale: "en",';
156
206
  async function scaffold(opts) {
157
207
  await cp(opts.templateDir, opts.dir, { recursive: true });
158
208
  await rename(join(opts.dir, "_gitignore"), join(opts.dir, ".gitignore"));
@@ -161,6 +211,17 @@ async function scaffold(opts) {
161
211
  pkg.name = opts.name;
162
212
  if (opts.smoodlyDep) pkg.dependencies.smoodly = opts.smoodlyDep;
163
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
+ }
164
225
  }
165
226
 
166
227
  // src/prompts.ts
@@ -184,6 +245,19 @@ async function resolveAnswers(flags2, ctx) {
184
245
  if (invalid) bail(invalid);
185
246
  const dir = resolve(ctx.cwd, name);
186
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);
187
261
  const supabase = flags2.supabase ?? (flags2.yes ? "local" : await answer(
188
262
  p.select({
189
263
  message: "Where should content live?",
@@ -195,13 +269,26 @@ async function resolveAnswers(flags2, ctx) {
195
269
  ));
196
270
  if (supabase === "local") {
197
271
  if (!ctx.hasSupabaseCli) bail("Local mode needs the Supabase CLI: https://supabase.com/docs/guides/cli (brew install supabase/tap/supabase)");
198
- return { name, dir, supabase, hasSupabaseCli: true, smoodlyDep: flags2.smoodlyDep };
272
+ return { name, dir, supabase, hasSupabaseCli: true, smoodlyDep: flags2.smoodlyDep, locales };
199
273
  }
200
- const url = flags2.url ?? await answer(p.text({ message: "Supabase project URL", placeholder: "https://abcd.supabase.co" }));
274
+ const badUrl = "Supabase project URL must be an http(s) URL, like https://abcd.supabase.co";
275
+ const typedUrl = flags2.url ?? await answer(
276
+ p.text({
277
+ message: "Supabase project URL",
278
+ placeholder: "https://abcd.supabase.co",
279
+ validate: (v) => normalizeProjectUrl(v ?? "") ? void 0 : badUrl
280
+ })
281
+ );
282
+ const url = normalizeProjectUrl(typedUrl);
283
+ if (!url) throw new Error(`${badUrl} (got "${typedUrl}")`);
201
284
  const publishableKey = flags2.publishableKey ?? await answer(p.text({ message: "Publishable (anon) key" }));
202
285
  const secretKey = flags2.secretKey ?? await answer(p.password({ message: "Secret (service role) key" }));
203
- const linkAndPush = !ctx.hasSupabaseCli ? false : flags2.skipLink ? false : flags2.yes ? true : await answer(p.confirm({ message: "Link this app to the project and push the migration now? (supabase link, supabase db push)" }));
204
- return { name, dir, supabase, hasSupabaseCli: ctx.hasSupabaseCli, hosted: { keys: { url, publishableKey, secretKey }, linkAndPush }, smoodlyDep: flags2.smoodlyDep };
286
+ const ref = projectRef(url);
287
+ if (ctx.hasSupabaseCli && !flags2.skipLink && !ref) {
288
+ p.log.warn(`No project ref in ${url}; skipping supabase link. Run \`supabase link --project-ref <project-ref>\` and \`supabase db push\` yourself.`);
289
+ }
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)` }));
291
+ return { name, dir, supabase, hasSupabaseCli: ctx.hasSupabaseCli, hosted: { keys: { url, publishableKey, secretKey }, linkAndPush }, smoodlyDep: flags2.smoodlyDep, locales };
205
292
  }
206
293
  function askEditorWith(flags2, env = process.env) {
207
294
  return async (existing) => {
@@ -233,28 +320,6 @@ function askEditorWith(flags2, env = process.env) {
233
320
  // src/run.ts
234
321
  import { join as join2 } from "node:path";
235
322
 
236
- // src/env.ts
237
- function renderEnv(keys) {
238
- return [
239
- "# Smoodly, self-host: the content project's values. SMOODLY_AUTH_URL and",
240
- "# SMOODLY_AUTH_KEY default to these two \u2014 leave them unset.",
241
- `SMOODLY_URL=${keys.url}`,
242
- `SMOODLY_SECRET_KEY=${keys.secretKey}`,
243
- `SMOODLY_PUBLISHABLE_KEY=${keys.publishableKey}`,
244
- ""
245
- ].join("\n");
246
- }
247
- function projectRef(url) {
248
- let host;
249
- try {
250
- host = new URL(url).hostname;
251
- } catch {
252
- return null;
253
- }
254
- const m = /^([a-z0-9]{20})\.supabase\.(co|in|red)$/.exec(host);
255
- return m ? m[1] : null;
256
- }
257
-
258
323
  // src/plan.ts
259
324
  function plan(a) {
260
325
  if (a.supabase === "local" && !a.hasSupabaseCli) {
@@ -328,7 +393,13 @@ async function run(answers2, deps) {
328
393
  try {
329
394
  switch (step) {
330
395
  case "copyTemplate":
331
- 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
+ });
332
403
  break;
333
404
  case "install":
334
405
  deps.log("Installing dependencies\u2026");
@@ -415,15 +486,21 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
415
486
  }
416
487
  }
417
488
  function linkArgs(answers2) {
418
- const ref = answers2.hosted ? projectRef(answers2.hosted.keys.url) : null;
419
- return ref ? ["link", "--project-ref", ref] : ["link"];
489
+ const url = answers2.hosted?.keys.url ?? "";
490
+ const ref = projectRef(url);
491
+ if (!ref) throw new Error(`no project ref in ${url}; run \`supabase link --project-ref <project-ref>\` yourself`);
492
+ return ["link", "--project-ref", ref];
420
493
  }
421
494
  function linkCommand(answers2) {
422
- return `supabase ${linkArgs(answers2).join(" ")}`;
495
+ const ref = answers2.hosted ? projectRef(answers2.hosted.keys.url) : null;
496
+ return `supabase link --project-ref ${ref ?? "<project-ref>"}`;
423
497
  }
424
498
 
425
499
  // src/index.ts
426
- 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
+ };
427
504
  var flags;
428
505
  try {
429
506
  flags = parseFlags(process.argv.slice(2));
@@ -444,7 +521,7 @@ try {
444
521
  answers = await resolveAnswers(flags, { hasSupabaseCli, cwd: process.cwd() });
445
522
  outcome = await run(answers, {
446
523
  exec,
447
- templateDir: TEMPLATE_DIR,
524
+ templateDirs: TEMPLATE_DIRS,
448
525
  editorClient: supabaseEditorClient,
449
526
  askEditor: askEditorWith(flags),
450
527
  log: (line) => p2.log.step(line),
@@ -467,7 +544,15 @@ if (!outcome.ok) {
467
544
  process.exit(1);
468
545
  }
469
546
  p2.note(
470
- [`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"),
471
556
  "Ready"
472
557
  );
473
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.3",
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.3"
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
+ }