Add Strapi CMS with Article and Category content types

Article is deliberately minimal: title, slug, Markdown body, cover image and
category. No Author content type — the administrator is the only writer.

The bootstrap hook grants the Public role find/findOne on the two content types
and disables self-registration, so the public API is read-only and the site has
no front-end accounts.
This commit is contained in:
2026-08-25 11:42:00 +02:00
parent 8e0a33b4fd
commit ec5976135f
32 changed files with 22291 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { StrapiApp } from '@strapi/strapi/admin';
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { mergeConfig, type UserConfig } from 'vite';
export default (config: UserConfig) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};
View File
@@ -0,0 +1,40 @@
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article",
"description": "Blog article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 160
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"content": {
"type": "richtext",
"required": true
},
"cover": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "articles"
}
}
}
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article');
+3
View File
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article');
+3
View File
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article');
@@ -0,0 +1,31 @@
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category",
"description": "Article category"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true,
"unique": true
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true
},
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "api::article.article",
"mappedBy": "category"
}
}
}
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::category.category');
+3
View File
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::category.category');
@@ -0,0 +1,3 @@
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::category.category');
View File
+55
View File
@@ -0,0 +1,55 @@
import type { Core } from '@strapi/strapi';
/** Read-only endpoints the public website needs. Nothing else is granted. */
const PUBLIC_READ_ACTIONS = ['article', 'category'].flatMap((type) => [
`api::${type}.${type}.find`,
`api::${type}.${type}.findOne`,
]);
/**
* Grants the Public role read access to the blog content types, so a fresh
* deployment serves content without anyone clicking through the admin panel.
* Existing permissions are left untouched.
*/
async function grantPublicReadAccess(strapi: Core.Strapi) {
const publicRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
if (!publicRole) return;
for (const action of PUBLIC_READ_ACTIONS) {
const existing = await strapi
.query('plugin::users-permissions.permission')
.findOne({ where: { action, role: publicRole.id } });
if (!existing) {
await strapi
.query('plugin::users-permissions.permission')
.create({ data: { action, role: publicRole.id } });
}
}
}
/**
* The site has no front-end accounts: only the administrators authoring content
* in the admin panel. Self-registration is therefore closed, so nobody can
* create an Authenticated user through the public API.
*/
async function disablePublicSignUp(strapi: Core.Strapi) {
const store = strapi.store({ type: 'plugin', name: 'users-permissions', key: 'advanced' });
const advanced = ((await store.get({ key: 'advanced' })) ?? {}) as Record<string, unknown>;
if (advanced.allow_register !== false) {
await store.set({ key: 'advanced', value: { ...advanced, allow_register: false } });
}
}
export default {
register() {},
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
await grantPublicReadAccess(strapi);
await disablePublicSignUp(strapi);
},
};