create-smoodly-app 0.0.17 → 0.0.18

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 (30) hide show
  1. package/package.json +1 -1
  2. package/templates/blank/README.md +1 -0
  3. package/templates/blank/components/LocaleNav.tsx +19 -0
  4. package/templates/blank/components/pages/Home.tsx +8 -2
  5. package/templates/blank/components/pages/Page.tsx +2 -0
  6. package/templates/blank/components/pages/Post.tsx +10 -6
  7. package/templates/blank/package.json +1 -1
  8. package/templates/blank-intl/README.md +2 -1
  9. package/templates/blank-intl/app/[locale]/layout.tsx +6 -11
  10. package/templates/blank-intl/components/LocaleNav.tsx +19 -0
  11. package/templates/blank-intl/components/pages/Home.tsx +8 -2
  12. package/templates/blank-intl/components/pages/Page.tsx +2 -0
  13. package/templates/blank-intl/components/pages/Post.tsx +10 -6
  14. package/templates/blank-intl/package.json +1 -1
  15. package/templates/lorem/README.md +3 -2
  16. package/templates/lorem/app/(site)/layout.tsx +6 -7
  17. package/templates/lorem/components/PageFrame.tsx +30 -0
  18. package/templates/lorem/components/pages/Article.tsx +7 -5
  19. package/templates/lorem/components/pages/Articles.tsx +4 -3
  20. package/templates/lorem/components/pages/Home.tsx +4 -3
  21. package/templates/lorem/components/pages/Page.tsx +3 -2
  22. package/templates/lorem/package.json +1 -1
  23. package/templates/lorem-intl/README.md +3 -2
  24. package/templates/lorem-intl/app/[locale]/layout.tsx +9 -11
  25. package/templates/lorem-intl/components/PageFrame.tsx +30 -0
  26. package/templates/lorem-intl/components/pages/Article.tsx +7 -5
  27. package/templates/lorem-intl/components/pages/Articles.tsx +4 -3
  28. package/templates/lorem-intl/components/pages/Home.tsx +4 -3
  29. package/templates/lorem-intl/components/pages/Page.tsx +3 -2
  30. package/templates/lorem-intl/package.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-smoodly-app",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
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,6 +14,7 @@ for an agent (or a person) building the site.
14
14
  - `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
15
15
  - `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
16
16
  - `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
17
+ - `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
17
18
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
18
19
  - `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
19
20
  - `app/globals.css` — a reset, a container, the two tones. Replace it.
@@ -0,0 +1,19 @@
1
+ import type { LocaleAlternate } from "smoodly";
2
+ import { href } from "@/smoodly/server";
3
+
4
+ // The language links, rendered by every page view from its own `locales`
5
+ // map (spec 2026-09-15): the same page in the other language where it
6
+ // exists and is published there, that language's home otherwise. A layout
7
+ // cannot know which page is on screen, so the links live with the page.
8
+ // Nothing with one locale, so the single-language scaffold shares the file.
9
+ export function LocaleNav({ locale, locales }: { locale: string; locales: Record<string, LocaleAlternate | null> }) {
10
+ const codes = Object.keys(locales);
11
+ if (codes.length < 2) return null;
12
+ return (
13
+ <nav className="container locales" aria-label="Language">
14
+ {codes.map((code) => (
15
+ <a key={code} href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>
16
+ ))}
17
+ </nav>
18
+ );
19
+ }
@@ -1,5 +1,6 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Text } from "../sections/Text";
3
+ import { LocaleNav } from "../LocaleNav";
3
4
 
4
5
  // A page shape: zones declared, <Zone /> placed, one file. `slug` makes
5
6
  // it fixed — one record editors can neither delete nor duplicate — and
@@ -15,8 +16,13 @@ export const homeSchema = smoodly.schema.page({
15
16
  meta: { seo: true },
16
17
  });
17
18
 
18
- function HomeView({ zones }: Props<typeof homeSchema>) {
19
- return <Zone of={zones.body} />;
19
+ function HomeView({ zones, page }: Props<typeof homeSchema>) {
20
+ return (
21
+ <>
22
+ <LocaleNav locale={page.locale} locales={page.locales} />
23
+ <Zone of={zones.body} />
24
+ </>
25
+ );
20
26
  }
21
27
 
22
28
  export const HomePage = smoodly.page(homeSchema, HomeView);
@@ -1,5 +1,6 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Text } from "../sections/Text";
3
+ import { LocaleNav } from "../LocaleNav";
3
4
 
4
5
  // An open page shape: no `slug`, so editors create as many as they like,
5
6
  // anywhere in the tree the config allows. `page` is where this one sits
@@ -16,6 +17,7 @@ export const pageSchema = smoodly.schema.page({
16
17
  function PageView({ zones, page }: Props<typeof pageSchema>) {
17
18
  return (
18
19
  <>
20
+ <LocaleNav locale={page.locale} locales={page.locales} />
19
21
  <h1 className="container">{page.title}</h1>
20
22
  <Zone of={zones.body} />
21
23
  </>
@@ -1,20 +1,24 @@
1
1
  import { smoodly, type EntryPageProps } from "smoodly";
2
2
  import { RichText } from "@/smoodly/richtext";
3
3
  import { posts } from "@/smoodly/collections";
4
+ import { LocaleNav } from "../LocaleNav";
4
5
 
5
6
  // The entry page for `posts`, served at /posts/<slug>. It receives the
6
7
  // entry's fields; the body is richtext rendered by the site's bound
7
8
  // RichText, which knows the registered elements.
8
9
  type PostFields = { title: string; date?: string; body?: unknown };
9
10
 
10
- function PostView({ entry }: EntryPageProps) {
11
+ function PostView({ entry, locale, locales }: EntryPageProps) {
11
12
  const post = entry.fields as PostFields;
12
13
  return (
13
- <article className="container prose">
14
- <h1>{post.title}</h1>
15
- {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
16
- <RichText doc={post.body} />
17
- </article>
14
+ <>
15
+ <LocaleNav locale={locale} locales={locales} />
16
+ <article className="container prose">
17
+ <h1>{post.title}</h1>
18
+ {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
19
+ <RichText doc={post.body} />
20
+ </article>
21
+ </>
18
22
  );
19
23
  }
20
24
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.17"
17
+ "smoodly": "0.0.18"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -10,11 +10,12 @@ for an agent (or a person) building the site.
10
10
 
11
11
  - `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
12
12
  - `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
13
- - `app/[locale]/layout.tsx` — the site's root layout: `app/globals.css`, the locale links, the footer line from the `site` item; the admin has its own under `app/(smoodly)/admin/`.
13
+ - `app/[locale]/layout.tsx` — the site's root layout: `app/globals.css`, the footer line from the `site` item; the admin has its own under `app/(smoodly)/admin/`.
14
14
  - `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded). UI strings go in `messages/<locale>.json`.
15
15
  - `components/pages/` — the page shapes and the entry page: `Home` (fixed at /, one freeform zone), `Page` (open, editors create many), `Post` (the entry page for posts).
16
16
  - `components/sections/Text.tsx` — the one section: a title and a richtext body. Listed in `smoodly/sections.ts`.
17
17
  - `components/elements/Button.tsx` — the one element: a link styled as a button, embedded in richtext through `smoodly/toolsets.ts`.
18
+ - `components/LocaleNav.tsx` — the language links every page view renders from its `locales` map: the same page in the other language, or its home when there is no translation; nothing with one locale.
18
19
  - `smoodly/` — the wiring: `config.ts` (the registry and settings), `collections.ts` (posts), `globals.ts` (the site item), `sections.ts`, `elements.ts`, `toolsets.ts`, `server.ts` (stores, site layer, renderer, the read API `q`), `ops.ts` (the admin's one server action), `admin.tsx` (the admin mount), `richtext.tsx` (the bound renderer).
19
20
  - `scripts/seed.ts` — the home page and the site item, written through the stores. `npm run seed` is idempotent.
20
21
  - `app/globals.css` — a reset, a container, the two tones. Replace it.
@@ -2,15 +2,18 @@ import type { ReactNode } from "react";
2
2
  import { notFound } from "next/navigation";
3
3
  import { hasLocale, NextIntlClientProvider } from "next-intl";
4
4
  import { routing } from "@/i18n/routing";
5
- import { href, q } from "@/smoodly/server";
5
+ import { q } from "@/smoodly/server";
6
6
  import "../globals.css";
7
7
 
8
8
  // The site's root layout under the locale segment; the admin has its own
9
9
  // under app/(smoodly)/admin, outside [locale], as a second root layout.
10
10
  // The `site` global item gives the document title and the footer line,
11
11
  // read per locale through `q`, null until published in this locale. The
12
- // locale links are the visitor's only language switch, since locale
13
- // detection is off (see i18n/routing.ts).
12
+ // language links are not here: a layout cannot know which page is on
13
+ // screen, so every page view renders them through components/LocaleNav.tsx
14
+ // from its own `locales` map, landing on the same page in the other
15
+ // language (spec 2026-09-15). They are the visitor's only language switch,
16
+ // since locale detection is off (see i18n/routing.ts).
14
17
  export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
15
18
  const { locale } = await params;
16
19
  const site = await q().getGlobal("site", { locale });
@@ -21,18 +24,10 @@ export default async function LocaleLayout({ children, params }: { children: Rea
21
24
  const { locale } = await params;
22
25
  if (!hasLocale(routing.locales, locale)) notFound();
23
26
  const site = await q().getGlobal("site", { locale });
24
- const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
25
27
  return (
26
28
  <html lang={locale}>
27
29
  <body>
28
30
  <NextIntlClientProvider>
29
- {locales.length > 1 && (
30
- <nav className="container locales" aria-label="Language">
31
- {locales.map((l) => (
32
- <a key={l.code} href={l.url} aria-current={l.current ? "page" : undefined}>{l.code}</a>
33
- ))}
34
- </nav>
35
- )}
36
31
  <main>{children}</main>
37
32
  {site && <footer className="container site-footer">{site.name} · {site.tagline}</footer>}
38
33
  </NextIntlClientProvider>
@@ -0,0 +1,19 @@
1
+ import type { LocaleAlternate } from "smoodly";
2
+ import { href } from "@/smoodly/server";
3
+
4
+ // The language links, rendered by every page view from its own `locales`
5
+ // map (spec 2026-09-15): the same page in the other language where it
6
+ // exists and is published there, that language's home otherwise. A layout
7
+ // cannot know which page is on screen, so the links live with the page.
8
+ // Nothing with one locale, so the single-language scaffold shares the file.
9
+ export function LocaleNav({ locale, locales }: { locale: string; locales: Record<string, LocaleAlternate | null> }) {
10
+ const codes = Object.keys(locales);
11
+ if (codes.length < 2) return null;
12
+ return (
13
+ <nav className="container locales" aria-label="Language">
14
+ {codes.map((code) => (
15
+ <a key={code} href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>
16
+ ))}
17
+ </nav>
18
+ );
19
+ }
@@ -1,5 +1,6 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Text } from "../sections/Text";
3
+ import { LocaleNav } from "../LocaleNav";
3
4
 
4
5
  // A page shape: zones declared, <Zone /> placed, one file. `slug` makes
5
6
  // it fixed — one record editors can neither delete nor duplicate — and
@@ -15,8 +16,13 @@ export const homeSchema = smoodly.schema.page({
15
16
  meta: { seo: true },
16
17
  });
17
18
 
18
- function HomeView({ zones }: Props<typeof homeSchema>) {
19
- return <Zone of={zones.body} />;
19
+ function HomeView({ zones, page }: Props<typeof homeSchema>) {
20
+ return (
21
+ <>
22
+ <LocaleNav locale={page.locale} locales={page.locales} />
23
+ <Zone of={zones.body} />
24
+ </>
25
+ );
20
26
  }
21
27
 
22
28
  export const HomePage = smoodly.page(homeSchema, HomeView);
@@ -1,5 +1,6 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { Text } from "../sections/Text";
3
+ import { LocaleNav } from "../LocaleNav";
3
4
 
4
5
  // An open page shape: no `slug`, so editors create as many as they like,
5
6
  // anywhere in the tree the config allows. `page` is where this one sits
@@ -16,6 +17,7 @@ export const pageSchema = smoodly.schema.page({
16
17
  function PageView({ zones, page }: Props<typeof pageSchema>) {
17
18
  return (
18
19
  <>
20
+ <LocaleNav locale={page.locale} locales={page.locales} />
19
21
  <h1 className="container">{page.title}</h1>
20
22
  <Zone of={zones.body} />
21
23
  </>
@@ -1,20 +1,24 @@
1
1
  import { smoodly, type EntryPageProps } from "smoodly";
2
2
  import { RichText } from "@/smoodly/richtext";
3
3
  import { posts } from "@/smoodly/collections";
4
+ import { LocaleNav } from "../LocaleNav";
4
5
 
5
6
  // The entry page for `posts`, served at /posts/<slug>. It receives the
6
7
  // entry's fields; the body is richtext rendered by the site's bound
7
8
  // RichText, which knows the registered elements.
8
9
  type PostFields = { title: string; date?: string; body?: unknown };
9
10
 
10
- function PostView({ entry }: EntryPageProps) {
11
+ function PostView({ entry, locale, locales }: EntryPageProps) {
11
12
  const post = entry.fields as PostFields;
12
13
  return (
13
- <article className="container prose">
14
- <h1>{post.title}</h1>
15
- {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
16
- <RichText doc={post.body} />
17
- </article>
14
+ <>
15
+ <LocaleNav locale={locale} locales={locales} />
16
+ <article className="container prose">
17
+ <h1>{post.title}</h1>
18
+ {post.date && <p><time dateTime={post.date}>{post.date.slice(0, 10)}</time></p>}
19
+ <RichText doc={post.body} />
20
+ </article>
21
+ </>
18
22
  );
19
23
  }
20
24
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.17",
17
+ "smoodly": "0.0.18",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {
@@ -10,8 +10,9 @@ for an agent (or a person) building the site.
10
10
 
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
- - `app/(site)/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from global content; the admin has its own under `app/(smoodly)/admin/`.
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
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).
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.
15
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`.
16
17
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
17
18
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
@@ -28,7 +29,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
28
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.
29
30
  - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
30
31
  - **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.
31
- - **Global content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
32
+ - **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.
32
33
 
33
34
  Reseed an empty database any time with `npm run seed`.
34
35
 
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Instrument_Sans, Newsreader } from "next/font/google";
3
- import { HeaderView } from "@/components/sections/Header";
4
3
  import { FooterView } from "@/components/sections/Footer";
5
4
  import { q } from "@/smoodly/server";
6
5
  import "../globals.css";
@@ -14,20 +13,20 @@ export const metadata = { title: "Lorem Co." };
14
13
  // The site's root layout, in its own route group so the admin's layout
15
14
  // under app/(smoodly)/admin is a second root layout beside it rather than
16
15
  // a child of this one — two root layouts, a full page load between them,
17
- // which is fine: they are separate apps in spirit. Header, footer and the
16
+ // which is fine: they are separate apps in spirit. The footer and the
18
17
  // contact block are global content: read through `q`, cached under their
19
- // tag, null until published.
18
+ // tag, null until published. The header is NOT here: every page view
19
+ // renders it through components/PageFrame.tsx, the same file that carries
20
+ // the language links in a multilingual site (spec 2026-09-15).
20
21
  export default async function RootLayout({ children }: { children: ReactNode }) {
21
- const [headerProps, footerProps, contactFields] = await Promise.all([
22
- q().getGlobal("header"),
22
+ const [footerProps, contactFields] = await Promise.all([
23
23
  q().getGlobal("footer"),
24
24
  q().getGlobal("contact"),
25
25
  ]);
26
26
  return (
27
27
  <html lang="en" className={`${serif.variable} ${sans.variable}`}>
28
28
  <body>
29
- {headerProps && <HeaderView {...headerProps} />}
30
- <main>{children}</main>
29
+ {children}
31
30
  {footerProps && <FooterView {...footerProps} contact={contactFields} />}
32
31
  </body>
33
32
  </html>
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LocaleAlternate } from "smoodly";
3
+ import { href, q } from "@/smoodly/server";
4
+ import { HeaderView, type LocaleLink } from "./sections/Header";
5
+
6
+ // The header and the <main> around a page's content, rendered by every
7
+ // page view rather than the layout: a layout cannot know which page is on
8
+ // screen (Next renders the two concurrently), and the language links must
9
+ // land on the same page in the other language. `locales` is the page's or
10
+ // entry's map (spec 2026-09-15): the other locale's path where the page
11
+ // exists and is published there, that locale's home otherwise. With one
12
+ // locale (the single-language scaffold) there are no links at all. The
13
+ // header itself is the global item, read through `q` per locale.
14
+ export async function PageFrame({ locale, locales, children }: {
15
+ locale: string;
16
+ locales: Record<string, LocaleAlternate | null>;
17
+ children: ReactNode;
18
+ }) {
19
+ const header = await q().getGlobal("header", { locale });
20
+ const codes = Object.keys(locales);
21
+ const links: LocaleLink[] | undefined = codes.length > 1
22
+ ? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
23
+ : undefined;
24
+ return (
25
+ <>
26
+ {header && <HeaderView {...header} locales={links} />}
27
+ <main>{children}</main>
28
+ </>
29
+ );
30
+ }
@@ -4,6 +4,7 @@ import { SmoodlyImage } from "smoodly/image";
4
4
  import { articles } from "@/smoodly/collections";
5
5
  import { q } from "@/smoodly/server";
6
6
  import { ArticleCard } from "../ArticleCard";
7
+ import { PageFrame } from "../PageFrame";
7
8
  import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
8
9
  import { ui } from "../strings";
9
10
 
@@ -12,15 +13,16 @@ import { ui } from "../strings";
12
13
  // resolved (author is a person). The body is richtext rendered by the
13
14
  // package's RichText; the read time is words / 200. "Related articles"
14
15
  // is the newest other published articles, fetched here, and absent when
15
- // there are none.
16
- async function ArticleView({ entry }: EntryPageProps) {
17
- const { locale, t, url } = await ui();
16
+ // there are none. The render locale and the alternates arrive as props
17
+ // (spec 2026-09-15); PageFrame renders the header from them.
18
+ async function ArticleView({ entry, locale, locales }: EntryPageProps) {
19
+ const { t, url } = await ui();
18
20
  const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
19
21
  // Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
20
22
  const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
21
23
  const index = url(articlesIndexPath(locale));
22
24
  return (
23
- <>
25
+ <PageFrame locale={locale} locales={locales}>
24
26
  <article className="container article">
25
27
  <a className="article__back" href={index}>← {t("allArticles")}</a>
26
28
  <div className="article__head">
@@ -59,7 +61,7 @@ async function ArticleView({ entry }: EntryPageProps) {
59
61
  </div>
60
62
  </section>
61
63
  )}
62
- </>
64
+ </PageFrame>
63
65
  );
64
66
  }
65
67
 
@@ -1,6 +1,7 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { PageHero } from "../sections/PageHero";
3
3
  import { ArticleCard } from "../ArticleCard";
4
+ import { PageFrame } from "../PageFrame";
4
5
  import { articlePath, type ArticleFields } from "../articles";
5
6
  import { ui } from "../strings";
6
7
  import { q } from "@/smoodly/server";
@@ -23,11 +24,11 @@ export const articlesSchema = smoodly.schema.page({
23
24
  meta: { seo: true },
24
25
  });
25
26
 
26
- async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
27
+ async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
27
28
  const { locale, url } = await ui();
28
29
  const items = await q().getEntries("articles", { locale });
29
30
  return (
30
- <>
31
+ <PageFrame locale={page.locale} locales={page.locales}>
31
32
  <Zone of={zones.hero} />
32
33
  <section className="container articles-index">
33
34
  <div className="card-grid">
@@ -37,7 +38,7 @@ async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
37
38
  })}
38
39
  </div>
39
40
  </section>
40
- </>
41
+ </PageFrame>
41
42
  );
42
43
  }
43
44
 
@@ -5,6 +5,7 @@ import { Points } from "../sections/Points";
5
5
  import { FavouriteArticles } from "../sections/FavouriteArticles";
6
6
  import { People } from "../sections/People";
7
7
  import { Quote } from "../sections/Quote";
8
+ import { PageFrame } from "../PageFrame";
8
9
 
9
10
  // A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
10
11
  // `slug` makes it fixed — one record the editor can neither delete nor
@@ -25,12 +26,12 @@ export const homeSchema = smoodly.schema.page({
25
26
  meta: { seo: true },
26
27
  });
27
28
 
28
- function HomeView({ zones }: Props<typeof homeSchema>) {
29
+ function HomeView({ zones, page }: Props<typeof homeSchema>) {
29
30
  return (
30
- <>
31
+ <PageFrame locale={page.locale} locales={page.locales}>
31
32
  <Zone of={zones.hero} />
32
33
  <Zone of={zones.body} />
33
- </>
34
+ </PageFrame>
34
35
  );
35
36
  }
36
37
 
@@ -6,6 +6,7 @@ import { Points } from "../sections/Points";
6
6
  import { FavouriteArticles } from "../sections/FavouriteArticles";
7
7
  import { People } from "../sections/People";
8
8
  import { Quote } from "../sections/Quote";
9
+ import { PageFrame } from "../PageFrame";
9
10
 
10
11
  // Open registration: editors create as many of these as they like, at
11
12
  // any depth the config allows. `page` is where this one sits in the tree.
@@ -28,7 +29,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
28
29
  // Sub-navigation: the published children that opted in through their page field.
29
30
  const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
30
31
  return (
31
- <>
32
+ <PageFrame locale={page.locale} locales={page.locales}>
32
33
  {page.ancestors.length > 0 && (
33
34
  // `url` is the ancestor's site URL through the app's href seam
34
35
  // (/fi/yritys under next-intl); `path` would be the bare CMS path.
@@ -46,7 +47,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
46
47
  )}
47
48
  <Zone of={zones.hero} />
48
49
  <Zone of={zones.body} />
49
- </>
50
+ </PageFrame>
50
51
  );
51
52
  }
52
53
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.17"
17
+ "smoodly": "0.0.18"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@next/env": "^16.3.4",
@@ -10,9 +10,10 @@ for an agent (or a person) building the site.
10
10
 
11
11
  - `app/[locale]/page.tsx` — the home page: one line calling renderSmoodlyPath([], { locale }).
12
12
  - `app/[locale]/[...path]/page.tsx` — every other page and every collection entry, at any depth, with translated segments per locale.
13
- - `app/[locale]/layout.tsx` — the site's root layout: fonts, `app/globals.css`, the header and footer from global content, the FI / EN links; the admin has its own under `app/(smoodly)/admin/`.
13
+ - `app/[locale]/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
14
  - `i18n/routing.ts` — the locales and the default; `smoodly/config.ts` reads them from here. `i18n/request.ts` and `i18n/navigation.ts` are next-intl's stock files; `proxy.ts` is its locale routing (`/admin` excluded).
15
15
  - `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).
16
+ - `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
17
  - `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
18
  - `components/strings.ts`, `messages/` — the site's own UI strings and the request locale, one seam.
18
19
  - `public/images/` — the starter's photos; image fields hold URLs like `/images/hero_image.jpg`.
@@ -35,7 +36,7 @@ The Lorem Co. site the installer seeded is a tour of the features:
35
36
  - **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.
36
37
  - **Company / About** — a folder and a page under it (the breadcrumb), the same body sections, plus people.
37
38
  - **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.
38
- - **Global content** — the header and footer the layout renders, the contact details the footer reads, the quote pages place.
39
+ - **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.
39
40
 
40
41
  Every page and entry exists in each locale with its own slug. Reseed an
41
42
  empty database any time with `npm run seed`.
@@ -3,9 +3,8 @@ import { notFound } from "next/navigation";
3
3
  import { Instrument_Sans, Newsreader } from "next/font/google";
4
4
  import { hasLocale, NextIntlClientProvider } from "next-intl";
5
5
  import { routing } from "@/i18n/routing";
6
- import { HeaderView } from "@/components/sections/Header";
7
6
  import { FooterView } from "@/components/sections/Footer";
8
- import { href, q } from "@/smoodly/server";
7
+ import { q } from "@/smoodly/server";
9
8
  import "../globals.css";
10
9
 
11
10
  // The starter's two faces, as CSS variables globals.css reads.
@@ -16,25 +15,24 @@ export const metadata = { title: "Lorem Co." };
16
15
 
17
16
  // The site's root layout. The admin has its own under app/(smoodly)/admin —
18
17
  // two root layouts, a full page load between them, which is fine: they
19
- // are separate apps in spirit. Header, footer and the contact block are
20
- // global content: read through `q`, cached per locale under their tag, null
21
- // until published in this locale. The header also gets the locale links
22
- // — the visitor's only language switch, since locale detection is off.
18
+ // are separate apps in spirit. The footer and the contact block are
19
+ // global content: read through `q`, cached per locale under their tag,
20
+ // null until published in this locale. The header is NOT here: a layout
21
+ // cannot know which page is on screen, and the FI / EN links must land on
22
+ // the same page in the other language, so every page view renders it
23
+ // through components/PageFrame.tsx from its own `locales` (spec 2026-09-15).
23
24
  export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
24
25
  const { locale } = await params;
25
26
  if (!hasLocale(routing.locales, locale)) notFound();
26
- const [headerProps, footerProps, contactFields] = await Promise.all([
27
- q().getGlobal("header", { locale }),
27
+ const [footerProps, contactFields] = await Promise.all([
28
28
  q().getGlobal("footer", { locale }),
29
29
  q().getGlobal("contact", { locale }),
30
30
  ]);
31
- const locales = routing.locales.map((code) => ({ code, url: href("/", code), current: code === locale }));
32
31
  return (
33
32
  <html lang={locale} className={`${serif.variable} ${sans.variable}`}>
34
33
  <body>
35
34
  <NextIntlClientProvider>
36
- {headerProps && <HeaderView {...headerProps} locales={locales} />}
37
- <main>{children}</main>
35
+ {children}
38
36
  {footerProps && <FooterView {...footerProps} contact={contactFields} />}
39
37
  </NextIntlClientProvider>
40
38
  </body>
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LocaleAlternate } from "smoodly";
3
+ import { href, q } from "@/smoodly/server";
4
+ import { HeaderView, type LocaleLink } from "./sections/Header";
5
+
6
+ // The header and the <main> around a page's content, rendered by every
7
+ // page view rather than the layout: a layout cannot know which page is on
8
+ // screen (Next renders the two concurrently), and the language links must
9
+ // land on the same page in the other language. `locales` is the page's or
10
+ // entry's map (spec 2026-09-15): the other locale's path where the page
11
+ // exists and is published there, that locale's home otherwise. With one
12
+ // locale (the single-language scaffold) there are no links at all. The
13
+ // header itself is the global item, read through `q` per locale.
14
+ export async function PageFrame({ locale, locales, children }: {
15
+ locale: string;
16
+ locales: Record<string, LocaleAlternate | null>;
17
+ children: ReactNode;
18
+ }) {
19
+ const header = await q().getGlobal("header", { locale });
20
+ const codes = Object.keys(locales);
21
+ const links: LocaleLink[] | undefined = codes.length > 1
22
+ ? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
23
+ : undefined;
24
+ return (
25
+ <>
26
+ {header && <HeaderView {...header} locales={links} />}
27
+ <main>{children}</main>
28
+ </>
29
+ );
30
+ }
@@ -4,6 +4,7 @@ import { SmoodlyImage } from "smoodly/image";
4
4
  import { articles } from "@/smoodly/collections";
5
5
  import { q } from "@/smoodly/server";
6
6
  import { ArticleCard } from "../ArticleCard";
7
+ import { PageFrame } from "../PageFrame";
7
8
  import { articlePath, articlesIndexPath, formatDate, readingMinutes, type ArticleFields } from "../articles";
8
9
  import { ui } from "../strings";
9
10
 
@@ -12,15 +13,16 @@ import { ui } from "../strings";
12
13
  // resolved (author is a person). The body is richtext rendered by the
13
14
  // package's RichText; the read time is words / 200. "Related articles"
14
15
  // is the newest other published articles, fetched here, and absent when
15
- // there are none.
16
- async function ArticleView({ entry }: EntryPageProps) {
17
- const { locale, t, url } = await ui();
16
+ // there are none. The render locale and the alternates arrive as props
17
+ // (spec 2026-09-15); PageFrame renders the header from them.
18
+ async function ArticleView({ entry, locale, locales }: EntryPageProps) {
19
+ const { t, url } = await ui();
18
20
  const a = entry.fields as ArticleFields & { author?: { name: string; role?: string } | null };
19
21
  // Four newest, minus this one, so three remain even when it is among them; the store applies the limit.
20
22
  const related = (await q().getEntries("articles", { locale, limit: 4 })).filter((r) => r.entry.id !== entry.id).slice(0, 3);
21
23
  const index = url(articlesIndexPath(locale));
22
24
  return (
23
- <>
25
+ <PageFrame locale={locale} locales={locales}>
24
26
  <article className="container article">
25
27
  <a className="article__back" href={index}>← {t("allArticles")}</a>
26
28
  <div className="article__head">
@@ -59,7 +61,7 @@ async function ArticleView({ entry }: EntryPageProps) {
59
61
  </div>
60
62
  </section>
61
63
  )}
62
- </>
64
+ </PageFrame>
63
65
  );
64
66
  }
65
67
 
@@ -1,6 +1,7 @@
1
1
  import { smoodly, z, Zone, type Props } from "smoodly";
2
2
  import { PageHero } from "../sections/PageHero";
3
3
  import { ArticleCard } from "../ArticleCard";
4
+ import { PageFrame } from "../PageFrame";
4
5
  import { articlePath, type ArticleFields } from "../articles";
5
6
  import { ui } from "../strings";
6
7
  import { q } from "@/smoodly/server";
@@ -23,11 +24,11 @@ export const articlesSchema = smoodly.schema.page({
23
24
  meta: { seo: true },
24
25
  });
25
26
 
26
- async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
27
+ async function ArticlesView({ zones, page }: Props<typeof articlesSchema>) {
27
28
  const { locale, url } = await ui();
28
29
  const items = await q().getEntries("articles", { locale });
29
30
  return (
30
- <>
31
+ <PageFrame locale={page.locale} locales={page.locales}>
31
32
  <Zone of={zones.hero} />
32
33
  <section className="container articles-index">
33
34
  <div className="card-grid">
@@ -37,7 +38,7 @@ async function ArticlesView({ zones }: Props<typeof articlesSchema>) {
37
38
  })}
38
39
  </div>
39
40
  </section>
40
- </>
41
+ </PageFrame>
41
42
  );
42
43
  }
43
44
 
@@ -5,6 +5,7 @@ import { Points } from "../sections/Points";
5
5
  import { FavouriteArticles } from "../sections/FavouriteArticles";
6
6
  import { People } from "../sections/People";
7
7
  import { Quote } from "../sections/Quote";
8
+ import { PageFrame } from "../PageFrame";
8
9
 
9
10
  // A page shape: zones declared, <Zone /> placed, one file (DESIGN.md §3).
10
11
  // `slug` makes it fixed — one record the editor can neither delete nor
@@ -25,12 +26,12 @@ export const homeSchema = smoodly.schema.page({
25
26
  meta: { seo: true },
26
27
  });
27
28
 
28
- function HomeView({ zones }: Props<typeof homeSchema>) {
29
+ function HomeView({ zones, page }: Props<typeof homeSchema>) {
29
30
  return (
30
- <>
31
+ <PageFrame locale={page.locale} locales={page.locales}>
31
32
  <Zone of={zones.hero} />
32
33
  <Zone of={zones.body} />
33
- </>
34
+ </PageFrame>
34
35
  );
35
36
  }
36
37
 
@@ -6,6 +6,7 @@ import { Points } from "../sections/Points";
6
6
  import { FavouriteArticles } from "../sections/FavouriteArticles";
7
7
  import { People } from "../sections/People";
8
8
  import { Quote } from "../sections/Quote";
9
+ import { PageFrame } from "../PageFrame";
9
10
 
10
11
  // Open registration: editors create as many of these as they like, at
11
12
  // any depth the config allows. `page` is where this one sits in the tree.
@@ -28,7 +29,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
28
29
  // Sub-navigation: the published children that opted in through their page field.
29
30
  const shown = page.children.filter((c) => c.hasPage && c.fields.showInNavigation === true);
30
31
  return (
31
- <>
32
+ <PageFrame locale={page.locale} locales={page.locales}>
32
33
  {page.ancestors.length > 0 && (
33
34
  // `url` is the ancestor's site URL through the app's href seam
34
35
  // (/fi/yritys under next-intl); `path` would be the bare CMS path.
@@ -46,7 +47,7 @@ function PageView({ zones, page }: Props<typeof pageSchema>) {
46
47
  )}
47
48
  <Zone of={zones.hero} />
48
49
  <Zone of={zones.body} />
49
- </>
50
+ </PageFrame>
50
51
  );
51
52
  }
52
53
 
@@ -14,7 +14,7 @@
14
14
  "next": "^16.3.4",
15
15
  "react": "^19",
16
16
  "react-dom": "^19",
17
- "smoodly": "0.0.17",
17
+ "smoodly": "0.0.18",
18
18
  "next-intl": "^4.14.2"
19
19
  },
20
20
  "devDependencies": {