create-smoodly-app 0.0.8 → 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 (74) 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 +17 -9
  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 -11
  11. package/templates/next/components/pages/Page.tsx +11 -9
  12. package/templates/next/components/sections/FavouriteArticles.tsx +42 -0
  13. package/templates/next/components/sections/Footer.tsx +50 -14
  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 +9 -2
  31. package/templates/next/smoodly/sections.ts +13 -4
  32. package/templates/next/smoodly/server.ts +37 -20
  33. package/templates/next/smoodly/shared.ts +9 -5
  34. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +7 -0
  35. package/templates/next-intl/README.md +19 -3
  36. package/templates/next-intl/app/[locale]/layout.tsx +24 -12
  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 -11
  43. package/templates/next-intl/components/pages/Page.tsx +11 -9
  44. package/templates/next-intl/components/sections/FavouriteArticles.tsx +42 -0
  45. package/templates/next-intl/components/sections/Footer.tsx +50 -14
  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 +9 -2
  65. package/templates/next-intl/smoodly/sections.ts +13 -4
  66. package/templates/next-intl/smoodly/server.ts +37 -20
  67. package/templates/next-intl/smoodly/shared.ts +9 -5
  68. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +7 -0
  69. package/templates/next/components/sections/ArticleList.tsx +0 -31
  70. package/templates/next/components/sections/CtaBanner.tsx +0 -35
  71. package/templates/next/components/sections/Testimonials.tsx +0 -30
  72. package/templates/next-intl/components/sections/ArticleList.tsx +0 -31
  73. package/templates/next-intl/components/sections/CtaBanner.tsx +0 -35
  74. package/templates/next-intl/components/sections/Testimonials.tsx +0 -30
@@ -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
  });
@@ -3,6 +3,7 @@ import { collections } from "./collections";
3
3
  import { sections } from "./sections";
4
4
  import { shared } from "./shared";
5
5
  import { HomePage } from "@/components/pages/Home";
6
+ import { ArticlesPage } from "@/components/pages/Articles";
6
7
  import { StandardPage } from "@/components/pages/Page";
7
8
  import { ArticlePage } from "@/components/pages/Article";
8
9
 
@@ -16,14 +17,20 @@ export const config = defineConfig({
16
17
  sections,
17
18
  collections,
18
19
  shared,
19
- pages: [HomePage, StandardPage, ArticlePage],
20
+ pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
20
21
  },
21
22
  locales: { default: "en", supported: ["en"] },
22
23
  // Smoodly's own default, spelled out: the record the site root serves.
23
24
  homePageSlug: "home",
24
25
  // Nesting on: an editor can put pages three deep.
25
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>").
26
30
  styles: {
27
- 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
+ },
28
35
  },
29
36
  });
@@ -1,10 +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";
7
17
  import { Footer } from "@/components/sections/Footer";
8
- import { Testimonials } from "@/components/sections/Testimonials";
9
18
 
10
- export const sections = [Hero, ArticleList, CtaBanner, Footer, Testimonials];
19
+ export const sections = [Hero, PageHero, ImageText, Points, FavouriteArticles, People, Quote, Header, Footer];
@@ -2,17 +2,28 @@
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
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);
@@ -26,7 +37,21 @@ function bound() {
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,21 +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
- shared,
62
- sections,
63
- locales: config.locales,
64
- homePageSlug: config.homePageSlug,
65
- });
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;
66
84
 
67
85
  /** The two route files call these. */
68
- export const { renderSmoodlyPath, smoodlyMetadata, renderSmoodlyPage, smoodlyPageMetadata, getSmoodlyShared } = createPageRenderer({
69
- site,
70
- sections,
71
- pages: (config.registry.pages ?? []) as PageRegistration[],
72
- href,
73
- });
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);
@@ -1,9 +1,13 @@
1
1
  // Shared content (spec 2026-09-07): code-declared singletons stored as
2
- // entries. `footer` is visual — the layout renders it; `contact` is an
3
- // object the layout reads; `testimonials` is visual and placed on pages.
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.
4
5
  import { f, smoodly } from "smoodly";
6
+ import { Header } from "@/components/sections/Header";
5
7
  import { Footer } from "@/components/sections/Footer";
6
- import { Testimonials } from "@/components/sections/Testimonials";
8
+ import { Quote } from "@/components/sections/Quote";
9
+
10
+ export const header = smoodly.shared({ name: "header", title: "Header", section: Header });
7
11
 
8
12
  export const footer = smoodly.shared({ name: "footer", title: "Footer", section: Footer, versions: true });
9
13
 
@@ -17,6 +21,6 @@ export const contact = smoodly.shared({
17
21
  },
18
22
  });
19
23
 
20
- export const testimonials = smoodly.shared({ name: "testimonials", title: "Testimonials", section: Testimonials });
24
+ export const quote = smoodly.shared({ name: "quote", title: "Quote", section: Quote });
21
25
 
22
- export const shared = [footer, contact, testimonials];
26
+ export const shared = [header, footer, contact, quote];
@@ -462,8 +462,15 @@ select
462
462
  l."updated_at",
463
463
  v."fields"->>'title' as "title",
464
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",
465
469
  v."fields"->>'author' as "author"
466
470
  from "entries" e
467
471
  join "entry_locales" l on l."entry_id" = e."id"
468
472
  join "entry_versions" v on v."id" = l."published_version_id"
469
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';
@@ -9,10 +9,13 @@ mounted at `/admin` from the `smoodly` package.
9
9
 
10
10
  - `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
11
11
  - `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
12
- - `app/[locale]/layout.tsx` — the site's root layout (`<html lang>`, next-intl's provider); the admin has its own under `app/(smoodly)/admin/`.
12
+ - `app/[locale]/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from shared content, the FI / EN links; the admin has its own under `app/(smoodly)/admin/`.
13
13
  - `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded).
14
- - `components/pages/` — the page shapes and entry pages: schema + view, one file each.
15
- - `components/sections/` — the building blocks editors compose with. Add one, list it in `smoodly/sections.ts`.
14
+ - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
15
+ - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the shared items. Add one, list it in `smoodly/sections.ts`.
16
+ - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
17
+ - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
18
+ - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
16
19
  - `smoodly/collections.ts` — structured content (people, articles). Add a collection, then add its view to a new migration with `collectionSQL` from `smoodly`.
17
20
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
18
21
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
@@ -23,6 +26,19 @@ admin's page settings; a collection's base segment per locale is its
23
26
  next-intl's messages — add `messages` in `i18n/request.ts`:
24
27
  https://next-intl.dev/docs/usage/configuration
25
28
 
29
+ ## What is in the starter
30
+
31
+ The Lorem Co. site the installer seeded is a tour of the features:
32
+
33
+ - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the shared **quote** placed from the picker's Shared group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
34
+ - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
35
+ - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
36
+ - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
37
+ - **Shared content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
38
+
39
+ Every page and entry exists in each locale with its own slug. Reseed an
40
+ empty database any time with `npm run seed`.
41
+
26
42
  ## Editors
27
43
 
28
44
  Anyone who may open `/admin` is a Supabase Auth user with a row in the
@@ -1,31 +1,43 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { notFound } from "next/navigation";
3
+ import { Instrument_Sans, Newsreader } from "next/font/google";
3
4
  import { hasLocale, NextIntlClientProvider } from "next-intl";
4
5
  import { routing } from "@/i18n/routing";
5
- import { Footer } from "@/components/sections/Footer";
6
- import { contact, footer } from "@/smoodly/shared";
7
- import { getSmoodlyShared } from "@/smoodly/server";
6
+ import { HeaderView } from "@/components/sections/Header";
7
+ import { FooterView } from "@/components/sections/Footer";
8
+ import { contact, footer, header } from "@/smoodly/shared";
9
+ import { getSmoodlyShared, href } from "@/smoodly/server";
10
+ import "../globals.css";
8
11
 
9
- export const metadata = { title: "Smoodly site" };
12
+ // The starter's two faces, as CSS variables globals.css reads.
13
+ const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
14
+ const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400", "500", "600"], variable: "--font-sans" });
15
+
16
+ export const metadata = { title: "Lorem Co." };
10
17
 
11
18
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
12
19
  // two root layouts, a full page load between them, which is fine: they
13
- // are separate apps in spirit. The footer and the contact block are
14
- // shared content: fetched here, cached per locale under their tag,
15
- // null until published in this locale.
20
+ // are separate apps in spirit. Header, footer and the contact block are
21
+ // shared content: fetched here, cached per locale under their tag, null
22
+ // until published in this locale. The header also gets the locale links
23
+ // — the visitor's only language switch, since locale detection is off.
16
24
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
17
25
  const { locale } = await params;
18
26
  if (!hasLocale(routing.locales, locale)) notFound();
19
- const [footerProps, contactFields] = await Promise.all([
27
+ const [headerProps, footerProps, contactFields] = await Promise.all([
28
+ getSmoodlyShared(header, { locale }),
20
29
  getSmoodlyShared(footer, { locale }),
21
30
  getSmoodlyShared(contact, { locale }),
22
31
  ]);
32
+ const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
23
33
  return (
24
- <html lang={locale}>
34
+ <html lang={locale} className={`${serif.variable} ${sans.variable}`}>
25
35
  <body>
26
- <NextIntlClientProvider>{children}</NextIntlClientProvider>
27
- {contactFields && <address>{contactFields.email} · {contactFields.address}</address>}
28
- {footerProps && <Footer {...footerProps} />}
36
+ <NextIntlClientProvider>
37
+ {headerProps && <HeaderView {...headerProps} locales={locales} />}
38
+ <main>{children}</main>
39
+ {footerProps && <FooterView {...footerProps} contact={contactFields} />}
40
+ </NextIntlClientProvider>
29
41
  </body>
30
42
  </html>
31
43
  );