使用 Strapi 和 Astro 搭建静态博客

2025-04-11 22:08:46

在 Strapi 中创建集合类型

文章 Article 集合

名称 类型
title 文本
description 文本
tags 关联 Tag
slug 文本
content 富文本编辑器

标签 Tag 集合

名称 类型
title 文本
slug 文本
articles 关联 Article

在 Astro 中编写 Strapi 的 Loader

创建 Zod 类型声明

实现 ArticleTag 的类型声明

const Article = z.object({
    id: z.string(),
    documentId: z.string(),
    title: z.string(),
    description: z.string().nullable(),
    slug: z.string().nullable(),
    content: z.string().nullable(),
    createdAt: z.string(),
    updatedAt: z.string(),
    publishedAt: z.string(),
    tags: z.array(reference('tag')),
    tocHtml: z.string().nullable(),
    contentHtml: z.string()
})

const Tag = z.object({
    id: z.string(),
    documentId: z.string(),
    name: z.string(),
    slug: z.string().nullable(),
    articles: z.array(reference('article'))
})

Strapi Api 接口

使用 Astro 官方提供的包装函数
导出 fetchArticleApifetchTagApi 用来获取内容
请求参数 query 中的 populate 字段是为了获取关联集合类型的 documentId

export async function fetchArticleApi() {
    return await fetchApi<Article[]>({
        endpoint: 'articles',
        query: {
            'populate[tags][fields]': 'documentId'
        },
        wrappedByKey: 'data',
    });
}

export async function fetchTagApi() {
    return await fetchApi<Tag[]>({
        endpoint: 'tags',
        query: {
            'populate[articles][fields]': 'documentId'
        },
        wrappedByKey: 'data',
    });
}

创建文章 Loader

使用 documentId 作为 Astro 内容集合的 id
使用文章内容创建摘要 digest 判断是否新增,用于增量更新
使用 unifiedMarkdown 渲染成 Html 格式保存下来
tocList 是使用 toc 生成的文章目录,用于生成目录的 Html
最后先从 store 中删除历史文章,再添加进去

function strapiArticleLoader(): Loader {
    return {
        name: 'strapi-article-loader',
        load: async (context: LoaderContext): Promise<void> => {
            const articles = await fetchArticleApi();
            for (const article of articles) {
                const id = article.documentId;
                const content = article.content ? article.content : "";
                const digest = context.generateDigest(content);

                const dataEntry = context.store.get(article.documentId);
                if (dataEntry && dataEntry.digest === digest) {
                    context.logger.info(`跳过 ${article.slug ? article.slug : article.id} 博客渲染,博客未更新`);
                    continue;
                } else {
                    context.logger.info(`渲染 ${article.slug ? article.slug : article.id} 博客`);
                }

                let tocList: List | undefined;
                let tocHtml: string | null = null;
                const contentFile = await unified()
                    .use(remarkParse)
                    .use(() => (tree: Root) => {
                        const result = toc(tree, { maxDepth: 2 });
                        tocList = result.map;
                    })
                    .use(remarkRehype)
                    .use(rehypeSlug)
                    .use(rehypeAutolinkHeadings)
                    .use(rehypeShiki, { themes: { light: 'one-dark-pro', dark: 'one-dark-pro' } })
                    .use(rehypeStringify)
                    .process(content);

                if (tocList) {
                    await unified()
                        .use(remarkRehype)
                        .run({ type: 'root', children: [tocList] })
                        .then((result) => {
                            tocHtml = unified()
                                .use(rehypeStringify)
                                .stringify(result);
                        })
                }

                const tags: string[] = [];
                for (const tag of article.tags) {
                    tags.push(tag.documentId);
                }

                const data = await context.parseData({
                    id, data: {
                        id: article.id.toString(),
                        documentId: id,
                        title: article.title,
                        description: article.description,
                        slug: article.slug,
                        content: article.content,
                        createdAt: article.createdAt,
                        updatedAt: article.updatedAt,
                        publishedAt: article.publishedAt,
                        tags: tags,
                        tocHtml: tocHtml,
                        contentHtml: contentFile.toString()
                    }
                });

                context.store.delete(id);
                context.store.set({ id, data, digest });
            }
        }
    }
}

创建标签 Loader

Tag 不需要渲染,每次全量更新

function strapiTagLoader(): Loader {
    return {
        name: 'strapi-tag-loader',
        load: async (context: LoaderContext): Promise<void> => {
            const tags = await fetchTagApi();

            context.store.clear();
            for (const tag of tags) {
                const id = tag.documentId;

                const articles: string[] = [];
                for (const article of tag.articles) {
                    articles.push(article.documentId);
                }

                const data = await context.parseData({
                    id, data: {
                        id: tag.id.toString(),
                        documentId: tag.documentId,
                        name: tag.name,
                        slug: tag.slug,
                        articles: articles
                    }
                });

                context.store.set({ id, data });
            }
        }
    }
}

最后导出文章和标签的内容集合

const article = defineCollection({
    loader: strapiArticleLoader(),
    schema: Article
});

const tag = defineCollection({
    loader: strapiTagLoader(),
    schema: Tag
});

export const collections = { article, tag };

实现 Webhook 服务

使用 express 实现一个监听 Strapi Webhook 的服务

import crypto from 'crypto';
import express from 'express';
import bodyParser from 'body-parser';
import { exec } from 'child_process';

const app = express();
let PORT = process.env.WEBHOOK_PORT;
let SECRET_TOKEN = process.env.WEBHOOK_TOKEN;

if (!PORT) {
    PORT = "14321";
}

if (!SECRET_TOKEN) {
    SECRET_TOKEN = crypto.randomBytes(32).toString('hex').toUpperCase();
    console.log(`生成 Token: ${SECRET_TOKEN}`);
}

app.use(bodyParser.json());

app.post('/webhook', (req, res) => {
    const authHeader = req.headers['authorization'];

    if (!authHeader || authHeader !== `Bearer ${SECRET_TOKEN}`) {
        console.warn(`无效的 Token: ${authHeader}`);
        res.status(401).end();
        return;
    }

    console.log('收到 Strapi Webhook 请求');
    console.log(JSON.stringify(req.body, null, 2));

    res.status(200).end();

    console.log('正在执行 Astro 构建...');
    exec('pnpm build', { cwd: "/app/astro" }, (error, stdout, stderr) => {
        if (error) {
            console.error(`构建失败: ${error.message}`);
            return;
        }

        if (stderr) {
            console.warn(`构建警告: ${stderr}`);
        }

        console.log(`构建成功: ${stdout}`);
    });
});

app.listen(PORT, () => {
    console.log(`Strapi Webhook 服务已启动, 监听端口 ${PORT}`);
});

在 Strapi 中添加 Webhook

Urlhttps://sssc.top:14321/webhook
标头密钥为 Authorization,值为 Bearer + TOKEN