create-smoodly-app 0.0.14 → 0.0.16

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 +25 -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/blank/smoodly/shared.ts +16 -0
  22. package/templates/blank/smoodly/toolsets.ts +14 -0
  23. package/templates/{next → blank}/supabase/migrations/00000000000000_smoodly.sql +19 -34
  24. package/templates/blank-intl/AGENTS.md +50 -0
  25. package/templates/blank-intl/CLAUDE.md +1 -0
  26. package/templates/blank-intl/README.md +76 -0
  27. package/templates/blank-intl/app/[locale]/layout.tsx +42 -0
  28. package/templates/blank-intl/app/globals.css +47 -0
  29. package/templates/blank-intl/components/elements/Button.tsx +26 -0
  30. package/templates/blank-intl/components/pages/Home.tsx +22 -0
  31. package/templates/blank-intl/components/pages/Page.tsx +25 -0
  32. package/templates/blank-intl/components/pages/Post.tsx +21 -0
  33. package/templates/blank-intl/components/sections/Text.tsx +39 -0
  34. package/templates/blank-intl/messages/en.json +1 -0
  35. package/templates/{next-intl → blank-intl}/package.json +1 -1
  36. package/templates/blank-intl/scripts/seed.ts +72 -0
  37. package/templates/blank-intl/smoodly/collections.ts +25 -0
  38. package/templates/blank-intl/smoodly/config.ts +38 -0
  39. package/templates/blank-intl/smoodly/elements.ts +5 -0
  40. package/templates/blank-intl/smoodly/sections.ts +5 -0
  41. package/templates/blank-intl/smoodly/shared.ts +16 -0
  42. package/templates/blank-intl/smoodly/toolsets.ts +14 -0
  43. package/templates/{next-intl → blank-intl}/supabase/migrations/00000000000000_smoodly.sql +19 -34
  44. package/templates/blank-intl.tsconfig.json +28 -0
  45. package/templates/blank.tsconfig.json +28 -0
  46. package/templates/lorem/AGENTS.md +50 -0
  47. package/templates/lorem/CLAUDE.md +1 -0
  48. package/templates/{next → lorem}/README.md +5 -4
  49. package/templates/lorem/_gitignore +5 -0
  50. package/templates/lorem/app/(site)/[...path]/page.tsx +15 -0
  51. package/templates/{next/app → lorem/app/(site)}/layout.tsx +7 -6
  52. package/templates/lorem/app/(site)/page.tsx +11 -0
  53. package/templates/lorem/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
  54. package/templates/lorem/app/(smoodly)/admin/layout.tsx +1 -0
  55. package/templates/lorem/next.config.ts +8 -0
  56. package/templates/lorem/package.json +27 -0
  57. package/templates/{next-intl → lorem}/scripts/seed.ts +1 -1
  58. package/templates/lorem/smoodly/admin.tsx +6 -0
  59. package/templates/{next → lorem}/smoodly/collections.ts +2 -6
  60. package/templates/lorem/smoodly/ops.ts +11 -0
  61. package/templates/lorem/smoodly/richtext.tsx +7 -0
  62. package/templates/lorem/smoodly/server.ts +96 -0
  63. package/templates/{next-intl → lorem}/smoodly/shared.ts +1 -1
  64. package/templates/lorem/supabase/migrations/00000000000000_smoodly.sql +540 -0
  65. package/templates/lorem/tsconfig.json +41 -0
  66. package/templates/lorem-intl/AGENTS.md +50 -0
  67. package/templates/lorem-intl/CLAUDE.md +1 -0
  68. package/templates/{next-intl → lorem-intl}/README.md +2 -1
  69. package/templates/lorem-intl/_gitignore +5 -0
  70. package/templates/lorem-intl/app/(smoodly)/admin/[[...segments]]/page.tsx +1 -0
  71. package/templates/lorem-intl/app/(smoodly)/admin/layout.tsx +15 -0
  72. package/templates/lorem-intl/app/[locale]/[...path]/page.tsx +18 -0
  73. package/templates/lorem-intl/app/[locale]/page.tsx +15 -0
  74. package/templates/lorem-intl/i18n/navigation.ts +6 -0
  75. package/templates/lorem-intl/i18n/request.ts +24 -0
  76. package/templates/lorem-intl/i18n/routing.ts +17 -0
  77. package/templates/lorem-intl/next.config.ts +11 -0
  78. package/templates/lorem-intl/package.json +28 -0
  79. package/templates/lorem-intl/proxy.ts +11 -0
  80. package/templates/{next → lorem-intl}/scripts/seed.ts +1 -1
  81. package/templates/lorem-intl/smoodly/admin.tsx +6 -0
  82. package/templates/{next-intl → lorem-intl}/smoodly/collections.ts +2 -6
  83. package/templates/lorem-intl/smoodly/ops.ts +11 -0
  84. package/templates/lorem-intl/smoodly/richtext.tsx +7 -0
  85. package/templates/lorem-intl/smoodly/server.ts +97 -0
  86. package/templates/{next → lorem-intl}/smoodly/shared.ts +1 -1
  87. package/templates/lorem-intl/supabase/migrations/00000000000000_smoodly.sql +540 -0
  88. package/templates/lorem-intl/tsconfig.json +41 -0
  89. package/templates/lorem-intl.tsconfig.json +28 -0
  90. package/templates/lorem.tsconfig.json +28 -0
  91. /package/templates/{next → blank}/_gitignore +0 -0
  92. /package/templates/{next/app → blank/app/(site)}/page.tsx +0 -0
  93. /package/templates/{next → blank}/app/(smoodly)/admin/[[...segments]]/page.tsx +0 -0
  94. /package/templates/{next → blank}/app/(smoodly)/admin/layout.tsx +0 -0
  95. /package/templates/{next → blank}/next.config.ts +0 -0
  96. /package/templates/{next → blank}/smoodly/admin.tsx +0 -0
  97. /package/templates/{next → blank}/smoodly/ops.ts +0 -0
  98. /package/templates/{next → blank}/smoodly/richtext.tsx +0 -0
  99. /package/templates/{next → blank}/smoodly/server.ts +0 -0
  100. /package/templates/{next → blank}/tsconfig.json +0 -0
  101. /package/templates/{next-intl → blank-intl}/_gitignore +0 -0
  102. /package/templates/{next-intl → blank-intl}/app/(smoodly)/admin/[[...segments]]/page.tsx +0 -0
  103. /package/templates/{next-intl → blank-intl}/app/(smoodly)/admin/layout.tsx +0 -0
  104. /package/templates/{next-intl → blank-intl}/app/[locale]/[...path]/page.tsx +0 -0
  105. /package/templates/{next-intl → blank-intl}/app/[locale]/page.tsx +0 -0
  106. /package/templates/{next-intl → blank-intl}/i18n/navigation.ts +0 -0
  107. /package/templates/{next-intl → blank-intl}/i18n/request.ts +0 -0
  108. /package/templates/{next-intl → blank-intl}/i18n/routing.ts +0 -0
  109. /package/templates/{next-intl → blank-intl}/next.config.ts +0 -0
  110. /package/templates/{next-intl → blank-intl}/proxy.ts +0 -0
  111. /package/templates/{next-intl → blank-intl}/smoodly/admin.tsx +0 -0
  112. /package/templates/{next-intl → blank-intl}/smoodly/ops.ts +0 -0
  113. /package/templates/{next-intl → blank-intl}/smoodly/richtext.tsx +0 -0
  114. /package/templates/{next-intl → blank-intl}/smoodly/server.ts +0 -0
  115. /package/templates/{next-intl → blank-intl}/tsconfig.json +0 -0
  116. /package/templates/{next → lorem}/app/globals.css +0 -0
  117. /package/templates/{next → lorem}/components/ArticleCard.tsx +0 -0
  118. /package/templates/{next → lorem}/components/articles.ts +0 -0
  119. /package/templates/{next → lorem}/components/elements/Callout.tsx +0 -0
  120. /package/templates/{next → lorem}/components/elements/Cta.tsx +0 -0
  121. /package/templates/{next → lorem}/components/pages/Article.tsx +0 -0
  122. /package/templates/{next → lorem}/components/pages/Articles.tsx +0 -0
  123. /package/templates/{next → lorem}/components/pages/Home.tsx +0 -0
  124. /package/templates/{next → lorem}/components/pages/Page.tsx +0 -0
  125. /package/templates/{next → lorem}/components/sections/FavouriteArticles.tsx +0 -0
  126. /package/templates/{next → lorem}/components/sections/Footer.tsx +0 -0
  127. /package/templates/{next → lorem}/components/sections/Header.tsx +0 -0
  128. /package/templates/{next → lorem}/components/sections/Hero.tsx +0 -0
  129. /package/templates/{next → lorem}/components/sections/ImageText.tsx +0 -0
  130. /package/templates/{next → lorem}/components/sections/PageHero.tsx +0 -0
  131. /package/templates/{next → lorem}/components/sections/People.tsx +0 -0
  132. /package/templates/{next → lorem}/components/sections/Points.tsx +0 -0
  133. /package/templates/{next → lorem}/components/sections/Quote.tsx +0 -0
  134. /package/templates/{next → lorem}/components/strings.ts +0 -0
  135. /package/templates/{next → lorem}/messages/en.json +0 -0
  136. /package/templates/{next → lorem}/public/images/about_main.jpg +0 -0
  137. /package/templates/{next → lorem}/public/images/article_photo.jpg +0 -0
  138. /package/templates/{next → lorem}/public/images/hero_image.jpg +0 -0
  139. /package/templates/{next → lorem}/public/images/home_section_2.jpg +0 -0
  140. /package/templates/{next → lorem}/smoodly/config.ts +0 -0
  141. /package/templates/{next → lorem}/smoodly/elements.ts +0 -0
  142. /package/templates/{next → lorem}/smoodly/sections.ts +0 -0
  143. /package/templates/{next → lorem}/smoodly/toolsets.ts +0 -0
  144. /package/templates/{next-intl → lorem-intl}/app/[locale]/layout.tsx +0 -0
  145. /package/templates/{next-intl → lorem-intl}/app/globals.css +0 -0
  146. /package/templates/{next-intl → lorem-intl}/components/ArticleCard.tsx +0 -0
  147. /package/templates/{next-intl → lorem-intl}/components/articles.ts +0 -0
  148. /package/templates/{next-intl → lorem-intl}/components/elements/Callout.tsx +0 -0
  149. /package/templates/{next-intl → lorem-intl}/components/elements/Cta.tsx +0 -0
  150. /package/templates/{next-intl → lorem-intl}/components/pages/Article.tsx +0 -0
  151. /package/templates/{next-intl → lorem-intl}/components/pages/Articles.tsx +0 -0
  152. /package/templates/{next-intl → lorem-intl}/components/pages/Home.tsx +0 -0
  153. /package/templates/{next-intl → lorem-intl}/components/pages/Page.tsx +0 -0
  154. /package/templates/{next-intl → lorem-intl}/components/sections/FavouriteArticles.tsx +0 -0
  155. /package/templates/{next-intl → lorem-intl}/components/sections/Footer.tsx +0 -0
  156. /package/templates/{next-intl → lorem-intl}/components/sections/Header.tsx +0 -0
  157. /package/templates/{next-intl → lorem-intl}/components/sections/Hero.tsx +0 -0
  158. /package/templates/{next-intl → lorem-intl}/components/sections/ImageText.tsx +0 -0
  159. /package/templates/{next-intl → lorem-intl}/components/sections/PageHero.tsx +0 -0
  160. /package/templates/{next-intl → lorem-intl}/components/sections/People.tsx +0 -0
  161. /package/templates/{next-intl → lorem-intl}/components/sections/Points.tsx +0 -0
  162. /package/templates/{next-intl → lorem-intl}/components/sections/Quote.tsx +0 -0
  163. /package/templates/{next-intl → lorem-intl}/components/strings.ts +0 -0
  164. /package/templates/{next-intl → lorem-intl}/messages/en.json +0 -0
  165. /package/templates/{next-intl → lorem-intl}/messages/fi.json +0 -0
  166. /package/templates/{next-intl → lorem-intl}/public/images/about_main.jpg +0 -0
  167. /package/templates/{next-intl → lorem-intl}/public/images/article_photo.jpg +0 -0
  168. /package/templates/{next-intl → lorem-intl}/public/images/hero_image.jpg +0 -0
  169. /package/templates/{next-intl → lorem-intl}/public/images/home_section_2.jpg +0 -0
  170. /package/templates/{next-intl → lorem-intl}/smoodly/config.ts +0 -0
  171. /package/templates/{next-intl → lorem-intl}/smoodly/elements.ts +0 -0
  172. /package/templates/{next-intl → lorem-intl}/smoodly/sections.ts +0 -0
  173. /package/templates/{next-intl → lorem-intl}/smoodly/toolsets.ts +0 -0
@@ -0,0 +1,72 @@
1
+ // The starter's content, written through the stores (never raw inserts:
2
+ // the refs index and publish integrity are part of the point). Ships in
3
+ // the scaffold; create-smoodly-app runs `npm run seed` after the first
4
+ // editor. Idempotent: exits once the home page exists in the default
5
+ // locale. Writes the same copy into every configured locale, whatever
6
+ // their codes, so every locale's "/" resolves.
7
+ // Run: npm run seed (needs .env.local and a Supabase stack).
8
+ import next from "@next/env";
9
+ import type { PageTree } from "smoodly";
10
+ const { loadEnvConfig } = next;
11
+
12
+ loadEnvConfig(process.cwd());
13
+
14
+ const { config } = await import("../smoodly/config");
15
+ const { stores } = await import("../smoodly/server");
16
+ const { pages, entries } = stores();
17
+
18
+ const DEFAULT = config.locales.default;
19
+ const OTHERS = config.locales.supported.filter((l) => l !== DEFAULT);
20
+
21
+ if (await pages.resolvePath(DEFAULT, "/")) {
22
+ console.log(`Already seeded — "home" exists in "${DEFAULT}".`);
23
+ process.exit(0);
24
+ }
25
+
26
+ // Richtext is TipTap JSON, the stored format; RichText renders it.
27
+ type Node = Record<string, unknown>;
28
+ const p = (s: string): Node => ({ type: "paragraph", content: [{ type: "text", text: s }] });
29
+ const button = (label: string, href: string): Node => ({ type: "element", attrs: { name: "button", fields: { label, href }, styles: {} } });
30
+ const doc = (...content: Node[]) => ({ type: "doc", content });
31
+
32
+ // --- the site item: name and tagline, published in every locale ------
33
+
34
+ const site = await entries.create("site", { name: "My site", tagline: "Built with Smoodly" }, { locale: DEFAULT });
35
+ await entries.setStatus("site", site.id, DEFAULT, "published");
36
+ for (const locale of OTHERS) {
37
+ await entries.addLocale("site", site.id, locale, { from: DEFAULT });
38
+ await entries.setStatus("site", site.id, locale, "published");
39
+ }
40
+
41
+ // --- the home page: one Text section ---------------------------------
42
+
43
+ const tree = {
44
+ meta: { description: "A new site, built with Smoodly." },
45
+ zones: {
46
+ body: [
47
+ {
48
+ id: "node-hello",
49
+ type: "text",
50
+ fields: {
51
+ title: "Hello",
52
+ body: doc(
53
+ p("This is your home page. Open the admin to edit this text, add sections, create pages and write posts."),
54
+ p("Everything you see is defined in components/ and registered in smoodly/. The README says where things are."),
55
+ button("Open the admin", "/admin"),
56
+ ),
57
+ },
58
+ },
59
+ ],
60
+ },
61
+ } as unknown as PageTree;
62
+
63
+ const home = await pages.createPage({ locale: DEFAULT, slug: "home", title: "Home", template: "home" });
64
+ await pages.saveDraft(home.id, DEFAULT, tree);
65
+ await pages.publish(home.id, DEFAULT);
66
+ for (const locale of OTHERS) {
67
+ await pages.addLocale(home.id, locale, { from: DEFAULT, title: "Home" });
68
+ await pages.saveDraft(home.id, locale, tree);
69
+ await pages.publish(home.id, locale);
70
+ }
71
+
72
+ console.log(`Seeded the blank starter in "${[DEFAULT, ...OTHERS].join('", "')}": the home page and the site item, published.`);
@@ -0,0 +1,25 @@
1
+ import { f, smoodly } from "smoodly";
2
+ import { text } from "./toolsets";
3
+
4
+ // A collection: structured content with its own admin list, served at
5
+ // `path` by its entry page (components/pages/Post.tsx). Fields marked
6
+ // .localized() are stored per locale; the rest are shared across
7
+ // locales. To add a
8
+ // collection: copy this block, add it to `collections`, write its view
9
+ // into a new migration with `collectionSQL` from "smoodly", and apply
10
+ // it (supabase db push, or supabase db reset locally).
11
+ export const posts = smoodly.collection({
12
+ name: "posts",
13
+ title: "Posts",
14
+ titleField: "title",
15
+ path: "posts",
16
+ order: { by: "date", direction: "desc" },
17
+ fields: {
18
+ title: f.text().localized(),
19
+ slug: f.slug({ from: "title" }),
20
+ date: f.date(),
21
+ body: f.richtext({ toolset: text }).localized(),
22
+ },
23
+ });
24
+
25
+ export const collections = [posts];
@@ -0,0 +1,35 @@
1
+ import { defineConfig, f } from "smoodly";
2
+ import { collections } from "./collections";
3
+ import { sections } from "./sections";
4
+ import { shared } from "./shared";
5
+ import { elements } from "./elements";
6
+ import { HomePage } from "@/components/pages/Home";
7
+ import { StandardPage } from "@/components/pages/Page";
8
+ import { PostPage } from "@/components/pages/Post";
9
+
10
+ export { collections, sections, shared, elements };
11
+
12
+ // The registry and the plain settings, one file. Runtime-free: nothing
13
+ // here imports a route file or Next, so a script and the CLI's template
14
+ // build can load it outside the app.
15
+ export const config = defineConfig({
16
+ registry: {
17
+ sections,
18
+ elements,
19
+ collections,
20
+ shared,
21
+ pages: [HomePage, StandardPage, PostPage],
22
+ },
23
+ locales: { default: "en", supported: ["en"] },
24
+ // The record the site root serves.
25
+ homePageSlug: "home",
26
+ // Editors can nest pages three deep.
27
+ pages: { tree: { depth: 3 } },
28
+ // Every section gets this select beside its own styles; the section
29
+ // wrapper emits it as data-tone, and app/globals.css styles it.
30
+ styles: {
31
+ sections: {
32
+ tone: f.select(["paper", "ink"]).default("paper"),
33
+ },
34
+ },
35
+ });
@@ -0,0 +1,5 @@
1
+ // The site's element registrations, in one list: the config registers
2
+ // them, toolsets embed them by value, and smoodly/richtext.tsx renders them.
3
+ import { Button } from "@/components/elements/Button";
4
+
5
+ export const elements = [Button];
@@ -0,0 +1,5 @@
1
+ // The site's section registrations, in one list. config.ts puts it in
2
+ // the registry; the stores and the renderer share the same array.
3
+ import { Text } from "@/components/sections/Text";
4
+
5
+ export const sections = [Text];
@@ -0,0 +1,16 @@
1
+ // Shared content: code-declared singletons, edited once under Globals
2
+ // and read anywhere. `site` is an object item — fields code consumes;
3
+ // the layout reads it through q().getShared("site"). A visual item
4
+ // (`section: SomeSection`) renders through a section instead.
5
+ import { f, smoodly } from "smoodly";
6
+
7
+ export const site = smoodly.shared({
8
+ name: "site",
9
+ title: "Site",
10
+ fields: {
11
+ name: f.text(),
12
+ tagline: f.text().localized(),
13
+ },
14
+ });
15
+
16
+ export const shared = [site];
@@ -0,0 +1,14 @@
1
+ // Toolsets are values, not registrations: a richtext field names one.
2
+ // `text` is the starter's one set — the default marks and block nodes,
3
+ // plus the Button element. Elements are embedded by value; the config
4
+ // must also register them (smoodly/elements.ts; defineConfig checks).
5
+ // Relative imports on purpose: collections.ts pulls this file into
6
+ // create-smoodly-app's template build, which runs outside the app.
7
+ import { smoodly } from "smoodly";
8
+ import { Button } from "../components/elements/Button";
9
+
10
+ export const text = smoodly.toolset({
11
+ marks: ["bold", "italic", "link"],
12
+ nodes: ["h2", "h3", "bulletList", "orderedList", "blockquote"],
13
+ elements: [Button],
14
+ });
@@ -37,6 +37,7 @@ create table if not exists "page_locales" (
37
37
  "draft_version_id" uuid,
38
38
  "published_version_id" uuid,
39
39
  "updated_at" timestamptz not null default now(),
40
+ "updated_by" text,
40
41
  primary key ("page_id", "locale")
41
42
  );
42
43
 
@@ -49,19 +50,20 @@ create or replace function "smoodly_reorder_pages"(p_ids uuid[]) returns void la
49
50
  $$;
50
51
 
51
52
  -- A version belongs to ONE locale of one page: each locale has its own
52
- -- draft, its own publish and its own history.
53
+ -- draft, its own publish and its own history. One row per publish plus
54
+ -- the live draft (spec 2026-09-13 history §1): the unpublished draft row
55
+ -- is rewritten in place and saved_at is when its content last changed.
53
56
  create table if not exists "page_versions" (
54
57
  "id" uuid primary key default gen_random_uuid(),
55
58
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
56
59
  "page_id" uuid not null references "pages" ("id") on delete cascade,
57
60
  "locale" text not null,
58
61
  "tree" jsonb not null,
59
- "created_by" text,
60
- "created_at" timestamptz not null default now()
62
+ "saved_at" timestamptz not null default now()
61
63
  );
62
64
 
63
65
  create index if not exists "page_versions_page_locale_idx"
64
- on "page_versions" ("page_id", "locale", "created_at");
66
+ on "page_versions" ("page_id", "locale", "saved_at");
65
67
 
66
68
  create table if not exists "entries" (
67
69
  "id" uuid primary key default gen_random_uuid(),
@@ -98,6 +100,7 @@ create table if not exists "entry_locales" (
98
100
  "draft_version_id" uuid,
99
101
  "published_version_id" uuid,
100
102
  "updated_at" timestamptz not null default now(),
103
+ "updated_by" text,
101
104
  primary key ("entry_id", "locale")
102
105
  );
103
106
 
@@ -107,19 +110,19 @@ create index if not exists "entry_locales_status_idx"
107
110
  -- A version belongs to ONE locale of one entry and stores the MERGED
108
111
  -- fields (shared + localized + slug) as they were at that save, so
109
112
  -- restoring an old Finnish version restores the shared fields too.
110
- -- Rows exist only for collections registered with `versions: true`.
113
+ -- Rows for every collection and every shared item (spec 2026-09-14 §1).
114
+ -- One row per publish plus the live draft (spec 2026-09-13 history §1).
111
115
  create table if not exists "entry_versions" (
112
116
  "id" uuid primary key default gen_random_uuid(),
113
117
  "space_id" uuid not null default '00000000-0000-0000-0000-000000000000',
114
118
  "entry_id" uuid not null references "entries" ("id") on delete cascade,
115
119
  "locale" text not null,
116
120
  "fields" jsonb not null,
117
- "created_by" text,
118
- "created_at" timestamptz not null default now()
121
+ "saved_at" timestamptz not null default now()
119
122
  );
120
123
 
121
124
  create index if not exists "entry_versions_entry_locale_idx"
122
- on "entry_versions" ("entry_id", "locale", "created_at");
125
+ on "entry_versions" ("entry_id", "locale", "saved_at");
123
126
 
124
127
  -- v1 reorder (DESIGN.md §3 Manual ordering): rewrite sort 1..N in ONE
125
128
  -- statement. Deliberately does not touch updated_at — a drag must not
@@ -498,21 +501,7 @@ create policy "smoodly_media_write" on storage.objects for all
498
501
  using ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text)
499
502
  with check ("bucket_id" = 'media' and (select public."smoodly_key_kind"()) = 'write' and not (select public."smoodly_space_key_revoked"()) and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text);
500
503
 
501
- create or replace view "people" with (security_invoker = true) as
502
- select
503
- e."id",
504
- l."locale",
505
- l."slug",
506
- e."sort",
507
- e."created_at",
508
- l."updated_at",
509
- e."fields"->>'name' as "name",
510
- l."fields"->>'role' as "role"
511
- from "entries" e
512
- join "entry_locales" l on l."entry_id" = e."id"
513
- where e."collection" = 'people' and l."status" = 'published';
514
-
515
- create or replace view "articles" with (security_invoker = true) as
504
+ create or replace view "posts" with (security_invoker = true) as
516
505
  select
517
506
  e."id",
518
507
  l."locale",
@@ -520,17 +509,13 @@ select
520
509
  e."sort",
521
510
  e."created_at",
522
511
  l."updated_at",
523
- v."fields"->>'title' as "title",
524
- v."fields"->'image' as "image",
525
- (v."fields"->>'date')::timestamptz as "date",
526
- v."fields"->>'author' as "author",
527
- v."fields"->>'excerpt' as "excerpt",
528
- v."fields"->'body' as "body",
529
- v."fields"->'tags' as "tags"
512
+ coalesce(v."fields", e."fields" || l."fields")->>'title' as "title",
513
+ (coalesce(v."fields", e."fields" || l."fields")->>'date')::timestamptz as "date",
514
+ coalesce(v."fields", e."fields" || l."fields")->'body' as "body"
530
515
  from "entries" e
531
516
  join "entry_locales" l on l."entry_id" = e."id"
532
- join "entry_versions" v on v."id" = l."published_version_id"
533
- where e."collection" = 'articles' and l."status" = 'published';
517
+ left join "entry_versions" v on v."id" = l."published_version_id"
518
+ where e."collection" = 'posts' and l."status" = 'published';
534
519
 
535
- create index if not exists "entries_articles_date_idx"
536
- on "entries" (("fields"->'date')) where "collection" = 'articles';
520
+ create index if not exists "entries_posts_date_idx"
521
+ on "entries" (("fields"->'date')) where "collection" = 'posts';
@@ -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
@@ -0,0 +1,76 @@
1
+ # Smoodly site
2
+
3
+ Created by `create-smoodly-app`. Content lives in Supabase; the admin is
4
+ mounted at `/admin` from the `smoodly` package. AGENTS.md holds the rules
5
+ for an agent (or a person) building the site.
6
+
7
+ npm run dev # http://localhost:3000 — /admin is the editor
8
+
9
+ ## Where things are
10
+
11
+ - `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
12
+ - `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
13
+ - `app/[locale]/layout.tsx` — the site's root layout: `app/globals.css`, the locale links, the footer line from the `site` item; the admin has its own under `app/(smoodly)/admin/`.
14
+ - `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). UI strings go in `messages/<locale>.json`.
15
+ - `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
16
+ - `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
17
+ - `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
18
+ - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `shared.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
19
+ - `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
20
+ - `app/globals.css` — a reset, a container, the two tones. Replace it.
21
+ - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
22
+
23
+ ## Add a section
24
+
25
+ Copy `components/sections/Text.tsx`, give the schema a new `name` and
26
+ `title`, declare its fields, render them inside `<smoodly.Section>` with
27
+ marks on each field. Add it to `sections` in `smoodly/sections.ts`, then
28
+ to the `sections` list of every zone that may hold it in
29
+ `components/pages/*.tsx`. It appears in the editor's "+" picker.
30
+
31
+ ## Add an element
32
+
33
+ Copy `components/elements/Button.tsx` (relative imports only). Add it to
34
+ `elements` in `smoodly/elements.ts` and to the `elements` of the toolset
35
+ in `smoodly/toolsets.ts` that may embed it. It appears in the richtext
36
+ editor's "/" menu.
37
+
38
+ ## Add a collection
39
+
40
+ Copy the `posts` block in `smoodly/collections.ts`, add the new
41
+ collection to `collections`, and write its entry page by copying
42
+ `components/pages/Post.tsx` (register it in `config.ts` under `pages`).
43
+ Then create a migration with its view:
44
+
45
+ import { collectionSQL } from "smoodly";
46
+ import { people } from "./smoodly/collections";
47
+ console.log(collectionSQL(people));
48
+
49
+ Save the output as `supabase/migrations/<timestamp>_people.sql` and apply
50
+ it with `supabase db push` (hosted) or `supabase db reset` (local).
51
+
52
+ ## Add a shared item
53
+
54
+ Copy the `site` block in `smoodly/shared.ts` and add it to `shared`. An
55
+ object item (`fields`) is read in code with `q().getShared("name")`; a
56
+ visual item (`section: SomeSection`) renders through its section and can
57
+ be placed on pages from the picker's Shared group. Edit both under
58
+ Globals in the admin.
59
+
60
+ Reseed an empty database any time with `npm run seed`.
61
+
62
+ ## Editors
63
+
64
+ Anyone who may open `/admin` is a Supabase Auth user with a row in the
65
+ `editors` table. To add one: create the user in Supabase Studio
66
+ (Authentication → Users), then in the SQL editor:
67
+
68
+ insert into editors (user_id, role) values ('<the user id>', 'editor');
69
+
70
+ ## Hosted Supabase
71
+
72
+ supabase init # if the CLI has not been set up here yet
73
+ supabase link # pick the project
74
+ supabase db push # applies supabase/migrations/
75
+
76
+ Then put the project's URL and keys in `.env.local`.
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from "react";
2
+ import { notFound } from "next/navigation";
3
+ import { hasLocale, NextIntlClientProvider } from "next-intl";
4
+ import { routing } from "@/i18n/routing";
5
+ import { href, q } from "@/smoodly/server";
6
+ import "../globals.css";
7
+
8
+ // The site's root layout under the locale segment; the admin has its own
9
+ // under app/(smoodly)/admin, outside [locale], as a second root layout.
10
+ // The `site` shared item gives the document title and the footer line,
11
+ // read per locale through `q`, null until published in this locale. The
12
+ // locale links are the visitor's only language switch, since locale
13
+ // detection is off (see i18n/routing.ts).
14
+ export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
15
+ const { locale } = await params;
16
+ const site = await q().getShared("site", { locale });
17
+ return { title: site?.name ?? "Smoodly site" };
18
+ }
19
+
20
+ export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
21
+ const { locale } = await params;
22
+ if (!hasLocale(routing.locales, locale)) notFound();
23
+ const site = await q().getShared("site", { locale });
24
+ const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
25
+ return (
26
+ <html lang={locale}>
27
+ <body>
28
+ <NextIntlClientProvider>
29
+ {locales.length > 1 && (
30
+ <nav className="container locales" aria-label="Language">
31
+ {locales.map((l) => (
32
+ <a key={l.code} href={l.url} aria-current={l.current ? "page" : undefined}>{l.code}</a>
33
+ ))}
34
+ </nav>
35
+ )}
36
+ <main>{children}</main>
37
+ {site && <footer className="container site-footer">{site.name} · {site.tagline}</footer>}
38
+ </NextIntlClientProvider>
39
+ </body>
40
+ </html>
41
+ );
42
+ }
@@ -0,0 +1,47 @@
1
+ /* The blank starter's one stylesheet: a reset, a system font, a
2
+ container, prose defaults, the button element, and the two tones the
3
+ config's section style emits as data-tone. Replace freely. */
4
+
5
+ :root {
6
+ --paper: #ffffff;
7
+ --ink: #1a1a1a;
8
+ --muted: #666666;
9
+ --container: 720px;
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+ html, body { margin: 0; padding: 0; }
14
+ body {
15
+ background: var(--paper);
16
+ color: var(--ink);
17
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18
+ line-height: 1.5;
19
+ min-height: 100vh;
20
+ display: flex;
21
+ flex-direction: column;
22
+ }
23
+ main { flex: 1; }
24
+ a { color: inherit; }
25
+
26
+ .container { width: min(var(--container), 100% - 2rem); margin-inline: auto; }
27
+
28
+ /* Sections carry data-tone from the config's section styles. */
29
+ [data-tone="paper"] { background: var(--paper); color: var(--ink); }
30
+ [data-tone="ink"] { background: var(--ink); color: var(--paper); }
31
+ section[data-tone] { padding-block: 3rem; }
32
+
33
+ .prose > * + * { margin-top: 1em; }
34
+ .prose h1, .prose h2, .prose h3 { line-height: 1.2; }
35
+
36
+ /* The Button element, inside richtext. */
37
+ .button {
38
+ display: inline-block;
39
+ padding: 0.6em 1.2em;
40
+ border: 1px solid currentColor;
41
+ border-radius: 4px;
42
+ text-decoration: none;
43
+ }
44
+
45
+ .site-footer { padding-block: 2rem; color: var(--muted); font-size: 0.875rem; }
46
+ .locales { display: flex; gap: 0.75rem; padding-block: 1rem; font-size: 0.875rem; }
47
+ .locales a[aria-current="page"] { font-weight: 600; }
@@ -0,0 +1,26 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // An Element: a small registered block editors insert inside richtext
4
+ // (the `richtext` context). This one is a link styled as a button. Copy
5
+ // this file to add another element, then list it in smoodly/elements.ts
6
+ // and in the toolsets that may embed it (smoodly/toolsets.ts).
7
+ // Relative imports only in this file and in smoodly/toolsets.ts: the
8
+ // template build imports them outside the app, where `@/` has no mapping.
9
+ export const buttonSchema = smoodly.schema.element({
10
+ name: "button",
11
+ title: "Button",
12
+ icon: "→",
13
+ description: "A link styled as a button.",
14
+ contexts: ["richtext"],
15
+ fields: {
16
+ label: f.text().max(60),
17
+ href: f.link(),
18
+ },
19
+ sample: { label: "Read more", href: "/" },
20
+ });
21
+
22
+ function ButtonView({ label, href }: Props<typeof buttonSchema>) {
23
+ return <a className="button" href={href}>{label}</a>;
24
+ }
25
+
26
+ export const Button = smoodly.element(buttonSchema, ButtonView);
@@ -0,0 +1,22 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { Text } from "../sections/Text";
3
+
4
+ // A page shape: zones declared, <Zone /> placed, one file. `slug` makes
5
+ // it fixed — one record editors can neither delete nor duplicate — and
6
+ // the config's homePageSlug maps that record to "/". The body is a
7
+ // freeform zone over the sections listed here.
8
+ export const homeSchema = smoodly.schema.page({
9
+ name: "home",
10
+ title: "Home",
11
+ slug: "home",
12
+ zones: {
13
+ body: z.freeform({ sections: [Text] }),
14
+ },
15
+ meta: { seo: true },
16
+ });
17
+
18
+ function HomeView({ zones }: Props<typeof homeSchema>) {
19
+ return <Zone of={zones.body} />;
20
+ }
21
+
22
+ export const HomePage = smoodly.page(homeSchema, HomeView);
@@ -0,0 +1,25 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { Text } from "../sections/Text";
3
+
4
+ // An open page shape: no `slug`, so editors create as many as they like,
5
+ // anywhere in the tree the config allows. `page` is where this one sits
6
+ // (title, path, ancestors, children).
7
+ export const pageSchema = smoodly.schema.page({
8
+ name: "page",
9
+ title: "Page",
10
+ zones: {
11
+ body: z.freeform({ sections: [Text] }),
12
+ },
13
+ meta: { seo: true },
14
+ });
15
+
16
+ function PageView({ zones, page }: Props<typeof pageSchema>) {
17
+ return (
18
+ <>
19
+ <h1 className="container">{page.title}</h1>
20
+ <Zone of={zones.body} />
21
+ </>
22
+ );
23
+ }
24
+
25
+ export const StandardPage = smoodly.page(pageSchema, PageView);
@@ -0,0 +1,21 @@
1
+ import { smoodly, type EntryPageProps } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
3
+ import { posts } from "@/smoodly/collections";
4
+
5
+ // The entry page for `posts`, served at /posts/<slug>. It receives the
6
+ // entry's fields; the body is richtext rendered by the site's bound
7
+ // RichText, which knows the registered elements.
8
+ type PostFields = { title: string; date?: string; body?: unknown };
9
+
10
+ function PostView({ entry }: EntryPageProps) {
11
+ const post = entry.fields as PostFields;
12
+ return (
13
+ <article className="container prose">
14
+ <h1>{post.title}</h1>
15
+ {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
16
+ <RichText doc={post.body} />
17
+ </article>
18
+ );
19
+ }
20
+
21
+ export const PostPage = smoodly.entryPage(posts, PostView);
@@ -0,0 +1,39 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
3
+ import { text } from "@/smoodly/toolsets";
4
+
5
+ // A section: a title and a richtext body. Registered in
6
+ // smoodly/sections.ts and allowed in the page shapes' zones
7
+ // (components/pages/*.tsx). Copy this file to add another section.
8
+ // The section wrapper renders the root element and emits the config's
9
+ // section styles as data attributes (data-tone here) for app/globals.css.
10
+ export const textSchema = smoodly.schema.section({
11
+ name: "text",
12
+ title: "Text",
13
+ description: "A title and a body of text.",
14
+ fields: {
15
+ title: f.text(),
16
+ body: f.richtext({ toolset: text }),
17
+ },
18
+ sample: {
19
+ title: "Hello",
20
+ body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Write something here." }] }] },
21
+ },
22
+ });
23
+
24
+ // Marks tie the rendered elements to their fields, so the editor can
25
+ // select and inline-edit them on the canvas.
26
+ const m = smoodly.marks(textSchema);
27
+
28
+ function TextView({ title, body, styles }: Props<typeof textSchema>) {
29
+ return (
30
+ <smoodly.Section styles={styles}>
31
+ <div className="container prose">
32
+ <h2 {...m.field("title")}>{title}</h2>
33
+ <div {...m.field("body")}><RichText doc={body} /></div>
34
+ </div>
35
+ </smoodly.Section>
36
+ );
37
+ }
38
+
39
+ export const Text = smoodly.section(textSchema, TextView);
@@ -0,0 +1 @@
1
+ {}