create-smoodly-app 0.0.7 → 0.0.9

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.
Files changed (72) hide show
  1. package/dist/index.js +22 -2
  2. package/package.json +1 -1
  3. package/templates/next/README.md +18 -2
  4. package/templates/next/app/globals.css +190 -0
  5. package/templates/next/app/layout.tsx +28 -4
  6. package/templates/next/components/ArticleCard.tsx +22 -0
  7. package/templates/next/components/articles.ts +38 -0
  8. package/templates/next/components/pages/Article.tsx +58 -10
  9. package/templates/next/components/pages/Articles.tsx +44 -0
  10. package/templates/next/components/pages/Home.tsx +12 -10
  11. package/templates/next/components/pages/Page.tsx +11 -8
  12. package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
  13. package/templates/next/components/sections/Footer.tsx +69 -0
  14. package/templates/next/components/sections/Header.tsx +44 -0
  15. package/templates/next/components/sections/Hero.tsx +32 -7
  16. package/templates/next/components/sections/ImageText.tsx +59 -0
  17. package/templates/next/components/sections/PageHero.tsx +38 -0
  18. package/templates/next/components/sections/People.tsx +36 -0
  19. package/templates/next/components/sections/Points.tsx +50 -0
  20. package/templates/next/components/sections/Quote.tsx +33 -0
  21. package/templates/next/components/strings.ts +23 -0
  22. package/templates/next/messages/en.json +5 -0
  23. package/templates/next/package.json +4 -1
  24. package/templates/next/public/images/about_main.jpg +0 -0
  25. package/templates/next/public/images/article_photo.jpg +0 -0
  26. package/templates/next/public/images/hero_image.jpg +0 -0
  27. package/templates/next/public/images/home_section_2.jpg +0 -0
  28. package/templates/next/scripts/seed.ts +310 -0
  29. package/templates/next/smoodly/collections.ts +12 -3
  30. package/templates/next/smoodly/config.ts +12 -3
  31. package/templates/next/smoodly/sections.ts +14 -3
  32. package/templates/next/smoodly/server.ts +39 -21
  33. package/templates/next/smoodly/shared.ts +26 -0
  34. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -52
  35. package/templates/next-intl/README.md +19 -3
  36. package/templates/next-intl/app/[locale]/layout.tsx +27 -4
  37. package/templates/next-intl/app/globals.css +190 -0
  38. package/templates/next-intl/components/ArticleCard.tsx +22 -0
  39. package/templates/next-intl/components/articles.ts +38 -0
  40. package/templates/next-intl/components/pages/Article.tsx +58 -10
  41. package/templates/next-intl/components/pages/Articles.tsx +44 -0
  42. package/templates/next-intl/components/pages/Home.tsx +12 -10
  43. package/templates/next-intl/components/pages/Page.tsx +11 -8
  44. package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
  45. package/templates/next-intl/components/sections/Footer.tsx +69 -0
  46. package/templates/next-intl/components/sections/Header.tsx +44 -0
  47. package/templates/next-intl/components/sections/Hero.tsx +32 -7
  48. package/templates/next-intl/components/sections/ImageText.tsx +59 -0
  49. package/templates/next-intl/components/sections/PageHero.tsx +38 -0
  50. package/templates/next-intl/components/sections/People.tsx +36 -0
  51. package/templates/next-intl/components/sections/Points.tsx +50 -0
  52. package/templates/next-intl/components/sections/Quote.tsx +33 -0
  53. package/templates/next-intl/components/strings.ts +23 -0
  54. package/templates/next-intl/i18n/request.ts +14 -7
  55. package/templates/next-intl/messages/en.json +5 -0
  56. package/templates/next-intl/messages/fi.json +5 -0
  57. package/templates/next-intl/package.json +4 -1
  58. package/templates/next-intl/public/images/about_main.jpg +0 -0
  59. package/templates/next-intl/public/images/article_photo.jpg +0 -0
  60. package/templates/next-intl/public/images/hero_image.jpg +0 -0
  61. package/templates/next-intl/public/images/home_section_2.jpg +0 -0
  62. package/templates/next-intl/scripts/seed.ts +310 -0
  63. package/templates/next-intl/smoodly/collections.ts +13 -4
  64. package/templates/next-intl/smoodly/config.ts +12 -3
  65. package/templates/next-intl/smoodly/sections.ts +14 -3
  66. package/templates/next-intl/smoodly/server.ts +39 -21
  67. package/templates/next-intl/smoodly/shared.ts +26 -0
  68. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -52
  69. package/templates/next/components/sections/ArticleList.tsx +0 -31
  70. package/templates/next/components/sections/CtaBanner.tsx +0 -35
  71. package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
  72. package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
@@ -0,0 +1,310 @@
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
4
+ // `npm run seed` after the first editor. (The example repo's demo
5
+ // editor account is a separate, example-only script.)
6
+ // Run: npm run seed (needs .env.local and a Supabase stack).
7
+ //
8
+ // Locales: the English content goes into the configured default locale,
9
+ // whatever its code, and the Finnish rows are written only when `fi` is
10
+ // supported and not the default (spec 2026-09-07 starter §4). The
11
+ // installer accepts any locale set; URL segments come from the
12
+ // registrations (the articles collection's per-locale `path`), so a
13
+ // `fi` default lands the index on `artikkelit`. Body copy is Latin in
14
+ // both; what a reader perceives as language (nav, headings, tags,
15
+ // roles, footer titles) is translated. Idempotent: exits once the home
16
+ // page exists.
17
+ import next from "@next/env";
18
+ import type { PageTree } from "smoodly";
19
+ const { loadEnvConfig } = next;
20
+
21
+ loadEnvConfig(process.cwd());
22
+
23
+ const { config } = await import("../smoodly/config");
24
+ const { articles } = await import("../smoodly/collections");
25
+ const { href, stores } = await import("../smoodly/server");
26
+ const { pages, entries } = stores();
27
+
28
+ const EN = config.locales.default;
29
+ const FI = config.locales.supported.includes("fi") && EN !== "fi" ? "fi" : null;
30
+
31
+ if (await pages.resolvePath(EN, "/")) {
32
+ console.log(`Already seeded — "home" exists in "${EN}".`);
33
+ process.exit(0);
34
+ }
35
+
36
+ // --- helpers -------------------------------------------------------
37
+
38
+ type Fields = Record<string, unknown>;
39
+ type Node = Record<string, unknown>;
40
+
41
+ // Richtext is TipTap JSON, the stored format; RichText renders it.
42
+ const text = (s: string) => [{ type: "text", text: s }];
43
+ const p = (s: string): Node => ({ type: "paragraph", content: text(s) });
44
+ const h2 = (s: string): Node => ({ type: "heading", attrs: { level: 2 }, content: text(s) });
45
+ const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] });
46
+ const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
47
+ const doc = (...content: Node[]) => ({ type: "doc", content });
48
+
49
+ const image = (name: string, alt: string) => ({ url: `/images/${name}.jpg`, alt });
50
+
51
+ /** The articles collection's URL segment in a locale, from its own `path`. */
52
+ const articlesSegment = (locale: string) => {
53
+ const declared = articles.path;
54
+ return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "posts");
55
+ };
56
+ /** Site URLs for the starter's own pages in a locale. */
57
+ const paths = (locale: string) => ({
58
+ home: href("/", locale),
59
+ articles: href(`/${articlesSegment(locale)}`, locale),
60
+ about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
61
+ });
62
+
63
+ /** Create an entry (a collection's or a shared item's) in EN, add the FI
64
+ * row when there is one, and publish both — every entry is published in
65
+ * every locale it has BEFORE any page, because publish integrity is per
66
+ * locale and would (rightly) block a page whose tree references an entry
67
+ * that is a draft, or absent, in that locale. `fi` holds the localized
68
+ * fields; shared ones are repeated from `en`. */
69
+ async function seedEntry(collection: string, en: Fields, fi?: Fields) {
70
+ const row = await entries.create(collection, en, { locale: EN });
71
+ await entries.setStatus(collection, row.id, EN, "published");
72
+ if (FI && fi) {
73
+ await entries.addLocale(collection, row.id, FI, { from: EN });
74
+ await entries.update(collection, row.id, FI, { ...en, ...fi });
75
+ await entries.setStatus(collection, row.id, FI, "published");
76
+ }
77
+ return row;
78
+ }
79
+
80
+ type Tree = { zones: Record<string, Node[]> };
81
+
82
+ /** Create a page (or a folder: `template: null`) in EN with its tree,
83
+ * publish it, then the FI row with its own tree — a locale's tree is
84
+ * its own (spec 2026-09-06); the Finnish page is copied from the
85
+ * English draft, then overwritten with the Finnish tree. */
86
+ async function seedPage(
87
+ input: { slug: string; title: string; template: string | null; parentId?: string; fi?: { slug?: string; title: string } },
88
+ trees?: { en: Tree; fi?: Tree },
89
+ ) {
90
+ const page = await pages.createPage({
91
+ locale: EN, slug: input.slug, title: input.title, template: input.template,
92
+ ...(input.parentId ? { parentId: input.parentId } : {}),
93
+ });
94
+ if (trees) {
95
+ await pages.saveDraft(page.id, EN, trees.en as unknown as PageTree);
96
+ await pages.publish(page.id, EN);
97
+ }
98
+ if (FI && input.fi) {
99
+ await pages.addLocale(page.id, FI, { from: EN, title: input.fi.title, ...(input.fi.slug ? { slug: input.fi.slug } : {}) });
100
+ if (trees?.fi) {
101
+ await pages.saveDraft(page.id, FI, trees.fi as unknown as PageTree);
102
+ await pages.publish(page.id, FI);
103
+ }
104
+ }
105
+ return page;
106
+ }
107
+
108
+ // --- people ---------------------------------------------------------
109
+
110
+ const jane = await seedEntry("people", { name: "Jane Doe", role: "Founder" }, { role: "Perustaja" });
111
+ const john = await seedEntry("people", { name: "John Smith", role: "Creative Director" }, { role: "Luova johtaja" });
112
+ const anna = await seedEntry("people", { name: "Anna Lorem", role: "Head of Design" }, { role: "Muotoilujohtaja" });
113
+ const mark = await seedEntry("people", { name: "Mark Ipsum", role: "Developer" }, { role: "Kehittäjä" });
114
+
115
+ // --- the article ----------------------------------------------------
116
+
117
+ const body = doc(
118
+ p("Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."),
119
+ p("Duis aute irure dolor in reprehenderit 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."),
120
+ h2("Sed ut perspiciatis unde omnis"),
121
+ p("Iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo."),
122
+ quoteBlock("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit."),
123
+ p("Sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit."),
124
+ h2("Quis autem vel eum iure"),
125
+ p("Reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur."),
126
+ bullets("At vero eos et accusamus et iusto odio", "Dignissimos ducimus qui blanditiis praesentium", "Voluptatum deleniti atque corrupti quos dolores"),
127
+ p("Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus."),
128
+ );
129
+
130
+ // Versioned (`versions: true` on the collection): every save below is a
131
+ // snapshot. Title and body are Latin in both locales; tags and slug are
132
+ // per locale, so the Finnish row reads Finnish where a reader would notice.
133
+ const article = await seedEntry("articles", {
134
+ title: "Lorem ipsum dolor sit amet consectetur adipiscing",
135
+ slug: "lorem-ipsum-dolor-sit-amet",
136
+ excerpt: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
137
+ body,
138
+ tags: ["Insights", "Design"],
139
+ image: image("article_photo", "Notebook on a desk"),
140
+ date: "2026-09-02",
141
+ author: jane.id,
142
+ }, {
143
+ slug: "lorem-ipsum-dolor-sit-amet-fi",
144
+ tags: ["Näkemyksiä", "Muotoilu"],
145
+ });
146
+
147
+ // --- shared items ---------------------------------------------------
148
+
149
+ const nav = (locale: string, labels: [string, string, string]) => {
150
+ const links = paths(locale);
151
+ return [
152
+ { label: labels[0], href: links.home },
153
+ { label: labels[1], href: links.articles },
154
+ { label: labels[2], href: links.about },
155
+ ];
156
+ };
157
+ await seedEntry("header",
158
+ { brand: "Lorem Co.", nav: nav(EN, ["Home", "Articles", "About"]) },
159
+ FI ? { nav: nav(FI, ["Etusivu", "Artikkelit", "Tietoa meistä"]) } : undefined,
160
+ );
161
+
162
+ await seedEntry("footer", {
163
+ tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
164
+ company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }] },
165
+ legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }] },
166
+ social: { title: "Social", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
167
+ copyright: "© 2026 Lorem Co. Built with Smoodly.",
168
+ }, FI ? {
169
+ tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
170
+ company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }] },
171
+ legal: { title: "Lakiasiat", links: [{ label: "Tietosuoja", href: "#" }, { label: "Ehdot", href: "#" }] },
172
+ social: { title: "Some", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
173
+ } : undefined);
174
+
175
+ await seedEntry("contact",
176
+ { email: "hello@lorem.co", phone: "+358 40 123 4567", address: "Lorem Street 1, 00100 Helsinki" },
177
+ { address: "Loremkatu 1, 00100 Helsinki" },
178
+ );
179
+
180
+ const quoteRow = await seedEntry("quote",
181
+ { text: "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", attribution: "Jane Doe, Founder" },
182
+ { attribution: "Jane Doe, perustaja" },
183
+ );
184
+ // A placement: the quote as a linked node, the same row wherever it sits.
185
+ const quote: Node = { id: "node-quote", type: "shared", fields: { item: "quote", ref: quoteRow.id } };
186
+
187
+ // --- pages ----------------------------------------------------------
188
+
189
+ const imageText = (locale: string, linkLabel: string): Node => ({
190
+ id: "node-intro",
191
+ type: "imageText",
192
+ fields: {
193
+ image: image("home_section_2", "Team working"),
194
+ heading: "Duis aute irure dolor in reprehenderit",
195
+ text: doc(
196
+ 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."),
197
+ p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium."),
198
+ ),
199
+ link: { label: linkLabel, href: paths(locale).about },
200
+ },
201
+ styles: { imageSide: "left" },
202
+ });
203
+
204
+ const points: Node = {
205
+ id: "node-points",
206
+ type: "points",
207
+ fields: {
208
+ heading: "Quis autem vel eum",
209
+ points: [
210
+ { icon: "circle", heading: "Iure reprehenderit", text: "Qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat." },
211
+ { icon: "square", heading: "Quo voluptas nulla", text: "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum." },
212
+ { icon: "diamond", heading: "Pariatur deleniti", text: "Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga." },
213
+ ],
214
+ },
215
+ styles: { spacing: "lg" },
216
+ };
217
+
218
+ const favourites = (heading: string): Node => ({
219
+ id: "node-favourites",
220
+ type: "favouriteArticles",
221
+ fields: { heading, items: [article.id] },
222
+ });
223
+
224
+ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]; link: string; favourites: string }): Tree => ({
225
+ zones: {
226
+ // The locked hero's one node. Pages created in the admin get theirs
227
+ // from the editor (fillLockedZones on load); a seeded tree brings its own.
228
+ hero: [{
229
+ id: "node-hero",
230
+ type: "hero",
231
+ fields: {
232
+ kicker: t.kicker,
233
+ heading: "Consectetur adipiscing elit sed do eiusmod",
234
+ text: "Tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.",
235
+ buttons: [
236
+ { label: t.buttons[0], href: paths(locale).about },
237
+ { label: t.buttons[1], href: paths(locale).articles },
238
+ ],
239
+ image: image("hero_image", "Office interior"),
240
+ },
241
+ }],
242
+ body: [imageText(locale, t.link), quote, points, favourites(t.favourites)],
243
+ },
244
+ });
245
+
246
+ await seedPage(
247
+ { slug: "home", title: "Home", template: "home", fi: { title: "Etusivu" } },
248
+ {
249
+ en: homeTree(EN, { kicker: "Lorem ipsum dolor", buttons: ["About us", "Read articles"], link: "About Lorem Co. →", favourites: "From the journal" }),
250
+ fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
251
+ },
252
+ );
253
+
254
+ // The Articles index: the fixed page at the collection's own segments,
255
+ // so the mount in the Pages list opens this page. Only a hero; the grid
256
+ // is the view's own fetch.
257
+ const articlesHero = (heading: string, text: string): Tree => ({
258
+ zones: { hero: [{ id: "node-articles-hero", type: "pageHero", fields: { heading, text } }] },
259
+ });
260
+ await seedPage(
261
+ { slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
262
+ {
263
+ en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
264
+ fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
265
+ },
266
+ );
267
+
268
+ // A folder and a page under it: the tree the admin lists, and the
269
+ // breadcrumb the Page shape renders from the `page` prop. Each exists in
270
+ // both locales with its own slug and title.
271
+ const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
272
+
273
+ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
274
+ zones: {
275
+ hero: [{
276
+ id: "node-about-hero",
277
+ type: "pageHero",
278
+ fields: {
279
+ kicker: t.kicker,
280
+ heading: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
281
+ text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
282
+ image: image("about_main", "Team meeting"),
283
+ },
284
+ }],
285
+ body: [
286
+ {
287
+ id: "node-about-text",
288
+ type: "imageText",
289
+ fields: {
290
+ heading: "Duis aute irure dolor in reprehenderit",
291
+ text: doc(
292
+ 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."),
293
+ p("Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis."),
294
+ p("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt."),
295
+ ),
296
+ },
297
+ },
298
+ quote,
299
+ { id: "node-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } },
300
+ ],
301
+ },
302
+ });
303
+ await seedPage(
304
+ { slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
305
+ { en: aboutTree({ kicker: "About us", people: "Our people" }), fi: aboutTree({ kicker: "Tietoa meistä", people: "Meidän väki" }) },
306
+ );
307
+
308
+ console.log(
309
+ `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us${FI ? " (yritys/tietoa-meista)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
310
+ );
@@ -9,23 +9,32 @@ export const people = smoodly.collection({
9
9
  titleField: "name",
10
10
  fields: {
11
11
  name: f.text(),
12
- role: f.text().optional().localized(),
12
+ role: f.text().localized(),
13
13
  },
14
14
  });
15
15
 
16
16
  // Articles keep a version history per locale (`versions: true`): every
17
17
  // save is a snapshot, publish moves a pointer, and the published site
18
- // keeps serving the published snapshot while a draft moves on.
18
+ // keeps serving the published snapshot while a draft moves on. The path
19
+ // is per locale — /posts/<slug> and /fi/artikkelit/<slug> — and the
20
+ // Articles fixed page (components/pages/Articles.tsx) claims the same
21
+ // segments, which makes it the collection's index page. Tags are a list
22
+ // localized as a whole, so the Finnish row carries Finnish tags.
19
23
  export const articles = smoodly.collection({
20
24
  name: "articles",
21
25
  title: "Articles",
22
26
  titleField: "title",
23
27
  path: "posts",
28
+ order: { by: "date", direction: "desc" },
24
29
  versions: true,
25
30
  fields: {
26
31
  title: f.text().localized(),
27
32
  slug: f.slug({ from: "title" }),
28
- excerpt: f.text().optional().localized(),
33
+ excerpt: f.text().localized(),
34
+ body: f.richtext().localized(),
35
+ tags: f.list(f.text()).localized(),
36
+ image: f.image(),
37
+ date: f.date(),
29
38
  author: f.ref(() => people),
30
39
  },
31
40
  });
@@ -1,11 +1,13 @@
1
1
  import { defineConfig, f } from "smoodly";
2
2
  import { collections } from "./collections";
3
3
  import { sections } from "./sections";
4
+ import { shared } from "./shared";
4
5
  import { HomePage } from "@/components/pages/Home";
6
+ import { ArticlesPage } from "@/components/pages/Articles";
5
7
  import { StandardPage } from "@/components/pages/Page";
6
8
  import { ArticlePage } from "@/components/pages/Article";
7
9
 
8
- export { collections, sections };
10
+ export { collections, sections, shared };
9
11
 
10
12
  // The registry and the plain settings, one file. Runtime-free: nothing
11
13
  // here imports a route file or Next, so a script, the CLI's template
@@ -14,14 +16,21 @@ export const config = defineConfig({
14
16
  registry: {
15
17
  sections,
16
18
  collections,
17
- pages: [HomePage, StandardPage, ArticlePage],
19
+ shared,
20
+ pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
18
21
  },
19
22
  locales: { default: "en", supported: ["en"] },
20
23
  // Smoodly's own default, spelled out: the record the site root serves.
21
24
  homePageSlug: "home",
22
25
  // Nesting on: an editor can put pages three deep.
23
26
  pages: { tree: { depth: 3 } },
27
+ // Every section gets these two selects beside its own styles; the
28
+ // section wrapper emits them as data-spacing / data-tone, and
29
+ // app/globals.css styles both (DESIGN.md §3 "<smoodly.Section>").
24
30
  styles: {
25
- sections: { spacing: f.select(["sm", "md", "lg"]).default("md") },
31
+ sections: {
32
+ spacing: f.select(["sm", "md", "lg"]).default("md"),
33
+ tone: f.select(["paper", "ink"]).default("paper"),
34
+ },
26
35
  },
27
36
  });
@@ -1,8 +1,19 @@
1
1
  // The site's section registrations, in one list. config.ts puts it
2
2
  // in the registry and re-exports it, so the config, the stores (which walk
3
3
  // trees against these schemas) and the renderer all share one array.
4
+ // Header and Footer are here because their shared items render through
5
+ // them; no zone lists them, so the "+" picker never offers them. Quote
6
+ // is both: its shared item renders through it, and the body zones list
7
+ // it so the shared quote can be placed (the picker offers a shared item
8
+ // only where its section is allowed).
4
9
  import { Hero } from "@/components/sections/Hero";
5
- import { ArticleList } from "@/components/sections/ArticleList";
6
- import { CtaBanner } from "@/components/sections/CtaBanner";
10
+ import { PageHero } from "@/components/sections/PageHero";
11
+ import { ImageText } from "@/components/sections/ImageText";
12
+ import { Points } from "@/components/sections/Points";
13
+ import { FavouriteArticles } from "@/components/sections/FavouriteArticles";
14
+ import { People } from "@/components/sections/People";
15
+ import { Quote } from "@/components/sections/Quote";
16
+ import { Header } from "@/components/sections/Header";
17
+ import { Footer } from "@/components/sections/Footer";
7
18
 
8
- export const sections = [Hero, ArticleList, CtaBanner];
19
+ export const sections = [Hero, PageHero, ImageText, Points, FavouriteArticles, People, Quote, Header, Footer];
@@ -2,31 +2,56 @@
2
2
  // renderer, constructed lazily so `next build` doesn't need env vars at
3
3
  // import time. The env shape is the package's (SMOODLY_*); whether the
4
4
  // secret is a service-role key or a cloud write key is invisible here.
5
+ //
6
+ // Lazy on purpose, the site and renderer included: config.ts imports the
7
+ // page shapes, and a page shape may import this file to fetch through
8
+ // the site (components/pages/Articles.tsx). That is a cycle, and the
9
+ // admin route enters it from config.ts — so nothing here may read the
10
+ // config at module scope.
5
11
  import {
6
- createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex,
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
7
13
  } from "smoodly";
8
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
9
15
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
10
16
  import type { SupabaseClient } from "@supabase/supabase-js";
11
- import { collections, config, sections } from "./config";
17
+ import { collections, config, sections, shared } from "./config";
12
18
 
13
- let cached: { db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth } | null = null;
19
+ type Renderer = ReturnType<typeof createPageRenderer>;
20
+ type Bound = {
21
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer;
22
+ };
14
23
 
15
- function bound() {
24
+ let cached: Bound | null = null;
25
+
26
+ function bound(): Bound {
16
27
  if (cached) return cached;
17
28
  const env = smoodlyEnv();
18
29
  const db = createSmoodlyClient(env);
19
30
  // ONE path index for both stores: pages and entries share the URL space.
20
31
  const paths = new SupabasePathIndex(db);
21
32
  const locales = config.locales;
22
- const entries = new SupabaseEntryStore(db, collections, { paths, locales });
33
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
23
34
  const pages = new SupabasePageStore(db, {
24
35
  sections, entries, paths, locales,
25
36
  homePageSlug: config.homePageSlug,
26
37
  tree: config.pages?.tree,
27
38
  });
28
39
  const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
29
- cached = { db, pages, entries, auth };
40
+ // Shadows the exported `stores` below on purpose: `createSmoodlySite`
41
+ // takes this local closure, not the export, so it never re-enters `bound()`.
42
+ const stores = () => ({ pages, entries });
43
+ // The site layer: (locale, path) → page or entry, and the content API
44
+ // (site.getPagesTree for menus, site.getEntries for lists).
45
+ // Shadows the exported `site` below on purpose: `createPageRenderer`
46
+ // takes this local value, not the export, so it never re-enters `bound()`.
47
+ const site = createSmoodlySite({ stores, collections, shared, sections, locales, homePageSlug: config.homePageSlug });
48
+ const renderer = createPageRenderer({
49
+ site,
50
+ sections,
51
+ pages: (config.registry.pages ?? []) as PageRegistration[],
52
+ href,
53
+ });
54
+ cached = { db, pages, entries, auth, site, renderer };
30
55
  return cached;
31
56
  }
32
57
 
@@ -53,20 +78,13 @@ export function publicAuth(): AuthConfig {
53
78
  * becomes a locale-prefixing lookup. */
54
79
  export const href = (path: string, _locale: string) => path;
55
80
 
56
- /** The site layer: (locale, path) → page or entry, and the content API
57
- * (site.getPagesTree for menus, site.getEntries for lists). */
58
- export const site = createSmoodlySite({
59
- stores,
60
- collections,
61
- sections,
62
- locales: config.locales,
63
- homePageSlug: config.homePageSlug,
64
- });
81
+ /** The content API for page views that fetch (the Articles index, an
82
+ * article's related list): `site().getEntries("articles", { locale })`. */
83
+ export const site = (): SmoodlySite => bound().site;
65
84
 
66
85
  /** The two route files call these. */
67
- export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata } = createPageRenderer({
68
- site,
69
- sections,
70
- pages: (config.registry.pages ?? []) as PageRegistration[],
71
- href,
72
- });
86
+ export const renderSmoodlyPath: Renderer["renderSmoodlyPath"] = (...args) => bound().renderer.renderSmoodlyPath(...args);
87
+ export const smoodlyMetadata: Renderer["smoodlyMetadata"] = (...args) => bound().renderer.smoodlyMetadata(...args);
88
+ export const renderSmoodlyPage: Renderer["renderSmoodlyPage"] = (...args) => bound().renderer.renderSmoodlyPage(...args);
89
+ export const smoodlyPageMetadata: Renderer["smoodlyPageMetadata"] = (...args) => bound().renderer.smoodlyPageMetadata(...args);
90
+ export const getSmoodlyShared: Renderer["getSmoodlyShared"] = (...args) => bound().renderer.getSmoodlyShared(...args);
@@ -0,0 +1,26 @@
1
+ // Shared content (spec 2026-09-07): code-declared singletons stored as
2
+ // entries. `header` and `footer` are visual — the layout renders them;
3
+ // `contact` is an object the footer reads through the layout; `quote` is
4
+ // visual and placed on pages from the picker's Shared group.
5
+ import { f, smoodly } from "smoodly";
6
+ import { Header } from "@/components/sections/Header";
7
+ import { Footer } from "@/components/sections/Footer";
8
+ import { Quote } from "@/components/sections/Quote";
9
+
10
+ export const header = smoodly.shared({ name: "header", title: "Header", section: Header });
11
+
12
+ export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
13
+
14
+ export const contact = smoodly.shared({
15
+ name: "contact",
16
+ title: "Contact details",
17
+ fields: {
18
+ email: f.text(),
19
+ phone: f.text().optional(),
20
+ address: f.text().localized(),
21
+ },
22
+ });
23
+
24
+ export const quote = smoodly.shared({ name: "quote", title: "Quote", section: Quote });
25
+
26
+ export const shared = [header, footer, contact, quote];
@@ -63,22 +63,6 @@ create table if not exists "page_versions" (
63
63
  create index if not exists "page_versions_page_locale_idx"
64
64
  on "page_versions" ("page_id", "locale", "created_at");
65
65
 
66
- create table if not exists "saved_sections" (
67
- "id" uuid primary key default gen_random_uuid(),
68
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
69
- "title" text not null,
70
- "fragment" jsonb not null,
71
- "created_at" timestamptz not null default now()
72
- );
73
-
74
- create table if not exists "global_sections" (
75
- "id" uuid primary key default gen_random_uuid(),
76
- "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
77
- "type" text not null,
78
- "content" jsonb not null,
79
- "updated_at" timestamptz not null default now()
80
- );
81
-
82
66
  create table if not exists "entries" (
83
67
  "id" uuid primary key default gen_random_uuid(),
84
68
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
@@ -204,8 +188,6 @@ $$;
204
188
  alter table "pages" enable row level security;
205
189
  alter table "page_locales" enable row level security;
206
190
  alter table "page_versions" enable row level security;
207
- alter table "saved_sections" enable row level security;
208
- alter table "global_sections" enable row level security;
209
191
  alter table "entries" enable row level security;
210
192
  alter table "entry_locales" enable row level security;
211
193
  alter table "entry_versions" enable row level security;
@@ -313,10 +295,6 @@ create index if not exists "entry_locales_space_idx" on "entry_locales" ("space_
313
295
 
314
296
  create index if not exists "entry_versions_space_idx" on "entry_versions" ("space_id");
315
297
 
316
- create index if not exists "saved_sections_space_idx" on "saved_sections" ("space_id");
317
-
318
- create index if not exists "global_sections_space_idx" on "global_sections" ("space_id");
319
-
320
298
  create index if not exists "refs_space_idx" on "refs" ("space_id");
321
299
 
322
300
  drop trigger if exists "smoodly_a_set_space" on "pages";
@@ -331,14 +309,6 @@ drop trigger if exists "smoodly_a_set_space" on "page_versions";
331
309
  create trigger "smoodly_a_set_space" before insert on "page_versions"
332
310
  for each row execute function "smoodly_set_space"();
333
311
 
334
- drop trigger if exists "smoodly_a_set_space" on "saved_sections";
335
- create trigger "smoodly_a_set_space" before insert on "saved_sections"
336
- for each row execute function "smoodly_set_space"();
337
-
338
- drop trigger if exists "smoodly_a_set_space" on "global_sections";
339
- create trigger "smoodly_a_set_space" before insert on "global_sections"
340
- for each row execute function "smoodly_set_space"();
341
-
342
312
  drop trigger if exists "smoodly_a_set_space" on "entries";
343
313
  create trigger "smoodly_a_set_space" before insert on "entries"
344
314
  for each row execute function "smoodly_set_space"();
@@ -375,14 +345,6 @@ drop trigger if exists "smoodly_b_row_cap" on "page_versions";
375
345
  create trigger "smoodly_b_row_cap" before insert on "page_versions"
376
346
  for each row execute function "smoodly_enforce_row_cap"();
377
347
 
378
- drop trigger if exists "smoodly_b_row_cap" on "saved_sections";
379
- create trigger "smoodly_b_row_cap" before insert on "saved_sections"
380
- for each row execute function "smoodly_enforce_row_cap"();
381
-
382
- drop trigger if exists "smoodly_b_row_cap" on "global_sections";
383
- create trigger "smoodly_b_row_cap" before insert on "global_sections"
384
- for each row execute function "smoodly_enforce_row_cap"();
385
-
386
348
  drop trigger if exists "smoodly_b_row_cap" on "entries";
387
349
  create trigger "smoodly_b_row_cap" before insert on "entries"
388
350
  for each row execute function "smoodly_enforce_row_cap"();
@@ -430,20 +392,6 @@ drop policy if exists "smoodly_read" on "page_versions";
430
392
  create policy "smoodly_read" on "page_versions" for select
431
393
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and exists (select 1 from "page_locales" l where l."published_version_id" = "page_versions"."id"));
432
394
 
433
- drop policy if exists "smoodly_write" on "saved_sections";
434
- create policy "smoodly_write" on "saved_sections" for all
435
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
436
- with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
437
-
438
- drop policy if exists "smoodly_write" on "global_sections";
439
- create policy "smoodly_write" on "global_sections" for all
440
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
441
- with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
442
-
443
- drop policy if exists "smoodly_read" on "global_sections";
444
- create policy "smoodly_read" on "global_sections" for select
445
- using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()));
446
-
447
395
  drop policy if exists "smoodly_write" on "entries";
448
396
  create policy "smoodly_write" on "entries" for all
449
397
  using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
@@ -514,8 +462,15 @@ select
514
462
  l."updated_at",
515
463
  v."fields"->>'title' as "title",
516
464
  v."fields"->>'excerpt' as "excerpt",
465
+ v."fields"->'body' as "body",
466
+ v."fields"->'tags' as "tags",
467
+ v."fields"->'image' as "image",
468
+ (v."fields"->>'date')::timestamptz as "date",
517
469
  v."fields"->>'author' as "author"
518
470
  from "entries" e
519
471
  join "entry_locales" l on l."entry_id" = e."id"
520
472
  join "entry_versions" v on v."id" = l."published_version_id"
521
473
  where e."collection" = 'articles' and l."status" = 'published';
474
+
475
+ create index if not exists "entries_articles_date_idx"
476
+ on "entries" (("fields"->'date')) where "collection" = 'articles';