create-smoodly-app 0.0.12 → 0.0.13

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/next/app/globals.css +16 -0
  3. package/templates/next/components/elements/Callout.tsx +35 -0
  4. package/templates/next/components/elements/Cta.tsx +35 -0
  5. package/templates/next/components/pages/Article.tsx +2 -1
  6. package/templates/next/components/sections/FavouriteArticles.tsx +4 -2
  7. package/templates/next/components/sections/Footer.tsx +15 -10
  8. package/templates/next/components/sections/Header.tsx +7 -2
  9. package/templates/next/components/sections/Hero.tsx +13 -7
  10. package/templates/next/components/sections/ImageText.tsx +10 -6
  11. package/templates/next/components/sections/PageHero.tsx +6 -4
  12. package/templates/next/components/sections/People.tsx +4 -2
  13. package/templates/next/components/sections/Points.tsx +15 -10
  14. package/templates/next/components/sections/Quote.tsx +4 -2
  15. package/templates/next/package.json +1 -1
  16. package/templates/next/scripts/seed.ts +9 -0
  17. package/templates/next/smoodly/collections.ts +7 -4
  18. package/templates/next/smoodly/config.ts +4 -1
  19. package/templates/next/smoodly/elements.ts +6 -0
  20. package/templates/next/smoodly/richtext.tsx +7 -0
  21. package/templates/next/smoodly/server.ts +4 -4
  22. package/templates/next/smoodly/toolsets.ts +16 -0
  23. package/templates/next/supabase/migrations/00000000000000_smoodly.sql +4 -4
  24. package/templates/next-intl/app/globals.css +16 -0
  25. package/templates/next-intl/components/elements/Callout.tsx +35 -0
  26. package/templates/next-intl/components/elements/Cta.tsx +35 -0
  27. package/templates/next-intl/components/pages/Article.tsx +2 -1
  28. package/templates/next-intl/components/sections/FavouriteArticles.tsx +4 -2
  29. package/templates/next-intl/components/sections/Footer.tsx +15 -10
  30. package/templates/next-intl/components/sections/Header.tsx +7 -2
  31. package/templates/next-intl/components/sections/Hero.tsx +13 -7
  32. package/templates/next-intl/components/sections/ImageText.tsx +10 -6
  33. package/templates/next-intl/components/sections/PageHero.tsx +6 -4
  34. package/templates/next-intl/components/sections/People.tsx +4 -2
  35. package/templates/next-intl/components/sections/Points.tsx +15 -10
  36. package/templates/next-intl/components/sections/Quote.tsx +4 -2
  37. package/templates/next-intl/package.json +1 -1
  38. package/templates/next-intl/scripts/seed.ts +9 -0
  39. package/templates/next-intl/smoodly/collections.ts +7 -4
  40. package/templates/next-intl/smoodly/config.ts +4 -1
  41. package/templates/next-intl/smoodly/elements.ts +6 -0
  42. package/templates/next-intl/smoodly/richtext.tsx +7 -0
  43. package/templates/next-intl/smoodly/server.ts +4 -4
  44. package/templates/next-intl/smoodly/toolsets.ts +16 -0
  45. package/templates/next-intl/supabase/migrations/00000000000000_smoodly.sql +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Create a Smoodly site: a fresh Next.js app with the admin mounted, a Supabase schema, an env file and a first editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -63,6 +63,22 @@ address { font-style: normal; }
63
63
  .prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
64
64
  .prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
65
65
 
66
+ /* ---------- richtext block nodes: images, callout, cta ---------- */
67
+ .prose figure { margin: 8px 0; }
68
+ .prose figure img { width: 100%; height: auto; border-radius: var(--radius); display: block; }
69
+ .prose figcaption { margin-top: 8px; font-size: 14px; color: var(--muted); }
70
+ .prose .callout { margin: 8px 0; padding: 20px 24px; border-radius: var(--radius); border-left: 3px solid var(--accent); background: #fff; display: flex; flex-direction: column; gap: 8px; font-size: 16px; line-height: 1.6; }
71
+ .prose .callout[data-tone="success"] { border-left-color: #3a8f5c; }
72
+ .prose .callout[data-tone="warning"] { border-left-color: #c9962b; background: #fff8e8; }
73
+ .prose .callout__title { color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
74
+ .prose .callout__body { display: flex; flex-direction: column; gap: 8px; }
75
+ .prose .cta { margin: 16px 0; padding: 28px 32px; border-radius: var(--radius); background: var(--ink); color: var(--paper); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
76
+ .prose .cta[data-variant="outline"] { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
77
+ .prose .cta__heading { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
78
+ .prose .cta__text { margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
79
+ .prose .cta .button { background: var(--paper); color: var(--ink); flex: none; }
80
+ .prose .cta[data-variant="outline"] .button { background: var(--ink); color: var(--paper); }
81
+
66
82
  /* ---------- section styles: the config's spacing and tone ---------- */
67
83
 
68
84
  /* Every section in the page body gets the medium spacing by element,
@@ -0,0 +1,35 @@
1
+ import { f, RichText, smoodly, type Props } from "smoodly";
2
+
3
+ // An Element: a small registered block editors insert inside an article
4
+ // body (the `richtext` context). Its body is richtext too — marks only —
5
+ // rendered by the package's pure RichText (never the project's bound one:
6
+ // smoodly/richtext.tsx imports this file, and an import back would cycle).
7
+ export const calloutSchema = smoodly.schema.element({
8
+ name: "callout",
9
+ title: "Callout",
10
+ icon: "!",
11
+ description: "A highlighted note inside the text.",
12
+ contexts: ["richtext"],
13
+ fields: {
14
+ title: f.text().max(80),
15
+ body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
16
+ },
17
+ styles: {
18
+ tone: f.select(["info", "success", "warning"]).default("info"),
19
+ },
20
+ sample: {
21
+ title: "Good to know",
22
+ body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit." }] }] },
23
+ },
24
+ });
25
+
26
+ function CalloutView({ title, body, styles }: Props<typeof calloutSchema>) {
27
+ return (
28
+ <aside className="callout" data-tone={styles.tone}>
29
+ <strong className="callout__title">{title}</strong>
30
+ <div className="callout__body"><RichText doc={body} /></div>
31
+ </aside>
32
+ );
33
+ }
34
+
35
+ export const Callout = smoodly.element(calloutSchema, CalloutView);
@@ -0,0 +1,35 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // A call to action inside an article body: heading, a line of text and
4
+ // one button. `variant` is a style — never translated.
5
+ export const ctaSchema = smoodly.schema.element({
6
+ name: "cta",
7
+ title: "Call to action",
8
+ icon: "→",
9
+ description: "A heading, a line and a button.",
10
+ contexts: ["richtext"],
11
+ fields: {
12
+ heading: f.text().max(80),
13
+ text: f.text().max(200).optional(),
14
+ label: f.text().max(40),
15
+ href: f.link(),
16
+ },
17
+ styles: {
18
+ variant: f.select(["primary", "outline"]).default("primary"),
19
+ },
20
+ sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
21
+ });
22
+
23
+ function CtaView({ heading, text, label, href, styles }: Props<typeof ctaSchema>) {
24
+ return (
25
+ <div className="cta" data-variant={styles.variant}>
26
+ <div className="cta__copy">
27
+ <strong className="cta__heading">{heading}</strong>
28
+ {text && <p className="cta__text">{text}</p>}
29
+ </div>
30
+ <a className={styles.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export const Cta = smoodly.element(ctaSchema, CtaView);
@@ -1,4 +1,5 @@
1
- import { RichText, smoodly, type EntryPageProps } from "smoodly";
1
+ import { smoodly, type EntryPageProps } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
2
3
  import { SmoodlyImage } from "smoodly/image";
3
4
  import { articles } from "@/smoodly/collections";
4
5
  import { site } from "@/smoodly/server";
@@ -19,6 +19,8 @@ export const favouriteArticlesSchema = smoodly.schema.section({
19
19
  sample: { heading: "From the journal", items: [] },
20
20
  });
21
21
 
22
+ const m = smoodly.marks(favouriteArticlesSchema);
23
+
22
24
  // async: the cards format dates in the request locale and link through
23
25
  // the locale's article segment, both from the strings seam. The canvas
24
26
  // is the site's own draft route in an iframe, so an async section
@@ -28,8 +30,8 @@ async function FavouriteArticlesView({ heading, items, styles }: Props<typeof fa
28
30
  return (
29
31
  <smoodly.Section styles={styles} className="favourites">
30
32
  <div className="container">
31
- <h2 className="h2">{heading}</h2>
32
- <div className="card-grid">
33
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
34
+ <div className="card-grid" {...m.field("items")}>
33
35
  {(items ?? []).filter(Boolean).map((a, i) => (
34
36
  <ArticleCard key={i} article={a} url={url(articlePath(a.slug, locale))} locale={locale} />
35
37
  ))}
@@ -1,4 +1,4 @@
1
- import { f, smoodly, type Props } from "smoodly";
1
+ import { f, smoodly, type Props, type Scope } from "smoodly";
2
2
 
3
3
  // The site footer — a VISUAL shared item (smoodly/shared.ts): one row,
4
4
  // edited under Shared content, rendered by the layout. Three link columns
@@ -33,12 +33,17 @@ export const footerSchema = smoodly.schema.section({
33
33
  export type ContactFields = { email: string; phone?: string; address: string };
34
34
  type Column = { title: string; links: { label: string; href: string }[] };
35
35
 
36
- function FooterColumn({ column }: { column: Column | undefined }) {
36
+ const m = smoodly.marks(footerSchema);
37
+
38
+ function FooterColumn({ column, scope }: { column: Column | undefined; scope: Scope<Column> }) {
37
39
  if (!column) return null;
38
40
  return (
39
- <div className="site-footer__column">
40
- <span className="site-footer__title">{column.title}</span>
41
- {(column.links ?? []).map((l, i) => <a key={i} href={l.href}>{l.label}</a>)}
41
+ <div className="site-footer__column" {...scope.attrs}>
42
+ <span className="site-footer__title" {...scope.field("title")}>{column.title}</span>
43
+ {(column.links ?? []).map((l, i) => {
44
+ const link = scope.item("links", i);
45
+ return <a key={i} href={l.href} {...link.attrs} {...link.field("label")}>{l.label}</a>;
46
+ })}
42
47
  </div>
43
48
  );
44
49
  }
@@ -48,7 +53,7 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
48
53
  <smoodly.Section as="footer" styles={styles} className="site-footer">
49
54
  <div className="container site-footer__grid">
50
55
  <div className="site-footer__about">
51
- <p className="site-footer__tagline">{tagline}</p>
56
+ <p className="site-footer__tagline" {...m.field("tagline")}>{tagline}</p>
52
57
  {contact && (
53
58
  <address className="site-footer__contact">
54
59
  <a href={`mailto:${contact.email}`}>{contact.email}</a>
@@ -57,11 +62,11 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
57
62
  </address>
58
63
  )}
59
64
  </div>
60
- <FooterColumn column={company} />
61
- <FooterColumn column={legal} />
62
- <FooterColumn column={social} />
65
+ <FooterColumn column={company} scope={m.object("company")} />
66
+ <FooterColumn column={legal} scope={m.object("legal")} />
67
+ <FooterColumn column={social} scope={m.object("social")} />
63
68
  </div>
64
- <div className="container site-footer__copyright">{copyright}</div>
69
+ <div className="container site-footer__copyright" {...m.field("copyright")}>{copyright}</div>
65
70
  </smoodly.Section>
66
71
  );
67
72
  }
@@ -20,14 +20,19 @@ export const headerSchema = smoodly.schema.section({
20
20
 
21
21
  export type LocaleLink = { code: string; url: string; current: boolean };
22
22
 
23
+ const m = smoodly.marks(headerSchema);
24
+
23
25
  export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
24
26
  const home = locales?.find((l) => l.current)?.url ?? "/";
25
27
  return (
26
28
  <smoodly.Section as="header" styles={styles} className="site-header">
27
29
  <div className="container site-header__bar">
28
- <a className="site-header__brand" href={home}>{brand}</a>
30
+ <a className="site-header__brand" href={home} {...m.field("brand")}>{brand}</a>
29
31
  <nav className="site-header__nav">
30
- {(nav ?? []).map((item, i) => <a key={i} href={item.href}>{item.label}</a>)}
32
+ {(nav ?? []).map((item, i) => {
33
+ const link = m.item("nav", i);
34
+ return <a key={i} href={item.href} {...link.attrs} {...link.field("label")}>{item.label}</a>;
35
+ })}
31
36
  </nav>
32
37
  {locales && locales.length > 1 && (
33
38
  <nav className="site-header__locales" aria-label="Language">
@@ -24,23 +24,29 @@ export const heroSchema = smoodly.schema.section({
24
24
  },
25
25
  });
26
26
 
27
+ const m = smoodly.marks(heroSchema);
28
+
27
29
  function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeof heroSchema>) {
28
30
  return (
29
31
  <smoodly.Section styles={styles} className="hero">
30
32
  <div className="container hero__grid">
31
33
  <div className="hero__copy">
32
- {kicker && <p className="kicker">{kicker}</p>}
33
- <h1 className="hero__heading">{heading}</h1>
34
- <p className="hero__text">{text}</p>
34
+ {kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
35
+ <h1 className="hero__heading" {...m.field("heading")}>{heading}</h1>
36
+ <p className="hero__text" {...m.field("text")}>{text}</p>
35
37
  {buttons?.length > 0 && (
36
38
  <div className="hero__buttons">
37
- {buttons.map((b, i) => (
38
- <a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}>{b.label}</a>
39
- ))}
39
+ {buttons.map((b, i) => {
40
+ const button = m.item("buttons", i);
41
+ return (
42
+ <a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
43
+ {...button.attrs} {...button.field("label")}>{b.label}</a>
44
+ );
45
+ })}
40
46
  </div>
41
47
  )}
42
48
  </div>
43
- {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority />}
49
+ {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
44
50
  </div>
45
51
  </smoodly.Section>
46
52
  );
@@ -1,4 +1,5 @@
1
- import { f, RichText, smoodly, type Props } from "smoodly";
1
+ import { f, smoodly, type Props } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
2
3
  import { SmoodlyImage } from "smoodly/image";
3
4
 
4
5
  // A photo beside a heading and richtext, the photo on either side (a
@@ -30,11 +31,14 @@ export const imageTextSchema = smoodly.schema.section({
30
31
  },
31
32
  });
32
33
 
34
+ const m = smoodly.marks(imageTextSchema);
35
+
33
36
  function ImageTextView({ image, heading, text, link, styles }: Props<typeof imageTextSchema>) {
37
+ const linkMarks = m.object("link");
34
38
  const body = (
35
39
  <>
36
- <div className="prose"><RichText doc={text} /></div>
37
- {link?.href && <a className="link-accent" href={link.href}>{link.label}</a>}
40
+ <div className="prose" {...m.field("text")}><RichText doc={text} /></div>
41
+ {link?.href && <a className="link-accent" href={link.href} {...linkMarks.attrs} {...linkMarks.field("label")}>{link.label}</a>}
38
42
  </>
39
43
  );
40
44
  return (
@@ -42,15 +46,15 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
42
46
  <div className="container image-text__grid">
43
47
  {image ? (
44
48
  <>
45
- <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
49
+ <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" {...m.field("image")} />
46
50
  <div className="image-text__copy">
47
- <h2 className="h2">{heading}</h2>
51
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
48
52
  {body}
49
53
  </div>
50
54
  </>
51
55
  ) : (
52
56
  <>
53
- <h2 className="h2">{heading}</h2>
57
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
54
58
  <div className="image-text__copy">{body}</div>
55
59
  </>
56
60
  )}
@@ -21,16 +21,18 @@ export const pageHeroSchema = smoodly.schema.section({
21
21
  },
22
22
  });
23
23
 
24
+ const m = smoodly.marks(pageHeroSchema);
25
+
24
26
  function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pageHeroSchema>) {
25
27
  return (
26
28
  <smoodly.Section styles={styles} className="page-hero">
27
29
  <div className="container page-hero__inner">
28
30
  <div className="page-hero__copy">
29
- {kicker && <p className="kicker">{kicker}</p>}
30
- <h1 className="page-hero__heading">{heading}</h1>
31
- <p className="page-hero__text">{text}</p>
31
+ {kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
32
+ <h1 className="page-hero__heading" {...m.field("heading")}>{heading}</h1>
33
+ <p className="page-hero__text" {...m.field("text")}>{text}</p>
32
34
  </div>
33
- {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority />}
35
+ {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority {...m.field("image")} />}
34
36
  </div>
35
37
  </smoodly.Section>
36
38
  );
@@ -14,12 +14,14 @@ export const peopleSchema = smoodly.schema.section({
14
14
  sample: { heading: "Our people", people: [] },
15
15
  });
16
16
 
17
+ const m = smoodly.marks(peopleSchema);
18
+
17
19
  function PeopleView({ heading, people: persons, styles }: Props<typeof peopleSchema>) {
18
20
  return (
19
21
  <smoodly.Section styles={styles} className="people">
20
22
  <div className="container">
21
- <h2 className="h2">{heading}</h2>
22
- <div className="people__grid">
23
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
24
+ <div className="people__grid" {...m.field("people")}>
23
25
  {(persons ?? []).filter(Boolean).map((person, i) => (
24
26
  <div key={i} className="person">
25
27
  <span className="person__mark" aria-hidden="true">{person.name?.charAt(0)}</span>
@@ -26,21 +26,26 @@ const ICONS = {
26
26
  diamond: <polygon points="20,2 38,20 20,38 2,20" />,
27
27
  };
28
28
 
29
+ const m = smoodly.marks(pointsSchema);
30
+
29
31
  function PointsView({ heading, points, styles }: Props<typeof pointsSchema>) {
30
32
  return (
31
33
  <smoodly.Section styles={styles} className="points">
32
34
  <div className="container">
33
- <h2 className="h2">{heading}</h2>
35
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
34
36
  <div className="points__grid">
35
- {(points ?? []).map((point, i) => (
36
- <div key={i} className="point">
37
- <svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5">
38
- {ICONS[point.icon] ?? ICONS.circle}
39
- </svg>
40
- <h3 className="point__heading">{point.heading}</h3>
41
- <p className="point__text">{point.text}</p>
42
- </div>
43
- ))}
37
+ {(points ?? []).map((point, i) => {
38
+ const p = m.item("points", i);
39
+ return (
40
+ <div key={i} className="point" {...p.attrs}>
41
+ <svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" {...p.field("icon")}>
42
+ {ICONS[point.icon] ?? ICONS.circle}
43
+ </svg>
44
+ <h3 className="point__heading" {...p.field("heading")}>{point.heading}</h3>
45
+ <p className="point__text" {...p.field("text")}>{point.text}</p>
46
+ </div>
47
+ );
48
+ })}
44
49
  </div>
45
50
  </div>
46
51
  </smoodly.Section>
@@ -19,12 +19,14 @@ export const quoteSchema = smoodly.schema.section({
19
19
  },
20
20
  });
21
21
 
22
+ const m = smoodly.marks(quoteSchema);
23
+
22
24
  function QuoteView({ text, attribution, styles }: Props<typeof quoteSchema>) {
23
25
  return (
24
26
  <smoodly.Section styles={styles} className="quote">
25
27
  <div className="quote__inner">
26
- <blockquote className="quote__text">“{text}”</blockquote>
27
- <p className="quote__attribution">{attribution}</p>
28
+ <blockquote className="quote__text">“<span {...m.field("text")}>{text}</span>”</blockquote>
29
+ <p className="quote__attribution" {...m.field("attribution")}>{attribution}</p>
28
30
  </div>
29
31
  </smoodly.Section>
30
32
  );
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.12"
17
+ "smoodly": "0.0.13"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -45,6 +45,12 @@ const p = (s: string): Node => ({ type: "paragraph", content: text(s) });
45
45
  const h2 = (s: string): Node => ({ type: "heading", attrs: { level: 2 }, content: text(s) });
46
46
  const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] });
47
47
  const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
48
+ // Block nodes (spec 2026-09-08): a library image, and the starter's two elements.
49
+ const imageBlock = (image: { asset: string }, caption: string): Node => ({ type: "image", attrs: { image, caption } });
50
+ const callout = (title: string, text: string, tone: "info" | "success" | "warning" = "info"): Node =>
51
+ ({ type: "element", attrs: { name: "callout", fields: { title, body: doc(p(text)) }, styles: { tone } } });
52
+ const cta = (heading: string, text: string, label: string, href: string): Node =>
53
+ ({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, styles: { variant: "primary" } } });
48
54
  const doc = (...content: Node[]) => ({ type: "doc", content });
49
55
 
50
56
  // The starter's photos become assets: each file is uploaded once through
@@ -139,12 +145,15 @@ const body = doc(
139
145
  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."),
140
146
  h2("Sed ut perspiciatis unde omnis"),
141
147
  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."),
148
+ imageBlock(img.home2, "Lorem ipsum dolor sit amet."),
142
149
  quoteBlock("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit."),
150
+ callout("Good to know", "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."),
143
151
  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."),
144
152
  h2("Quis autem vel eum iure"),
145
153
  p("Reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur."),
146
154
  bullets("At vero eos et accusamus et iusto odio", "Dignissimos ducimus qui blanditiis praesentium", "Voluptatum deleniti atque corrupti quos dolores"),
147
155
  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."),
156
+ cta("Ready to talk?", "Sed ut perspiciatis unde omnis iste natus error sit voluptatem.", "Get in touch", paths(EN).about),
148
157
  );
149
158
 
150
159
  // Versioned (`versions: true` on the collection): every save below is a
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly } from "smoodly";
2
+ import { article } from "./toolsets";
2
3
 
3
4
  // A person's name is the same in every language; the role is translated.
4
5
  // Shared fields live on the entry, .localized() ones on its locale rows
@@ -7,6 +8,7 @@ export const people = smoodly.collection({
7
8
  name: "people",
8
9
  title: "People",
9
10
  titleField: "name",
11
+ order: "manual",
10
12
  fields: {
11
13
  name: f.text(),
12
14
  role: f.text().localized(),
@@ -29,13 +31,14 @@ export const articles = smoodly.collection({
29
31
  versions: true,
30
32
  fields: {
31
33
  title: f.text().localized(),
32
- slug: f.slug({ from: "title" }),
33
- excerpt: f.text().localized(),
34
- body: f.richtext().localized(),
35
- tags: f.list(f.text()).localized(),
36
34
  image: f.image({ aspect: "3:2" }),
37
35
  date: f.date(),
38
36
  author: f.ref(() => people),
37
+ slug: f.slug({ from: "title" }),
38
+ excerpt: f.text().localized(),
39
+ // The article toolset: the default marks and blocks, body images and the elements.
40
+ body: f.richtext({ toolset: article }).localized(),
41
+ tags: f.list(f.text()).localized(),
39
42
  },
40
43
  });
41
44
 
@@ -2,12 +2,13 @@ import { defineConfig, f } from "smoodly";
2
2
  import { collections } from "./collections";
3
3
  import { sections } from "./sections";
4
4
  import { shared } from "./shared";
5
+ import { elements } from "./elements";
5
6
  import { HomePage } from "@/components/pages/Home";
6
7
  import { ArticlesPage } from "@/components/pages/Articles";
7
8
  import { StandardPage } from "@/components/pages/Page";
8
9
  import { ArticlePage } from "@/components/pages/Article";
9
10
 
10
- export { collections, sections, shared };
11
+ export { collections, sections, shared, elements };
11
12
 
12
13
  // The registry and the plain settings, one file. Runtime-free: nothing
13
14
  // here imports a route file or Next, so a script, the CLI's template
@@ -15,6 +16,8 @@ export { collections, sections, shared };
15
16
  export const config = defineConfig({
16
17
  registry: {
17
18
  sections,
19
+ // Elements (block nodes spec 2026-09-08): what an article body may embed.
20
+ elements,
18
21
  collections,
19
22
  shared,
20
23
  pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
@@ -0,0 +1,6 @@
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 { Callout } from "@/components/elements/Callout";
4
+ import { Cta } from "@/components/elements/Cta";
5
+
6
+ export const elements = [Callout, Cta];
@@ -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" });
@@ -14,7 +14,7 @@ import {
14
14
  import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admin/next";
15
15
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
16
16
  import type { SupabaseClient } from "@supabase/supabase-js";
17
- import { collections, config, sections, shared } from "./config";
17
+ import { collections, config, elements, sections, shared } from "./config";
18
18
 
19
19
  type Renderer = ReturnType<typeof createPageRenderer>;
20
20
  type Bound = {
@@ -30,9 +30,9 @@ function bound(): Bound {
30
30
  // ONE path index for both stores: pages and entries share the URL space.
31
31
  const paths = new SupabasePathIndex(db);
32
32
  const locales = config.locales;
33
- const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
33
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
34
34
  const pages = new SupabasePageStore(db, {
35
- sections, entries, paths, locales,
35
+ sections, elements, entries, paths, locales,
36
36
  homePageSlug: config.homePageSlug,
37
37
  tree: config.pages?.tree,
38
38
  });
@@ -46,7 +46,7 @@ function bound(): Bound {
46
46
  // (site.getPagesTree for menus, site.getEntries for lists).
47
47
  // Shadows the exported `site` below on purpose: `createPageRenderer`
48
48
  // takes this local value, not the export, so it never re-enters `bound()`.
49
- const site = createSmoodlySite({ stores, collections, shared, sections, locales, homePageSlug: config.homePageSlug });
49
+ const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug });
50
50
  const renderer = createPageRenderer({
51
51
  site,
52
52
  sections,
@@ -0,0 +1,16 @@
1
+ // Toolsets are values, not registrations: a richtext field imports one.
2
+ // `article` is the long-form set — the default marks and blocks plus body
3
+ // images and the two elements. Elements are embedded by value; the
4
+ // config must also register them (defineConfig checks). Relative imports
5
+ // on purpose: collections.ts pulls this file into create-smoodly-app's
6
+ // template build, which runs outside the app where the `@/` alias has
7
+ // no mapping.
8
+ import { smoodly } from "smoodly";
9
+ import { Callout } from "../components/elements/Callout";
10
+ import { Cta } from "../components/elements/Cta";
11
+
12
+ export const article = smoodly.toolset({
13
+ marks: ["bold", "italic", "link"],
14
+ nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote", "image"],
15
+ elements: [Callout, Cta],
16
+ });
@@ -521,12 +521,12 @@ select
521
521
  e."created_at",
522
522
  l."updated_at",
523
523
  v."fields"->>'title' as "title",
524
- v."fields"->>'excerpt' as "excerpt",
525
- v."fields"->'body' as "body",
526
- v."fields"->'tags' as "tags",
527
524
  v."fields"->'image' as "image",
528
525
  (v."fields"->>'date')::timestamptz as "date",
529
- v."fields"->>'author' as "author"
526
+ v."fields"->>'author' as "author",
527
+ v."fields"->>'excerpt' as "excerpt",
528
+ v."fields"->'body' as "body",
529
+ v."fields"->'tags' as "tags"
530
530
  from "entries" e
531
531
  join "entry_locales" l on l."entry_id" = e."id"
532
532
  join "entry_versions" v on v."id" = l."published_version_id"
@@ -63,6 +63,22 @@ address { font-style: normal; }
63
63
  .prose--lead { gap: 24px; font-size: 18px; line-height: 1.7; color: var(--body); }
64
64
  .prose--lead > p:first-child { font-family: var(--serif); font-size: 24px; line-height: 1.45; color: var(--ink); }
65
65
 
66
+ /* ---------- richtext block nodes: images, callout, cta ---------- */
67
+ .prose figure { margin: 8px 0; }
68
+ .prose figure img { width: 100%; height: auto; border-radius: var(--radius); display: block; }
69
+ .prose figcaption { margin-top: 8px; font-size: 14px; color: var(--muted); }
70
+ .prose .callout { margin: 8px 0; padding: 20px 24px; border-radius: var(--radius); border-left: 3px solid var(--accent); background: #fff; display: flex; flex-direction: column; gap: 8px; font-size: 16px; line-height: 1.6; }
71
+ .prose .callout[data-tone="success"] { border-left-color: #3a8f5c; }
72
+ .prose .callout[data-tone="warning"] { border-left-color: #c9962b; background: #fff8e8; }
73
+ .prose .callout__title { color: var(--ink); font-size: 15px; letter-spacing: 0.02em; }
74
+ .prose .callout__body { display: flex; flex-direction: column; gap: 8px; }
75
+ .prose .cta { margin: 16px 0; padding: 28px 32px; border-radius: var(--radius); background: var(--ink); color: var(--paper); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
76
+ .prose .cta[data-variant="outline"] { background: transparent; color: var(--ink); border: 1px solid var(--line-strong); }
77
+ .prose .cta__heading { font-family: var(--serif); font-size: 24px; font-weight: 500; line-height: 1.2; display: block; }
78
+ .prose .cta__text { margin: 6px 0 0; font-size: 15px; color: inherit; opacity: 0.8; }
79
+ .prose .cta .button { background: var(--paper); color: var(--ink); flex: none; }
80
+ .prose .cta[data-variant="outline"] .button { background: var(--ink); color: var(--paper); }
81
+
66
82
  /* ---------- section styles: the config's spacing and tone ---------- */
67
83
 
68
84
  /* Every section in the page body gets the medium spacing by element,
@@ -0,0 +1,35 @@
1
+ import { f, RichText, smoodly, type Props } from "smoodly";
2
+
3
+ // An Element: a small registered block editors insert inside an article
4
+ // body (the `richtext` context). Its body is richtext too — marks only —
5
+ // rendered by the package's pure RichText (never the project's bound one:
6
+ // smoodly/richtext.tsx imports this file, and an import back would cycle).
7
+ export const calloutSchema = smoodly.schema.element({
8
+ name: "callout",
9
+ title: "Callout",
10
+ icon: "!",
11
+ description: "A highlighted note inside the text.",
12
+ contexts: ["richtext"],
13
+ fields: {
14
+ title: f.text().max(80),
15
+ body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
16
+ },
17
+ styles: {
18
+ tone: f.select(["info", "success", "warning"]).default("info"),
19
+ },
20
+ sample: {
21
+ title: "Good to know",
22
+ body: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit." }] }] },
23
+ },
24
+ });
25
+
26
+ function CalloutView({ title, body, styles }: Props<typeof calloutSchema>) {
27
+ return (
28
+ <aside className="callout" data-tone={styles.tone}>
29
+ <strong className="callout__title">{title}</strong>
30
+ <div className="callout__body"><RichText doc={body} /></div>
31
+ </aside>
32
+ );
33
+ }
34
+
35
+ export const Callout = smoodly.element(calloutSchema, CalloutView);
@@ -0,0 +1,35 @@
1
+ import { f, smoodly, type Props } from "smoodly";
2
+
3
+ // A call to action inside an article body: heading, a line of text and
4
+ // one button. `variant` is a style — never translated.
5
+ export const ctaSchema = smoodly.schema.element({
6
+ name: "cta",
7
+ title: "Call to action",
8
+ icon: "→",
9
+ description: "A heading, a line and a button.",
10
+ contexts: ["richtext"],
11
+ fields: {
12
+ heading: f.text().max(80),
13
+ text: f.text().max(200).optional(),
14
+ label: f.text().max(40),
15
+ href: f.link(),
16
+ },
17
+ styles: {
18
+ variant: f.select(["primary", "outline"]).default("primary"),
19
+ },
20
+ sample: { heading: "Ready to start?", text: "Sed ut perspiciatis unde omnis iste natus.", label: "Get in touch", href: "/company/about-us" },
21
+ });
22
+
23
+ function CtaView({ heading, text, label, href, styles }: Props<typeof ctaSchema>) {
24
+ return (
25
+ <div className="cta" data-variant={styles.variant}>
26
+ <div className="cta__copy">
27
+ <strong className="cta__heading">{heading}</strong>
28
+ {text && <p className="cta__text">{text}</p>}
29
+ </div>
30
+ <a className={styles.variant === "outline" ? "button button--outline" : "button"} href={href}>{label}</a>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export const Cta = smoodly.element(ctaSchema, CtaView);
@@ -1,4 +1,5 @@
1
- import { RichText, smoodly, type EntryPageProps } from "smoodly";
1
+ import { smoodly, type EntryPageProps } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
2
3
  import { SmoodlyImage } from "smoodly/image";
3
4
  import { articles } from "@/smoodly/collections";
4
5
  import { site } from "@/smoodly/server";
@@ -19,6 +19,8 @@ export const favouriteArticlesSchema = smoodly.schema.section({
19
19
  sample: { heading: "From the journal", items: [] },
20
20
  });
21
21
 
22
+ const m = smoodly.marks(favouriteArticlesSchema);
23
+
22
24
  // async: the cards format dates in the request locale and link through
23
25
  // the locale's article segment, both from the strings seam. The canvas
24
26
  // is the site's own draft route in an iframe, so an async section
@@ -28,8 +30,8 @@ async function FavouriteArticlesView({ heading, items, styles }: Props<typeof fa
28
30
  return (
29
31
  <smoodly.Section styles={styles} className="favourites">
30
32
  <div className="container">
31
- <h2 className="h2">{heading}</h2>
32
- <div className="card-grid">
33
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
34
+ <div className="card-grid" {...m.field("items")}>
33
35
  {(items ?? []).filter(Boolean).map((a, i) => (
34
36
  <ArticleCard key={i} article={a} url={url(articlePath(a.slug, locale))} locale={locale} />
35
37
  ))}
@@ -1,4 +1,4 @@
1
- import { f, smoodly, type Props } from "smoodly";
1
+ import { f, smoodly, type Props, type Scope } from "smoodly";
2
2
 
3
3
  // The site footer — a VISUAL shared item (smoodly/shared.ts): one row,
4
4
  // edited under Shared content, rendered by the layout. Three link columns
@@ -33,12 +33,17 @@ export const footerSchema = smoodly.schema.section({
33
33
  export type ContactFields = { email: string; phone?: string; address: string };
34
34
  type Column = { title: string; links: { label: string; href: string }[] };
35
35
 
36
- function FooterColumn({ column }: { column: Column | undefined }) {
36
+ const m = smoodly.marks(footerSchema);
37
+
38
+ function FooterColumn({ column, scope }: { column: Column | undefined; scope: Scope<Column> }) {
37
39
  if (!column) return null;
38
40
  return (
39
- <div className="site-footer__column">
40
- <span className="site-footer__title">{column.title}</span>
41
- {(column.links ?? []).map((l, i) => <a key={i} href={l.href}>{l.label}</a>)}
41
+ <div className="site-footer__column" {...scope.attrs}>
42
+ <span className="site-footer__title" {...scope.field("title")}>{column.title}</span>
43
+ {(column.links ?? []).map((l, i) => {
44
+ const link = scope.item("links", i);
45
+ return <a key={i} href={l.href} {...link.attrs} {...link.field("label")}>{l.label}</a>;
46
+ })}
42
47
  </div>
43
48
  );
44
49
  }
@@ -48,7 +53,7 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
48
53
  <smoodly.Section as="footer" styles={styles} className="site-footer">
49
54
  <div className="container site-footer__grid">
50
55
  <div className="site-footer__about">
51
- <p className="site-footer__tagline">{tagline}</p>
56
+ <p className="site-footer__tagline" {...m.field("tagline")}>{tagline}</p>
52
57
  {contact && (
53
58
  <address className="site-footer__contact">
54
59
  <a href={`mailto:${contact.email}`}>{contact.email}</a>
@@ -57,11 +62,11 @@ export function FooterView({ tagline, company, legal, social, copyright, contact
57
62
  </address>
58
63
  )}
59
64
  </div>
60
- <FooterColumn column={company} />
61
- <FooterColumn column={legal} />
62
- <FooterColumn column={social} />
65
+ <FooterColumn column={company} scope={m.object("company")} />
66
+ <FooterColumn column={legal} scope={m.object("legal")} />
67
+ <FooterColumn column={social} scope={m.object("social")} />
63
68
  </div>
64
- <div className="container site-footer__copyright">{copyright}</div>
69
+ <div className="container site-footer__copyright" {...m.field("copyright")}>{copyright}</div>
65
70
  </smoodly.Section>
66
71
  );
67
72
  }
@@ -20,14 +20,19 @@ export const headerSchema = smoodly.schema.section({
20
20
 
21
21
  export type LocaleLink = { code: string; url: string; current: boolean };
22
22
 
23
+ const m = smoodly.marks(headerSchema);
24
+
23
25
  export function HeaderView({ brand, nav, locales, styles }: Props<typeof headerSchema> & { locales?: LocaleLink[] }) {
24
26
  const home = locales?.find((l) => l.current)?.url ?? "/";
25
27
  return (
26
28
  <smoodly.Section as="header" styles={styles} className="site-header">
27
29
  <div className="container site-header__bar">
28
- <a className="site-header__brand" href={home}>{brand}</a>
30
+ <a className="site-header__brand" href={home} {...m.field("brand")}>{brand}</a>
29
31
  <nav className="site-header__nav">
30
- {(nav ?? []).map((item, i) => <a key={i} href={item.href}>{item.label}</a>)}
32
+ {(nav ?? []).map((item, i) => {
33
+ const link = m.item("nav", i);
34
+ return <a key={i} href={item.href} {...link.attrs} {...link.field("label")}>{item.label}</a>;
35
+ })}
31
36
  </nav>
32
37
  {locales && locales.length > 1 && (
33
38
  <nav className="site-header__locales" aria-label="Language">
@@ -24,23 +24,29 @@ export const heroSchema = smoodly.schema.section({
24
24
  },
25
25
  });
26
26
 
27
+ const m = smoodly.marks(heroSchema);
28
+
27
29
  function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeof heroSchema>) {
28
30
  return (
29
31
  <smoodly.Section styles={styles} className="hero">
30
32
  <div className="container hero__grid">
31
33
  <div className="hero__copy">
32
- {kicker && <p className="kicker">{kicker}</p>}
33
- <h1 className="hero__heading">{heading}</h1>
34
- <p className="hero__text">{text}</p>
34
+ {kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
35
+ <h1 className="hero__heading" {...m.field("heading")}>{heading}</h1>
36
+ <p className="hero__text" {...m.field("text")}>{text}</p>
35
37
  {buttons?.length > 0 && (
36
38
  <div className="hero__buttons">
37
- {buttons.map((b, i) => (
38
- <a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}>{b.label}</a>
39
- ))}
39
+ {buttons.map((b, i) => {
40
+ const button = m.item("buttons", i);
41
+ return (
42
+ <a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
43
+ {...button.attrs} {...button.field("label")}>{b.label}</a>
44
+ );
45
+ })}
40
46
  </div>
41
47
  )}
42
48
  </div>
43
- {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority />}
49
+ {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
44
50
  </div>
45
51
  </smoodly.Section>
46
52
  );
@@ -1,4 +1,5 @@
1
- import { f, RichText, smoodly, type Props } from "smoodly";
1
+ import { f, smoodly, type Props } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
2
3
  import { SmoodlyImage } from "smoodly/image";
3
4
 
4
5
  // A photo beside a heading and richtext, the photo on either side (a
@@ -30,11 +31,14 @@ export const imageTextSchema = smoodly.schema.section({
30
31
  },
31
32
  });
32
33
 
34
+ const m = smoodly.marks(imageTextSchema);
35
+
33
36
  function ImageTextView({ image, heading, text, link, styles }: Props<typeof imageTextSchema>) {
37
+ const linkMarks = m.object("link");
34
38
  const body = (
35
39
  <>
36
- <div className="prose"><RichText doc={text} /></div>
37
- {link?.href && <a className="link-accent" href={link.href}>{link.label}</a>}
40
+ <div className="prose" {...m.field("text")}><RichText doc={text} /></div>
41
+ {link?.href && <a className="link-accent" href={link.href} {...linkMarks.attrs} {...linkMarks.field("label")}>{link.label}</a>}
38
42
  </>
39
43
  );
40
44
  return (
@@ -42,15 +46,15 @@ function ImageTextView({ image, heading, text, link, styles }: Props<typeof imag
42
46
  <div className="container image-text__grid">
43
47
  {image ? (
44
48
  <>
45
- <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" />
49
+ <SmoodlyImage image={image} className="image-text__image" sizes="(min-width: 900px) 50vw, 100vw" {...m.field("image")} />
46
50
  <div className="image-text__copy">
47
- <h2 className="h2">{heading}</h2>
51
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
48
52
  {body}
49
53
  </div>
50
54
  </>
51
55
  ) : (
52
56
  <>
53
- <h2 className="h2">{heading}</h2>
57
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
54
58
  <div className="image-text__copy">{body}</div>
55
59
  </>
56
60
  )}
@@ -21,16 +21,18 @@ export const pageHeroSchema = smoodly.schema.section({
21
21
  },
22
22
  });
23
23
 
24
+ const m = smoodly.marks(pageHeroSchema);
25
+
24
26
  function PageHeroView({ kicker, heading, text, image, styles }: Props<typeof pageHeroSchema>) {
25
27
  return (
26
28
  <smoodly.Section styles={styles} className="page-hero">
27
29
  <div className="container page-hero__inner">
28
30
  <div className="page-hero__copy">
29
- {kicker && <p className="kicker">{kicker}</p>}
30
- <h1 className="page-hero__heading">{heading}</h1>
31
- <p className="page-hero__text">{text}</p>
31
+ {kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
32
+ <h1 className="page-hero__heading" {...m.field("heading")}>{heading}</h1>
33
+ <p className="page-hero__text" {...m.field("text")}>{text}</p>
32
34
  </div>
33
- {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority />}
35
+ {image && <SmoodlyImage image={image} className="page-hero__image" sizes="100vw" priority {...m.field("image")} />}
34
36
  </div>
35
37
  </smoodly.Section>
36
38
  );
@@ -14,12 +14,14 @@ export const peopleSchema = smoodly.schema.section({
14
14
  sample: { heading: "Our people", people: [] },
15
15
  });
16
16
 
17
+ const m = smoodly.marks(peopleSchema);
18
+
17
19
  function PeopleView({ heading, people: persons, styles }: Props<typeof peopleSchema>) {
18
20
  return (
19
21
  <smoodly.Section styles={styles} className="people">
20
22
  <div className="container">
21
- <h2 className="h2">{heading}</h2>
22
- <div className="people__grid">
23
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
24
+ <div className="people__grid" {...m.field("people")}>
23
25
  {(persons ?? []).filter(Boolean).map((person, i) => (
24
26
  <div key={i} className="person">
25
27
  <span className="person__mark" aria-hidden="true">{person.name?.charAt(0)}</span>
@@ -26,21 +26,26 @@ const ICONS = {
26
26
  diamond: <polygon points="20,2 38,20 20,38 2,20" />,
27
27
  };
28
28
 
29
+ const m = smoodly.marks(pointsSchema);
30
+
29
31
  function PointsView({ heading, points, styles }: Props<typeof pointsSchema>) {
30
32
  return (
31
33
  <smoodly.Section styles={styles} className="points">
32
34
  <div className="container">
33
- <h2 className="h2">{heading}</h2>
35
+ <h2 className="h2" {...m.field("heading")}>{heading}</h2>
34
36
  <div className="points__grid">
35
- {(points ?? []).map((point, i) => (
36
- <div key={i} className="point">
37
- <svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5">
38
- {ICONS[point.icon] ?? ICONS.circle}
39
- </svg>
40
- <h3 className="point__heading">{point.heading}</h3>
41
- <p className="point__text">{point.text}</p>
42
- </div>
43
- ))}
37
+ {(points ?? []).map((point, i) => {
38
+ const p = m.item("points", i);
39
+ return (
40
+ <div key={i} className="point" {...p.attrs}>
41
+ <svg className="point__icon" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" {...p.field("icon")}>
42
+ {ICONS[point.icon] ?? ICONS.circle}
43
+ </svg>
44
+ <h3 className="point__heading" {...p.field("heading")}>{point.heading}</h3>
45
+ <p className="point__text" {...p.field("text")}>{point.text}</p>
46
+ </div>
47
+ );
48
+ })}
44
49
  </div>
45
50
  </div>
46
51
  </smoodly.Section>
@@ -19,12 +19,14 @@ export const quoteSchema = smoodly.schema.section({
19
19
  },
20
20
  });
21
21
 
22
+ const m = smoodly.marks(quoteSchema);
23
+
22
24
  function QuoteView({ text, attribution, styles }: Props<typeof quoteSchema>) {
23
25
  return (
24
26
  <smoodly.Section styles={styles} className="quote">
25
27
  <div className="quote__inner">
26
- <blockquote className="quote__text">“{text}”</blockquote>
27
- <p className="quote__attribution">{attribution}</p>
28
+ <blockquote className="quote__text">“<span {...m.field("text")}>{text}</span>”</blockquote>
29
+ <p className="quote__attribution" {...m.field("attribution")}>{attribution}</p>
28
30
  </div>
29
31
  </smoodly.Section>
30
32
  );
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.12",
17
+ "smoodly": "0.0.13",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -45,6 +45,12 @@ const p = (s: string): Node => ({ type: "paragraph", content: text(s) });
45
45
  const h2 = (s: string): Node => ({ type: "heading", attrs: { level: 2 }, content: text(s) });
46
46
  const quoteBlock = (s: string): Node => ({ type: "blockquote", content: [p(s)] });
47
47
  const bullets = (...items: string[]): Node => ({ type: "bulletList", content: items.map((s) => ({ type: "listItem", content: [p(s)] })) });
48
+ // Block nodes (spec 2026-09-08): a library image, and the starter's two elements.
49
+ const imageBlock = (image: { asset: string }, caption: string): Node => ({ type: "image", attrs: { image, caption } });
50
+ const callout = (title: string, text: string, tone: "info" | "success" | "warning" = "info"): Node =>
51
+ ({ type: "element", attrs: { name: "callout", fields: { title, body: doc(p(text)) }, styles: { tone } } });
52
+ const cta = (heading: string, text: string, label: string, href: string): Node =>
53
+ ({ type: "element", attrs: { name: "cta", fields: { heading, text, label, href }, styles: { variant: "primary" } } });
48
54
  const doc = (...content: Node[]) => ({ type: "doc", content });
49
55
 
50
56
  // The starter's photos become assets: each file is uploaded once through
@@ -139,12 +145,15 @@ const body = doc(
139
145
  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."),
140
146
  h2("Sed ut perspiciatis unde omnis"),
141
147
  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."),
148
+ imageBlock(img.home2, "Lorem ipsum dolor sit amet."),
142
149
  quoteBlock("Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit."),
150
+ callout("Good to know", "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."),
143
151
  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."),
144
152
  h2("Quis autem vel eum iure"),
145
153
  p("Reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur."),
146
154
  bullets("At vero eos et accusamus et iusto odio", "Dignissimos ducimus qui blanditiis praesentium", "Voluptatum deleniti atque corrupti quos dolores"),
147
155
  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."),
156
+ cta("Ready to talk?", "Sed ut perspiciatis unde omnis iste natus error sit voluptatem.", "Get in touch", paths(EN).about),
148
157
  );
149
158
 
150
159
  // Versioned (`versions: true` on the collection): every save below is a
@@ -1,4 +1,5 @@
1
1
  import { f, smoodly } from "smoodly";
2
+ import { article } from "./toolsets";
2
3
 
3
4
  // A person's name is the same in every language; the role is translated.
4
5
  // Shared fields live on the entry, .localized() ones on its locale rows
@@ -7,6 +8,7 @@ export const people = smoodly.collection({
7
8
  name: "people",
8
9
  title: "People",
9
10
  titleField: "name",
11
+ order: "manual",
10
12
  fields: {
11
13
  name: f.text(),
12
14
  role: f.text().localized(),
@@ -29,13 +31,14 @@ export const articles = smoodly.collection({
29
31
  versions: true,
30
32
  fields: {
31
33
  title: f.text().localized(),
32
- slug: f.slug({ from: "title" }),
33
- excerpt: f.text().localized(),
34
- body: f.richtext().localized(),
35
- tags: f.list(f.text()).localized(),
36
34
  image: f.image({ aspect: "3:2" }),
37
35
  date: f.date(),
38
36
  author: f.ref(() => people),
37
+ slug: f.slug({ from: "title" }),
38
+ excerpt: f.text().localized(),
39
+ // The article toolset: the default marks and blocks, body images and the elements.
40
+ body: f.richtext({ toolset: article }).localized(),
41
+ tags: f.list(f.text()).localized(),
39
42
  },
40
43
  });
41
44
 
@@ -3,12 +3,13 @@ import { routing } from "@/i18n/routing";
3
3
  import { collections } from "./collections";
4
4
  import { sections } from "./sections";
5
5
  import { shared } from "./shared";
6
+ import { elements } from "./elements";
6
7
  import { HomePage } from "@/components/pages/Home";
7
8
  import { ArticlesPage } from "@/components/pages/Articles";
8
9
  import { StandardPage } from "@/components/pages/Page";
9
10
  import { ArticlePage } from "@/components/pages/Article";
10
11
 
11
- export { collections, sections, shared };
12
+ export { collections, sections, shared, elements };
12
13
 
13
14
  // The registry and the plain settings, one file. Runtime-free: nothing
14
15
  // here imports a route file or Next, so a script, the CLI's template
@@ -19,6 +20,8 @@ export { collections, sections, shared };
19
20
  export const config = defineConfig({
20
21
  registry: {
21
22
  sections,
23
+ // Elements (block nodes spec 2026-09-08): what an article body may embed.
24
+ elements,
22
25
  collections,
23
26
  shared,
24
27
  pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
@@ -0,0 +1,6 @@
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 { Callout } from "@/components/elements/Callout";
4
+ import { Cta } from "@/components/elements/Cta";
5
+
6
+ export const elements = [Callout, Cta];
@@ -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" });
@@ -15,7 +15,7 @@ import { supabaseAdminAuth, type AdminAuth, type AuthConfig } from "smoodly/admi
15
15
  import { createPageRenderer, type PageRegistration } from "smoodly/next";
16
16
  import type { SupabaseClient } from "@supabase/supabase-js";
17
17
  import { getPathname } from "@/i18n/navigation";
18
- import { collections, config, sections, shared } from "./config";
18
+ import { collections, config, elements, sections, shared } from "./config";
19
19
 
20
20
  type Renderer = ReturnType<typeof createPageRenderer>;
21
21
  type Bound = {
@@ -31,9 +31,9 @@ function bound(): Bound {
31
31
  // ONE path index for both stores: pages and entries share the URL space.
32
32
  const paths = new SupabasePathIndex(db);
33
33
  const locales = config.locales;
34
- const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared });
34
+ const entries = new SupabaseEntryStore(db, collections, { paths, locales, shared, elements });
35
35
  const pages = new SupabasePageStore(db, {
36
- sections, entries, paths, locales,
36
+ sections, elements, entries, paths, locales,
37
37
  homePageSlug: config.homePageSlug,
38
38
  tree: config.pages?.tree,
39
39
  });
@@ -47,7 +47,7 @@ function bound(): Bound {
47
47
  // (site.getPagesTree for menus, site.getEntries for lists).
48
48
  // Shadows the exported `site` below on purpose: `createPageRenderer`
49
49
  // takes this local value, not the export, so it never re-enters `bound()`.
50
- const site = createSmoodlySite({ stores, collections, shared, sections, locales, homePageSlug: config.homePageSlug });
50
+ const site = createSmoodlySite({ stores, collections, shared, sections, elements, locales, homePageSlug: config.homePageSlug });
51
51
  const renderer = createPageRenderer({
52
52
  site,
53
53
  sections,
@@ -0,0 +1,16 @@
1
+ // Toolsets are values, not registrations: a richtext field imports one.
2
+ // `article` is the long-form set — the default marks and blocks plus body
3
+ // images and the two elements. Elements are embedded by value; the
4
+ // config must also register them (defineConfig checks). Relative imports
5
+ // on purpose: collections.ts pulls this file into create-smoodly-app's
6
+ // template build, which runs outside the app where the `@/` alias has
7
+ // no mapping.
8
+ import { smoodly } from "smoodly";
9
+ import { Callout } from "../components/elements/Callout";
10
+ import { Cta } from "../components/elements/Cta";
11
+
12
+ export const article = smoodly.toolset({
13
+ marks: ["bold", "italic", "link"],
14
+ nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote", "image"],
15
+ elements: [Callout, Cta],
16
+ });
@@ -521,12 +521,12 @@ select
521
521
  e."created_at",
522
522
  l."updated_at",
523
523
  v."fields"->>'title' as "title",
524
- v."fields"->>'excerpt' as "excerpt",
525
- v."fields"->'body' as "body",
526
- v."fields"->'tags' as "tags",
527
524
  v."fields"->'image' as "image",
528
525
  (v."fields"->>'date')::timestamptz as "date",
529
- v."fields"->>'author' as "author"
526
+ v."fields"->>'author' as "author",
527
+ v."fields"->>'excerpt' as "excerpt",
528
+ v."fields"->'body' as "body",
529
+ v."fields"->'tags' as "tags"
530
530
  from "entries" e
531
531
  join "entry_locales" l on l."entry_id" = e."id"
532
532
  join "entry_versions" v on v."id" = l."published_version_id"