Replace Strapi CMS with PocketBase
Strapi + Postgres are gone in favor of PocketBase: a single Go binary with embedded SQLite, built-in admin UI and per-collection API rules. No content existed yet, so this is a clean swap with no data migration. Collections and rules are defined as code in pocketbase/pb_migrations/ and applied automatically on first boot. Draft & Publish has no native PocketBase equivalent, so it's reproduced with a nullable `publishedAt` field enforced by listRule/viewRule, matching the old Strapi semantics. Routing flips: PocketBase's admin UI and REST/file API are hardwired to `/_/` and `/api/*` at the domain root (its own dashboard assets and API calls reference those paths directly, so a stripped path prefix like `/admin/*` would break them). `/api` is therefore reserved for PocketBase now, and the frontend's Nitro endpoints move to `/content/*` (frontend/server/routes/content/, not server/api/). A `/admin` vanity route in Nitro (not Caddy) redirects to `/_/`, so it works the same in dev, where Caddy isn't part of the stack, and in production. frontend/server/utils/strapi.ts becomes pocketbase.ts; queries.ts is rewritten for PocketBase's filter/sort/fields/expand query syntax. StrapiImage becomes MediaImage (no width/height — PocketBase file fields don't store dimensions, and the cover images already reserve their aspect ratio via CSS, so this is not a regression). docs/*.md, CLAUDE.md and README.md are updated in the same commit.
This commit is contained in:
@@ -21,9 +21,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<img
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
loading="lazy"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { StrapiImage } from '#shared/types/blog'
|
||||
import type { MediaImage } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Strapi returns media paths relative to its own origin; the browser needs
|
||||
* absolute ones. Remote providers (S3) already return absolute URLs.
|
||||
* PocketBase returns file paths relative to its own origin; the browser
|
||||
* needs absolute ones.
|
||||
*/
|
||||
export function useMediaUrl() {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
|
||||
return (image: StrapiImage | null | undefined): string | null => {
|
||||
return (image: MediaImage | null | undefined): string | null => {
|
||||
if (!image?.url) return null
|
||||
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}`
|
||||
return image.url.startsWith('http') ? image.url : `${config.pocketbaseUrl}${image.url}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Article } from '#shared/types/blog'
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
|
||||
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`)
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
@@ -79,9 +79,7 @@ useHead({
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alternativeText ?? ''"
|
||||
:width="article.cover?.width ?? undefined"
|
||||
:height="article.cover?.height ?? undefined"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
|
||||
@@ -8,7 +8,7 @@ const page = computed(() => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { page },
|
||||
})
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ const page = computed(() => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
|
||||
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`)
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { page, category: slug },
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: 'home-articles',
|
||||
query: { page: 1 },
|
||||
})
|
||||
@@ -82,9 +82,7 @@ useSeoMeta({
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alternativeText ?? ''"
|
||||
:width="featured.cover?.width ?? undefined"
|
||||
:height="featured.cover?.height ?? undefined"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
@@ -20,18 +20,17 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// Server-only: internal Strapi address, never exposed to the browser.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
strapiToken: '',
|
||||
// Server-only: internal PocketBase address, never exposed to the browser.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
public: {
|
||||
// Canonical origin of the public website.
|
||||
siteUrl: 'http://localhost:3000',
|
||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
// Browser-reachable PocketBase origin, used to build absolute media URLs.
|
||||
pocketbaseUrl: 'http://localhost:8090',
|
||||
},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Article } from '#shared/types/blog'
|
||||
|
||||
interface AdminUser {
|
||||
firstname: string | null
|
||||
lastname: string | null
|
||||
}
|
||||
|
||||
type RawArticle = Omit<Article, 'html' | 'summary' | 'author'> & {
|
||||
content: string | null
|
||||
createdBy?: AdminUser | null
|
||||
}
|
||||
|
||||
/** Joins the admin user's name parts; returns null when neither is set. */
|
||||
function byline(user: AdminUser | null | undefined): string | null {
|
||||
const name = [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
|
||||
return name || null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<RawArticle>>(
|
||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const article = response.data[0]
|
||||
|
||||
if (!article) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const { content, createdBy, ...rest } = article
|
||||
return {
|
||||
...rest,
|
||||
html: renderMarkdown(content),
|
||||
summary: summarise(content),
|
||||
author: byline(createdBy),
|
||||
}
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filter = category
|
||||
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
|
||||
: ''
|
||||
|
||||
const response = await strapiFetch<StrapiList<ArticleSummary>>(
|
||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.data,
|
||||
page: response.meta.pagination.page,
|
||||
pageCount: response.meta.pagination.pageCount,
|
||||
total: response.meta.pagination.total,
|
||||
}
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
'/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
|
||||
)
|
||||
|
||||
return response.data
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vanity redirect to the PocketBase admin UI, whose own route is fixed at
|
||||
* `/_/`. Handled here (not in Caddy) so it works the same in dev, where
|
||||
* Caddy isn't in the stack, and in production.
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
return sendRedirect(event, `${config.pocketbaseUrl}/_/`, 302)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
interface RawArticle {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
content: string | null
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
authorName: string | null
|
||||
expand?: { category?: Category }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const response = await pbFetch<PbList<RawArticle>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_DETAIL_FIELDS}&expand=category&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&${pagination(1, 1)}`
|
||||
)
|
||||
|
||||
const article = response.items[0]
|
||||
|
||||
if (!article) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
publishedAt: article.publishedAt,
|
||||
cover: toMediaImage('articles', article.id, article.cover, article.coverAlt),
|
||||
category: article.expand?.category ?? null,
|
||||
html: renderMarkdown(article.content),
|
||||
summary: summarise(article.content),
|
||||
author: article.authorName || null,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
|
||||
interface RawArticleSummary {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: string
|
||||
coverAlt: string | null
|
||||
expand?: { category?: Category }
|
||||
}
|
||||
|
||||
function toSummary(raw: RawArticleSummary): ArticleSummary {
|
||||
return {
|
||||
title: raw.title,
|
||||
slug: raw.slug,
|
||||
publishedAt: raw.publishedAt,
|
||||
cover: toMediaImage('articles', raw.id, raw.cover, raw.coverAlt),
|
||||
category: raw.expand?.category ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filter = category
|
||||
? `&filter=${encodeURIComponent(`category.slug = ${quote(category)}`)}`
|
||||
: ''
|
||||
|
||||
const response = await pbFetch<PbList<RawArticleSummary>>(
|
||||
`/api/collections/articles/records?fields=${ARTICLE_SUMMARY_FIELDS}&expand=category&sort=-publishedAt&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.items.map(toSummary),
|
||||
page: response.page,
|
||||
pageCount: response.totalPages,
|
||||
total: response.totalItems,
|
||||
}
|
||||
})
|
||||
+3
-3
@@ -7,11 +7,11 @@ export default defineEventHandler(async (event): Promise<Category> => {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
const response = await pbFetch<PbList<Category>>(
|
||||
`/api/collections/categories/records?fields=name,slug&filter=${encodeURIComponent(`slug = ${quote(slug)}`)}&perPage=1`
|
||||
)
|
||||
|
||||
const category = response.data[0]
|
||||
const category = response.items[0]
|
||||
|
||||
if (!category) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||
const response = await pbFetch<PbList<Category>>(
|
||||
'/api/collections/categories/records?fields=name,slug&sort=name&perPage=100'
|
||||
)
|
||||
|
||||
return response.items
|
||||
})
|
||||
@@ -1,19 +1,17 @@
|
||||
import { marked } from 'marked'
|
||||
import type { MediaImage } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Calls the Strapi REST API from the server only, so the internal URL and any
|
||||
* future API token never reach the browser.
|
||||
* Calls the PocketBase REST API from the server only, so the internal URL
|
||||
* never reaches the browser.
|
||||
*/
|
||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
||||
export async function pbFetch<T>(path: string): Promise<T> {
|
||||
const { pocketbaseUrl } = useRuntimeConfig()
|
||||
|
||||
try {
|
||||
return (await $fetch(path, {
|
||||
baseURL: strapiUrl,
|
||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
||||
})) as T
|
||||
return (await $fetch(path, { baseURL: pocketbaseUrl })) as T
|
||||
} catch (error) {
|
||||
console.error(`Strapi request failed: ${path}`, error)
|
||||
console.error(`PocketBase request failed: ${path}`, error)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
||||
}
|
||||
}
|
||||
@@ -48,7 +46,24 @@ export function summarise(source: string | null | undefined, maxLength = 155): s
|
||||
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
||||
}
|
||||
|
||||
export interface StrapiList<T> {
|
||||
data: T[]
|
||||
meta: { pagination: { page: number; pageCount: number; total: number } }
|
||||
export interface PbList<T> {
|
||||
page: number
|
||||
perPage: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a PocketBase file URL, relative to its own origin — PocketBase file
|
||||
* fields store only a filename, not a full path or dimensions.
|
||||
*/
|
||||
export function toMediaImage(
|
||||
collection: string,
|
||||
id: string,
|
||||
filename: string | null | undefined,
|
||||
alt: string | null | undefined
|
||||
): MediaImage | null {
|
||||
if (!filename) return null
|
||||
return { url: `/api/files/${collection}/${id}/${filename}`, alt: alt || null }
|
||||
}
|
||||
@@ -1,33 +1,20 @@
|
||||
/** Strapi query fragments. Only the fields the pages actually render are requested. */
|
||||
/** PocketBase query fragments. Only the fields the pages actually render are requested. */
|
||||
|
||||
const list = (prefix: string, names: readonly string[]) =>
|
||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
||||
export const ARTICLE_SUMMARY_FIELDS =
|
||||
'id,title,slug,publishedAt,cover,coverAlt,expand.category.name,expand.category.slug'
|
||||
|
||||
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
|
||||
|
||||
export const ARTICLE_SUMMARY_QUERY = [
|
||||
list('fields', ['title', 'slug', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
'sort[0]=publishedAt:desc',
|
||||
].join('&')
|
||||
|
||||
export const ARTICLE_DETAIL_QUERY = [
|
||||
list('fields', ['title', 'slug', 'content', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
// The byline is the admin user who created the entry; Strapi exposes the
|
||||
// name fields only because the content type sets populateCreatorFields.
|
||||
list('populate[createdBy][fields]', ['firstname', 'lastname']),
|
||||
].join('&')
|
||||
export const ARTICLE_DETAIL_FIELDS =
|
||||
'id,title,slug,content,publishedAt,cover,coverAlt,authorName,expand.category.name,expand.category.slug'
|
||||
|
||||
export const PAGE_SIZE = 12
|
||||
|
||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
||||
export const pagination = (page: number, perPage = PAGE_SIZE) => `page=${page}&perPage=${perPage}`
|
||||
|
||||
/** Reads a positive integer query param, falling back to 1. */
|
||||
export const pageParam = (value: unknown): number => {
|
||||
const page = Number(value)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
/** Quotes a value for PocketBase's `filter` DSL, escaping embedded quotes. */
|
||||
export const quote = (value: string) => `"${value.replace(/"/g, '\\"')}"`
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */
|
||||
/** Shape of the PocketBase payloads, narrowed to what the site actually renders. */
|
||||
|
||||
export interface StrapiImage {
|
||||
export interface MediaImage {
|
||||
url: string
|
||||
alternativeText: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
alt: string | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
@@ -17,7 +15,7 @@ export interface ArticleSummary {
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: StrapiImage | null
|
||||
cover: MediaImage | null
|
||||
category: Category | null
|
||||
}
|
||||
|
||||
@@ -26,7 +24,7 @@ export interface Article extends ArticleSummary {
|
||||
html: string
|
||||
/** Plain-text opening of the body, used as the meta description. */
|
||||
summary: string
|
||||
/** Byline: the full name of the admin user who wrote the article. */
|
||||
/** Byline: the article's authorName field, filled in manually by the editor. */
|
||||
author: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user