create-smoodly-app 0.0.37 → 0.0.38
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/dist/index.js +64 -31
- package/package.json +1 -1
- package/templates/blank/AGENTS.md +7 -5
- package/templates/blank/README.md +3 -3
- package/templates/blank/_gitignore +1 -0
- package/templates/blank/package.json +2 -2
- package/templates/blank/scripts/seed.ts +45 -47
- package/templates/blank/smoodly/ops.ts +2 -2
- package/templates/blank/smoodly/server.ts +22 -11
- package/templates/blank-intl/AGENTS.md +7 -5
- package/templates/blank-intl/README.md +3 -3
- package/templates/blank-intl/_gitignore +1 -0
- package/templates/blank-intl/package.json +2 -2
- package/templates/blank-intl/scripts/seed.ts +45 -47
- package/templates/blank-intl/smoodly/ops.ts +2 -2
- package/templates/blank-intl/smoodly/server.ts +22 -11
- package/templates/lorem/AGENTS.md +7 -5
- package/templates/lorem/README.md +2 -2
- package/templates/lorem/_gitignore +1 -0
- package/templates/lorem/package.json +2 -2
- package/templates/lorem/scripts/seed.ts +118 -131
- package/templates/lorem/smoodly/ops.ts +2 -2
- package/templates/lorem/smoodly/server.ts +22 -11
- package/templates/lorem-intl/AGENTS.md +7 -5
- package/templates/lorem-intl/README.md +2 -2
- package/templates/lorem-intl/_gitignore +1 -0
- package/templates/lorem-intl/package.json +2 -2
- package/templates/lorem-intl/scripts/seed.ts +118 -131
- package/templates/lorem-intl/smoodly/ops.ts +2 -2
- package/templates/lorem-intl/smoodly/server.ts +22 -11
package/dist/index.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import * as p2 from "@clack/prompts";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readdir as readdir2, readFile as readFile2, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
7
|
+
import { join as join3, relative } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
9
|
|
|
8
10
|
// src/args.ts
|
|
9
11
|
import { parseArgs } from "node:util";
|
|
@@ -61,7 +63,10 @@ function parseFlags(argv) {
|
|
|
61
63
|
}
|
|
62
64
|
var HELP = `create-smoodly-app \u2014 a fresh Next.js site with Smoodly installed
|
|
63
65
|
|
|
64
|
-
Usage: npx create-smoodly-app [
|
|
66
|
+
Usage: npx create-smoodly-app [folder] [options]
|
|
67
|
+
|
|
68
|
+
folder: a name, a path or "." (the current empty folder). Its last part becomes
|
|
69
|
+
the package name: lowercase letters, digits, dots, dashes and underscores.
|
|
65
70
|
|
|
66
71
|
Options:
|
|
67
72
|
--supabase local|hosted Where content lives (asked when omitted; local needs the Supabase CLI)
|
|
@@ -115,7 +120,7 @@ function supabaseEditorClient(keys) {
|
|
|
115
120
|
|
|
116
121
|
// src/exec.ts
|
|
117
122
|
import { spawn } from "node:child_process";
|
|
118
|
-
var exec = (cmd, args, opts) => new Promise((
|
|
123
|
+
var exec = (cmd, args, opts) => new Promise((resolve3, reject) => {
|
|
119
124
|
const child = spawn(cmd, args, {
|
|
120
125
|
cwd: opts.cwd,
|
|
121
126
|
stdio: [opts.stdin ?? "inherit", opts.capture ? "pipe" : "inherit", "inherit"],
|
|
@@ -126,7 +131,7 @@ var exec = (cmd, args, opts) => new Promise((resolve2, reject) => {
|
|
|
126
131
|
child.on("error", reject);
|
|
127
132
|
child.on(
|
|
128
133
|
"close",
|
|
129
|
-
(code) => code === 0 ?
|
|
134
|
+
(code) => code === 0 ? resolve3({ stdout }) : reject(new Error(`\`${cmd} ${args.join(" ")}\` exited with code ${code}`))
|
|
130
135
|
);
|
|
131
136
|
});
|
|
132
137
|
async function hasCommand(cmd, run2, cwd) {
|
|
@@ -141,10 +146,10 @@ async function hasCommand(cmd, run2, cwd) {
|
|
|
141
146
|
// src/ports-net.ts
|
|
142
147
|
import { createServer } from "node:net";
|
|
143
148
|
function canListen(port, host) {
|
|
144
|
-
return new Promise((
|
|
149
|
+
return new Promise((resolve3) => {
|
|
145
150
|
const server = createServer();
|
|
146
|
-
server.once("error", () =>
|
|
147
|
-
server.listen(port, host, () => server.close(() =>
|
|
151
|
+
server.once("error", () => resolve3(false));
|
|
152
|
+
server.listen(port, host, () => server.close(() => resolve3(true)));
|
|
148
153
|
});
|
|
149
154
|
}
|
|
150
155
|
async function isPortFree(port) {
|
|
@@ -153,13 +158,15 @@ async function isPortFree(port) {
|
|
|
153
158
|
|
|
154
159
|
// src/prompts.ts
|
|
155
160
|
import * as p from "@clack/prompts";
|
|
156
|
-
import { resolve } from "node:path";
|
|
161
|
+
import { resolve as resolve2 } from "node:path";
|
|
157
162
|
|
|
158
163
|
// src/env.ts
|
|
159
164
|
function renderEnv(keys) {
|
|
160
165
|
return [
|
|
161
|
-
"# Smoodly, self-host: the content project's values.
|
|
162
|
-
"#
|
|
166
|
+
"# Smoodly, self-host: the content project's values. The live site reads",
|
|
167
|
+
"# with SMOODLY_URL and SMOODLY_SECRET_KEY; the admin also signs in with",
|
|
168
|
+
"# SMOODLY_PUBLISHABLE_KEY. SMOODLY_AUTH_URL and SMOODLY_AUTH_KEY default",
|
|
169
|
+
"# to these \u2014 leave them unset.",
|
|
163
170
|
`SMOODLY_URL=${keys.url}`,
|
|
164
171
|
`SMOODLY_SECRET_KEY=${keys.secretKey}`,
|
|
165
172
|
`SMOODLY_PUBLISHABLE_KEY=${keys.publishableKey}`,
|
|
@@ -196,10 +203,13 @@ function projectRef(url) {
|
|
|
196
203
|
|
|
197
204
|
// src/scaffold.ts
|
|
198
205
|
import { cp, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
199
|
-
import { join } from "node:path";
|
|
206
|
+
import { basename, join, resolve } from "node:path";
|
|
207
|
+
var nameFromDir = (dir) => basename(resolve(dir));
|
|
200
208
|
function validName(name) {
|
|
201
209
|
if (!name) return "The name is empty.";
|
|
202
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
210
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
|
|
211
|
+
return `The folder name "${name}" becomes the package name: use lowercase letters, digits, dots, dashes and underscores.`;
|
|
212
|
+
}
|
|
203
213
|
return null;
|
|
204
214
|
}
|
|
205
215
|
async function isEmptyOrMissing(dir) {
|
|
@@ -244,6 +254,13 @@ async function scaffold(opts) {
|
|
|
244
254
|
routingPath,
|
|
245
255
|
routing.replace(ROUTING_LOCALES, ` locales: [${opts.locales.map((l) => JSON.stringify(l)).join(", ")}],`).replace(ROUTING_DEFAULT, ` defaultLocale: ${JSON.stringify(opts.locales[0])},`)
|
|
246
256
|
);
|
|
257
|
+
const source = await readFile(join(opts.dir, "messages", "en.json"), "utf8").catch(() => null);
|
|
258
|
+
if (source !== null) {
|
|
259
|
+
for (const locale of opts.locales) {
|
|
260
|
+
const file = join(opts.dir, "messages", `${locale}.json`);
|
|
261
|
+
if (await readFile(file, "utf8").catch(() => null) === null) await writeFile(file, source);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
247
264
|
if (opts.starter !== "blank") {
|
|
248
265
|
const rewrites = [
|
|
249
266
|
{ map: articlesSegments(opts.locales), from: '{ en: "posts", fi: "artikkelit" }', lines: [["smoodly/collections.ts", ARTICLES_PATH_LINE], ["components/pages/Articles.tsx", ARTICLES_SLUG_LINE]] },
|
|
@@ -252,10 +269,10 @@ async function scaffold(opts) {
|
|
|
252
269
|
for (const { map, from, lines } of rewrites) {
|
|
253
270
|
for (const [rel, line] of lines) {
|
|
254
271
|
const path = join(opts.dir, rel);
|
|
255
|
-
const
|
|
256
|
-
if (!
|
|
272
|
+
const source2 = await readFile(path, "utf8");
|
|
273
|
+
if (!source2.includes(line)) throw new Error(`${rel} in the template has drifted; expected the line
|
|
257
274
|
${line}`);
|
|
258
|
-
await writeFile(path,
|
|
275
|
+
await writeFile(path, source2.replace(line, line.replace(from, map)));
|
|
259
276
|
}
|
|
260
277
|
}
|
|
261
278
|
}
|
|
@@ -278,10 +295,12 @@ async function answerOrThrow(value) {
|
|
|
278
295
|
return v;
|
|
279
296
|
}
|
|
280
297
|
async function resolveAnswers(flags2, ctx, env = process.env) {
|
|
281
|
-
const
|
|
298
|
+
const folderName = (typed2) => nameFromDir(resolve2(ctx.cwd, typed2));
|
|
299
|
+
const typed = flags2.name ?? await answer(p.text({ message: "Project folder", placeholder: "my-site", validate: (v) => v ? validName(folderName(v)) ?? void 0 : "A folder name or path." }));
|
|
300
|
+
const dir = resolve2(ctx.cwd, typed);
|
|
301
|
+
const name = nameFromDir(dir);
|
|
282
302
|
const invalid = validName(name);
|
|
283
303
|
if (invalid) bail(invalid);
|
|
284
|
-
const dir = resolve(ctx.cwd, name);
|
|
285
304
|
if (!await isEmptyOrMissing(dir)) bail(`${dir} exists and is not empty.`);
|
|
286
305
|
const locales = flags2.locales ?? (flags2.yes ? void 0 : await answer(p.confirm({ message: "Multilingual site? (next-intl, translated URLs)", initialValue: false })) ? await answer(
|
|
287
306
|
p.text({
|
|
@@ -376,13 +395,13 @@ function plan(a) {
|
|
|
376
395
|
if (a.supabase === "local" && !a.hasSupabaseCli) {
|
|
377
396
|
throw new Error("Local mode needs the Supabase CLI: https://supabase.com/docs/guides/cli (brew install supabase/tap/supabase)");
|
|
378
397
|
}
|
|
379
|
-
const steps = ["copyTemplate", "install"
|
|
398
|
+
const steps = ["copyTemplate", "install"];
|
|
380
399
|
if (a.supabase === "local") steps.push("supabaseLocal");
|
|
381
400
|
else if (a.hasSupabaseCli) {
|
|
382
401
|
steps.push("supabaseInit");
|
|
383
402
|
if (a.hosted?.linkAndPush) steps.push("linkAndPush");
|
|
384
403
|
}
|
|
385
|
-
steps.push("writeEnv", "ensureEditor", "seed", "finish");
|
|
404
|
+
steps.push("writeEnv", "ensureEditor", "seed", "gitInit", "finish");
|
|
386
405
|
return steps;
|
|
387
406
|
}
|
|
388
407
|
|
|
@@ -459,6 +478,8 @@ async function run(answers2, deps) {
|
|
|
459
478
|
await sh("npm", ["install"]);
|
|
460
479
|
break;
|
|
461
480
|
case "gitInit":
|
|
481
|
+
await deps.agentFiles?.(cwd).catch(() => {
|
|
482
|
+
});
|
|
462
483
|
await sh("git", ["init"], { capture: true });
|
|
463
484
|
await sh("git", ["add", "-A"], { capture: true });
|
|
464
485
|
await sh("git", ["commit", "-m", "Smoodly site"], { capture: true });
|
|
@@ -467,19 +488,22 @@ async function run(answers2, deps) {
|
|
|
467
488
|
deps.log("Starting the local Supabase stack (first run pulls Docker images)\u2026");
|
|
468
489
|
await sh("supabase", ["init"], { stdin: "ignore" });
|
|
469
490
|
const configPath = join2(cwd, "supabase/config.toml");
|
|
470
|
-
const toml = await deps.fs.readFile(configPath);
|
|
491
|
+
const toml = withoutSeedSql(await deps.fs.readFile(configPath));
|
|
471
492
|
const base = await findFreeBlock(blockPorts(toml), deps.isPortFree);
|
|
493
|
+
await deps.fs.writeFile(configPath, base !== DEFAULT_BASE ? shiftBlock(toml, base) : toml);
|
|
472
494
|
if (base !== DEFAULT_BASE) {
|
|
473
|
-
await deps.fs.writeFile(configPath, shiftBlock(toml, base));
|
|
474
495
|
deps.log(`Ports ${DEFAULT_BASE / 100}xx are busy (another Supabase project running?), using ${base / 100}xx.`);
|
|
475
496
|
}
|
|
476
497
|
await sh("supabase", LOCAL_START_ARGS);
|
|
477
498
|
ctx.keys = parseStatusEnv((await sh("supabase", ["status", "-o", "env"], { capture: true })).stdout);
|
|
478
499
|
break;
|
|
479
500
|
}
|
|
480
|
-
case "supabaseInit":
|
|
501
|
+
case "supabaseInit": {
|
|
481
502
|
await sh("supabase", ["init"], { stdin: "ignore" });
|
|
503
|
+
const configPath = join2(cwd, "supabase/config.toml");
|
|
504
|
+
await deps.fs.writeFile(configPath, withoutSeedSql(await deps.fs.readFile(configPath)));
|
|
482
505
|
break;
|
|
506
|
+
}
|
|
483
507
|
case "linkAndPush":
|
|
484
508
|
await sh("supabase", linkArgs(answers2));
|
|
485
509
|
await sh("supabase", ["db", "push"]);
|
|
@@ -512,10 +536,11 @@ async function run(answers2, deps) {
|
|
|
512
536
|
}
|
|
513
537
|
return { ok: true, keys: ctx.keys, editor: ctx.editor };
|
|
514
538
|
}
|
|
539
|
+
var withoutSeedSql = (toml) => toml.replace(/(\[db\.seed\]\n(?:#.*\n)*enabled = )true/, "$1false");
|
|
515
540
|
function fallbackFor(step, answers2, keys) {
|
|
516
|
-
const envLines = keys ? `Write ${answers2.
|
|
541
|
+
const envLines = keys ? `Write ${answers2.dir}/.env.local:
|
|
517
542
|
|
|
518
|
-
${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimEnd()}` : `Write ${answers2.
|
|
543
|
+
${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimEnd()}` : `Write ${answers2.dir}/.env.local with SMOODLY_URL, SMOODLY_SECRET_KEY and SMOODLY_PUBLISHABLE_KEY (from \`supabase status\`).`;
|
|
519
544
|
const editorLines = [
|
|
520
545
|
"Create the first editor: add a user in Supabase Studio (Authentication \u2192 Users), then in the SQL editor:",
|
|
521
546
|
"",
|
|
@@ -523,13 +548,13 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
|
|
|
523
548
|
].join("\n");
|
|
524
549
|
switch (step) {
|
|
525
550
|
case "supabaseLocal":
|
|
526
|
-
return [`cd ${answers2.
|
|
551
|
+
return [`cd ${answers2.dir} && supabase init && supabase ${LOCAL_START_ARGS.join(" ")}`, "", envLines, "", editorLines].join("\n");
|
|
527
552
|
case "supabaseInit": {
|
|
528
|
-
const commands = answers2.hosted?.linkAndPush ? [`cd ${answers2.
|
|
553
|
+
const commands = answers2.hosted?.linkAndPush ? [`cd ${answers2.dir} && supabase init && ${linkCommand(answers2)} && supabase db push`] : [`cd ${answers2.dir} && supabase init`, "", `When you are ready: ${linkCommand(answers2)} && supabase db push`];
|
|
529
554
|
return [...commands, "", envLines, "", editorLines].join("\n");
|
|
530
555
|
}
|
|
531
556
|
case "linkAndPush":
|
|
532
|
-
return [`cd ${answers2.
|
|
557
|
+
return [`cd ${answers2.dir} && ${linkCommand(answers2)} && supabase db push`, "", envLines, "", editorLines].join("\n");
|
|
533
558
|
case "writeEnv":
|
|
534
559
|
return [envLines, "", editorLines].join("\n");
|
|
535
560
|
case "ensureEditor":
|
|
@@ -539,7 +564,7 @@ ${renderEnv(keys).split("\n").filter((l) => !l.startsWith("#")).join("\n").trimE
|
|
|
539
564
|
editorLines
|
|
540
565
|
].join("\n");
|
|
541
566
|
case "seed":
|
|
542
|
-
return `cd ${answers2.
|
|
567
|
+
return `cd ${answers2.dir} && npm run seed`;
|
|
543
568
|
default:
|
|
544
569
|
return "";
|
|
545
570
|
}
|
|
@@ -580,6 +605,7 @@ var outcome;
|
|
|
580
605
|
try {
|
|
581
606
|
const hasSupabaseCli = await hasCommand("supabase", exec, process.cwd());
|
|
582
607
|
answers = await resolveAnswers(flags, { hasSupabaseCli, cwd: process.cwd() });
|
|
608
|
+
const existed = existsSync(answers.dir);
|
|
583
609
|
outcome = await run(answers, {
|
|
584
610
|
exec,
|
|
585
611
|
templateDirs: TEMPLATE_DIRS,
|
|
@@ -592,7 +618,14 @@ try {
|
|
|
592
618
|
scaffold,
|
|
593
619
|
readFile: (path) => readFile2(path, "utf8"),
|
|
594
620
|
writeFile: (path, text2) => writeFile2(path, text2),
|
|
595
|
-
rm: (dir) =>
|
|
621
|
+
rm: async (dir) => {
|
|
622
|
+
if (!existed) return rm(dir, { recursive: true, force: true });
|
|
623
|
+
for (const name of await readdir2(dir)) await rm(join3(dir, name), { recursive: true, force: true });
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
agentFiles: async (dir) => {
|
|
627
|
+
const gen = await import(pathToFileURL(join3(dir, "node_modules/next/dist/server/lib/generate-agent-files.js")).href);
|
|
628
|
+
(gen.writeAgentFiles ?? gen.default?.writeAgentFiles)(dir);
|
|
596
629
|
}
|
|
597
630
|
});
|
|
598
631
|
} catch (e) {
|
|
@@ -606,7 +639,7 @@ if (!outcome.ok) {
|
|
|
606
639
|
}
|
|
607
640
|
p2.note(
|
|
608
641
|
[
|
|
609
|
-
`cd ${answers.
|
|
642
|
+
...relative(process.cwd(), answers.dir) ? [`cd ${relative(process.cwd(), answers.dir)}`] : [],
|
|
610
643
|
"npm run dev",
|
|
611
644
|
"",
|
|
612
645
|
answers.starter === "blank" ? "Site: http://localhost:3000 (a blank site \u2014 build it in components/ and at /admin)" : "Site: http://localhost:3000 (the Lorem Co. starter \u2014 edit it at /admin)",
|
package/package.json
CHANGED
|
@@ -13,14 +13,16 @@ where things are.
|
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
15
|
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
-
and apply what it writes (`supabase db push` hosted, `supabase
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase migration up`
|
|
17
17
|
local). It copies the core schema files the package ships and writes a
|
|
18
18
|
read view and order index per collection. Never edit a file it wrote.
|
|
19
19
|
The admin reads the base tables, so it lists a collection either way;
|
|
20
20
|
the view is for SQL and PostgREST readers, the index for `order`.
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
- **Content from code goes through `content()`.** `scripts/seed.ts` is
|
|
22
|
+
starter content written with `content()` from `smoodly/server.ts`:
|
|
23
|
+
the admin's own operations, so validation and publish checks apply and
|
|
24
|
+
a failure throws. Never write rows or call the stores directly. The
|
|
25
|
+
seed is idempotent; run it with `npm run seed`.
|
|
24
26
|
- **Never import the admin into the site.** Nothing under `app/(site)`,
|
|
25
27
|
`app/[locale]` or `components/`, and nothing they import
|
|
26
28
|
(`smoodly/server.ts` included), may import `@/smoodly/admin` or
|
|
@@ -50,6 +52,6 @@ where things are.
|
|
|
50
52
|
## Commands
|
|
51
53
|
|
|
52
54
|
npm run dev # http://localhost:3000, /admin is the editor
|
|
53
|
-
npm run build # next build
|
|
55
|
+
npm run build # checks the schema files (smoodly migrate --check), then next build
|
|
54
56
|
npm run seed # starter content, idempotent
|
|
55
57
|
npm run typecheck # tsc --noEmit
|
|
@@ -15,8 +15,8 @@ for an agent (or a person) building the site.
|
|
|
15
15
|
- `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
|
|
16
16
|
- `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
|
|
17
17
|
- `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
|
|
18
|
-
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (
|
|
19
|
-
- `scripts/seed.ts` — the home page and the site item, written through the
|
|
18
|
+
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (site layer, renderer, the read API `q`, the content API `content()`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
|
|
19
|
+
- `scripts/seed.ts` — the home page and the site item, written through the content API (`content()`). `npm run seed` is idempotent.
|
|
20
20
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
21
21
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
22
22
|
|
|
@@ -43,7 +43,7 @@ collection to `collections`, and write its entry page by copying
|
|
|
43
43
|
Then write its read view and order index and apply them:
|
|
44
44
|
|
|
45
45
|
npx smoodly migrate
|
|
46
|
-
supabase db push # hosted; `supabase
|
|
46
|
+
supabase db push # hosted; `supabase migration up` for the local stack (keeps the content)
|
|
47
47
|
|
|
48
48
|
The same command brings the core schema forward after a `smoodly` upgrade.
|
|
49
49
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
7
|
-
"build": "next build",
|
|
7
|
+
"build": "smoodly migrate --check && next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"seed": "tsx scripts/seed.ts",
|
|
10
10
|
"typecheck": "tsc --noEmit"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"next": "^16.3.4",
|
|
15
15
|
"react": "^19",
|
|
16
16
|
"react-dom": "^19",
|
|
17
|
-
"smoodly": "0.0.
|
|
17
|
+
"smoodly": "0.0.38"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@next/env": "^16.3.4",
|
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
// The starter's content, written through the
|
|
2
|
-
//
|
|
3
|
-
// the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The starter's content, written through the content API (`content()`
|
|
2
|
+
// from smoodly/server.ts): the admin's own operations, so validation and
|
|
3
|
+
// the publish checks apply and a failure throws. It is also the example
|
|
4
|
+
// of writing content from code. Ships in the scaffold; create-smoodly-app
|
|
5
|
+
// runs `npm run seed` after the first editor. Idempotent: exits once the
|
|
6
|
+
// home page has content in the default locale. Writes the same copy into every configured locale, whatever
|
|
6
7
|
// their codes, so every locale's "/" resolves.
|
|
7
8
|
// Run: npm run seed (needs .env.local and a Supabase stack).
|
|
8
9
|
import next from "@next/env";
|
|
9
|
-
import type { PageTree } from "smoodly";
|
|
10
|
+
import type { PageTree, TreeNode } from "smoodly";
|
|
10
11
|
const { loadEnvConfig } = next;
|
|
11
12
|
|
|
12
13
|
loadEnvConfig(process.cwd());
|
|
13
14
|
|
|
14
15
|
const { config } = await import("../smoodly/config");
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
16
|
+
const { content, node } = await import("../smoodly/server");
|
|
17
|
+
const { Text } = await import("../components/sections/Text");
|
|
18
|
+
const c = content();
|
|
17
19
|
|
|
18
20
|
const DEFAULT = config.locales.default;
|
|
19
21
|
const OTHERS = config.locales.supported.filter((l) => l !== DEFAULT);
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
// The home page is code-owned: `find` returns its record, creating it when
|
|
24
|
+
// the database is new. It is seeded once somebody has given it sections.
|
|
25
|
+
const home = (await c.pages.find(DEFAULT, "/"))!;
|
|
26
|
+
if (Object.values((await c.pages.getTree(home.id, DEFAULT))?.zones ?? {}).some((nodes) => nodes.length > 0)) {
|
|
27
|
+
console.log(`Already seeded — the home page has content in "${DEFAULT}".`);
|
|
23
28
|
process.exit(0);
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -31,54 +36,47 @@ const doc = (...content: Node[]) => ({ type: "doc", content });
|
|
|
31
36
|
|
|
32
37
|
// --- the site item: name and tagline, published in every locale ------
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
await
|
|
36
|
-
|
|
37
|
-
await entries.addLocale("site", site.id, locale, { from: DEFAULT });
|
|
38
|
-
await entries.setStatus("site", site.id, locale, "published");
|
|
39
|
+
for (const locale of [DEFAULT, ...OTHERS]) {
|
|
40
|
+
await c.globals.set("site", locale, { name: "My site", tagline: "Built with Smoodly" });
|
|
41
|
+
await c.globals.publish("site", locale);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
// --- the home page: one Text section and one custom section ----------
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
// The custom section is not a registered section, so it is a plain node object.
|
|
47
|
+
const custom: TreeNode = {
|
|
48
|
+
id: "node-custom",
|
|
49
|
+
type: "custom",
|
|
50
|
+
fields: { rows: [
|
|
51
|
+
{ spans: [6, 6], gap: "md", align: "top", reverse: false, columns: [
|
|
52
|
+
doc(p("A custom section holds rows of columns. Drag the width bar in the inspector to resize them.")),
|
|
53
|
+
doc(p("Each column is richtext. Columns stack on small screens; the developer's stylesheet needs nothing.")),
|
|
54
|
+
] },
|
|
55
|
+
] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const tree: PageTree = {
|
|
44
59
|
meta: { description: "A new site, built with Smoodly." },
|
|
45
60
|
zones: {
|
|
46
61
|
body: [
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
p("Everything you see is defined in components/ and registered in smoodly/. The README says where things are."),
|
|
56
|
-
button("Open the admin", "/admin"),
|
|
57
|
-
),
|
|
58
|
-
},
|
|
59
|
-
},
|
|
62
|
+
node(Text, {
|
|
63
|
+
title: "Hello",
|
|
64
|
+
body: doc(
|
|
65
|
+
p("This is your home page. Open the admin to edit this text, add sections, create pages and write posts."),
|
|
66
|
+
p("Everything you see is defined in components/ and registered in smoodly/. The README says where things are."),
|
|
67
|
+
button("Open the admin", "/admin"),
|
|
68
|
+
),
|
|
69
|
+
}, { id: "node-hello", settings: { background: "paper" } }),
|
|
60
70
|
// The custom section: the editor's own rows of columns, rendered by Smoodly.
|
|
61
|
-
|
|
62
|
-
id: "node-custom",
|
|
63
|
-
type: "custom",
|
|
64
|
-
fields: { rows: [
|
|
65
|
-
{ spans: [6, 6], gap: "md", align: "top", reverse: false, columns: [
|
|
66
|
-
doc(p("A custom section holds rows of columns. Drag the width bar in the inspector to resize them.")),
|
|
67
|
-
doc(p("Each column is richtext. Columns stack on small screens; the developer's stylesheet needs nothing.")),
|
|
68
|
-
] },
|
|
69
|
-
] },
|
|
70
|
-
},
|
|
71
|
+
custom,
|
|
71
72
|
],
|
|
72
73
|
},
|
|
73
|
-
}
|
|
74
|
+
};
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await pages.
|
|
78
|
-
|
|
79
|
-
await pages.addLocale(home.id, locale, { from: DEFAULT, title: "Home" });
|
|
80
|
-
await pages.saveDraft(home.id, locale, tree);
|
|
81
|
-
await pages.publish(home.id, locale);
|
|
76
|
+
// The home page exists in every locale already (its registration says so): write the tree and publish.
|
|
77
|
+
for (const locale of [DEFAULT, ...OTHERS]) {
|
|
78
|
+
await c.pages.setTree(home.id, locale, tree);
|
|
79
|
+
await c.pages.publish(home.id, locale);
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
console.log(`Seeded the blank starter in "${[DEFAULT, ...OTHERS].join('", "')}": the home page and the site item, published.`);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// async functions — hence the shape.)
|
|
5
5
|
import { createAdminOpHandler, type AdminOpCall } from "smoodly/admin/next";
|
|
6
6
|
import { config } from "./config";
|
|
7
|
-
import {
|
|
7
|
+
import { adminDeps } from "./server";
|
|
8
8
|
|
|
9
9
|
export async function smoodlyOp(call: AdminOpCall) {
|
|
10
|
-
return createAdminOpHandler({ config, ...
|
|
10
|
+
return createAdminOpHandler({ config, ...adminDeps() })(call);
|
|
11
11
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Server-only glue: the client, the stores, the site layer and the
|
|
2
2
|
// renderer, constructed lazily so `next build` doesn't need env vars at
|
|
3
|
-
// import time. The env shape is the package's (SMOODLY_*)
|
|
3
|
+
// import time. The env shape is the package's (SMOODLY_*): the live site
|
|
4
|
+
// needs SMOODLY_URL and SMOODLY_SECRET_KEY, the admin also
|
|
5
|
+
// SMOODLY_PUBLISHABLE_KEY (the `smoodlyEnv("admin")` calls below); whether the
|
|
4
6
|
// secret is a service-role key or a cloud write key is invisible here.
|
|
5
7
|
//
|
|
6
8
|
// Lazy on purpose, the site and renderer included: config.ts imports the
|
|
@@ -14,7 +16,7 @@ import {
|
|
|
14
16
|
// The admin gate from `smoodly/admin`, which holds no client code: the site
|
|
15
17
|
// routes import this file, and `smoodly/admin/next` would put the admin
|
|
16
18
|
// shell in every live page's JavaScript.
|
|
17
|
-
import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin";
|
|
19
|
+
import { createSmoodlyContent, supabaseAdminAuth, type AdminAuth, type AuthConfig, type SmoodlyContent } from "smoodly/admin";
|
|
18
20
|
import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
|
|
19
21
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
20
22
|
import { collections, config, elements, sections, globals } from "./config";
|
|
@@ -50,8 +52,7 @@ function bound(): Bound {
|
|
|
50
52
|
// The admission table, read by the Members page (spec 2026-09-25 §4).
|
|
51
53
|
const members = new SupabaseMemberStore(db);
|
|
52
54
|
const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
|
|
53
|
-
//
|
|
54
|
-
// takes this local closure, not the export, so it never re-enters `bound()`.
|
|
55
|
+
// The site layer's own view of the stores; nothing outside this file reads them.
|
|
55
56
|
const stores = () => ({ pages, entries, assets });
|
|
56
57
|
// The site layer: (locale, path) → page or entry, and the content API
|
|
57
58
|
// `q` wraps below. Not exported — the app reads through `q`, which adds
|
|
@@ -70,27 +71,37 @@ function bound(): Bound {
|
|
|
70
71
|
return cached;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
/** The admin's wiring, for `smoodly/ops.ts` only. Scripts write through `content()`. */
|
|
75
|
+
export function adminDeps() {
|
|
76
|
+
smoodlyEnv("admin"); // the admin signs in with the publishable key: a missing one is named here, not on a live page
|
|
77
|
+
const { pages, entries, assets, members, auth } = bound();
|
|
78
|
+
return { pages, entries, assets, members, auth, href };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Write content from code — seeds, migrations, route handlers (spec 2026-09-30 tester findings §1).
|
|
82
|
+
* The admin's own operations with no signed-in editor: validation and publish checks apply, a failure
|
|
83
|
+
* throws `SmoodlyContentError`. `content().pages.create(…)`, `.entries.create(…)`, `.globals.set(…)`,
|
|
84
|
+
* `.media.upload(…)`; build tree nodes with `node(Section, fields)`. */
|
|
85
|
+
export function content(): SmoodlyContent {
|
|
74
86
|
const { pages, entries, assets, members } = bound();
|
|
75
|
-
return { pages, entries, assets, members };
|
|
87
|
+
return createSmoodlyContent({ config, pages, entries, assets, members, href });
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
export { node } from "smoodly";
|
|
91
|
+
|
|
78
92
|
/** The server-side client (seed script, one-off admin tasks). */
|
|
79
93
|
export const client = () => bound().db;
|
|
80
94
|
|
|
81
|
-
/** The admin gate: session verified against the auth project, editors row read with the write key. */
|
|
82
|
-
export const adminAuth = () => bound().auth;
|
|
83
|
-
|
|
84
95
|
/** What the browser needs to sign in — public values only. */
|
|
85
96
|
export function publicAuth(): AuthConfig {
|
|
86
|
-
const env = smoodlyEnv();
|
|
97
|
+
const env = smoodlyEnv("admin");
|
|
87
98
|
return { url: env.authUrl, key: env.authKey };
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
|
|
91
102
|
* The space is asked of the database once per process. */
|
|
92
103
|
export function presence(): Promise<PresenceConfig> {
|
|
93
|
-
return presenceConfig(smoodlyEnv());
|
|
104
|
+
return presenceConfig(smoodlyEnv("admin"));
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
/** Locale-relative CMS path → site URL: the one seam for the renderer
|
|
@@ -13,14 +13,16 @@ where things are.
|
|
|
13
13
|
is the one registry. A component that is not registered does not exist
|
|
14
14
|
for the admin.
|
|
15
15
|
- **After a new collection or a `smoodly` upgrade, run `npx smoodly migrate`**
|
|
16
|
-
and apply what it writes (`supabase db push` hosted, `supabase
|
|
16
|
+
and apply what it writes (`supabase db push` hosted, `supabase migration up`
|
|
17
17
|
local). It copies the core schema files the package ships and writes a
|
|
18
18
|
read view and order index per collection. Never edit a file it wrote.
|
|
19
19
|
The admin reads the base tables, so it lists a collection either way;
|
|
20
20
|
the view is for SQL and PostgREST readers, the index for `order`.
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
- **Content from code goes through `content()`.** `scripts/seed.ts` is
|
|
22
|
+
starter content written with `content()` from `smoodly/server.ts`:
|
|
23
|
+
the admin's own operations, so validation and publish checks apply and
|
|
24
|
+
a failure throws. Never write rows or call the stores directly. The
|
|
25
|
+
seed is idempotent; run it with `npm run seed`.
|
|
24
26
|
- **Never import the admin into the site.** Nothing under `app/(site)`,
|
|
25
27
|
`app/[locale]` or `components/`, and nothing they import
|
|
26
28
|
(`smoodly/server.ts` included), may import `@/smoodly/admin` or
|
|
@@ -50,6 +52,6 @@ where things are.
|
|
|
50
52
|
## Commands
|
|
51
53
|
|
|
52
54
|
npm run dev # http://localhost:3000, /admin is the editor
|
|
53
|
-
npm run build # next build
|
|
55
|
+
npm run build # checks the schema files (smoodly migrate --check), then next build
|
|
54
56
|
npm run seed # starter content, idempotent
|
|
55
57
|
npm run typecheck # tsc --noEmit
|
|
@@ -16,8 +16,8 @@ for an agent (or a person) building the site.
|
|
|
16
16
|
- `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
|
|
17
17
|
- `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
|
|
18
18
|
- `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
|
|
19
|
-
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (
|
|
20
|
-
- `scripts/seed.ts` — the home page and the site item, written through the
|
|
19
|
+
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (site layer, renderer, the read API `q`, the content API `content()`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
|
|
20
|
+
- `scripts/seed.ts` — the home page and the site item, written through the content API (`content()`). `npm run seed` is idempotent.
|
|
21
21
|
- `app/globals.css` — a reset, a container, the two tones. Replace it.
|
|
22
22
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
23
23
|
|
|
@@ -44,7 +44,7 @@ collection to `collections`, and write its entry page by copying
|
|
|
44
44
|
Then write its read view and order index and apply them:
|
|
45
45
|
|
|
46
46
|
npx smoodly migrate
|
|
47
|
-
supabase db push # hosted; `supabase
|
|
47
|
+
supabase db push # hosted; `supabase migration up` for the local stack (keeps the content)
|
|
48
48
|
|
|
49
49
|
The same command brings the core schema forward after a `smoodly` upgrade.
|
|
50
50
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
7
|
-
"build": "next build",
|
|
7
|
+
"build": "smoodly migrate --check && next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"seed": "tsx scripts/seed.ts",
|
|
10
10
|
"typecheck": "tsc --noEmit"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"next": "^16.3.4",
|
|
15
15
|
"react": "^19",
|
|
16
16
|
"react-dom": "^19",
|
|
17
|
-
"smoodly": "0.0.
|
|
17
|
+
"smoodly": "0.0.38",
|
|
18
18
|
"next-intl": "^4.14.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|