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.
- package/README.md +64 -10
- package/dist/index.js +66 -9
- package/package.json +1 -1
- package/templates/next/README.md +6 -5
- package/templates/next/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -1
- package/templates/next/app/(smoodly)/admin/layout.tsx +1 -1
- package/templates/next/app/[...path]/page.tsx +15 -0
- package/templates/next/app/page.tsx +5 -42
- package/templates/next/components/pages/Article.tsx +15 -0
- package/templates/next/components/pages/Home.tsx +35 -0
- package/templates/next/components/pages/Page.tsx +40 -0
- package/templates/next/components/sections/ArticleList.tsx +1 -1
- package/templates/next/package.json +2 -2
- package/templates/next/smoodly/admin.tsx +6 -0
- package/templates/next/smoodly/collections.ts +26 -0
- package/templates/next/smoodly/config.ts +27 -0
- package/templates/next/smoodly/ops.ts +11 -0
- package/templates/next/smoodly/sections.ts +8 -0
- package/templates/next/smoodly/server.ts +72 -0
- package/templates/next/supabase/migrations/00000000000000_smoodly.sql +92 -5
- package/templates/next/tsconfig.json +5 -0
- package/templates/next-intl/README.md +40 -0
- package/templates/next-intl/_gitignore +5 -0
- package/templates/next-intl/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
- package/templates/next-intl/app/(smoodly)/admin/layout.tsx +15 -0
- package/templates/next-intl/app/[locale]/[...path]/page.tsx +18 -0
- package/templates/next-intl/app/[locale]/layout.tsx +21 -0
- package/templates/next-intl/app/[locale]/page.tsx +15 -0
- package/templates/next-intl/components/pages/Article.tsx +15 -0
- package/templates/next-intl/components/pages/Home.tsx +35 -0
- package/templates/next-intl/components/pages/Page.tsx +40 -0
- package/templates/next-intl/components/sections/ArticleList.tsx +31 -0
- package/templates/next-intl/components/sections/CtaBanner.tsx +35 -0
- package/templates/next-intl/components/sections/Hero.tsx +24 -0
- package/templates/next-intl/i18n/navigation.ts +6 -0
- package/templates/next-intl/i18n/request.ts +17 -0
- package/templates/next-intl/i18n/routing.ts +17 -0
- package/templates/next-intl/next.config.ts +8 -0
- package/templates/next-intl/package.json +25 -0
- package/templates/next-intl/proxy.ts +11 -0
- package/templates/{next/lib → next-intl}/smoodly/admin.tsx +2 -2
- package/templates/{next → next-intl/smoodly}/collections.ts +1 -0
- package/templates/next-intl/smoodly/config.ts +31 -0
- package/templates/{next/lib → next-intl}/smoodly/ops.ts +3 -3
- package/templates/next-intl/smoodly/sections.ts +8 -0
- package/templates/next-intl/smoodly/server.ts +73 -0
- package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +427 -0
- package/templates/next-intl/tsconfig.json +41 -0
- package/templates/next/app/[slug]/page.tsx +0 -42
- package/templates/next/lib/smoodly/index.ts +0 -64
- package/templates/next/sections.ts +0 -8
- 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.
|
|
13
|
-
|
|
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
|
-
|
|
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`, `--
|
|
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/`
|
|
37
|
-
`examples/site` —
|
|
38
|
-
`package.json` (with `smoodly` pinned to this package's
|
|
39
|
-
`next.config`, README and the one migration
|
|
40
|
-
collections' views).
|
|
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({
|
|
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",
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
[
|
|
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
package/templates/next/README.md
CHANGED
|
@@ -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:
|
|
11
|
-
- `app/[
|
|
12
|
-
- `components/
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
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 "
|
|
1
|
+
export { AdminPage as default } from "@/smoodly/admin";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { AdminLayout as default } from "
|
|
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 {
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
-
"
|
|
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];
|