strapi-plugin-casso-article-editor
v1.2.0
Published
Strapi 5 custom field to write article bodies as HTML (CKEditor 5), Notion-like TipTap, or Markdown — with paste-to-upload images and Lark Docs import
Maintainers
Readme
strapi-plugin-casso-article-editor
A Strapi 5 custom field for long-form article bodies. One field, three writing modes — and the content keeps its original format instead of being flattened to HTML.
| Mode | Editor | Stored as |
| --- | --- | --- |
| HTML | CKEditor 5 document canvas | HTML string |
| Notion | TipTap with / slash menu, callouts, columns, tables, task lists, code blocks | TipTap JSON |
| Markdown | Split editor with live preview and > [!NOTE] callouts | Markdown |
Also included: paste-an-image straight into the Media Library (with alt text dialog), and one-click import from a Lark Docs / Wiki link.
Tài liệu tiếng Việt ở phần dưới.
Requirements
- Strapi
^5.0.0 - Node
>=20 - React 18,
styled-components6 (provided by the Strapi admin)
Install
npm install strapi-plugin-casso-article-editor1. Enable the plugin
config/plugins.ts (or .js):
export default {
'casso-article-editor': {
enabled: true,
},
};Every option has a default and reads from env, so this is all the config you need to get started.
2. Wrap the admin Vite config
Create or edit src/admin/vite.config.ts:
import { withCassoArticleEditor } from 'strapi-plugin-casso-article-editor/vite';
export default (config) => withCassoArticleEditor(config);This is required. It pre-bundles the CJS dependencies of @_sh/strapi-plugin-ckeditor (which Strapi 5.50+ auto-excludes from optimizeDeps, leaving them unbundled and blanking the admin panel) and injects plugin CSS through JS so the CKEditor toolbar survives strapi build. If you already have a Vite config, merge yours first and pass the result in.
3. Point a content type at the custom field
In src/api/<name>/content-types/<name>/schema.json:
{
"attributes": {
"content": {
"type": "richtext",
"customField": "plugin::casso-article-editor.body"
},
"contentHtml": {
"type": "text",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
},
"contentToc": {
"type": "json",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
},
"contentFormat": {
"type": "string",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
}
}
}The three sibling fields are optional — declare the ones you want and the plugin fills them on every create/update, named <field><suffix>:
| Field | Suffix | Contents |
| --- | --- | --- |
| contentHtml | Html | Display-ready HTML, heading ids assigned, code highlighted |
| contentToc | Toc | [{ id, text, level }] matching those ids |
| contentFormat | Format | "html" | "markdown" | "tiptap" |
Do not mark them "writable": false: Strapi's document service strips non-writable attributes from the payload, so the plugin's writes are silently dropped and the fields stay empty forever. Hide them from the edit form with pluginOptions as above.
Restart Strapi, rebuild the admin, and the field shows up in the Content-Type Builder under custom fields.
Reading the content
Websites should read contentHtml and contentToc and render nothing themselves:
const article = await fetch(
`${STRAPI_URL}/api/articles?filters[slug][$eq]=${slug}&fields[0]=title&fields[1]=contentHtml&fields[2]=contentToc`,
).then((r) => r.json()).then((j) => j.data?.[0]);
// article.contentHtml -> "<h2 id=\"gioi-thieu\">Giới thiệu</h2>…"That HTML is produced by the same renderer as the editor's own preview, so preview and website agree. It is written at save time, which makes it a cache: after adding the field or upgrading the plugin, re-render existing rows with POST /casso-article-editor/rerender.
The body itself is stored as a JSON envelope so the source format round-trips:
{ "__casso": 1, "format": "tiptap", "body": "{\"type\":\"doc\",…}" }Legacy plain-HTML strings are still understood and reported as format: "html".
From a Strapi lifecycle, controller or script:
const { parseContent } = strapi.plugin('casso-article-editor').service('envelope');
const { format, body } = parseContent(entry.content);
// render on demand, same output as the stored HTML
const { html, toc } = strapi.plugin('casso-article-editor').service('render').render(entry.content);Outside Strapi (a Next.js app, a migration script):
import { parseContent, serializeContent } from 'strapi-plugin-casso-article-editor/envelope';
import { renderArticle } from 'strapi-plugin-casso-article-editor/render';The envelope subpath has no dependencies; render pulls TipTap, marked and lowlight, so keep it server-side. Both ship ESM and CJS.
Rendering it on a website
RENDERING.md is the full frontend spec: the fields to fetch and how, the complete list of classes and data-* attributes the stored HTML contains, the article stylesheet, the TOC contract, image URL handling, sanitisation, how to re-render stale HTML, and the mistakes that break rendering.
It ships inside the package, so an agent working in a consuming repo can read it from node_modules/strapi-plugin-casso-article-editor/RENDERING.md.
Lark Docs import
Paste a …/docx/<token> or …/wiki/<token> link and the plugin walks the document block tree through the Lark API, maps it to TipTap JSON (headings, lists, tables, grids to columns, callouts, code, task lists, quotes) and uploads every image into the Media Library with a Vietnamese-slug filename.
It uses its own Lark app, separate from any SSO app you may have, because it needs tenant-level read access to documents:
| Env | Default | Notes |
| --- | --- | --- |
| LARK_IMPORT_APP_ID | — | Lark app ID; import button stays hidden while empty |
| LARK_IMPORT_APP_SECRET | — | Lark app secret |
| LARK_IMPORT_API_BASE | https://open.larksuite.com | Use https://open.feishu.cn for Feishu |
| LARK_IMPORT_ENABLED | true | Set false to disable the feature entirely |
Create a custom app in the Lark Developer Console, then:
- Add the permission scopes
docx:document:readonly,wiki:wiki:readonlyanddrive:drive:readonly. - Publish the app so the tenant token works.
- Share the documents (or the whole wiki space) with the app.
The admin calls GET /casso-article-editor/lark-import/status once per page load and only renders the "Import Lark" button when the server reports the app as configured — so leaving the env vars unset is a supported configuration, not an error.
Configuration reference
Set these in config/plugins.ts if you need to override the env defaults:
export default ({ env }) => ({
'casso-article-editor': {
enabled: true,
config: {
// Sibling fields the plugin keeps in sync, as `content` + suffix.
// Set any of them to null to stop writing that field.
formatFieldSuffix: 'Format',
htmlFieldSuffix: 'Html',
tocFieldSuffix: 'Toc',
// Shift h1-h3 down one level when the body still has an <h1>
// (Lark imports usually do), so the page title stays the only h1.
demoteHeadings: true,
// Re-render rows with no HTML yet on every boot. Off by default: on a
// large site, call POST /casso-article-editor/rerender after a deploy instead.
backfillOnBootstrap: false,
larkImport: {
enabled: true,
appId: env('LARK_IMPORT_APP_ID', ''),
appSecret: env('LARK_IMPORT_APP_SECRET', ''),
apiBase: env('LARK_IMPORT_API_BASE', 'https://open.larksuite.com'),
},
},
},
});Customising the CKEditor preset
The HTML mode ships a preset named cassoArticle (a document-style canvas built on defaultHtmlPreset). To replace it, register your own preset with the same name from src/admin/app.ts — the plugin only adds its preset when that name is not already taken:
import { setPluginConfig, defaultHtmlPreset, getPluginPresets } from '@_sh/strapi-plugin-ckeditor';
export default {
register() {
setPluginConfig({
presets: [
...Object.values(getPluginPresets()),
{ ...defaultHtmlPreset, name: 'cassoArticle', description: 'My canvas' },
],
});
},
};Development
npm install
npm run build # dist/{admin,server,envelope,render,vite}
npm run watch # rebuild on change
npm run verify # validate package.json exports against dist
npm test # render regression suite (HTML contract + TOC)To try changes against a real app, npm pack and install the tarball, or use npm run watch:link with strapi-plugin link.
Tiếng Việt
Custom field cho Strapi 5 dùng viết bài dài. Một field, ba chế độ viết, và nội dung giữ đúng format gốc thay vì bị ép hết về HTML.
| Chế độ | Editor | Lưu dưới dạng |
| --- | --- | --- |
| HTML | CKEditor 5, canvas kiểu trang giấy | chuỗi HTML |
| Notion | TipTap với menu /, callout, cột, bảng, checklist, code block | TipTap JSON |
| Markdown | Editor chia đôi có preview, hỗ trợ callout > [!NOTE] | Markdown |
Kèm theo: dán ảnh là upload thẳng vào Media Library (có hộp thoại đặt alt), và import một phát từ link Lark Docs / Wiki.
Cài đặt
npm install strapi-plugin-casso-article-editorBước 1 — bật plugin trong config/plugins.ts:
export default {
'casso-article-editor': { enabled: true },
};Mọi option đều có default đọc từ env nên chỉ cần bấy nhiêu là chạy.
Bước 2 — bọc vite config của admin tại src/admin/vite.config.ts. Bước này bắt buộc:
import { withCassoArticleEditor } from 'strapi-plugin-casso-article-editor/vite';
export default (config) => withCassoArticleEditor(config);Lý do: từ Strapi 5.50, @_sh/strapi-plugin-ckeditor bị tự động đưa vào optimizeDeps.exclude, kéo theo các dependency CJS của nó không được pre-bundle và admin trắng trang. Helper cũng inject CSS qua JS để toolbar CKEditor không mất style sau strapi build.
Bước 3 — khai báo field trong schema content type:
{
"attributes": {
"content": {
"type": "richtext",
"customField": "plugin::casso-article-editor.body"
},
"contentHtml": {
"type": "text",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
},
"contentToc": {
"type": "json",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
},
"contentFormat": {
"type": "string",
"configurable": false,
"pluginOptions": { "content-manager": { "visible": false } }
}
}
}Ba field đi kèm đều không bắt buộc — khai báo field nào thì plugin ghi field đó mỗi lần create/update, tên theo quy ước <field><suffix>:
| Field | Suffix | Nội dung |
| --- | --- | --- |
| contentHtml | Html | HTML render sẵn, heading đã có id, code đã highlight |
| contentToc | Toc | [{ id, text, level }] khớp đúng id đó |
| contentFormat | Format | "html" | "markdown" | "tiptap" |
Đừng đặt "writable": false cho mấy field này: document service của Strapi loại attribute không writable khỏi payload, nên plugin ghi xong bị bỏ và field rỗng vĩnh viễn. Muốn ẩn khỏi form edit thì dùng pluginOptions như trên.
Đọc nội dung
Website nên đọc contentHtml + contentToc và không tự render gì:
const article = await fetch(
`${STRAPI_URL}/api/articles?filters[slug][$eq]=${slug}&fields[0]=title&fields[1]=contentHtml&fields[2]=contentToc`,
).then((r) => r.json()).then((j) => j.data?.[0]);HTML đó do đúng renderer chạy trong preview của editor sinh ra, nên preview và website không lệch nhau. Nó được ghi lúc save, tức là một dạng cache: sau khi thêm field hoặc nâng version plugin thì render lại bản ghi cũ bằng POST /casso-article-editor/rerender.
Bản thân body vẫn lưu dưới dạng JSON envelope để giữ được format gốc:
{ "__casso": 1, "format": "tiptap", "body": "{\"type\":\"doc\",…}" }Dữ liệu cũ là HTML thuần vẫn đọc được và trả về format: "html".
Trong Strapi:
const { parseContent } = strapi.plugin('casso-article-editor').service('envelope');
const { html, toc } = strapi.plugin('casso-article-editor').service('render').render(entry.content);Ngoài Strapi (app Next.js, script migration):
import { parseContent, serializeContent } from 'strapi-plugin-casso-article-editor/envelope';
import { renderArticle } from 'strapi-plugin-casso-article-editor/render';Subpath envelope không có dependency nào; render kéo theo TipTap, marked, lowlight nên chỉ dùng ở server. Cả hai ship ESM và CJS.
Render ở website
RENDERING.md là đặc tả đầy đủ cho frontend: fetch field nào và fetch thế nào, danh sách trọn vẹn class và data-* có trong HTML đã lưu, CSS cho bài viết, hợp đồng dữ liệu mục lục, xử lý URL ảnh, sanitize, cách render lại HTML đã cũ, và bảng các lỗi thường gặp.
File này nằm trong package nên khi vibe code ở dự án khác, agent đọc trực tiếp được ở node_modules/strapi-plugin-casso-article-editor/RENDERING.md.
Import từ Lark Docs
Dán link …/docx/<token> hoặc …/wiki/<token>, plugin đọc cây block qua Lark API rồi map sang TipTap JSON (heading, list, bảng, grid thành cột, callout, code, checklist, quote) và upload toàn bộ ảnh vào Media Library với tên file slug tiếng Việt.
Plugin dùng Lark app riêng, độc lập với app SSO nếu dự án có, vì cần quyền tenant đọc tài liệu:
| Env | Mặc định | Ghi chú |
| --- | --- | --- |
| LARK_IMPORT_APP_ID | — | Chưa set thì nút Import tự ẩn |
| LARK_IMPORT_APP_SECRET | — | |
| LARK_IMPORT_API_BASE | https://open.larksuite.com | Feishu dùng https://open.feishu.cn |
| LARK_IMPORT_ENABLED | true | Đặt false để tắt hẳn |
Tạo custom app trên Lark Developer Console, bật scope docx:document:readonly, wiki:wiki:readonly, drive:drive:readonly, publish app, rồi share tài liệu (hoặc cả wiki space) cho app.
Admin gọi GET /casso-article-editor/lark-import/status một lần mỗi lần load trang và chỉ hiện nút "Import Lark" khi server báo đã cấu hình — nên để trống env là trạng thái hợp lệ, không phải lỗi.
License
MIT
