website: switch to typed PayloadCMS SDK via @unom/cms
Build & Deploy unom website / build (push) Successful in 19s
Build & Deploy unom website / deploy (push) Successful in 6s

- Add @payloadcms/sdk + @unom/cms (typed Config) to deps
- .npmrc maps @unom to git.unom.io/api/packages/unom/npm/
- Rewrite src/lib/cms.ts: PayloadSDK<Config> client + typed helpers
  (findPageBySlug, findPostBySlug, findPosts, findFooter, findHeader)
- Re-export the structural types (Page, Post, Footer, Header) plus the
  legacy aliases (RichTextBlock, LexRoot/LexNode, NavigationSection,
  NavigationLink) so existing components keep compiling
- Dockerfile mounts /root/.npmrc as a build secret so bun install can
  pull @unom/cms from the private gitea registry
- deploy.yml stages an .npmrc with REGISTRY_TOKEN auth + passes it as
  the 'npmrc' build secret
- Add blog routes: /blog (list) + /blog/ (detail), PostCard, all
  reading from the CMS via the SDK
- Fix two pre-existing TS errors (@fontsource/inter import, server.tsx
  return type)
This commit is contained in:
2026-05-26 19:10:27 +02:00
parent 70bd04a3f6
commit f19457337d
12 changed files with 1455 additions and 85 deletions
+10
View File
@@ -35,6 +35,15 @@ jobs:
run: |
printf '%s' "$REGISTRY_TOKEN" | docker login git.unom.io -u "$REGISTRY_USER" --password-stdin
- name: Stage .npmrc with @unom registry auth
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
cat > /tmp/.npmrc <<EOF
@unom:registry=https://git.unom.io/api/packages/unom/npm/
//git.unom.io/api/packages/unom/npm/:_authToken=${REGISTRY_TOKEN}
EOF
- name: Build & push
env:
BUILDER: builder-unom-website
@@ -47,6 +56,7 @@ jobs:
--file ./Dockerfile \
--tag "$IMAGE:latest" \
--tag "$IMAGE:$SHA" \
--secret id=npmrc,src=/tmp/.npmrc \
--cache-from "type=registry,ref=$IMAGE:cache" \
--cache-to "type=registry,ref=$IMAGE:cache,mode=min" \
.
+1
View File
@@ -0,0 +1 @@
@unom:registry=https://git.unom.io/api/packages/unom/npm/
+5 -1
View File
@@ -7,8 +7,12 @@ RUN apk update && apk add --no-cache libc6-compat
FROM base AS installer
WORKDIR /app
COPY package.json bun.lock ./
COPY package.json bun.lock .npmrc ./
# @unom/cms lives in the private gitea npm registry; CI mounts an .npmrc
# with the auth token at /root/.npmrc as a build secret. Fall through to
# the in-repo .npmrc (registry mapping only, no token) if no secret.
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=shared \
--mount=type=secret,id=npmrc,target=/root/.npmrc \
bun install --frozen-lockfile
COPY . .
+1233 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -16,10 +16,12 @@
},
"dependencies": {
"@fontsource/inter": "^5.2.5",
"@payloadcms/sdk": "^3.84.1",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-router": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.168.9",
"@unom/cms": "^0.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"nitro": "^3.0.260429-beta",
+27
View File
@@ -0,0 +1,27 @@
import { Link } from "@tanstack/react-router";
import type { Post } from "@/lib/cms";
const dateFmt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
export default function PostCard({ post }: { post: Post }) {
return (
<Link
to="/blog/$slug"
params={{ slug: post.slug }}
className="block bg-neutral-accent rounded-card p-padding-card no-underline hover:no-underline group"
>
<time
dateTime={post.createdAt}
className="text-secondary text-sm block mb-2"
>
{dateFmt.format(new Date(post.createdAt))}
</time>
<h3 className="text-main mb-2 group-hover:text-main">{post.title}</h3>
<p className="text-secondary">{post.summary}</p>
</Link>
);
}
+27 -10
View File
@@ -1,18 +1,31 @@
// Minimal Lexical → React renderer. Only handles the node kinds our CMS
// emits today (heading h1-h4, paragraph, list/listitem, link, text,
// linebreak). Add cases as new node kinds appear.
// Minimal Lexical → React renderer for the node kinds our CMS emits today
// (heading h1-h4, paragraph, list/listitem, link, text, linebreak, plus the
// text-format bitflags). Add cases as new node kinds appear.
//
// Payload's payload-types declare lexical nodes loosely — `children` is
// `unknown` because the type can't enumerate all possible nodes. We cast
// internally and only read fields we know exist for the supported kinds.
import type { JSX } from "react";
import type { LexNode, LexRoot } from "@/lib/cms";
import type { LexRoot } from "@/lib/cms";
function renderNodes(nodes: LexNode[] | undefined): JSX.Element[] {
type AnyNode = {
type: string;
tag?: string;
text?: string;
format?: number | string;
children?: AnyNode[];
fields?: { url?: string; newTab?: boolean; linkType?: string };
};
function renderNodes(nodes: AnyNode[] | undefined): JSX.Element[] {
return (nodes ?? []).map((n, i) => renderNode(n, i));
}
function renderNode(n: LexNode, key: number): JSX.Element {
function renderNode(n: AnyNode, key: number): JSX.Element {
switch (n.type) {
case "text": {
const format = (n as { format?: number }).format ?? 0;
const format = typeof n.format === "number" ? n.format : 0;
let el: JSX.Element = <>{n.text}</>;
if (format & 1) el = <strong>{el}</strong>;
if (format & 2) el = <em>{el}</em>;
@@ -33,7 +46,7 @@ function renderNode(n: LexNode, key: number): JSX.Element {
}
case "list": {
const Tag = (n.tag === "ol" ? "ol" : "ul") as "ol" | "ul";
const Tag = n.tag === "ol" ? "ol" : "ul";
return <Tag key={key}>{renderNodes(n.children)}</Tag>;
}
@@ -61,7 +74,11 @@ function renderNode(n: LexNode, key: number): JSX.Element {
}
}
export default function RichText({ data }: { data: LexRoot | null | undefined }) {
export default function RichText({
data,
}: {
data: LexRoot | null | undefined;
}) {
if (!data?.root) return null;
return <>{renderNodes(data.root.children)}</>;
return <>{renderNodes(data.root.children as AnyNode[] | undefined)}</>;
}
+56 -71
View File
@@ -1,87 +1,72 @@
// Thin REST client for the unom CMS. Pages have `read: () => true` access,
// globals likewise — so no auth is needed for fetching content. Route loaders
// in TanStack Start call these on the server during SSR and on the client
// during navigation.
// Typed PayloadCMS client. Same pattern as played/plaza — PayloadSDK with the
// Config from `@unom/cms/payload-types` for autocomplete + return-type safety.
import { PayloadSDK } from "@payloadcms/sdk";
import type { Config, Page, Post } from "@unom/cms/payload-types";
const CMS_URL = "https://cms.unom.io";
type LexNode = {
type: string;
children?: LexNode[];
tag?: string;
text?: string;
fields?: { url?: string; newTab?: boolean; linkType?: string };
listType?: string;
};
type LexRoot = { root: LexNode };
type RichTextBlock = { blockType: "RichText"; body: LexRoot; id?: string };
type Page = {
id: number;
slug: string;
title: string;
description?: string | null;
blocks?: Array<RichTextBlock | { blockType: string; [k: string]: unknown }>;
meta?: { title?: string | null; description?: string | null };
};
type NavigationLink = {
blockType: "NavigationLink";
label: string;
to: string;
id?: string;
};
type NavigationSection = {
blockType: "NavigationSection";
title?: string | null;
entries?: NavigationLink[] | null;
id?: string;
};
type Footer = {
id: number;
tagline?: string | null;
sections?: NavigationSection[];
};
type Header = {
id: number;
sections?: NavigationSection[];
};
async function cmsFetch<T>(path: string): Promise<T> {
const res = await fetch(`${CMS_URL}${path}`, {
headers: { Accept: "application/json" },
export const cmsClient = new PayloadSDK<Config>({
baseURL: `${CMS_URL}/api`,
});
if (!res.ok) {
throw new Error(`CMS ${path}${res.status}`);
}
return res.json() as Promise<T>;
}
export async function findPageBySlug(
slug: string,
locale = "de",
locale: "de" | "en" = "de",
): Promise<Page | null> {
const qs = new URLSearchParams({
"where[slug][equals]": slug,
limit: "1",
const res = await cmsClient.find({
collection: "pages",
where: { slug: { equals: slug } },
limit: 1,
locale,
depth: "1",
depth: 1,
});
const data = await cmsFetch<{ docs: Page[] }>(`/api/pages?${qs}`);
return data.docs?.[0] ?? null;
return res.docs[0] ?? null;
}
export async function findFooter(locale = "de"): Promise<Footer> {
return cmsFetch<Footer>(`/api/globals/footer?locale=${locale}&depth=1`);
export async function findPosts(
locale: "de" | "en" = "de",
limit = 50,
) {
return cmsClient.find({
collection: "posts",
limit,
sort: "-createdAt",
locale,
});
}
export async function findHeader(locale = "de"): Promise<Header> {
return cmsFetch<Header>(`/api/globals/header?locale=${locale}&depth=1`);
export async function findPostBySlug(
slug: string,
locale: "de" | "en" = "de",
): Promise<Post | null> {
const res = await cmsClient.find({
collection: "posts",
where: { slug: { equals: slug } },
limit: 1,
locale,
depth: 1,
});
return res.docs[0] ?? null;
}
export type { Page, RichTextBlock, LexRoot, LexNode, Footer, Header, NavigationSection, NavigationLink };
export async function findFooter(locale: "de" | "en" = "de") {
return cmsClient.findGlobal({ slug: "footer", locale });
}
export async function findHeader(locale: "de" | "en" = "de") {
return cmsClient.findGlobal({ slug: "header", locale });
}
export type { Page, Post, Footer, Header } from "@unom/cms/payload-types";
// Lexical content shape lives inside Payload's serialized editor state.
// Pull a few aliases out for the renderer + block typing.
type PageBlocks = NonNullable<Page["blocks"]>;
export type RichTextBlock = Extract<PageBlocks[number], { blockType: "RichText" }>;
export type LexRoot = RichTextBlock["body"];
export type LexNode = LexRoot["root"]["children"][number];
type FooterSections = NonNullable<import("@unom/cms/payload-types").Footer["sections"]>;
export type NavigationSection = FooterSections[number];
export type NavigationLink = NonNullable<NavigationSection["entries"]>[number];
+1
View File
@@ -3,6 +3,7 @@ import {
HeadContent,
Scripts,
} from "@tanstack/react-router";
// @ts-expect-error — @fontsource/inter ships CSS only, no types
import "@fontsource/inter";
import Layout from "@/components/Layout";
import { findFooter, type Footer } from "@/lib/cms";
+51
View File
@@ -0,0 +1,51 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import RichText from "@/components/RichText";
import Section from "@/components/Section";
import { findPostBySlug } from "@/lib/cms";
export const Route = createFileRoute("/blog/$slug")({
loader: async ({ params }) => {
const post = await findPostBySlug(params.slug);
if (!post) throw notFound();
return { post };
},
component: BlogPost,
head: ({ loaderData }) => ({
meta: [
{
title: `unom - ${loaderData?.post?.meta?.title ?? loaderData?.post?.title ?? "Blog"}`,
},
...(loaderData?.post?.summary
? [{ name: "description", content: loaderData.post.summary }]
: []),
],
}),
});
const dateFmt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "long",
year: "numeric",
});
function BlogPost() {
const { post } = Route.useLoaderData();
return (
<Section className="pt-height-header">
<Link to="/blog" className="text-secondary text-sm">
Blog
</Link>
<article className="markdown mt-4">
<time
dateTime={post.createdAt}
className="text-secondary text-sm block mb-2"
>
{dateFmt.format(new Date(post.createdAt))}
</time>
<h1>{post.title}</h1>
<p className="text-secondary text-lg mb-8">{post.summary}</p>
<RichText data={post.content} />
</article>
</Section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import PostCard from "@/components/PostCard";
import Section from "@/components/Section";
import { findPosts } from "@/lib/cms";
export const Route = createFileRoute("/blog/")({
loader: async () => {
const posts = await findPosts();
return { posts: posts.docs };
},
component: BlogIndex,
head: () => ({
meta: [
{ title: "unom - Blog" },
{
name: "description",
content: "Notizen, Gedanken und Tutorials aus der Werkstatt von unom.",
},
],
}),
});
function BlogIndex() {
const { posts } = Route.useLoaderData();
return (
<Section className="pt-height-header">
<h1>Blog</h1>
{posts.length === 0 ? (
<p>Noch keine Beiträge.</p>
) : (
<div className="grid gap-6 md:grid-cols-2 mt-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</Section>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import handler from "@tanstack/react-start/server-entry";
export default {
fetch(req: Request): Promise<Response> {
async fetch(req: Request): Promise<Response> {
return handler.fetch(req);
},
};