create-smoodly-app 0.0.37 → 0.0.39
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/collections.ts +1 -1
- 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/collections.ts +1 -1
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// The starter's content, through the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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. This file is also the
|
|
4
|
+
// example of writing content from code. Content only; it ships in the template and create-smoodly-app runs
|
|
4
5
|
// `npm run seed` after the first editor. (The example repo's demo
|
|
5
6
|
// editor account is a separate, example-only script.)
|
|
6
7
|
// Run: npm run seed (needs .env.local and a Supabase stack).
|
|
@@ -13,24 +14,32 @@
|
|
|
13
14
|
// `fi` default lands the index on `artikkelit`. Body copy is Latin in
|
|
14
15
|
// both; what a reader perceives as language (nav, headings, tags,
|
|
15
16
|
// roles, footer titles) is translated. Idempotent: exits once the home
|
|
16
|
-
// page
|
|
17
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
+
// page has content.
|
|
18
18
|
import next from "@next/env";
|
|
19
|
-
import {
|
|
19
|
+
import type { PageTree, TreeNode } from "smoodly";
|
|
20
20
|
const { loadEnvConfig } = next;
|
|
21
21
|
|
|
22
22
|
loadEnvConfig(process.cwd());
|
|
23
23
|
|
|
24
24
|
const { config } = await import("../smoodly/config");
|
|
25
25
|
const { articles, news } = await import("../smoodly/collections");
|
|
26
|
-
const { href,
|
|
27
|
-
const {
|
|
26
|
+
const { content, href, node } = await import("../smoodly/server");
|
|
27
|
+
const { Hero } = await import("../components/sections/Hero");
|
|
28
|
+
const { PageHero } = await import("../components/sections/PageHero");
|
|
29
|
+
const { ImageText } = await import("../components/sections/ImageText");
|
|
30
|
+
const { Points } = await import("../components/sections/Points");
|
|
31
|
+
const { FavouriteArticles } = await import("../components/sections/FavouriteArticles");
|
|
32
|
+
const { People } = await import("../components/sections/People");
|
|
33
|
+
const c = content();
|
|
28
34
|
|
|
29
35
|
const EN = config.locales.default;
|
|
30
36
|
const FI = config.locales.supported.includes("fi") && EN !== "fi" ? "fi" : null;
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
// The home page is code-owned: `find` returns its record, creating it when
|
|
39
|
+
// the database is new. It is seeded once somebody has given it sections.
|
|
40
|
+
const home = (await c.pages.find(EN, "/"))!;
|
|
41
|
+
if (Object.values((await c.pages.getTree(home.id, EN))?.zones ?? {}).some((nodes) => nodes.length > 0)) {
|
|
42
|
+
console.log(`Already seeded — the home page has content in "${EN}".`);
|
|
34
43
|
process.exit(0);
|
|
35
44
|
}
|
|
36
45
|
|
|
@@ -53,20 +62,11 @@ const cta = (heading: string, text: string, label: string, href: string): Node =
|
|
|
53
62
|
({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, settings: { variant: "primary" } } });
|
|
54
63
|
const doc = (...content: Node[]) => ({ type: "doc", content });
|
|
55
64
|
|
|
56
|
-
// The starter's photos become assets: each file is uploaded once
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
let id = uploaded.get(name);
|
|
62
|
-
if (!id) {
|
|
63
|
-
const bytes = new Uint8Array(await readFile(`public/images/${name}.jpg`));
|
|
64
|
-
const dims = imageDimensions(bytes) ?? {};
|
|
65
|
-
id = (await assets.create({ filename: `${name}.jpg`, mime: "image/jpeg", size: bytes.byteLength, bytes, alt, title: alt, ...dims })).id;
|
|
66
|
-
uploaded.set(name, id);
|
|
67
|
-
}
|
|
68
|
-
return { asset: id };
|
|
69
|
-
}
|
|
65
|
+
// The starter's photos become assets: each file is uploaded once (a row
|
|
66
|
+
// in `assets`, the bytes in the `media` bucket), and a field value is
|
|
67
|
+
// `{ asset: <id> }` — the alt lives on the asset (spec 2026-09-08).
|
|
68
|
+
const image = async (name: string, alt: string): Promise<{ asset: string }> =>
|
|
69
|
+
({ asset: (await c.media.upload(`public/images/${name}.jpg`, { alt, title: alt })).asset });
|
|
70
70
|
const img = {
|
|
71
71
|
article: await image("article_photo", "Notebook on a desk"),
|
|
72
72
|
home2: await image("home_section_2", "Team working"),
|
|
@@ -92,53 +92,73 @@ const paths = (locale: string) => ({
|
|
|
92
92
|
about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
/** Create an entry
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
95
|
+
/** Create an entry in EN, add the FI row when there is one, and publish
|
|
96
|
+
* both — every entry is published in every locale it has BEFORE any
|
|
97
|
+
* page, because publish integrity is per locale and would (rightly)
|
|
98
|
+
* block a page whose tree references an entry that is a draft, or
|
|
99
|
+
* absent, in that locale. `fi` holds the localized fields; shared ones
|
|
100
|
+
* are repeated from `en`. */
|
|
101
101
|
async function seedEntry(collection: string, en: Fields, fi?: Fields) {
|
|
102
|
-
const row = await entries.create(collection,
|
|
103
|
-
await entries.
|
|
102
|
+
const row = await c.entries.create(collection, { locale: EN, fields: en });
|
|
103
|
+
await c.entries.publish(collection, row.id, EN);
|
|
104
104
|
if (FI && fi) {
|
|
105
|
-
await entries.addLocale(collection, row.id, FI, { from: EN });
|
|
106
|
-
await entries.
|
|
107
|
-
|
|
105
|
+
await c.entries.addLocale(collection, row.id, FI, { from: EN, fields: { ...en, ...fi } });
|
|
106
|
+
await c.entries.publish(collection, row.id, FI);
|
|
107
|
+
}
|
|
108
|
+
return row;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A global item exists in every locale from the start: set and publish each. */
|
|
112
|
+
async function seedGlobal(name: string, en: Fields, fi?: Fields) {
|
|
113
|
+
const row = await c.globals.set(name, EN, en);
|
|
114
|
+
await c.globals.publish(name, EN);
|
|
115
|
+
if (FI) {
|
|
116
|
+
await c.globals.set(name, FI, { ...en, ...fi });
|
|
117
|
+
await c.globals.publish(name, FI);
|
|
108
118
|
}
|
|
109
119
|
return row;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
type Tree =
|
|
122
|
+
type Tree = PageTree;
|
|
123
|
+
type Trees = { en: Tree; fi?: Tree };
|
|
113
124
|
|
|
114
125
|
/** Create a page (or a folder: `template: null`) in EN with its tree,
|
|
115
126
|
* publish it, then the FI row with its own tree — a locale's tree is
|
|
116
127
|
* its own (spec 2026-09-06); the Finnish page is copied from the
|
|
117
128
|
* English draft, then overwritten with the Finnish tree. */
|
|
118
129
|
async function seedPage(
|
|
119
|
-
input: { slug: string; title: string; template: string | null; parentId?: string;
|
|
120
|
-
trees?:
|
|
130
|
+
input: { slug: string; title: string; template: string | null; parentId?: string; fi?: { slug?: string; title: string } },
|
|
131
|
+
trees?: Trees,
|
|
121
132
|
) {
|
|
122
|
-
const page = await pages.
|
|
123
|
-
locale: EN, slug: input.slug, title: input.title, template: input.template,
|
|
124
|
-
...(input.parentId ? { parentId: input.parentId } : {}),
|
|
125
|
-
// A code-owned record names its owner (spec 2026-09-29 nested paths §3).
|
|
126
|
-
...(input.fixed ? { fixed: input.fixed } : {}),
|
|
127
|
-
});
|
|
133
|
+
const page = await c.pages.create({ parentId: input.parentId ?? null, locale: EN, slug: input.slug, title: input.title, template: input.template });
|
|
128
134
|
if (trees) {
|
|
129
|
-
await pages.
|
|
130
|
-
await pages.publish(page.id, EN);
|
|
135
|
+
await c.pages.setTree(page.id, EN, trees.en);
|
|
136
|
+
await c.pages.publish(page.id, EN);
|
|
131
137
|
}
|
|
132
138
|
if (FI && input.fi) {
|
|
133
|
-
await pages.addLocale(page.id, FI, { from: EN,
|
|
139
|
+
await c.pages.addLocale(page.id, FI, { from: EN, ...input.fi });
|
|
134
140
|
if (trees?.fi) {
|
|
135
|
-
await pages.
|
|
136
|
-
await pages.publish(page.id, FI);
|
|
141
|
+
await c.pages.setTree(page.id, FI, trees.fi);
|
|
142
|
+
await c.pages.publish(page.id, FI);
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
return page;
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
/** A code-owned page (the home page, a collection's index): its address,
|
|
149
|
+
* title and locales come from the registration, so there is nothing to
|
|
150
|
+
* create — `find` returns the record, and the trees are written to it. */
|
|
151
|
+
async function seedCodePage(path: string, trees: Trees) {
|
|
152
|
+
const page = (await c.pages.find(EN, path))!;
|
|
153
|
+
await c.pages.setTree(page.id, EN, trees.en);
|
|
154
|
+
await c.pages.publish(page.id, EN);
|
|
155
|
+
if (FI && trees.fi) {
|
|
156
|
+
await c.pages.setTree(page.id, FI, trees.fi);
|
|
157
|
+
await c.pages.publish(page.id, FI);
|
|
158
|
+
}
|
|
159
|
+
return page;
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
// --- people ---------------------------------------------------------
|
|
143
163
|
|
|
144
164
|
const jane = await seedEntry("people", { name: "Jane Doe", role: "Founder" }, { role: "Perustaja" });
|
|
@@ -208,12 +228,12 @@ const nav = (locale: string, labels: [string, string]) => {
|
|
|
208
228
|
{ label: labels[1], href: links.about },
|
|
209
229
|
];
|
|
210
230
|
};
|
|
211
|
-
await
|
|
231
|
+
await seedGlobal("header",
|
|
212
232
|
{ brand: "Lorem Co.", nav: nav(EN, ["Articles", "About"]) },
|
|
213
233
|
FI ? { nav: nav(FI, ["Artikkelit", "Tietoa meistä"]) } : undefined,
|
|
214
234
|
);
|
|
215
235
|
|
|
216
|
-
await
|
|
236
|
+
await seedGlobal("footer", {
|
|
217
237
|
tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
|
218
238
|
company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }, { label: "News", href: paths(EN).news }] },
|
|
219
239
|
legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }] },
|
|
@@ -226,26 +246,23 @@ await seedEntry("footer", {
|
|
|
226
246
|
social: { title: "Some", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
|
|
227
247
|
} : undefined);
|
|
228
248
|
|
|
229
|
-
await
|
|
249
|
+
await seedGlobal("contact",
|
|
230
250
|
{ email: "hello@lorem.co", phone: "+358 40 123 4567", address: "Lorem Street 1, 00100 Helsinki" },
|
|
231
251
|
{ address: "Loremkatu 1, 00100 Helsinki" },
|
|
232
252
|
);
|
|
233
253
|
|
|
234
254
|
// A visual global item's settings ride the row under the reserved
|
|
235
255
|
// `$settings` key (spec 2026-09-13 §1): the quote is the site's one dark band.
|
|
236
|
-
const quoteRow = await
|
|
256
|
+
const quoteRow = await seedGlobal("quote",
|
|
237
257
|
{ text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", attribution: "Jane Doe, Founder", $settings: { background: "ink" } },
|
|
238
258
|
{ attribution: "Jane Doe, perustaja" },
|
|
239
259
|
);
|
|
240
260
|
// A placement: the quote as a linked node, the same row wherever it sits.
|
|
241
|
-
const quote:
|
|
261
|
+
const quote: TreeNode = { id: "node-quote", type: "global", fields: { item: "quote", ref: quoteRow.id } };
|
|
242
262
|
|
|
243
263
|
// --- pages ----------------------------------------------------------
|
|
244
264
|
|
|
245
|
-
const imageText = (locale: string, linkLabel: string)
|
|
246
|
-
id: "node-intro",
|
|
247
|
-
type: "imageText",
|
|
248
|
-
fields: {
|
|
265
|
+
const imageText = (locale: string, linkLabel: string) => node(ImageText, {
|
|
249
266
|
image: img.home2,
|
|
250
267
|
heading: "Duis aute irure dolor in reprehenderit",
|
|
251
268
|
text: doc(
|
|
@@ -253,34 +270,27 @@ const imageText = (locale: string, linkLabel: string): Node => ({
|
|
|
253
270
|
p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium."),
|
|
254
271
|
),
|
|
255
272
|
link: { label: linkLabel, href: paths(locale).about },
|
|
256
|
-
},
|
|
257
|
-
settings: { imageSide: "left" },
|
|
258
|
-
});
|
|
273
|
+
}, { id: "node-intro", settings: { imageSide: "left" } });
|
|
259
274
|
|
|
260
|
-
const points:
|
|
261
|
-
|
|
262
|
-
type: "points",
|
|
263
|
-
fields: {
|
|
275
|
+
const points: TreeNode = {
|
|
276
|
+
...node(Points, {
|
|
264
277
|
heading: "Quis autem vel eum",
|
|
265
278
|
points: [
|
|
266
279
|
{ icon: "circle", heading: "Iure reprehenderit", text: "Qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat." },
|
|
267
280
|
{ icon: "square", heading: "Quo voluptas nulla", text: "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum." },
|
|
268
281
|
{ icon: "diamond", heading: "Pariatur deleniti", text: "Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga." },
|
|
269
282
|
],
|
|
270
|
-
},
|
|
283
|
+
}, { id: "node-points" }),
|
|
271
284
|
// One Spacing & layout step stored (spec 2026-09-14 §2); the rest are Default and store nothing.
|
|
272
285
|
styles: { spacingBottom: "lg" },
|
|
273
286
|
};
|
|
274
287
|
|
|
275
|
-
const favourites = (heading: string)
|
|
276
|
-
id: "node-favourites",
|
|
277
|
-
type: "favouriteArticles",
|
|
278
|
-
fields: { heading, items: [article.id] },
|
|
279
|
-
});
|
|
288
|
+
const favourites = (heading: string) => node(FavouriteArticles, { heading, items: [article.id] }, { id: "node-favourites" });
|
|
280
289
|
|
|
281
290
|
// The custom section (spec 2026-09-14 §6): the editor's own rows of
|
|
282
291
|
// richtext columns, widths in twelfths; Smoodly renders its layout CSS.
|
|
283
|
-
|
|
292
|
+
// Not a registered section, so it is a plain node object.
|
|
293
|
+
const customSection = (locale: string): TreeNode => ({
|
|
284
294
|
id: "node-custom",
|
|
285
295
|
type: "custom",
|
|
286
296
|
fields: { rows: [
|
|
@@ -299,93 +309,70 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
|
|
|
299
309
|
zones: {
|
|
300
310
|
// The locked hero's one node. Pages created in the admin get theirs
|
|
301
311
|
// from the editor (fillLockedZones on load); a seeded tree brings its own.
|
|
302
|
-
hero: [{
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
],
|
|
313
|
-
image: img.hero,
|
|
314
|
-
},
|
|
315
|
-
}],
|
|
312
|
+
hero: [node(Hero, {
|
|
313
|
+
kicker: t.kicker,
|
|
314
|
+
heading: "Consectetur adipiscing elit sed do eiusmod",
|
|
315
|
+
text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
|
|
316
|
+
buttons: [
|
|
317
|
+
{ label: t.buttons[0], href: paths(locale).about },
|
|
318
|
+
{ label: t.buttons[1], href: paths(locale).articles },
|
|
319
|
+
],
|
|
320
|
+
image: img.hero,
|
|
321
|
+
}, { id: "node-hero" })],
|
|
316
322
|
body: [imageText(locale, t.link), quote, points, customSection(locale), favourites(t.favourites)],
|
|
317
323
|
},
|
|
318
324
|
});
|
|
319
325
|
|
|
320
|
-
await
|
|
321
|
-
{ slug: "home", title: "Home", template: "home", fixed: "page:home", fi: { title: "Etusivu" } },
|
|
322
|
-
{
|
|
326
|
+
await seedCodePage("/", {
|
|
323
327
|
en: homeTree(EN, { kicker: "Lorem ipsum dolor", buttons: ["About us", "Read articles"], link: "About Lorem Co. →", favourites: "From the journal" }),
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
);
|
|
328
|
+
fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
|
|
329
|
+
});
|
|
327
330
|
|
|
328
331
|
// The Articles index: the fixed page at the collection's own segments,
|
|
329
332
|
// so the mount in the Pages list opens this page. Only a hero; the grid
|
|
330
333
|
// is the view's own fetch.
|
|
331
334
|
const articlesHero = (heading: string, text: string): Tree => ({
|
|
332
|
-
zones: { hero: [{ id: "node-articles-hero"
|
|
335
|
+
zones: { hero: [node(PageHero, { heading, text }, { id: "node-articles-hero" })] },
|
|
336
|
+
});
|
|
337
|
+
await seedCodePage(`/${articlesSegment(EN)}`, {
|
|
338
|
+
en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
339
|
+
fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
333
340
|
});
|
|
334
|
-
await seedPage(
|
|
335
|
-
{ slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fixed: "collection:articles", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
|
|
336
|
-
{
|
|
337
|
-
en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
338
|
-
fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
|
|
339
|
-
},
|
|
340
|
-
);
|
|
341
341
|
|
|
342
342
|
// A folder and a page under it: the tree the admin lists, and the
|
|
343
343
|
// breadcrumb the Page shape renders from the `page` prop. Each exists in
|
|
344
344
|
// both locales with its own slug and title.
|
|
345
345
|
const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
|
|
346
346
|
|
|
347
|
-
// The News index: the
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{
|
|
353
|
-
|
|
354
|
-
en: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "News", text: "Lorem ipsum dolor sit amet — what is new at Lorem Co." } }] } },
|
|
355
|
-
fi: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "Uutiset", text: "Lorem ipsum dolor sit amet — mitä Lorem Co:lle kuuluu." } }] } },
|
|
356
|
-
},
|
|
357
|
-
);
|
|
347
|
+
// The News index: the code-owned page on the news collection's nested
|
|
348
|
+
// address (spec 2026-09-29 nested paths §3) — it shows under Company in
|
|
349
|
+
// URL only; renaming Company never moves it.
|
|
350
|
+
await seedCodePage(`/${newsAddress(EN)}`, {
|
|
351
|
+
en: { zones: { hero: [node(PageHero, { heading: "News", text: "Lorem ipsum dolor sit amet — what is new at Lorem Co." }, { id: "node-news-hero" })] } },
|
|
352
|
+
fi: { zones: { hero: [node(PageHero, { heading: "Uutiset", text: "Lorem ipsum dolor sit amet — mitä Lorem Co:lle kuuluu." }, { id: "node-news-hero" })] } },
|
|
353
|
+
});
|
|
358
354
|
|
|
359
355
|
const aboutTree = (t: { kicker: string; people: string; description: string }): Tree => ({
|
|
360
356
|
fields: { showInNavigation: true },
|
|
361
357
|
meta: { description: t.description },
|
|
362
358
|
zones: {
|
|
363
|
-
hero: [{
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
|
|
370
|
-
image: img.about,
|
|
371
|
-
},
|
|
372
|
-
}],
|
|
359
|
+
hero: [node(PageHero, {
|
|
360
|
+
kicker: t.kicker,
|
|
361
|
+
heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
|
|
362
|
+
text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
|
|
363
|
+
image: img.about,
|
|
364
|
+
}, { id: "node-about-hero" })],
|
|
373
365
|
body: [
|
|
374
|
-
{
|
|
375
|
-
id: "node-about-text",
|
|
376
|
-
type: "imageText",
|
|
377
|
-
settings: { imageSide: "left" },
|
|
378
|
-
fields: {
|
|
366
|
+
node(ImageText, {
|
|
379
367
|
heading: "Duis aute irure dolor in reprehenderit",
|
|
380
368
|
text: doc(
|
|
381
369
|
p("In voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."),
|
|
382
370
|
p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis."),
|
|
383
371
|
p("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt."),
|
|
384
372
|
),
|
|
385
|
-
|
|
386
|
-
},
|
|
373
|
+
}, { id: "node-about-text", settings: { imageSide: "left" } }),
|
|
387
374
|
quote,
|
|
388
|
-
|
|
375
|
+
node(People, { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] }, { id: "node-people" }),
|
|
389
376
|
],
|
|
390
377
|
},
|
|
391
378
|
});
|
|
@@ -403,8 +390,8 @@ const teamTree = (t: { heading: string; text: string; people: string }): Tree =>
|
|
|
403
390
|
fields: { showInNavigation: true },
|
|
404
391
|
meta: { description: t.text },
|
|
405
392
|
zones: {
|
|
406
|
-
hero: [
|
|
407
|
-
body: [
|
|
393
|
+
hero: [node(PageHero, { kicker: t.heading, heading: t.heading, text: t.text, image: img.about }, { id: "node-team-hero" })],
|
|
394
|
+
body: [node(People, { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] }, { id: "node-team-people" })],
|
|
408
395
|
},
|
|
409
396
|
});
|
|
410
397
|
await seedPage(
|
|
@@ -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
|
|
@@ -17,9 +17,9 @@ for an agent (or a person) building the site.
|
|
|
17
17
|
- `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
|
|
18
18
|
- `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
|
|
19
19
|
- `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
|
|
20
|
-
- `scripts/seed.ts` — the starter content, written through the
|
|
20
|
+
- `scripts/seed.ts` — the starter content, written through the content API (`content()`). `npm run seed` is idempotent.
|
|
21
21
|
- `smoodly/collections.ts` — structured content (people, articles, news). Add a collection, run `npx smoodly migrate`, apply the file it writes.
|
|
22
|
-
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (
|
|
22
|
+
- `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (site layer, renderer, the content API `content()`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
|
|
23
23
|
- `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
|
|
24
24
|
|
|
25
25
|
Translated URLs: a page's slug and title are edited per locale in the
|
|
@@ -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.39",
|
|
18
18
|
"next-intl": "^4.14.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|