website: switch to typed PayloadCMS SDK via @unom/cms
- 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:
@@ -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" \
|
||||
.
|
||||
|
||||
+5
-1
@@ -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 . .
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)}</>;
|
||||
}
|
||||
|
||||
+57
-72
@@ -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" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`CMS ${path} → ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
export const cmsClient = new PayloadSDK<Config>({
|
||||
baseURL: `${CMS_URL}/api`,
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user