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.
- package/README.md +52 -11
- package/dist/index.js +119 -34
- 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,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.
|
|
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
|
-
|
|
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`, `--
|
|
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/`
|
|
34
|
-
`examples/site` —
|
|
35
|
-
`package.json` (with `smoodly` pinned to this package's
|
|
36
|
-
`next.config`, README and the one migration
|
|
37
|
-
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`.
|
|
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
|
|
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
|
|
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
|
|
204
|
-
|
|
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({
|
|
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
|
|
419
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
[
|
|
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
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.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
|
+
}
|