懶惰的headless cms - keystonjs

Nov 02, 2024

最近開把保固內的物品一一送修,其實有個念頭再買一個新的就好,但能用的為何不把它修好,其實工業化的現在,買一個新的有時候比維修更便宜,製造是由無情的機器與產線而出,但維修則是透過人力,寄送與等待花的時間往往更久,真的環保就是花更多的時間與金錢,讓有些微故障的東西恢復他的功能,人與物之間的關係如此,人與人之間的關係不更也是?在這分手與離婚比換電腦還頻繁的年代,更顯得諷刺。

想起來之前的文章 厭煩搬家有說要記錄一下Blog的技術堆疊,趁著假日來寫一下有關 headless CMS keystonejs。

概念

wordpress是現在最多人用的Content Management System,有需多的前台樣板,以及不太需要寫Code就能完成的後台,上次裝起來一看後台,想起我的國中時期,那個網站素顏的年代,在驚嚇之中,刪除了整個docker....

Headless CMS就是不管畫面,只管提供APi給前台可以渲染,後台只管可以上資料就好,處理好資料的結構與內容,提供給前台,設計完自己把資料塞一塞。

Keystonejs,可以輕鬆完成 headless CMS的基本需求,每一個輸入元件還可以自己客製化,最讓人驚艷的是他的 stack是:

剛好是這一兩年自己在摸的技術與工作上使用的整合,只要制定好每一個 Model 的欄位(column name)要用什麼 component作為輸入,同時也可以知道資料的型別(type),在設定一下介面的樣式,以及欄位是否為其他Model的關聯(relation),他就能自動生成 prisma schema,加上 prisma本身對graphql就很友善,連API都能一氣呵成寫完。

keystonjs 結構

import { config } from "@keystone-6/core"; import { lists } from "./schema"; import { withAuth, session } from "./auth"; import { local, localFile, r2Storage } from './storage' import { server } from "./config/server"; import * as dotenv from 'dotenv' import { db } from './config/db' dotenv.config() export default withAuth( config({ db, lists, session, server, storage: { local, localFile, r2Storage }, }), );

就這樣.....可以參考官網,他的教學是都寫在一個檔案,但多的時候管理麻煩,我通常喜歡紀錄的是概念與思路。

DB

import * as dotenv from 'dotenv' import { DatabaseProvider, IdFieldConfig } from '@keystone-6/core/types' dotenv.config() export const db = { provider: "postgresql" as DatabaseProvider, url: process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/keystone", idField: { kind: "autoincrement" } as IdFieldConfig, }
  • provider
    支援database有 postgres, mysql, sqlite,我是懶惰,開了 neon
  • url
    寫 database路徑,好習慣是寫在 .env,明碼可以寫開發用的
  • idField
    看你需要是用自動+1的ID,或是用UUID

List

參考 文件

  • Model
    ORM中的Model就是映射到database的 table,範例是用 Tag

fields

  • 欄位名稱
    會轉換成 database的 column name,範例用到 name, slug
  • 輸入Component
    表面看起來是指定輸入的UI,事實上它抽象的使用在 database的 data type , 範例用到 text, relationship Screenshot 2024-11-02 at 3.56.31 PM.png

access

顧名思義就是設定權限,範例的內容:qeury 允許 所有內外部查詢,其他就限定可以登入的使用者與管理者

ui

定義

  • searchFields
    可以搜尋name, slug欄位
  • listView
    列表呈現Screenshot 2024-11-02 at 3.55.18 PM.pngScreenshot 2024-11-02 at 3.56.00 PM.pngname, slug欄位
import {list} from '@keystone-6/core' import {allowAll} from '@keystone-6/core/access' import {relationship, text} from '@keystone-6/core/fields' import createdAt from "../fields/createdAt"; import updatedAt from "../fields/updatedAt"; const Tag = list({ access: { operation: { query: allowAll, create: ({ session }) => Boolean(session?.data.id), update: ({ session }) => Boolean(session?.data.isAdmin), delete: ({ session }) => Boolean(session?.data.isAdmin), }, }, fields: { name: text({ isIndexed: 'unique', validation: { isRequired: true, }, }), slug: text({ isIndexed: 'unique', validation: { isRequired: true, match: { regex: /^[0-9a-z-]+$/, explanation: 'Only lowercase alphanumeric characters and hyphens are allowed.', } }, }), posts: relationship({ ref: 'Post.tags', many: true, }), createdAt, updatedAt, }, ui: { searchFields: ['name', 'slug'], listView: { initialColumns: ['name', 'slug'], }, }, }) export default Tag

Session

幾乎原裝不動,如果你想從seesion取得其他資料可以透過設定:

... sessionData: 'id name createdAt isAdmin', ...
import { createAuth } from '@keystone-6/auth' import { statelessSessions } from '@keystone-6/core/session' const { withAuth } = createAuth({ listKey: 'User', identityField: 'email', // this is a GraphQL query fragment for fetching what data will be attached to a context.session // this can be helpful for when you are writing your access control functions // you can find out more at https://keystonejs.com/docs/guides/auth-and-access-control sessionData: 'id name createdAt isAdmin', secretField: 'password', // WARNING: remove initFirstItem functionality in production // see https://keystonejs.com/docs/config/auth#init-first-item for more initFirstItem: { // if there are no items in the database, by configuring this field // you are asking the Keystone AdminUI to create a new user // providing inputs for these fields fields: ['name', 'email', 'password'], // it uses context.sudo() to do this, which bypasses any access control you might have // you shouldn't use this in production }, }) // statelessSessions uses cookies for session tracking // these cookies have an expiry, in seconds // we use an expiry of 30 days for this starter const sessionMaxAge = 60 * 60 * 24 * 30 // you can find out more at https://keystonejs.com/docs/apis/session#session-api const session = statelessSessions({ maxAge: sessionMaxAge, secret: process.env.SESSION_SECRET, }) export { withAuth, session }

Server (optional)

這個是選項,因為我有做一個客製化的component來寫 markdown,所以需要API來上傳檔案,因為啟用的時候,我都是放在本機上,忘記寫token驗證,要上線的話一定要加。

這個是使用s3相容的bucket,來存放檔案。

import { Express } from "express"; import multer from "multer"; import * as dotenv from 'dotenv' import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' import { createHash } from 'crypto'; dotenv.config() const upload = multer({ storage: multer.memoryStorage() }); const s3Client = new S3Client({ region: process.env.R2_REGION ?? "auto", endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '', secretAccessKey: process.env.R2_SECRET_ACCESS ?? '', }, }); function getStringHash(content: Buffer, algorithm = 'sha256') { return createHash(algorithm).update(content).digest('hex'); } const extendExpressApp = (app: Express) => { app.post("/api/upload", upload.single("file"), async (req, res) => { const file = req.file; if (!file) { res.status(400).send("No file uploaded"); return; } const fileName = getStringHash(file.buffer) + file.mimetype.replace('image/', '.') const command = new PutObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'blog-dev', Key: `images/${fileName}`, Body: file.buffer, }); const response = await s3Client.send(command); if (response.$metadata.httpStatusCode !== 200) { res.status(500).send("Upload failed"); return; } res.status(200).json({ url: `${process.env.R2_API_ENDPOINT}/images/${fileName}`, }); }); }; export const server = { cors: { origin: "*" }, port: +(process.env.KEYSTONE_PORT ?? 8000), extendExpressApp, };

Storage

這個設定是為了使用在 ImageFile時的抽象化設定,我使用的時免費仔愛用的Cloudflare R2

import type { StorageConfig } from "@keystone-6/core/types"; import * as dotenv from 'dotenv' dotenv.config() export const local: StorageConfig = { kind: "local", type: "image", generateUrl: (path) => `${process.env.KEYSTONE_BASE_URL}:${process.env.KEYSTONE_PORT ?? 8000}/images${path}`, serverRoute: { path: "/images", }, storagePath: "public/images", }; export const localFile: StorageConfig = { kind: "local", type: "file", generateUrl: (path) => `${process.env.KEYSTONE_BASE_URL}:${process.env.KEYSTONE_PORT ?? 8000}/files${path}`, serverRoute: { path: "/files", }, storagePath: "public/files", }; export const r2Storage: StorageConfig = { kind: "s3", type: "image", pathPrefix: 'images/', generateUrl: (path: string) => { const file = path.split('/').pop() return process.env.R2_API_ENDPOINT + '/images/' + file }, bucketName: process.env.R2_BUCKET ?? 'blog-dev', // from your S3_BUCKET_NAME environment variable region: process.env.R2_REGION ?? "auto", // from your S3_REGION environment variable accessKeyId: process.env.R2_ACCESS_KEY_ID, // from your S3_ACCESS_KEY_ID environment variable secretAccessKey: process.env.R2_SECRET_ACCESS, // from your S3_SECRET_ACCESS_KEY environment variable endpoint: process.env.R2_ENDPOINT, // from your S3_ENDPOINT environment variable acl: "public-read", // from your S3_ACL environment variable };

成果

http://localhost:8000/api/graphql

整個headless CMS 的 graphql全部幫你寫完.....(抖)

Screenshot 2024-11-02 at 4.12.31 PM.png

然後他連各種filter都寫好了.....自己點一點args Screenshot 2024-11-02 at 4.13.00 PM.png

結語

沒做到step by step都寫得好久....,當除本來是在前前家公司要做年度KPI對內部的教育訓練分享技術,因為公司沒在用PHP,本來是用Laravel寫的,結果整個打掉重寫,意外的找到這個好東西,如果有用過nestjs寫過graphql,就知道搞這些參數有多麻煩,雖然用 prisma寫orm超舒服,但也不會比寫一個config就能長出整個網站開心,但他也是有他的限制,怎麼樣使用,就得自己摸索。

Ekman Hsieh

文字工作者,寫作時間常常在人類與電腦之間拉鋸,相信閱讀,相信文字與思想所構築的美麗境界