create-smoodly-app 0.0.13 → 0.0.15

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 (173) hide show
  1. package/README.md +38 -10
  2. package/dist/index.js +39 -15
  3. package/package.json +3 -2
  4. package/templates/blank/AGENTS.md +50 -0
  5. package/templates/blank/CLAUDE.md +1 -0
  6. package/templates/blank/README.md +75 -0
  7. package/templates/{next/app → blank/app/(site)}/[...path]/page.tsx +2 -2
  8. package/templates/blank/app/(site)/layout.tsx +25 -0
  9. package/templates/blank/app/globals.css +47 -0
  10. package/templates/blank/components/elements/Button.tsx +26 -0
  11. package/templates/blank/components/pages/Home.tsx +22 -0
  12. package/templates/blank/components/pages/Page.tsx +25 -0
  13. package/templates/blank/components/pages/Post.tsx +21 -0
  14. package/templates/blank/components/sections/Text.tsx +39 -0
  15. package/templates/{next → blank}/package.json +1 -1
  16. package/templates/blank/scripts/seed.ts +72 -0
  17. package/templates/blank/smoodly/collections.ts +26 -0
  18. package/templates/blank/smoodly/config.ts +35 -0
  19. package/templates/blank/smoodly/elements.ts +5 -0
  20. package/templates/blank/smoodly/sections.ts +5 -0
  21. package/templates/{next → blank}/smoodly/server.ts +17 -13
  22. package/templates/blank/smoodly/shared.ts +16 -0
  23. package/templates/blank/smoodly/toolsets.ts +14 -0
  24. package/templates/blank/supabase/migrations/00000000000000_smoodly.sql +518 -0
  25. package/templates/blank-intl/AGENTS.md +50 -0
  26. package/templates/blank-intl/CLAUDE.md +1 -0
  27. package/templates/blank-intl/README.md +76 -0
  28. package/templates/blank-intl/app/[locale]/layout.tsx +42 -0
  29. package/templates/blank-intl/app/globals.css +47 -0
  30. package/templates/blank-intl/components/elements/Button.tsx +26 -0
  31. package/templates/blank-intl/components/pages/Home.tsx +22 -0
  32. package/templates/blank-intl/components/pages/Page.tsx +25 -0
  33. package/templates/blank-intl/components/pages/Post.tsx +21 -0
  34. package/templates/blank-intl/components/sections/Text.tsx +39 -0
  35. package/templates/blank-intl/messages/en.json +1 -0
  36. package/templates/{next-intl → blank-intl}/package.json +1 -1
  37. package/templates/blank-intl/scripts/seed.ts +72 -0
  38. package/templates/blank-intl/smoodly/collections.ts +26 -0
  39. package/templates/blank-intl/smoodly/config.ts +38 -0
  40. package/templates/blank-intl/smoodly/elements.ts +5 -0
  41. package/templates/blank-intl/smoodly/sections.ts +5 -0
  42. package/templates/{next-intl → blank-intl}/smoodly/server.ts +17 -13
  43. package/templates/blank-intl/smoodly/shared.ts +16 -0
  44. package/templates/blank-intl/smoodly/toolsets.ts +14 -0
  45. package/templates/blank-intl/supabase/migrations/00000000000000_smoodly.sql +518 -0
  46. package/templates/blank-intl.tsconfig.json +28 -0
  47. package/templates/blank.tsconfig.json +28 -0
  48. package/templates/lorem/AGENTS.md +50 -0
  49. package/templates/lorem/CLAUDE.md +1 -0
  50. package/templates/{next → lorem}/README.md +5 -4
  51. package/templates/lorem/_gitignore +5 -0
  52. package/templates/lorem/app/(site)/[...path]/page.tsx +15 -0
  53. package/templates/{next/app → lorem/app/(site)}/layout.tsx +11 -11
  54. package/templates/lorem/app/(site)/page.tsx +11 -0
  55. package/templates/lorem/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
  56. package/templates/lorem/app/(smoodly)/admin/layout.tsx +1 -0
  57. package/templates/{next → lorem}/app/globals.css +4 -0
  58. package/templates/{next-intl → lorem}/components/elements/Callout.tsx +1 -1
  59. package/templates/{next → lorem}/components/elements/Cta.tsx +1 -1
  60. package/templates/{next → lorem}/components/pages/Article.tsx +3 -2
  61. package/templates/{next → lorem}/components/pages/Articles.tsx +7 -7
  62. package/templates/{next-intl → lorem}/components/pages/Page.tsx +11 -1
  63. package/templates/{next → lorem}/components/sections/ImageText.tsx +1 -1
  64. package/templates/{next → lorem}/components/sections/Points.tsx +1 -1
  65. package/templates/lorem/next.config.ts +8 -0
  66. package/templates/lorem/package.json +27 -0
  67. package/templates/{next-intl → lorem}/scripts/seed.ts +30 -5
  68. package/templates/lorem/smoodly/admin.tsx +6 -0
  69. package/templates/lorem/smoodly/ops.ts +11 -0
  70. package/templates/lorem/smoodly/richtext.tsx +7 -0
  71. package/templates/lorem/smoodly/server.ts +96 -0
  72. package/templates/lorem/tsconfig.json +41 -0
  73. package/templates/lorem-intl/AGENTS.md +50 -0
  74. package/templates/lorem-intl/CLAUDE.md +1 -0
  75. package/templates/{next-intl → lorem-intl}/README.md +2 -1
  76. package/templates/lorem-intl/_gitignore +5 -0
  77. package/templates/lorem-intl/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
  78. package/templates/lorem-intl/app/(smoodly)/admin/layout.tsx +15 -0
  79. package/templates/lorem-intl/app/[locale]/[...path]/page.tsx +18 -0
  80. package/templates/{next-intl → lorem-intl}/app/[locale]/layout.tsx +5 -6
  81. package/templates/lorem-intl/app/[locale]/page.tsx +15 -0
  82. package/templates/{next-intl → lorem-intl}/app/globals.css +4 -0
  83. package/templates/{next → lorem-intl}/components/elements/Callout.tsx +1 -1
  84. package/templates/{next-intl → lorem-intl}/components/elements/Cta.tsx +1 -1
  85. package/templates/{next-intl → lorem-intl}/components/pages/Article.tsx +3 -2
  86. package/templates/{next-intl → lorem-intl}/components/pages/Articles.tsx +7 -7
  87. package/templates/{next → lorem-intl}/components/pages/Page.tsx +11 -1
  88. package/templates/{next-intl → lorem-intl}/components/sections/ImageText.tsx +1 -1
  89. package/templates/{next-intl → lorem-intl}/components/sections/Points.tsx +1 -1
  90. package/templates/lorem-intl/i18n/navigation.ts +6 -0
  91. package/templates/lorem-intl/i18n/request.ts +24 -0
  92. package/templates/lorem-intl/i18n/routing.ts +17 -0
  93. package/templates/lorem-intl/next.config.ts +11 -0
  94. package/templates/lorem-intl/package.json +28 -0
  95. package/templates/lorem-intl/proxy.ts +11 -0
  96. package/templates/{next → lorem-intl}/scripts/seed.ts +30 -5
  97. package/templates/lorem-intl/smoodly/admin.tsx +6 -0
  98. package/templates/lorem-intl/smoodly/ops.ts +11 -0
  99. package/templates/lorem-intl/smoodly/richtext.tsx +7 -0
  100. package/templates/lorem-intl/smoodly/server.ts +97 -0
  101. package/templates/lorem-intl/tsconfig.json +41 -0
  102. package/templates/lorem-intl.tsconfig.json +28 -0
  103. package/templates/lorem.tsconfig.json +28 -0
  104. /package/templates/{next → blank}/_gitignore +0 -0
  105. /package/templates/{next/app → blank/app/(site)}/page.tsx +0 -0
  106. /package/templates/{next → blank}/app/(smoodly)/admin/[[...segments]]/page.tsx +0 -0
  107. /package/templates/{next → blank}/app/(smoodly)/admin/layout.tsx +0 -0
  108. /package/templates/{next → blank}/next.config.ts +0 -0
  109. /package/templates/{next → blank}/smoodly/admin.tsx +0 -0
  110. /package/templates/{next → blank}/smoodly/ops.ts +0 -0
  111. /package/templates/{next → blank}/smoodly/richtext.tsx +0 -0
  112. /package/templates/{next → blank}/tsconfig.json +0 -0
  113. /package/templates/{next-intl → blank-intl}/_gitignore +0 -0
  114. /package/templates/{next-intl → blank-intl}/app/(smoodly)/admin/[[...segments]]/page.tsx +0 -0
  115. /package/templates/{next-intl → blank-intl}/app/(smoodly)/admin/layout.tsx +0 -0
  116. /package/templates/{next-intl → blank-intl}/app/[locale]/[...path]/page.tsx +0 -0
  117. /package/templates/{next-intl → blank-intl}/app/[locale]/page.tsx +0 -0
  118. /package/templates/{next-intl → blank-intl}/i18n/navigation.ts +0 -0
  119. /package/templates/{next-intl → blank-intl}/i18n/request.ts +0 -0
  120. /package/templates/{next-intl → blank-intl}/i18n/routing.ts +0 -0
  121. /package/templates/{next-intl → blank-intl}/next.config.ts +0 -0
  122. /package/templates/{next-intl → blank-intl}/proxy.ts +0 -0
  123. /package/templates/{next-intl → blank-intl}/smoodly/admin.tsx +0 -0
  124. /package/templates/{next-intl → blank-intl}/smoodly/ops.ts +0 -0
  125. /package/templates/{next-intl → blank-intl}/smoodly/richtext.tsx +0 -0
  126. /package/templates/{next-intl → blank-intl}/tsconfig.json +0 -0
  127. /package/templates/{next → lorem}/components/ArticleCard.tsx +0 -0
  128. /package/templates/{next → lorem}/components/articles.ts +0 -0
  129. /package/templates/{next → lorem}/components/pages/Home.tsx +0 -0
  130. /package/templates/{next → lorem}/components/sections/FavouriteArticles.tsx +0 -0
  131. /package/templates/{next → lorem}/components/sections/Footer.tsx +0 -0
  132. /package/templates/{next → lorem}/components/sections/Header.tsx +0 -0
  133. /package/templates/{next → lorem}/components/sections/Hero.tsx +0 -0
  134. /package/templates/{next → lorem}/components/sections/PageHero.tsx +0 -0
  135. /package/templates/{next → lorem}/components/sections/People.tsx +0 -0
  136. /package/templates/{next → lorem}/components/sections/Quote.tsx +0 -0
  137. /package/templates/{next → lorem}/components/strings.ts +0 -0
  138. /package/templates/{next → lorem}/messages/en.json +0 -0
  139. /package/templates/{next → lorem}/public/images/about_main.jpg +0 -0
  140. /package/templates/{next → lorem}/public/images/article_photo.jpg +0 -0
  141. /package/templates/{next → lorem}/public/images/hero_image.jpg +0 -0
  142. /package/templates/{next → lorem}/public/images/home_section_2.jpg +0 -0
  143. /package/templates/{next → lorem}/smoodly/collections.ts +0 -0
  144. /package/templates/{next → lorem}/smoodly/config.ts +0 -0
  145. /package/templates/{next → lorem}/smoodly/elements.ts +0 -0
  146. /package/templates/{next → lorem}/smoodly/sections.ts +0 -0
  147. /package/templates/{next → lorem}/smoodly/shared.ts +0 -0
  148. /package/templates/{next → lorem}/smoodly/toolsets.ts +0 -0
  149. /package/templates/{next → lorem}/supabase/migrations/00000000000000_smoodly.sql +0 -0
  150. /package/templates/{next-intl → lorem-intl}/components/ArticleCard.tsx +0 -0
  151. /package/templates/{next-intl → lorem-intl}/components/articles.ts +0 -0
  152. /package/templates/{next-intl → lorem-intl}/components/pages/Home.tsx +0 -0
  153. /package/templates/{next-intl → lorem-intl}/components/sections/FavouriteArticles.tsx +0 -0
  154. /package/templates/{next-intl → lorem-intl}/components/sections/Footer.tsx +0 -0
  155. /package/templates/{next-intl → lorem-intl}/components/sections/Header.tsx +0 -0
  156. /package/templates/{next-intl → lorem-intl}/components/sections/Hero.tsx +0 -0
  157. /package/templates/{next-intl → lorem-intl}/components/sections/PageHero.tsx +0 -0
  158. /package/templates/{next-intl → lorem-intl}/components/sections/People.tsx +0 -0
  159. /package/templates/{next-intl → lorem-intl}/components/sections/Quote.tsx +0 -0
  160. /package/templates/{next-intl → lorem-intl}/components/strings.ts +0 -0
  161. /package/templates/{next-intl → lorem-intl}/messages/en.json +0 -0
  162. /package/templates/{next-intl → lorem-intl}/messages/fi.json +0 -0
  163. /package/templates/{next-intl → lorem-intl}/public/images/about_main.jpg +0 -0
  164. /package/templates/{next-intl → lorem-intl}/public/images/article_photo.jpg +0 -0
  165. /package/templates/{next-intl → lorem-intl}/public/images/hero_image.jpg +0 -0
  166. /package/templates/{next-intl → lorem-intl}/public/images/home_section_2.jpg +0 -0
  167. /package/templates/{next-intl → lorem-intl}/smoodly/collections.ts +0 -0
  168. /package/templates/{next-intl → lorem-intl}/smoodly/config.ts +0 -0
  169. /package/templates/{next-intl → lorem-intl}/smoodly/elements.ts +0 -0
  170. /package/templates/{next-intl → lorem-intl}/smoodly/sections.ts +0 -0
  171. /package/templates/{next-intl → lorem-intl}/smoodly/shared.ts +0 -0
  172. /package/templates/{next-intl → lorem-intl}/smoodly/toolsets.ts +0 -0
  173. /package/templates/{next-intl → lorem-intl}/supabase/migrations/00000000000000_smoodly.sql +0 -0
@@ -0,0 +1,15 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type Params = Promise<{ path: string[] }>;
4
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
5
+
6
+ // Smoodly: everything else — pages at any depth and collection entries
7
+ // under their `path`. Add app/(site)/<segment>/page.tsx beside this file
8
+ // and it wins for that exact path; deeper paths still resolve here.
9
+ export async function generateMetadata({ params }: { params: Params }) {
10
+ return smoodlyMetadata((await params).path);
11
+ }
12
+
13
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
14
+ return renderSmoodlyPath((await params).path, { searchParams });
15
+ }
@@ -2,9 +2,8 @@ import type { ReactNode } from "react";
2
2
  import { Instrument_Sans, Newsreader } from "next/font/google";
3
3
  import { HeaderView } from "@/components/sections/Header";
4
4
  import { FooterView } from "@/components/sections/Footer";
5
- import { contact, footer, header } from "@/smoodly/shared";
6
- import { getSmoodlyShared } from "@/smoodly/server";
7
- import "./globals.css";
5
+ import { q } from "@/smoodly/server";
6
+ import "../globals.css";
8
7
 
9
8
  // The starter's two faces, as CSS variables globals.css reads.
10
9
  const serif = Newsreader({ subsets: ["latin", "latin-ext"], style: ["normal", "italic"], variable: "--font-serif" });
@@ -12,16 +11,17 @@ const sans = Instrument_Sans({ subsets: ["latin", "latin-ext"], weight: ["400",
12
11
 
13
12
  export const metadata = { title: "Lorem Co." };
14
13
 
15
- // The site's root layout. The admin has its own under app/(smoodly)/admin —
16
- // two root layouts, a full page load between them, which is fine: they
17
- // are separate apps in spirit. Header, footer and the contact block are
18
- // shared content: fetched here, cached under their tag, null until
19
- // published.
14
+ // The site's root layout, in its own route group so the admin's layout
15
+ // under app/(smoodly)/admin is a second root layout beside it rather than
16
+ // a child of this one — two root layouts, a full page load between them,
17
+ // which is fine: they are separate apps in spirit. Header, footer and the
18
+ // contact block are shared content: read through `q`, cached under their
19
+ // tag, null until published.
20
20
  export default async function RootLayout({ children }: { children: ReactNode }) {
21
21
  const [headerProps, footerProps, contactFields] = await Promise.all([
22
- getSmoodlyShared(header),
23
- getSmoodlyShared(footer),
24
- getSmoodlyShared(contact),
22
+ q().getShared("header"),
23
+ q().getShared("footer"),
24
+ q().getShared("contact"),
25
25
  ]);
26
26
  return (
27
27
  <html lang="en" className={`${serif.variable} ${sans.variable}`}>
@@ -0,0 +1,11 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
4
+
5
+ // Smoodly: the home page. Editing this file replaces or wraps the CMS
6
+ // home; the shapes it can render live in components/pages/.
7
+ export const generateMetadata = () => smoodlyMetadata([]);
8
+
9
+ export default async function Page({ searchParams }: { searchParams: SearchParams }) {
10
+ return renderSmoodlyPath([], { searchParams });
11
+ }
@@ -0,0 +1 @@
1
+ export { AdminPage as default } from "@/smoodly/admin";
@@ -0,0 +1 @@
1
+ export { AdminLayout as default } from "@/smoodly/admin";
@@ -173,6 +173,10 @@ h3.card__title { font-size: 24px; }
173
173
  .breadcrumb { padding-top: 24px; font-size: 14px; color: var(--muted); }
174
174
  .breadcrumb a:hover { color: var(--accent); }
175
175
 
176
+ .subnav { display: flex; flex-wrap: wrap; gap: 20px; padding-top: 12px; font-size: 14px; }
177
+ .subnav a { color: var(--muted); }
178
+ .subnav a:hover { color: var(--accent); }
179
+
176
180
  .articles-index { padding-bottom: 96px; }
177
181
 
178
182
  .article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
@@ -15,7 +15,7 @@ export const calloutSchema = smoodly.schema.element({
15
15
  body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
16
16
  },
17
17
  styles: {
18
- tone: f.select(["info", "success", "warning"]).default("info"),
18
+ tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
19
19
  },
20
20
  sample: {
21
21
  title: "Good to know",
@@ -15,7 +15,7 @@ export const ctaSchema = smoodly.schema.element({
15
15
  href: f.link(),
16
16
  },
17
17
  styles: {
18
- variant: f.select(["primary", "outline"]).default("primary"),
18
+ variant: f.select([{ value: "primary", label: "Primary" }, { value: "outline", label: "Outline" }]).default("primary"),
19
19
  },
20
20
  sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
21
21
  });
@@ -2,7 +2,7 @@ import { smoodly, type EntryPageProps } from "smoodly";
2
2
  import { RichText } from "@/smoodly/richtext";
3
3
  import { SmoodlyImage } from "smoodly/image";
4
4
  import { articles } from "@/smoodly/collections";
5
- import { site } from "@/smoodly/server";
5
+ import { q } from "@/smoodly/server";
6
6
  import { ArticleCard } from "../ArticleCard";
7
7
  import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
8
8
  import { ui } from "../strings";
@@ -16,7 +16,8 @@ import { ui } from "../strings";
16
16
  async function ArticleView({ entry }: EntryPageProps) {
17
17
  const { locale, t, url } = await ui();
18
18
  const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
19
- const related = (await site().getEntries("articles", { locale })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
19
+ // Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
20
+ const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
20
21
  const index = url(articlesIndexPath(locale));
21
22
  return (
22
23
  <>
@@ -3,16 +3,16 @@ import { PageHero } from "../sections/PageHero";
3
3
  import { ArticleCard } from "../ArticleCard";
4
4
  import { articlePath, type ArticleFields } from "../articles";
5
5
  import { ui } from "../strings";
6
- import { site } from "@/smoodly/server";
6
+ import { q } from "@/smoodly/server";
7
7
 
8
8
  // The Articles index: a fixed page whose slugs are the collection's own
9
9
  // segments (smoodly/collections.ts), which makes it the collection's
10
10
  // index page — the mount in the Pages list opens this page's editor. The
11
- // editor owns the hero; the grid below is developer JSX that fetches the
12
- // published articles through the site layer, newest first (the
13
- // collection's `order`). In the editor canvas the hero is the draft while
14
- // the grid shows published articles only: the content API is
15
- // published-only by design.
11
+ // editor owns the hero; the grid below is developer JSX that reads the
12
+ // articles through `q`, newest first (the collection's `order`), cached
13
+ // under the collection's tag. In the editor canvas the site runs in draft
14
+ // mode, so the grid reads the live rows too: an unpublished article shows
15
+ // there as it will (spec 2026-09-11 §2).
16
16
  export const articlesSchema = smoodly.schema.page({
17
17
  name: "articlesIndex",
18
18
  title: "Articles",
@@ -25,7 +25,7 @@ export const articlesSchema = smoodly.schema.page({
25
25
 
26
26
  async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
27
27
  const { locale, url } = await ui();
28
- const items = await site().getEntries("articles", { locale });
28
+ const items = await q().getEntries("articles", { locale });
29
29
  return (
30
30
  <>
31
31
  <Zone of={zones.hero} />
@@ -1,5 +1,5 @@
1
1
  import Link from "next/link";
2
- import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { f, smoodly, z, Zone, type Props } from "smoodly";
3
3
  import { PageHero } from "../sections/PageHero";
4
4
  import { ImageText } from "../sections/ImageText";
5
5
  import { Points } from "../sections/Points";
@@ -19,9 +19,14 @@ export const pageSchema = smoodly.schema.page({
19
19
  body: z.freeform({ sections: [ImageText, Points, FavouriteArticles, People, Quote] }),
20
20
  },
21
21
  meta: { seo: true },
22
+ // A page field (spec 2026-09-10 page fields §7): the parent's view lists
23
+ // the children that keep this on. Edited on the Settings tab.
24
+ fields: { showInNavigation: f.boolean().default(true) },
22
25
  });
23
26
 
24
27
  function PageView({ zones, page }: Props<typeof pageSchema>) {
28
+ // Sub-navigation: the published children that opted in through their page field.
29
+ const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
25
30
  return (
26
31
  <>
27
32
  {page.ancestors.length > 0 && (
@@ -34,6 +39,11 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
34
39
  {page.title}
35
40
  </nav>
36
41
  )}
42
+ {shown.length > 0 && (
43
+ <nav className="container subnav" aria-label="Pages in this section">
44
+ {shown.map((c) => <Link key={c.path} href={c.url}>{c.title}</Link>)}
45
+ </nav>
46
+ )}
37
47
  <Zone of={zones.hero} />
38
48
  <Zone of={zones.body} />
39
49
  </>
@@ -20,7 +20,7 @@ export const imageTextSchema = smoodly.schema.section({
20
20
  link: f.object({ label: f.text(), href: f.link() }).optional(),
21
21
  },
22
22
  styles: {
23
- imageSide: f.select(["left", "right"]).default("left"),
23
+ imageSide: f.select([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
24
24
  },
25
25
  sample: {
26
26
  heading: "Duis aute irure dolor in reprehenderit",
@@ -9,7 +9,7 @@ export const pointsSchema = smoodly.schema.section({
9
9
  fields: {
10
10
  heading: f.text(),
11
11
  points: f.list(f.object({
12
- icon: f.select(["circle", "square", "diamond"]),
12
+ icon: f.select([{ value: "circle", label: "Circle" }, { value: "square", label: "Square" }, { value: "diamond", label: "Diamond" }]),
13
13
  heading: f.text(),
14
14
  text: f.text(),
15
15
  })).min(1).max(6),
@@ -0,0 +1,8 @@
1
+ import type { NextConfig } from "next";
2
+ import { smoodlyImages } from "smoodly/image/config";
3
+
4
+ // next/image may only optimize from the Supabase host named in SMOODLY_URL
5
+ // (a local stack also needs Next's local-IP allowance; the helper sets it).
6
+ const config: NextConfig = { images: smoodlyImages() };
7
+
8
+ export default config;
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "smoodly-site",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "seed": "tsx scripts/seed.ts",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@supabase/supabase-js": "^2.113.0",
14
+ "next": "^16.3.4",
15
+ "react": "^19",
16
+ "react-dom": "^19",
17
+ "smoodly": "0.0.15"
18
+ },
19
+ "devDependencies": {
20
+ "@next/env": "^16.3.4",
21
+ "@types/node": "^26.4.1",
22
+ "@types/react": "^19",
23
+ "@types/react-dom": "^19.2.5",
24
+ "tsx": "^4",
25
+ "typescript": "^5"
26
+ }
27
+ }
@@ -103,7 +103,7 @@ async function seedEntry(collection: string, en: Fields, fi?: Fields) {
103
103
  return row;
104
104
  }
105
105
 
106
- type Tree = { zones: Record<string, Node[]> };
106
+ type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; meta?: Record<string, unknown> };
107
107
 
108
108
  /** Create a page (or a folder: `template: null`) in EN with its tree,
109
109
  * publish it, then the FI row with its own tree — a locale's tree is
@@ -251,6 +251,8 @@ const favourites = (heading: string): Node => ({
251
251
  });
252
252
 
253
253
  const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]; link: string; favourites: string }): Tree => ({
254
+ // The built-in SEO set rides the tree (spec 2026-09-10 page fields §2).
255
+ meta: { description: "Lorem Co. — consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore." },
254
256
  zones: {
255
257
  // The locked hero's one node. Pages created in the admin get theirs
256
258
  // from the editor (fillLockedZones on load); a seeded tree brings its own.
@@ -299,7 +301,9 @@ await seedPage(
299
301
  // both locales with its own slug and title.
300
302
  const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
301
303
 
302
- const aboutTree = (t: { kicker: string; people: string }): Tree => ({
304
+ const aboutTree = (t: { kicker: string; people: string; description: string }): Tree => ({
305
+ fields: { showInNavigation: true },
306
+ meta: { description: t.description },
303
307
  zones: {
304
308
  hero: [{
305
309
  id: "node-about-hero",
@@ -329,11 +333,32 @@ const aboutTree = (t: { kicker: string; people: string }): Tree => ({
329
333
  ],
330
334
  },
331
335
  });
332
- await seedPage(
336
+ const about = await seedPage(
333
337
  { slug: "about-us", title: "About us", template: "page", parentId: company.id, fi: { slug: "tietoa-meista", title: "Tietoa meistä" } },
334
- { en: aboutTree({ kicker: "About us", people: "Our people" }), fi: aboutTree({ kicker: "Tietoa meistä", people: "Meidän väki" }) },
338
+ {
339
+ en: aboutTree({ kicker: "About us", people: "Our people", description: "Lorem ipsum dolor sit amet — who we are and what we do." }),
340
+ fi: aboutTree({ kicker: "Tietoa meistä", people: "Meidän väki", description: "Lorem ipsum dolor sit amet — keitä olemme ja mitä teemme." }),
341
+ },
342
+ );
343
+
344
+ // A page under About us, shown in its sub-navigation through the Page
345
+ // shape's `showInNavigation` field (spec 2026-09-10 page fields §7).
346
+ const teamTree = (t: { heading: string; text: string; people: string }): Tree => ({
347
+ fields: { showInNavigation: true },
348
+ meta: { description: t.text },
349
+ zones: {
350
+ hero: [{ id: "node-team-hero", type: "pageHero", fields: { kicker: t.heading, heading: t.heading, text: t.text, image: img.about } }],
351
+ body: [{ id: "node-team-people", type: "peopleSection", fields: { heading: t.people, people: [jane.id, john.id, anna.id, mark.id] } }],
352
+ },
353
+ });
354
+ await seedPage(
355
+ { slug: "team", title: "Team", template: "page", parentId: about.id, fi: { slug: "tiimi", title: "Tiimi" } },
356
+ {
357
+ en: teamTree({ heading: "Team", text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.", people: "The whole team" }),
358
+ fi: teamTree({ heading: "Tiimi", text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.", people: "Koko tiimi" }),
359
+ },
335
360
  );
336
361
 
337
362
  console.log(
338
- `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.`,
363
+ `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
339
364
  );
@@ -0,0 +1,6 @@
1
+ import { createSmoodlyAdmin } from "smoodly/admin/next";
2
+ import { config } from "./config";
3
+ import { smoodlyOp } from "./ops";
4
+ import { publicAuth } from "./server";
5
+
6
+ export const { AdminPage, AdminLayout } = createSmoodlyAdmin({ config, op: smoodlyOp, auth: publicAuth });
@@ -0,0 +1,11 @@
1
+ "use server";
2
+ // The app's entire write surface for the admin: ONE exported action,
3
+ // gated by the editors table. ('use server' modules may only export
4
+ // async functions — hence the shape.)
5
+ import { createAdminOpHandler, type AdminOpCall } from "smoodly/admin/next";
6
+ import { config } from "./config";
7
+ import { adminAuth, href, stores } from "./server";
8
+
9
+ export async function smoodlyOp(call: AdminOpCall) {
10
+ return createAdminOpHandler({ config, ...stores(), auth: adminAuth(), href })(call);
11
+ }
@@ -0,0 +1,7 @@
1
+ // The site's richtext renderer, bound once: it knows the elements a body
2
+ // may embed and renders body images through <SmoodlyImage>. Sections and
3
+ // entry pages import RichText from here, never from the package root.
4
+ import { createRichText } from "smoodly/richtext";
5
+ import { elements } from "./elements";
6
+
7
+ export const RichText = createRichText({ elements, sizes: "(min-width: 900px) 720px, 100vw" });
@@ -0,0 +1,96 @@
1
+ // Server-only glue: the client, both stores, the site layer and the
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
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 read through
8
+ // `q` (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.
11
+ import {
12
+ createSmoodlyClient, createSmoodlySite, smoodlyEnv, SupabaseAssetStore, SupabaseEntryStore, SupabasePageStore, SupabasePathIndex, type SmoodlySite,
13
+ } from "smoodly";
14
+ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
+ import { createPageRenderer, createSmoodlyQueries, type PageRegistration } from "smoodly/next";
16
+ import type { SupabaseClient } from "@supabase/supabase-js";
17
+ import { collections, config, elements, sections, shared } from "./config";
18
+
19
+ type Renderer = ReturnType<typeof createPageRenderer>;
20
+ type Queries = ReturnType<typeof createSmoodlyQueries<typeof collections, typeof shared>>;
21
+ type Bound = {
22
+ db: SupabaseClient; pages: SupabasePageStore; entries: SupabaseEntryStore; assets: SupabaseAssetStore; auth: AdminAuth; site: SmoodlySite; renderer: Renderer; q: Queries;
23
+ };
24
+
25
+ let cached: Bound | null = null;
26
+
27
+ function bound(): Bound {
28
+ if (cached) return cached;
29
+ const env = smoodlyEnv();
30
+ const db = createSmoodlyClient(env);
31
+ // ONE path index for both stores: pages and entries share the URL space.
32
+ const paths = new SupabasePathIndex(db);
33
+ const locales = config.locales;
34
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
35
+ const pages = new SupabasePageStore(db, {
36
+ sections, elements, entries, paths, locales,
37
+ homePageSlug: config.homePageSlug,
38
+ tree: config.pages?.tree,
39
+ });
40
+ // The media library: rows in `assets`, bytes in the public `media` bucket.
41
+ const assets = new SupabaseAssetStore(db);
42
+ const auth = supabaseAdminAuth({ authUrl: env.authUrl, authKey: env.authKey, db });
43
+ // Shadows the exported `stores` below on purpose: `createSmoodlySite`
44
+ // takes this local closure, not the export, so it never re-enters `bound()`.
45
+ const stores = () => ({ pages, entries, assets });
46
+ // The site layer: (locale, path) → page or entry, and the content API
47
+ // `q` wraps below. Not exported — the app reads through `q`, which adds
48
+ // draft awareness and the cache.
49
+ const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug, pages: config.registry.pages ?? [] });
50
+ const renderer = createPageRenderer({
51
+ site,
52
+ sections,
53
+ pages: (config.registry.pages ?? []) as PageRegistration[],
54
+ href,
55
+ });
56
+ // The read API (spec 2026-09-11): typed by the registry, draft-aware, cached under tags the admin expires.
57
+ const q = createSmoodlyQueries({ site, collections, shared });
58
+ cached = { db, pages, entries, assets, auth, site, renderer, q };
59
+ return cached;
60
+ }
61
+
62
+ export function stores() {
63
+ const { pages, entries, assets } = bound();
64
+ return { pages, entries, assets };
65
+ }
66
+
67
+ /** The server-side client (seed script, one-off admin tasks). */
68
+ export const client = () => bound().db;
69
+
70
+ /** The admin gate: session verified against the auth project, editors row read with the write key. */
71
+ export const adminAuth = () => bound().auth;
72
+
73
+ /** What the browser needs to sign in — public values only. */
74
+ export function publicAuth(): AuthConfig {
75
+ const env = smoodlyEnv();
76
+ return { url: env.authUrl, key: env.authKey };
77
+ }
78
+
79
+ /** Locale-relative CMS path → site URL: the one seam for the renderer
80
+ * (metadata alternates, the `page` prop's links) and the admin (canvas,
81
+ * View live). The identity for a single-language site; with next-intl it
82
+ * becomes a locale-prefixing lookup. */
83
+ export const href = (path: string, _locale: string) => path;
84
+
85
+ /** The read API (spec 2026-09-11) for layouts and page views that fetch:
86
+ * `q().getEntries("articles", { locale, limit: 3 })`, `q().getShared("header",
87
+ * { locale })`, `q().getPageTree({ locale })`. Typed by the registry, the
88
+ * live draft in the editor preview, cached under tags the admin expires.
89
+ * Rename it if `q` is not to your taste — this file is yours. */
90
+ export const q = (): Queries => bound().q;
91
+
92
+ /** The two route files call these. */
93
+ export const renderSmoodlyPath: Renderer["renderSmoodlyPath"] = (...args) => bound().renderer.renderSmoodlyPath(...args);
94
+ export const smoodlyMetadata: Renderer["smoodlyMetadata"] = (...args) => bound().renderer.smoodlyMetadata(...args);
95
+ export const renderSmoodlyPage: Renderer["renderSmoodlyPage"] = (...args) => bound().renderer.renderSmoodlyPage(...args);
96
+ export const smoodlyPageMetadata: Renderer["smoodlyPageMetadata"] = (...args) => bound().renderer.smoodlyPageMetadata(...args);
@@ -0,0 +1,41 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "esModuleInterop": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "react-jsx",
19
+ "incremental": true,
20
+ "paths": {
21
+ "@/*": [
22
+ "./*"
23
+ ]
24
+ },
25
+ "plugins": [
26
+ {
27
+ "name": "next"
28
+ }
29
+ ]
30
+ },
31
+ "include": [
32
+ "next-env.d.ts",
33
+ "**/*.ts",
34
+ "**/*.tsx",
35
+ ".next/types/**/*.ts",
36
+ ".next/dev/types/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }
@@ -0,0 +1,50 @@
1
+ # Working in this Smoodly site
2
+
3
+ This is a Next.js app with Smoodly installed: content lives in Supabase,
4
+ the admin is mounted at /admin from the `smoodly` package, and the site
5
+ reads content through `smoodly/server.ts`. Read README.md first: it says
6
+ where things are.
7
+
8
+ ## Rules
9
+
10
+ - **Register everything in `smoodly/`.** Every section, element,
11
+ collection and shared item is listed in its barrel (`sections.ts`,
12
+ `elements.ts`, `collections.ts`, `shared.ts`) and `smoodly/config.ts`
13
+ is the one registry. A component that is not registered does not exist
14
+ for the admin.
15
+ - **A new collection is a new migration.** Write its view with
16
+ `collectionSQL` from `smoodly` into a new file under
17
+ `supabase/migrations/`, then apply it: `supabase db push` (hosted) or
18
+ `supabase db reset` (local). The admin cannot list a collection whose
19
+ view is missing.
20
+ - **Seed through the stores.** `scripts/seed.ts` is starter content,
21
+ written through the page and entry stores, never raw SQL. It is
22
+ idempotent; run it with `npm run seed`.
23
+ - **Never import the admin into the site.** Nothing under `app/(site)`,
24
+ `app/[locale]` or `components/` may import `@/smoodly/admin` or
25
+ `smoodly/admin/next`; the admin's chunk must not reach visitors.
26
+ - **Two root layouts.** The site's layout lives in its route group or
27
+ locale segment and is never the admin's parent; `app/(smoodly)/admin`
28
+ is a second root layout. Do not add an `app/layout.tsx`.
29
+ - **Toolsets and elements.** A richtext field names a toolset
30
+ (`smoodly/toolsets.ts`); a toolset embeds elements by value; the config
31
+ must register those elements too. Files the toolset imports use
32
+ relative paths, not `@/`.
33
+ - **Section styles are data attributes.** `<smoodly.Section>` renders the
34
+ root element and emits the config's section styles as `data-*`
35
+ attributes; `app/globals.css` styles them. Marks
36
+ (`smoodly.marks(schema)`) tie rendered elements to fields so the
37
+ editor can select and edit them in place.
38
+ - **Multilingual sites.** Content locales are Smoodly's; URL locales are
39
+ next-intl's. The locale set is `i18n/routing.ts`. UI strings of the
40
+ site's own go through next-intl's messages in `messages/<locale>.json`.
41
+ - **Fixed versus open pages.** A page shape with a `slug` is fixed: one
42
+ record, created for you, never deleted. Without one it is open: editors
43
+ create as many as they like.
44
+
45
+ ## Commands
46
+
47
+ npm run dev # http://localhost:3000, /admin is the editor
48
+ npm run build # next build
49
+ npm run seed # starter content, idempotent
50
+ npm run typecheck # tsc --noEmit
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -1,7 +1,8 @@
1
1
  # Smoodly site
2
2
 
3
3
  Created by `create-smoodly-app`. Content lives in Supabase; the admin is
4
- mounted at `/admin` from the `smoodly` package.
4
+ mounted at `/admin` from the `smoodly` package. AGENTS.md holds the rules
5
+ for an agent (or a person) building the site.
5
6
 
6
7
  npm run dev # http://localhost:3000 — /admin is the editor
7
8
 
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ .next/
3
+ .env.local
4
+ next-env.d.ts
5
+ *.tsbuildinfo
@@ -0,0 +1 @@
1
+ export { AdminPage as default } from "@/smoodly/admin";
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import { AdminLayout } from "@/smoodly/admin";
3
+
4
+ // The admin's own root layout: the site's is app/[locale]/layout.tsx and
5
+ // the admin sits outside the locale segment (the proxy skips /admin), so
6
+ // Next needs a second <html> here.
7
+ export default function AdminRootLayout({ children }: { children: ReactNode }) {
8
+ return (
9
+ <html lang="en">
10
+ <body>
11
+ <AdminLayout>{children}</AdminLayout>
12
+ </body>
13
+ </html>
14
+ );
15
+ }
@@ -0,0 +1,18 @@
1
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
2
+
3
+ type Params = Promise<{ locale: string; path: string[] }>;
4
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
5
+
6
+ // Smoodly: everything else — pages at any depth and collection entries
7
+ // under their `path`, with translated segments per locale. Add
8
+ // app/[locale]/<segment>/page.tsx beside this file and it wins for that
9
+ // exact path; deeper paths still resolve here.
10
+ export async function generateMetadata({ params }: { params: Params }) {
11
+ const { locale, path } = await params;
12
+ return smoodlyMetadata(path, { locale });
13
+ }
14
+
15
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
16
+ const { locale, path } = await params;
17
+ return renderSmoodlyPath(path, { locale, searchParams });
18
+ }
@@ -5,8 +5,7 @@ import { hasLocale, NextIntlClientProvider } from "next-intl";
5
5
  import { routing } from "@/i18n/routing";
6
6
  import { HeaderView } from "@/components/sections/Header";
7
7
  import { FooterView } from "@/components/sections/Footer";
8
- import { contact, footer, header } from "@/smoodly/shared";
9
- import { getSmoodlyShared, href } from "@/smoodly/server";
8
+ import { href, q } from "@/smoodly/server";
10
9
  import "../globals.css";
11
10
 
12
11
  // The starter's two faces, as CSS variables globals.css reads.
@@ -18,16 +17,16 @@ export const metadata = { title: "Lorem Co." };
18
17
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
19
18
  // two root layouts, a full page load between them, which is fine: they
20
19
  // are separate apps in spirit. Header, footer and the contact block are
21
- // shared content: fetched here, cached per locale under their tag, null
20
+ // shared content: read through `q`, cached per locale under their tag, null
22
21
  // until published in this locale. The header also gets the locale links
23
22
  // — the visitor's only language switch, since locale detection is off.
24
23
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
25
24
  const { locale } = await params;
26
25
  if (!hasLocale(routing.locales, locale)) notFound();
27
26
  const [headerProps, footerProps, contactFields] = await Promise.all([
28
- getSmoodlyShared(header, { locale }),
29
- getSmoodlyShared(footer, { locale }),
30
- getSmoodlyShared(contact, { locale }),
27
+ q().getShared("header", { locale }),
28
+ q().getShared("footer", { locale }),
29
+ q().getShared("contact", { locale }),
31
30
  ]);
32
31
  const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
33
32
  return (