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.
- package/dist/index.js +17 -6
- package/package.json +1 -1
- package/templates/blank/package.json +1 -1
- package/templates/blank/scripts/seed.ts +1 -1
- package/templates/blank/smoodly/server.ts +7 -3
- package/templates/blank/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/templates/blank/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +1 -1
- package/templates/blank-intl/package.json +1 -1
- package/templates/blank-intl/scripts/seed.ts +1 -1
- package/templates/blank-intl/smoodly/server.ts +7 -3
- package/templates/blank-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/templates/blank-intl/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +1 -1
- package/templates/lorem/README.md +3 -2
- package/templates/lorem/app/globals.css +9 -0
- package/templates/lorem/components/news.ts +14 -0
- package/templates/lorem/components/pages/News.tsx +48 -0
- package/templates/lorem/components/pages/NewsPost.tsx +28 -0
- package/templates/lorem/messages/en.json +2 -1
- package/templates/lorem/package.json +1 -1
- package/templates/lorem/scripts/seed.ts +45 -8
- package/templates/lorem/smoodly/collections.ts +21 -1
- package/templates/lorem/smoodly/config.ts +3 -1
- package/templates/lorem/smoodly/server.ts +7 -3
- package/templates/lorem/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
- package/templates/lorem/supabase/migrations/{20260926150100_smoodly_views.sql → 20260929120100_smoodly_views.sql} +22 -1
- package/templates/lorem-intl/README.md +3 -2
- package/templates/lorem-intl/app/globals.css +9 -0
- package/templates/lorem-intl/components/news.ts +14 -0
- package/templates/lorem-intl/components/pages/News.tsx +48 -0
- package/templates/lorem-intl/components/pages/NewsPost.tsx +28 -0
- package/templates/lorem-intl/messages/en.json +2 -1
- package/templates/lorem-intl/messages/fi.json +2 -1
- package/templates/lorem-intl/package.json +1 -1
- package/templates/lorem-intl/scripts/seed.ts +45 -8
- package/templates/lorem-intl/smoodly/collections.ts +21 -1
- package/templates/lorem-intl/smoodly/config.ts +3 -1
- package/templates/lorem-intl/smoodly/server.ts +7 -3
- package/templates/lorem-intl/supabase/migrations/20260929120000_smoodly_v5.sql +56 -0
- 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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
254
|
+
await writeFile(path, source.replace(line, line.replace(from, map)));
|
|
255
|
+
}
|
|
245
256
|
}
|
|
246
257
|
}
|
|
247
258
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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-
|
|
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);
|
|
@@ -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
|
-
|
|
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).
|