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.
@@ -1,6 +1,7 @@
1
- // The starter's content, through the stores (never raw inserts — the
2
- // refs index and publish integrity must be exercised, that's the point).
3
- // Content only; it ships in the template and create-smoodly-app runs
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 exists.
17
- import { readFile } from "node:fs/promises";
17
+ // page has content.
18
18
  import next from "@next/env";
19
- import { imageDimensions, lastPart, type PageTree } from "smoodly";
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, stores } = await import("../smoodly/server");
27
- const { pages, entries, assets } = stores();
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
- if (await pages.resolvePath(EN, "/")) {
33
- console.log(`Already seeded — "home" exists in "${EN}".`);
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 through
57
- // the store (rows in `assets`, bytes in the `media` bucket), and a field
58
- // value is `{ asset: <id> }` — the alt lives on the asset (spec 2026-09-08).
59
- const uploaded = new Map<string, string>();
60
- async function image(name: string, alt: string): Promise<{ asset: string }> {
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 (a collection's or a global item's) in EN, add the FI
96
- * row when there is one, and publish both — every entry is published in
97
- * every locale it has BEFORE any page, because publish integrity is per
98
- * locale and would (rightly) block a page whose tree references an entry
99
- * that is a draft, or absent, in that locale. `fi` holds the localized
100
- * fields; shared ones are repeated from `en`. */
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, en, { locale: EN });
103
- await entries.setStatus(collection, row.id, EN, "published");
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.update(collection, row.id, FI, { ...en, ...fi });
107
- await entries.setStatus(collection, row.id, FI, "published");
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 = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; meta?: Record<string, unknown> };
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; fixed?: string; fi?: { slug?: string; title: string } },
120
- trees?: { en: Tree; fi?: Tree },
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.createPage({
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.saveDraft(page.id, EN, trees.en as unknown as PageTree);
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, title: input.fi.title, ...(input.fi.slug ? { slug: input.fi.slug } : {}) });
139
+ await c.pages.addLocale(page.id, FI, { from: EN, ...input.fi });
134
140
  if (trees?.fi) {
135
- await pages.saveDraft(page.id, FI, trees.fi as unknown as PageTree);
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 seedEntry("header",
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 seedEntry("footer", {
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 seedEntry("contact",
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 seedEntry("quote",
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: Node = { id: "node-quote", type: "global", fields: { item: "quote", ref: quoteRow.id } };
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): Node => ({
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: Node = {
261
- id: "node-points",
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): Node => ({
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
- const customSection = (locale: string): Node => ({
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
- id: "node-hero",
304
- type: "hero",
305
- fields: {
306
- kicker: t.kicker,
307
- heading: "Consectetur adipiscing elit sed do eiusmod",
308
- text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
309
- buttons: [
310
- { label: t.buttons[0], href: paths(locale).about },
311
- { label: t.buttons[1], href: paths(locale).articles },
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 seedPage(
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
- fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
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", type: "pageHero", fields: { heading, text } }] },
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 fixed page on the news collection's nested address.
348
- // Code owns the whole address, so it is a root record holding the last
349
- // part as its slug (spec 2026-09-29 nested paths §3) — it shows under
350
- // Company in URL only; renaming Company never moves it.
351
- await seedPage(
352
- { slug: lastPart(newsAddress(EN)), title: "News", template: "newsIndex", fixed: "collection:news", fi: { slug: lastPart(newsAddress(FI ?? EN)), title: "Uutiset" } },
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
- id: "node-about-hero",
365
- type: "pageHero",
366
- fields: {
367
- kicker: t.kicker,
368
- heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
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
- { id: "node-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } },
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: [{ id: "node-team-hero", type: "pageHero", fields: { kicker: t.heading, heading: t.heading, text: t.text, image: img.about } }],
407
- body: [{ id: "node-team-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } }],
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 { adminAuth, href, stores } from "./server";
7
+ import { adminDeps } from "./server";
8
8
 
9
9
  export async function smoodlyOp(call: AdminOpCall) {
10
- return createAdminOpHandler({ config, ...stores(), auth: adminAuth(), href })(call);
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_*); whether the
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 { getPathname } from "@/i18n/navigation";
@@ -51,8 +53,7 @@ function bound(): Bound {
51
53
  // The admission table, read by the Members page (spec 2026-09-25 §4).
52
54
  const members = new SupabaseMemberStore(db);
53
55
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
54
- // Shadows the exported `stores` below on purpose: `createSmoodlySite`
55
- // takes this local closure, not the export, so it never re-enters `bound()`.
56
+ // The site layer's own view of the stores; nothing outside this file reads them.
56
57
  const stores = () => ({ pages, entries, assets });
57
58
  // The site layer: (locale, path) → page or entry, and the content API
58
59
  // `q` wraps below. Not exported — the app reads through `q`, which adds
@@ -71,27 +72,37 @@ function bound(): Bound {
71
72
  return cached;
72
73
  }
73
74
 
74
- export function stores() {
75
+ /** The admin's wiring, for `smoodly/ops.ts` only. Scripts write through `content()`. */
76
+ export function adminDeps() {
77
+ smoodlyEnv("admin"); // the admin signs in with the publishable key: a missing one is named here, not on a live page
78
+ const { pages, entries, assets, members, auth } = bound();
79
+ return { pages, entries, assets, members, auth, href };
80
+ }
81
+
82
+ /** Write content from code — seeds, migrations, route handlers (spec 2026-09-30 tester findings §1).
83
+ * The admin's own operations with no signed-in editor: validation and publish checks apply, a failure
84
+ * throws `SmoodlyContentError`. `content().pages.create(…)`, `.entries.create(…)`, `.globals.set(…)`,
85
+ * `.media.upload(…)`; build tree nodes with `node(Section, fields)`. */
86
+ export function content(): SmoodlyContent {
75
87
  const { pages, entries, assets, members } = bound();
76
- return { pages, entries, assets, members };
88
+ return createSmoodlyContent({ config, pages, entries, assets, members, href });
77
89
  }
78
90
 
91
+ export { node } from "smoodly";
92
+
79
93
  /** The server-side client (seed script, one-off admin tasks). */
80
94
  export const client = () => bound().db;
81
95
 
82
- /** The admin gate: session verified against the auth project, editors row read with the write key. */
83
- export const adminAuth = () => bound().auth;
84
-
85
96
  /** What the browser needs to sign in — public values only. */
86
97
  export function publicAuth(): AuthConfig {
87
- const env = smoodlyEnv();
98
+ const env = smoodlyEnv("admin");
88
99
  return { url: env.authUrl, key: env.authKey };
89
100
  }
90
101
 
91
102
  /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
92
103
  * The space is asked of the database once per process. */
93
104
  export function presence(): Promise<PresenceConfig> {
94
- return presenceConfig(smoodlyEnv());
105
+ return presenceConfig(smoodlyEnv("admin"));
95
106
  }
96
107
 
97
108
  /** Locale-relative CMS path → site URL: the one seam for the renderer