281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
import type { VbenFormSchema } from '#/adapter/form';
|
|
import type { WordpressBlogApi } from '#/api/blog';
|
|
|
|
export type BlogArticleEditorMode = 'html-rich' | 'html-source' | 'markdown';
|
|
export type BlogArticleContentFormat = 'html' | 'markdown';
|
|
export type BlogArticleFormValues = WordpressBlogApi.ArticleBody & {
|
|
editorMode?: BlogArticleEditorMode;
|
|
};
|
|
|
|
export const BLOG_ARTICLE_FORM_CLASS = 'blog-article-form';
|
|
export const BLOG_ARTICLE_MODAL_CLASS = 'blog-article-modal';
|
|
export const BLOG_ARTICLE_MODAL_CONTENT_CLASS = 'blog-article-modal__content';
|
|
export const BLOG_ARTICLE_CONTENT_FIELD_CLASS = 'blog-article-form__content';
|
|
export const BLOG_ARTICLE_HTML_RICH_CLASS = 'blog-article-form__html-rich';
|
|
export const BLOG_ARTICLE_HTML_TEXTAREA_CLASS =
|
|
'blog-article-form__html-source';
|
|
export const BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT = 560;
|
|
|
|
const ARGON_BASE_CODEBLOCK_PATTERN = /\b(?:wp-block-code|hljs-codeblock)\b/;
|
|
const SOURCE_ONLY_HTML_PATTERN =
|
|
/\b(?:hljs-ln|hljs-control|fancybox-wrapper|lazyload|collapse-block|wp-block-(?!code\b)[\w-]+)/;
|
|
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
|
|
|
|
/**
|
|
* Builds the content field schema for Markdown, rich HTML, or raw WordPress HTML preservation.
|
|
* @param mode Current editor mode; source mode uses a raw textarea to avoid runtime DOM rewrites.
|
|
* @param markdownEditor Markdown editor component used for normal local article authoring.
|
|
* @param richHtmlEditor Tiptap HTML editor component used for plain HTML authoring.
|
|
* @returns Vben form schema for the article content field.
|
|
*/
|
|
export function createBlogArticleContentSchema(
|
|
mode: BlogArticleEditorMode,
|
|
markdownEditor: unknown,
|
|
richHtmlEditor?: unknown,
|
|
): VbenFormSchema {
|
|
const common = {
|
|
fieldName: 'content',
|
|
formItemClass: BLOG_ARTICLE_CONTENT_FIELD_CLASS,
|
|
label: '内容',
|
|
};
|
|
|
|
if (mode === 'html-source') {
|
|
return {
|
|
...common,
|
|
component: 'Textarea',
|
|
componentProps: {
|
|
autoSize: { maxRows: 30, minRows: 18 },
|
|
class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
|
|
placeholder: '保留 WordPress / Argon HTML 原文,保存时仅做安全清洗',
|
|
},
|
|
controlClass: 'w-full',
|
|
wrapperClass: 'items-stretch',
|
|
} as VbenFormSchema;
|
|
}
|
|
|
|
if (mode === 'html-rich') {
|
|
return {
|
|
...common,
|
|
component: richHtmlEditor as VbenFormSchema['component'],
|
|
componentProps: {
|
|
class: BLOG_ARTICLE_HTML_RICH_CLASS,
|
|
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
|
|
placeholder: '请输入 HTML 富文本正文',
|
|
},
|
|
controlClass: 'w-full',
|
|
wrapperClass: 'items-stretch',
|
|
} as VbenFormSchema;
|
|
}
|
|
|
|
return {
|
|
...common,
|
|
component: markdownEditor as VbenFormSchema['component'],
|
|
componentProps: {
|
|
minHeight: BLOG_ARTICLE_MARKDOWN_MIN_HEIGHT,
|
|
placeholder: '请输入 Markdown 正文',
|
|
},
|
|
controlClass: 'w-full',
|
|
wrapperClass: 'items-stretch',
|
|
} as VbenFormSchema;
|
|
}
|
|
|
|
/**
|
|
* Builds the editor mode selector schema and forwards mode changes to the article list page.
|
|
* @param onChange Callback that receives the next editor mode selected in the form.
|
|
* @returns Vben form schema for selecting the article editor mode.
|
|
*/
|
|
export function createBlogArticleEditorModeSchema(
|
|
onChange?: (mode: BlogArticleEditorMode) => void,
|
|
): VbenFormSchema {
|
|
return {
|
|
component: 'RadioGroup',
|
|
componentProps: {
|
|
buttonStyle: 'solid',
|
|
onChange: (event: { target?: { value?: BlogArticleEditorMode } }) => {
|
|
const mode = event?.target?.value;
|
|
if (mode) onChange?.(mode);
|
|
},
|
|
options: [
|
|
{ label: 'Markdown', value: 'markdown' },
|
|
{ label: '富文本 HTML', value: 'html-rich' },
|
|
{ label: '源码 HTML', value: 'html-source' },
|
|
],
|
|
optionType: 'button',
|
|
},
|
|
fieldName: 'editorMode',
|
|
label: '编辑模式',
|
|
} as VbenFormSchema;
|
|
}
|
|
|
|
/**
|
|
* Maps the UI editor mode back to the API's persisted content format contract.
|
|
* @param mode Current editor mode from the article form.
|
|
* @returns API content format saved with the article.
|
|
*/
|
|
export function getContentFormatForEditorMode(
|
|
mode: BlogArticleEditorMode,
|
|
): BlogArticleContentFormat {
|
|
return mode === 'markdown' ? 'markdown' : 'html';
|
|
}
|
|
|
|
/**
|
|
* Chooses the editor mode for an existing article without forcing Markdown source or imported Argon HTML into the wrong editor.
|
|
* @param article Article row returned by the local blog API.
|
|
* @returns Markdown for source-backed articles, source HTML for strong runtime DOM, rich HTML for plain tags.
|
|
*/
|
|
export function getBlogArticleEditorMode(
|
|
article?: Partial<WordpressBlogApi.Article>,
|
|
): BlogArticleEditorMode {
|
|
const html = getRenderedValue(article?.contentHtml || article?.content);
|
|
if (!html) return 'markdown';
|
|
if (SOURCE_ONLY_HTML_PATTERN.test(html)) return 'html-source';
|
|
if (hasMarkdownSource(article)) return 'markdown';
|
|
if (ARGON_BASE_CODEBLOCK_PATTERN.test(html)) return 'html-source';
|
|
return HTML_TAG_PATTERN.test(html) ? 'html-rich' : 'markdown';
|
|
}
|
|
|
|
/**
|
|
* Chooses the persisted content format for an existing article.
|
|
* @param article Article row returned by the local blog API.
|
|
* @returns API content format derived from the detected editor mode.
|
|
*/
|
|
export function getBlogArticleContentFormat(
|
|
article?: Partial<WordpressBlogApi.Article>,
|
|
): BlogArticleContentFormat {
|
|
return getContentFormatForEditorMode(getBlogArticleEditorMode(article));
|
|
}
|
|
|
|
/**
|
|
* Builds form defaults for creating a new article from current table filters.
|
|
* @param searchValues Active table search filters used to prefill category and tag fields.
|
|
* @param searchValues.categories Active category filters copied into the create form.
|
|
* @param searchValues.tags Active tag filters copied into the create form.
|
|
* @returns Article form defaults in Markdown mode.
|
|
*/
|
|
export function getBlogArticleCreateFormDefaults(
|
|
searchValues: {
|
|
categories?: string[];
|
|
tags?: string[];
|
|
} = {},
|
|
): BlogArticleFormValues {
|
|
return {
|
|
categories: [...(searchValues.categories || [])],
|
|
content: '',
|
|
contentFormat: 'markdown',
|
|
editorMode: 'markdown',
|
|
excerpt: '',
|
|
slug: '',
|
|
status: 'draft',
|
|
sticky: false,
|
|
tags: [...(searchValues.tags || [])],
|
|
title: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds edit form values, preserving WordPress/Argon HTML when the article depends on runtime classes.
|
|
* @param row Article row selected from the article table.
|
|
* @returns Form values and content format for the edit modal.
|
|
*/
|
|
export function getBlogArticleEditFormValues(
|
|
row: WordpressBlogApi.Article,
|
|
): BlogArticleFormValues {
|
|
const editorMode = getBlogArticleEditorMode(row);
|
|
const contentFormat = getContentFormatForEditorMode(editorMode);
|
|
|
|
return {
|
|
categories: row.categories || [],
|
|
content:
|
|
contentFormat === 'html'
|
|
? getRenderedValue(row.contentHtml || row.content)
|
|
: getEditableMarkdown(row.content, row.contentMarkdown),
|
|
contentFormat,
|
|
editorMode,
|
|
excerpt: getRenderedText(row.excerpt),
|
|
id: row.id,
|
|
slug: row.slug || '',
|
|
status: row.status || 'draft',
|
|
sticky: !!row.sticky,
|
|
tags: row.tags || [],
|
|
title: getRenderedText(row.title),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds the API payload while preserving the current content format selected for the modal.
|
|
* @param values Current form values from Vben Form.
|
|
* @param editingId Current article id, or undefined for create.
|
|
* @param editorMode Current editor mode.
|
|
* @returns Payload accepted by the blog article save/update API.
|
|
*/
|
|
export function buildBlogArticleSubmitPayload(
|
|
values: BlogArticleFormValues,
|
|
editingId: string | undefined,
|
|
editorMode: BlogArticleEditorMode,
|
|
): WordpressBlogApi.ArticleBody {
|
|
const { editorMode: _editorMode, ...payloadValues } = values;
|
|
return {
|
|
...payloadValues,
|
|
contentFormat: getContentFormatForEditorMode(editorMode),
|
|
id: editingId,
|
|
title: values.title?.trim() || '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converts WordPress rendered fields or strings into readable plain text.
|
|
* @param value Rendered field or string value.
|
|
* @returns HTML-free text.
|
|
*/
|
|
export function getRenderedText(
|
|
value?: string | WordpressBlogApi.RenderedField,
|
|
) {
|
|
return stripHtml(getRenderedValue(value));
|
|
}
|
|
|
|
/**
|
|
* Reads a rendered field without stripping HTML.
|
|
* @param value Rendered field or string value.
|
|
* @returns Raw/rendered string.
|
|
*/
|
|
function getRenderedValue(value?: string | WordpressBlogApi.RenderedField) {
|
|
if (!value) return '';
|
|
if (typeof value === 'string') return value;
|
|
return value.raw || value.rendered || '';
|
|
}
|
|
|
|
/**
|
|
* Detects whether the article still owns an editable Markdown source snapshot.
|
|
* @param article Article row returned by the local blog API.
|
|
* @returns true when Admin should prefer Milkdown unless strong runtime-only HTML is present.
|
|
*/
|
|
function hasMarkdownSource(article?: Partial<WordpressBlogApi.Article>) {
|
|
return !!article?.contentMarkdown?.trim();
|
|
}
|
|
|
|
/**
|
|
* Chooses editable Markdown from an API rendered field and its explicit Markdown source.
|
|
* @param value Rendered field or string value.
|
|
* @param markdown Explicit Markdown source saved on local articles.
|
|
* @returns Markdown text for the Milkdown editor.
|
|
*/
|
|
function getEditableMarkdown(
|
|
value?: string | WordpressBlogApi.RenderedField,
|
|
markdown?: string,
|
|
) {
|
|
if (markdown) return markdown;
|
|
return getRenderedValue(value);
|
|
}
|
|
|
|
/**
|
|
* Strips simple HTML tags from Admin labels and excerpts.
|
|
* @param value HTML or text value.
|
|
* @returns Trimmed text.
|
|
*/
|
|
function stripHtml(value: string) {
|
|
return value
|
|
.replaceAll(/<[^>]+>/g, '')
|
|
.replaceAll(' ', ' ')
|
|
.trim();
|
|
}
|