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