create-smoodly-app 0.0.33 → 0.0.34

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 (39) hide show
  1. package/dist/index.js +17 -6
  2. package/package.json +1 -1
  3. package/templates/blank/package.json +1 -1
  4. package/templates/blank/scripts/seed.ts +1 -1
  5. package/templates/blank/smoodly/server.ts +7 -3
  6. package/templates/blank/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  7. package/templates/blank/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +1 -1
  8. package/templates/blank-intl/package.json +1 -1
  9. package/templates/blank-intl/scripts/seed.ts +1 -1
  10. package/templates/blank-intl/smoodly/server.ts +7 -3
  11. package/templates/blank-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  12. package/templates/blank-intl/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +1 -1
  13. package/templates/lorem/README.md +3 -2
  14. package/templates/lorem/app/globals.css +9 -0
  15. package/templates/lorem/components/news.ts +14 -0
  16. package/templates/lorem/components/pages/News.tsx +48 -0
  17. package/templates/lorem/components/pages/NewsPost.tsx +28 -0
  18. package/templates/lorem/messages/en.json +2 -1
  19. package/templates/lorem/package.json +1 -1
  20. package/templates/lorem/scripts/seed.ts +45 -8
  21. package/templates/lorem/smoodly/collections.ts +21 -1
  22. package/templates/lorem/smoodly/config.ts +3 -1
  23. package/templates/lorem/smoodly/server.ts +7 -3
  24. package/templates/lorem/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  25. package/templates/lorem/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +22 -1
  26. package/templates/lorem-intl/README.md +3 -2
  27. package/templates/lorem-intl/app/globals.css +9 -0
  28. package/templates/lorem-intl/components/news.ts +14 -0
  29. package/templates/lorem-intl/components/pages/News.tsx +48 -0
  30. package/templates/lorem-intl/components/pages/NewsPost.tsx +28 -0
  31. package/templates/lorem-intl/messages/en.json +2 -1
  32. package/templates/lorem-intl/messages/fi.json +2 -1
  33. package/templates/lorem-intl/package.json +1 -1
  34. package/templates/lorem-intl/scripts/seed.ts +45 -8
  35. package/templates/lorem-intl/smoodly/collections.ts +21 -1
  36. package/templates/lorem-intl/smoodly/config.ts +3 -1
  37. package/templates/lorem-intl/smoodly/server.ts +7 -3
  38. package/templates/lorem-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
  39. package/templates/lorem-intl/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +22 -1
package/dist/index.js CHANGED
@@ -212,10 +212,16 @@ var ROUTING_LOCALES = ' locales: ["en", "fi"],';
212
212
  var ROUTING_DEFAULT = ' defaultLocale: "en",';
213
213
  var ARTICLES_PATH_LINE = ' path: { en: "posts", fi: "artikkelit" },';
214
214
  var ARTICLES_SLUG_LINE = ' slug: { en: "posts", fi: "artikkelit" },';
215
+ var NEWS_PATH_LINE = ' path: { en: "company/news", fi: "yritys/uutiset" },';
216
+ var NEWS_SLUG_LINE = ' slug: { en: "company/news", fi: "yritys/uutiset" },';
215
217
  function articlesSegments(locales) {
216
218
  const entries = locales.map((l) => `${/^[a-z]+$/.test(l) ? l : JSON.stringify(l)}: ${l === "fi" ? '"artikkelit"' : '"posts"'}`);
217
219
  return `{ ${entries.join(", ")} }`;
218
220
  }
221
+ function newsSegments(locales) {
222
+ const entries = locales.map((l) => `${/^[a-z]+$/.test(l) ? l : JSON.stringify(l)}: ${l === "fi" ? '"yritys/uutiset"' : '"company/news"'}`);
223
+ return `{ ${entries.join(", ")} }`;
224
+ }
219
225
  async function scaffold(opts) {
220
226
  await cp(opts.templateDir, opts.dir, { recursive: true });
221
227
  await rename(join(opts.dir, "_gitignore"), join(opts.dir, ".gitignore"));
@@ -235,13 +241,18 @@ async function scaffold(opts) {
235
241
  routing.replace(ROUTING_LOCALES, ` locales: [${opts.locales.map((l) => JSON.stringify(l)).join(", ")}],`).replace(ROUTING_DEFAULT, ` defaultLocale: ${JSON.stringify(opts.locales[0])},`)
236
242
  );
237
243
  if (opts.starter !== "blank") {
238
- const map = articlesSegments(opts.locales);
239
- for (const [rel, line] of [["smoodly/collections.ts", ARTICLES_PATH_LINE], ["components/pages/Articles.tsx", ARTICLES_SLUG_LINE]]) {
240
- const path = join(opts.dir, rel);
241
- const source = await readFile(path, "utf8");
242
- if (!source.includes(line)) throw new Error(`${rel} in the template has drifted; expected the line
244
+ const rewrites = [
245
+ { map: articlesSegments(opts.locales), from: '{ en: "posts", fi: "artikkelit" }', lines: [["smoodly/collections.ts", ARTICLES_PATH_LINE], ["components/pages/Articles.tsx", ARTICLES_SLUG_LINE]] },
246
+ { map: newsSegments(opts.locales), from: '{ en: "company/news", fi: "yritys/uutiset" }', lines: [["smoodly/collections.ts", NEWS_PATH_LINE], ["components/pages/News.tsx", NEWS_SLUG_LINE]] }
247
+ ];
248
+ for (const { map, from, lines } of rewrites) {
249
+ for (const [rel, line] of lines) {
250
+ const path = join(opts.dir, rel);
251
+ const source = await readFile(path, "utf8");
252
+ if (!source.includes(line)) throw new Error(`${rel} in the template has drifted; expected the line
243
253
  ${line}`);
244
- await writeFile(path, source.replace(line, line.replace('{ en: "posts", fi: "artikkelit" }', map)));
254
+ await writeFile(path, source.replace(line, line.replace(from, map)));
255
+ }
245
256
  }
246
257
  }
247
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
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": {
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.33"
17
+ "smoodly": "0.0.34"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -72,7 +72,7 @@ const tree = {
72
72
  },
73
73
  } as unknown as PageTree;
74
74
 
75
- const home = await pages.createPage({ locale: DEFAULT, slug: "home", title: "Home", template: "home" });
75
+ const home = await pages.createPage({ locale: DEFAULT, slug: "home", title: "Home", template: "home", fixed: "page:home" });
76
76
  await pages.saveDraft(home.id, DEFAULT, tree);
77
77
  await pages.publish(home.id, DEFAULT);
78
78
  for (const locale of OTHERS) {
@@ -32,7 +32,8 @@ function bound(): Bound {
32
32
  const env = smoodlyEnv();
33
33
  const db = createSmoodlyClient(env);
34
34
  // ONE path index for both stores: pages and entries share the URL space.
35
- const paths = new SupabasePathIndex(db);
35
+ // The index reads the code-owned addresses from the config (spec 2026-09-29 anchored paths §2).
36
+ const paths = new SupabasePathIndex(db, config.anchors);
36
37
  const locales = config.locales;
37
38
  const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
38
39
  const pages = new SupabasePageStore(db, {
@@ -40,6 +41,8 @@ function bound(): Bound {
40
41
  // The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
41
42
  custom: config.custom,
42
43
  homePageSlug: config.homePageSlug,
44
+ // Code-owned addresses by owner: a fixed page's or a collection's path (spec 2026-09-29 nested paths §3).
45
+ addresses: config.addresses,
43
46
  tree: config.pages?.tree,
44
47
  });
45
48
  // The media library: rows in `assets`, bytes in the public `media` bucket.
@@ -84,8 +87,9 @@ export function publicAuth(): AuthConfig {
84
87
  return { url: env.authUrl, key: env.authKey };
85
88
  }
86
89
 
87
- /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5). */
88
- export function publicContent(): ContentConfig {
90
+ /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
91
+ * The space is asked of the database once per process. */
92
+ export function publicContent(): Promise<ContentConfig> {
89
93
  return publicContentConfig(smoodlyEnv());
90
94
  }
91
95
 
@@ -0,0 +1,56 @@
1
+ -- Smoodly core schema, version 5 (docs/superpowers/specs/2026-09-29-nested-collection-paths-design.md,
2
+ -- docs/superpowers/specs/2026-09-29-anchored-paths-design.md).
3
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
4
+ -- Never edit a published file: the next change is the next file.
5
+ -- The delta over version 4: `pages.fixed` names the registration that owns a
6
+ -- code-owned record, unique per space; `paths.anchor` names the code address
7
+ -- a URL row hangs under, and the row keeps only the part editors made.
8
+ -- No backfill: a record without `fixed` is an editor's page, a row without
9
+ -- an anchor is the editors' tree.
10
+
11
+ set client_min_messages = warning;
12
+
13
+ alter table "pages" add column if not exists "fixed" text;
14
+
15
+ create unique index if not exists "pages_fixed_idx"
16
+ on "pages" ("space_id", "fixed") where "fixed" is not null;
17
+
18
+ alter table "paths" add column if not exists "anchor" text not null default '';
19
+ alter table "paths" drop constraint if exists "paths_pkey";
20
+ alter table "paths" add constraint "paths_pkey" primary key ("space_id", "locale", "anchor", "path");
21
+
22
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
23
+ delete from "paths" p
24
+ using jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
25
+ where p."redirect" and p."locale" = r.locale
26
+ and exists (select 1 from jsonb_to_recordset(r.keys) as k(anchor text, path text) where k.anchor = p."anchor" and k.path = p."path")
27
+ and p."space_id" = "smoodly_current_space"();
28
+ update "paths" p set "redirect" = true
29
+ from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
30
+ where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
31
+ and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
32
+ and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
33
+ cross join lateral jsonb_to_recordset(r.keys) as k(anchor text, path text)
34
+ where r.locale = p."locale" and k.anchor = p."anchor" and k.path = p."path")
35
+ and p."space_id" = "smoodly_current_space"();
36
+ delete from "paths" p
37
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
38
+ where p."kind" = t.kind and p."target_id" = t.id
39
+ and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid)
40
+ where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
41
+ and p."space_id" = "smoodly_current_space"();
42
+ insert into "paths" ("locale", "anchor", "path", "kind", "target_id")
43
+ select r.locale, r.anchor, r.path, r.kind, r.id
44
+ from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid);
45
+ $$;
46
+
47
+ revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;
48
+
49
+ create or replace function "smoodly_resolve_path"(p_locale text, p_keys jsonb) returns setof "paths" language sql stable set search_path = public as $$
50
+ select p.* from "paths" p
51
+ join jsonb_to_recordset(p_keys) as k(anchor text, path text) on p."anchor" = k.anchor and p."path" = k.path
52
+ where p."locale" = p_locale;
53
+ $$;
54
+
55
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '5')
56
+ on conflict ("key") do update set "value" = excluded."value";
@@ -1,5 +1,5 @@
1
1
  -- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
2
- -- (2026-09-26T15:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
2
+ -- (2026-09-29T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
3
3
  -- Never edit a written file: change the collection and run migrate again.
4
4
 
5
5
  -- smoodly:collection posts sha256:2e648b887b8e7146
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.33",
17
+ "smoodly": "0.0.34",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -72,7 +72,7 @@ const tree = {
72
72
  },
73
73
  } as unknown as PageTree;
74
74
 
75
- const home = await pages.createPage({ locale: DEFAULT, slug: "home", title: "Home", template: "home" });
75
+ const home = await pages.createPage({ locale: DEFAULT, slug: "home", title: "Home", template: "home", fixed: "page:home" });
76
76
  await pages.saveDraft(home.id, DEFAULT, tree);
77
77
  await pages.publish(home.id, DEFAULT);
78
78
  for (const locale of OTHERS) {
@@ -33,7 +33,8 @@ function bound(): Bound {
33
33
  const env = smoodlyEnv();
34
34
  const db = createSmoodlyClient(env);
35
35
  // ONE path index for both stores: pages and entries share the URL space.
36
- const paths = new SupabasePathIndex(db);
36
+ // The index reads the code-owned addresses from the config (spec 2026-09-29 anchored paths §2).
37
+ const paths = new SupabasePathIndex(db, config.anchors);
37
38
  const locales = config.locales;
38
39
  const entries = new SupabaseEntryStore(db, collections, { paths, locales, globals, elements });
39
40
  const pages = new SupabasePageStore(db, {
@@ -41,6 +42,8 @@ function bound(): Bound {
41
42
  // The custom section's columns toolset, so a column's images count as usage (spec 2026-09-14 §6).
42
43
  custom: config.custom,
43
44
  homePageSlug: config.homePageSlug,
45
+ // Code-owned addresses by owner: a fixed page's or a collection's path (spec 2026-09-29 nested paths §3).
46
+ addresses: config.addresses,
44
47
  tree: config.pages?.tree,
45
48
  });
46
49
  // The media library: rows in `assets`, bytes in the public `media` bucket.
@@ -85,8 +88,9 @@ export function publicAuth(): AuthConfig {
85
88
  return { url: env.authUrl, key: env.authKey };
86
89
  }
87
90
 
88
- /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5). */
89
- export function publicContent(): ContentConfig {
91
+ /** What the browser needs to join the editing-presence channel — public values only (spec 2026-09-26 concurrency §5).
92
+ * The space is asked of the database once per process. */
93
+ export function publicContent(): Promise<ContentConfig> {
90
94
  return publicContentConfig(smoodlyEnv());
91
95
  }
92
96
 
@@ -0,0 +1,56 @@
1
+ -- Smoodly core schema, version 5 (docs/superpowers/specs/2026-09-29-nested-collection-paths-design.md,
2
+ -- docs/superpowers/specs/2026-09-29-anchored-paths-design.md).
3
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
4
+ -- Never edit a published file: the next change is the next file.
5
+ -- The delta over version 4: `pages.fixed` names the registration that owns a
6
+ -- code-owned record, unique per space; `paths.anchor` names the code address
7
+ -- a URL row hangs under, and the row keeps only the part editors made.
8
+ -- No backfill: a record without `fixed` is an editor's page, a row without
9
+ -- an anchor is the editors' tree.
10
+
11
+ set client_min_messages = warning;
12
+
13
+ alter table "pages" add column if not exists "fixed" text;
14
+
15
+ create unique index if not exists "pages_fixed_idx"
16
+ on "pages" ("space_id", "fixed") where "fixed" is not null;
17
+
18
+ alter table "paths" add column if not exists "anchor" text not null default '';
19
+ alter table "paths" drop constraint if exists "paths_pkey";
20
+ alter table "paths" add constraint "paths_pkey" primary key ("space_id", "locale", "anchor", "path");
21
+
22
+ create or replace function "smoodly_rewrite_paths"(p_targets jsonb, p_rows jsonb) returns void language sql set search_path = public as $$
23
+ delete from "paths" p
24
+ using jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
25
+ where p."redirect" and p."locale" = r.locale
26
+ and exists (select 1 from jsonb_to_recordset(r.keys) as k(anchor text, path text) where k.anchor = p."anchor" and k.path = p."path")
27
+ and p."space_id" = "smoodly_current_space"();
28
+ update "paths" p set "redirect" = true
29
+ from jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
30
+ where not p."redirect" and p."kind" = t.kind and p."target_id" = t.id
31
+ and p."locale" = any (coalesce(t.redirect_locales, '{}'::text[]))
32
+ and not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid, keys jsonb)
33
+ cross join lateral jsonb_to_recordset(r.keys) as k(anchor text, path text)
34
+ where r.locale = p."locale" and k.anchor = p."anchor" and k.path = p."path")
35
+ and p."space_id" = "smoodly_current_space"();
36
+ delete from "paths" p
37
+ using jsonb_to_recordset(p_targets) as t(kind text, id uuid, redirect_locales text[])
38
+ where p."kind" = t.kind and p."target_id" = t.id
39
+ and (not p."redirect" or not exists (select 1 from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid)
40
+ where r.kind = p."kind" and r.id = p."target_id" and r.locale = p."locale"))
41
+ and p."space_id" = "smoodly_current_space"();
42
+ insert into "paths" ("locale", "anchor", "path", "kind", "target_id")
43
+ select r.locale, r.anchor, r.path, r.kind, r.id
44
+ from jsonb_to_recordset(p_rows) as r(locale text, anchor text, path text, kind text, id uuid);
45
+ $$;
46
+
47
+ revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;
48
+
49
+ create or replace function "smoodly_resolve_path"(p_locale text, p_keys jsonb) returns setof "paths" language sql stable set search_path = public as $$
50
+ select p.* from "paths" p
51
+ join jsonb_to_recordset(p_keys) as k(anchor text, path text) on p."anchor" = k.anchor and p."path" = k.path
52
+ where p."locale" = p_locale;
53
+ $$;
54
+
55
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '5')
56
+ on conflict ("key") do update set "value" = excluded."value";
@@ -1,5 +1,5 @@
1
1
  -- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`
2
- -- (2026-09-26T15:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
2
+ -- (2026-09-29T12:01:00.000Z). One block per collection; the marker's hash is the block's SQL, compared on the next run.
3
3
  -- Never edit a written file: change the collection and run migrate again.
4
4
 
5
5
  -- smoodly:collection posts sha256:2e648b887b8e7146
@@ -11,13 +11,13 @@ for an agent (or a person) building the site.
11
11
  - `app/(site)/page.tsx` — the home page: one line calling renderSmoodlyPath([]).
12
12
  - `app/(site)/[...path]/page.tsx` — every other page and every collection entry, at any depth.
13
13
  - `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the footer from global content; the admin has its own under `app/(smoodly)/admin/`.
14
- - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page).
14
+ - `components/pages/` — the page shapes and the entry page: `Home` (locked hero, freeform body), `Articles` (a fixed page at the collection's path — its index — with a code-fetched grid), `Page` (open; About lives here), `Article` (the entry page), `News` and `NewsPost` (a collection at the nested address `company/news`, its index and entry page).
15
15
  - `components/PageFrame.tsx` — the header and `<main>` every page view renders (a layout cannot know which page is on screen); with more than one locale, the language links from the page's `locales` map — the same page in the other language, or its home when there is no translation.
16
16
  - `components/sections/` — the building blocks editors compose with, and the header, footer and quote behind the global items. Add one, list it in `smoodly/sections.ts`.
17
17
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
18
18
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
19
19
  - `scripts/seed.ts` — the starter content, written through the stores. `npm run seed` is idempotent.
20
- - `smoodly/collections.ts` — structured content (people, articles). Add a collection, run `npx smoodly migrate`, apply the file it writes.
20
+ - `smoodly/collections.ts` — structured content (people, articles, news). Add a collection, run `npx smoodly migrate`, apply the file it writes.
21
21
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts`, `sections.ts`, `server.ts` (stores, site layer, renderer), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount).
22
22
  - `.env.local` — `SMOODLY_URL`, `SMOODLY_SECRET_KEY`, `SMOODLY_PUBLISHABLE_KEY`.
23
23
 
@@ -28,6 +28,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
28
28
  - **Home** — a locked hero (fields only; nobody adds, moves or removes it), then a freeform body: image and text (richtext), the global **quote** placed from the picker's Global group, points (a list with a minimum and a maximum, an object per item), favourite articles (an editor-picked list of refs).
29
29
  - **Articles** — a fixed page at the collection's own URL segment, so it is the collection's index: the hero is the editor's, the grid is code fetching published articles.
30
30
  - **Company / About** — a path and a page under it (the breadcrumb), the same body sections, plus people.
31
+ - **News** — a collection at a nested address, `company/news`: code owns the whole address, so its index page and posts stay put when an editor renames Company, and nobody can create a page there.
31
32
  - **An article** — a collection entry with a richtext body, tags (a localized list), a date and an author ref; versioned, so every save is a snapshot.
32
33
  - **Global content** — the header every page renders through `components/PageFrame.tsx`, the footer the layout renders, the contact details the footer reads, the quote pages place.
33
34
 
@@ -188,6 +188,15 @@ h3.card__title { font-size: 24px; }
188
188
 
189
189
  .articles-index { padding-bottom: 96px; }
190
190
 
191
+ .news-index { padding-bottom: 96px; }
192
+ .news-list { list-style: none; margin: 0; padding: 0; max-width: 760px; display: flex; flex-direction: column; }
193
+ .news-list__item { display: flex; flex-direction: column; gap: 8px; padding-block: 28px; border-top: 1px solid var(--line); }
194
+ .news-list__item:last-child { border-bottom: 1px solid var(--line); }
195
+ .news-list__date { font-size: 13px; color: var(--muted); }
196
+ .news-list__title { font-family: var(--serif); font-weight: 500; font-size: 26px; line-height: 1.2; letter-spacing: -0.01em; color: inherit; text-wrap: balance; }
197
+ .news-list__title:hover { color: var(--accent); }
198
+ .news-list__excerpt { font-size: 15px; line-height: 1.6; color: var(--muted); text-wrap: pretty; }
199
+
191
200
  .article { padding-top: 48px; display: flex; flex-direction: column; gap: 40px; }
192
201
  .article__back { font-size: 14px; color: var(--muted); }
193
202
  .article__head { max-width: 760px; display: flex; flex-direction: column; gap: 20px; }
@@ -0,0 +1,14 @@
1
+ // The news index's URL, for the post page's back link, from the
2
+ // collection's own nested path (smoodly/collections.ts).
3
+ import { news } from "@/smoodly/collections";
4
+
5
+ /** The collection's address in a locale: its per-locale path, or the
6
+ * first declared one for a locale the map does not name. */
7
+ function address(locale: string): string {
8
+ const p = news.path;
9
+ if (typeof p === "string") return p;
10
+ return p?.[locale] ?? Object.values(p ?? {})[0] ?? "company/news";
11
+ }
12
+
13
+ export type NewsFields = { title: string; slug: string; date: string; excerpt?: string; body?: unknown };
14
+ export const newsIndexPath = (locale: string) => `/${address(locale)}`;
@@ -0,0 +1,48 @@
1
+ import { smoodly, z, Zone, type Props } from "smoodly";
2
+ import { PageHero } from "../sections/PageHero";
3
+ import { PageFrame } from "../PageFrame";
4
+ import { formatDate } from "../articles";
5
+ import type { NewsFields } from "../news";
6
+ import { ui } from "../strings";
7
+ import { q } from "@/smoodly/server";
8
+
9
+ // The News index: a fixed page on the news collection's own nested
10
+ // address (smoodly/collections.ts), so it is the collection's index page.
11
+ // Code owns /company/news whole: the Company page above it is an
12
+ // editor's, and renaming it leaves the news where it is. The editor owns
13
+ // the hero; the list below reads the posts through `q`, newest first.
14
+ export const newsSchema = smoodly.schema.page({
15
+ name: "newsIndex",
16
+ title: "News",
17
+ slug: "company/news",
18
+ zones: {
19
+ hero: z.locked(PageHero),
20
+ },
21
+ meta: { seo: true },
22
+ });
23
+
24
+ async function NewsView({ zones, page }: Props<typeof newsSchema>) {
25
+ const { locale, url } = await ui();
26
+ const items = await q().getEntries("news", { locale });
27
+ return (
28
+ <PageFrame locale={page.locale} locales={page.locales}>
29
+ <Zone of={zones.hero} />
30
+ <section className="container news-index">
31
+ <ul className="news-list">
32
+ {items.map(({ entry, path }) => {
33
+ const n = entry.fields as NewsFields;
34
+ return (
35
+ <li key={entry.id} className="news-list__item">
36
+ <time className="news-list__date" dateTime={n.date}>{formatDate(n.date, locale)}</time>
37
+ <a className="news-list__title" href={url(path ?? "#")}>{n.title}</a>
38
+ {n.excerpt && <p className="news-list__excerpt">{n.excerpt}</p>}
39
+ </li>
40
+ );
41
+ })}
42
+ </ul>
43
+ </section>
44
+ </PageFrame>
45
+ );
46
+ }
47
+
48
+ export const NewsPage = smoodly.page(newsSchema, NewsView);
@@ -0,0 +1,28 @@
1
+ import { smoodly, type EntryPageProps } from "smoodly";
2
+ import { RichText } from "@/smoodly/richtext";
3
+ import { news } from "@/smoodly/collections";
4
+ import { PageFrame } from "../PageFrame";
5
+ import { formatDate } from "../articles";
6
+ import { newsIndexPath, type NewsFields } from "../news";
7
+ import { ui } from "../strings";
8
+
9
+ // The entry page for `news`, at /company/news/<slug> and
10
+ // /fi/yritys/uutiset/<slug>: the collection's nested path, then the slug.
11
+ async function NewsPostView({ entry, locale, locales }: EntryPageProps) {
12
+ const { t, url } = await ui();
13
+ const n = entry.fields as NewsFields;
14
+ return (
15
+ <PageFrame locale={locale} locales={locales}>
16
+ <article className="container article">
17
+ <a className="article__back" href={url(newsIndexPath(locale))}>← {t("allNews")}</a>
18
+ <div className="article__head">
19
+ <h1 className="article__title">{n.title}</h1>
20
+ {n.date && <p className="article__byline">{formatDate(n.date, locale)}</p>}
21
+ </div>
22
+ <div className="article__body prose prose--lead"><RichText doc={n.body} /></div>
23
+ </article>
24
+ </PageFrame>
25
+ );
26
+ }
27
+
28
+ export const NewsPostPage = smoodly.entryPage(news, NewsPostView);
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "allArticles": "All articles",
3
3
  "relatedArticles": "Related articles",
4
- "minRead": "{minutes} min read"
4
+ "minRead": "{minutes} min read",
5
+ "allNews": "All news"
5
6
  }
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.33"
17
+ "smoodly": "0.0.34"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -16,13 +16,13 @@
16
16
  // page exists.
17
17
  import { readFile } from "node:fs/promises";
18
18
  import next from "@next/env";
19
- import { imageDimensions, type PageTree } from "smoodly";
19
+ import { imageDimensions, lastPart, type PageTree } from "smoodly";
20
20
  const { loadEnvConfig } = next;
21
21
 
22
22
  loadEnvConfig(process.cwd());
23
23
 
24
24
  const { config } = await import("../smoodly/config");
25
- const { articles } = await import("../smoodly/collections");
25
+ const { articles, news } = await import("../smoodly/collections");
26
26
  const { href, stores } = await import("../smoodly/server");
27
27
  const { pages, entries, assets } = stores();
28
28
 
@@ -79,10 +79,16 @@ const articlesSegment = (locale: string) => {
79
79
  const declared = articles.path;
80
80
  return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "posts");
81
81
  };
82
+ /** The news collection's nested address in a locale (`company/news`), from its own `path`. */
83
+ const newsAddress = (locale: string) => {
84
+ const declared = news.path;
85
+ return typeof declared === "string" ? declared : (declared?.[locale] ?? declared?.[EN] ?? "company/news");
86
+ };
82
87
  /** Site URLs for the starter's own pages in a locale. */
83
88
  const paths = (locale: string) => ({
84
89
  home: href("/", locale),
85
90
  articles: href(`/${articlesSegment(locale)}`, locale),
91
+ news: href(`/${newsAddress(locale)}`, locale),
86
92
  about: href(locale === FI ? "/yritys/tietoa-meista" : "/company/about-us", locale),
87
93
  });
88
94
 
@@ -110,12 +116,14 @@ type Tree = { zones: Record<string, Node[]>; fields?: Record<string, unknown>; m
110
116
  * its own (spec 2026-09-06); the Finnish page is copied from the
111
117
  * English draft, then overwritten with the Finnish tree. */
112
118
  async function seedPage(
113
- input: { slug: string; title: string; template: string | null; parentId?: string; fi?: { slug?: string; title: string } },
119
+ input: { slug: string; title: string; template: string | null; parentId?: string; fixed?: string; fi?: { slug?: string; title: string } },
114
120
  trees?: { en: Tree; fi?: Tree },
115
121
  ) {
116
122
  const page = await pages.createPage({
117
123
  locale: EN, slug: input.slug, title: input.title, template: input.template,
118
124
  ...(input.parentId ? { parentId: input.parentId } : {}),
125
+ // A code-owned record names its owner (spec 2026-09-29 nested paths §3).
126
+ ...(input.fixed ? { fixed: input.fixed } : {}),
119
127
  });
120
128
  if (trees) {
121
129
  await pages.saveDraft(page.id, EN, trees.en as unknown as PageTree);
@@ -173,6 +181,23 @@ const article = await seedEntry("articles", {
173
181
  tags: ["Näkemyksiä", "Muotoilu"],
174
182
  });
175
183
 
184
+ // Two news posts at the collection's nested address (/company/news/<slug>,
185
+ // /fi/yritys/uutiset/<slug>): Latin copy, a slug per locale.
186
+ await seedEntry("news", {
187
+ title: "Lorem ipsum dolor sit amet nova",
188
+ slug: "lorem-news",
189
+ date: "2026-09-20",
190
+ excerpt: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
191
+ body: doc(p("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")),
192
+ }, { slug: "lorem-uutinen" });
193
+ await seedEntry("news", {
194
+ title: "Ipsum dolor sit amet consectetur",
195
+ slug: "ipsum-update",
196
+ date: "2026-09-27",
197
+ excerpt: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
198
+ body: doc(p("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.")),
199
+ }, { slug: "ipsum-paivitys" });
200
+
176
201
  // --- global items ---------------------------------------------------
177
202
 
178
203
  // No Home item: the brand in the header is the way home.
@@ -190,13 +215,13 @@ await seedEntry("header",
190
215
 
191
216
  await seedEntry("footer", {
192
217
  tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
193
- company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }] },
218
+ company: { title: "Company", links: [{ label: "About", href: paths(EN).about }, { label: "Articles", href: paths(EN).articles }, { label: "News", href: paths(EN).news }] },
194
219
  legal: { title: "Legal", links: [{ label: "Privacy", href: "#" }, { label: "Terms", href: "#" }] },
195
220
  social: { title: "Social", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
196
221
  copyright: "© 2026 Lorem Co. Built with Smoodly.",
197
222
  }, FI ? {
198
223
  tagline: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
199
- company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }] },
224
+ company: { title: "Yritys", links: [{ label: "Tietoa meistä", href: paths(FI).about }, { label: "Artikkelit", href: paths(FI).articles }, { label: "Uutiset", href: paths(FI).news }] },
200
225
  legal: { title: "Lakiasiat", links: [{ label: "Tietosuoja", href: "#" }, { label: "Ehdot", href: "#" }] },
201
226
  social: { title: "Some", links: [{ label: "Instagram", href: "#" }, { label: "LinkedIn", href: "#" }] },
202
227
  } : undefined);
@@ -293,7 +318,7 @@ const homeTree = (locale: string, t: { kicker: string; buttons: [string, string]
293
318
  });
294
319
 
295
320
  await seedPage(
296
- { slug: "home", title: "Home", template: "home", fi: { title: "Etusivu" } },
321
+ { slug: "home", title: "Home", template: "home", fixed: "page:home", fi: { title: "Etusivu" } },
297
322
  {
298
323
  en: homeTree(EN, { kicker: "Lorem ipsum dolor", buttons: ["About us", "Read articles"], link: "About Lorem Co. →", favourites: "From the journal" }),
299
324
  fi: FI ? homeTree(FI, { kicker: "Lorem ipsum dolor", buttons: ["Tietoa meistä", "Lue artikkelit"], link: "Tietoa Lorem Co:sta →", favourites: "Ajankohtaista" }) : undefined,
@@ -307,7 +332,7 @@ const articlesHero = (heading: string, text: string): Tree => ({
307
332
  zones: { hero: [{ id: "node-articles-hero", type: "pageHero", fields: { heading, text } }] },
308
333
  });
309
334
  await seedPage(
310
- { slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
335
+ { slug: articlesSegment(EN), title: "Articles", template: "articlesIndex", fixed: "collection:articles", fi: { slug: articlesSegment(FI ?? EN), title: "Artikkelit" } },
311
336
  {
312
337
  en: articlesHero("Articles", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
313
338
  fi: articlesHero("Artikkelit", "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt."),
@@ -319,6 +344,18 @@ await seedPage(
319
344
  // both locales with its own slug and title.
320
345
  const company = await seedPage({ slug: "company", title: "Company", template: null, fi: { slug: "yritys", title: "Yritys" } });
321
346
 
347
+ // The News index: the fixed page on the news collection's nested address.
348
+ // Code owns the whole address, so it is a root record holding the last
349
+ // part as its slug (spec 2026-09-29 nested paths §3) — it shows under
350
+ // Company in URL only; renaming Company never moves it.
351
+ await seedPage(
352
+ { slug: lastPart(newsAddress(EN)), title: "News", template: "newsIndex", fixed: "collection:news", fi: { slug: lastPart(newsAddress(FI ?? EN)), title: "Uutiset" } },
353
+ {
354
+ en: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "News", text: "Lorem ipsum dolor sit amet — what is new at Lorem Co." } }] } },
355
+ fi: { zones: { hero: [{ id: "node-news-hero", type: "pageHero", fields: { heading: "Uutiset", text: "Lorem ipsum dolor sit amet — mitä Lorem Co:lle kuuluu." } }] } },
356
+ },
357
+ );
358
+
322
359
  const aboutTree = (t: { kicker: string; people: string; description: string }): Tree => ({
323
360
  fields: { showInNavigation: true },
324
361
  meta: { description: t.description },
@@ -379,5 +416,5 @@ await seedPage(
379
416
  );
380
417
 
381
418
  console.log(
382
- `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, four people, header, footer, contact and quote — all published.`,
419
+ `Seeded Lorem Co. in "${EN}"${FI ? ` and "${FI}"` : ""}: home, the Articles index (${articlesSegment(EN)}${FI ? ` / ${articlesSegment(FI)}` : ""}), company/about-us and its Team page${FI ? " (yritys/tietoa-meista/tiimi)" : ""}, one article, News (${newsAddress(EN)}${FI ? ` / ${newsAddress(FI)}` : ""}) with two posts, four people, header, footer, contact and quote — all published.`,
383
420
  );
@@ -38,4 +38,24 @@ export const articles = smoodly.collection({
38
38
  },
39
39
  });
40
40
 
41
- export const collections = [people, articles];
41
+ // News lives under the company: a nested address, owned by code whole
42
+ // (spec 2026-09-29 nested paths). The Company page at /company is an
43
+ // editor's page; renaming it never moves the news. The News fixed page
44
+ // (components/pages/News.tsx) claims the same address, which makes it
45
+ // the collection's index page.
46
+ export const news = smoodly.collection({
47
+ name: "news",
48
+ title: "News",
49
+ titleField: "title",
50
+ path: "company/news",
51
+ order: { by: "date", direction: "desc" },
52
+ fields: {
53
+ title: f.text().localized(),
54
+ date: f.date(),
55
+ slug: f.slug({ from: "title" }),
56
+ excerpt: f.text().localized(),
57
+ body: f.richtext({ toolset: article }).localized(),
58
+ },
59
+ });
60
+
61
+ export const collections = [people, articles, news];
@@ -9,6 +9,8 @@ import { HomePage } from "@/components/pages/Home";
9
9
  import { ArticlesPage } from "@/components/pages/Articles";
10
10
  import { StandardPage } from "@/components/pages/Page";
11
11
  import { ArticlePage } from "@/components/pages/Article";
12
+ import { NewsPage } from "@/components/pages/News";
13
+ import { NewsPostPage } from "@/components/pages/NewsPost";
12
14
 
13
15
  export { collections, sections, globals, elements };
14
16
 
@@ -22,7 +24,7 @@ export const config = defineConfig({
22
24
  elements,
23
25
  collections,
24
26
  globals,
25
- pages: [HomePage, ArticlesPage, StandardPage, ArticlePage],
27
+ pages: [HomePage, ArticlesPage, StandardPage, ArticlePage, NewsPage, NewsPostPage],
26
28
  },
27
29
  locales: { default: "en", supported: ["en"] },
28
30
  // Editors pick dates and times in this zone; the views format with it (an IANA name, never an offset).