diff --git a/apps/web-antdv-next/src/views/blog/article/list.scss b/apps/web-antdv-next/src/views/blog/article/list.scss new file mode 100644 index 0000000..73c3d87 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/article/list.scss @@ -0,0 +1,49 @@ +.blog-article-modal { + width: min(1080px, calc(100vw - 48px)) !important; + + &__content { + padding: 16px 20px 20px; + } +} + +.blog-article-form { + margin: 0; + + &__content { + align-items: flex-start !important; + + > label { + padding-top: 8px; + } + } + + &__content, + &__html-rich, + &__html-rich .kt-tiptap-editor, + &__content .kt-milkdown-editor, + &__content .ant-input, + &__html-source { + width: 100%; + } + + &__html-rich .kt-tiptap-editor, + &__content .kt-milkdown-editor, + &__html-source { + min-height: 560px; + } + + &__html-rich .kt-tiptap-editor { + align-self: stretch; + } + + &__html-source { + resize: vertical; + font-family: + "SFMono-Regular", + Consolas, + "Liberation Mono", + monospace; + font-size: 13px; + line-height: 1.65; + } +} diff --git a/apps/web-antdv-next/src/views/blog/article/list.spec.ts b/apps/web-antdv-next/src/views/blog/article/list.spec.ts new file mode 100644 index 0000000..e605080 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/article/list.spec.ts @@ -0,0 +1,30 @@ +/* @vitest-environment node */ + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('list.tsx', import.meta.url), 'utf8'); + +describe('blog article list preview entry', () => { + it('keeps the preview row action wired to the hidden iframe route', () => { + expect(source).toContain("key: 'preview'"); + expect(source).toContain("permissionCodes: ['Blog:Article:Preview']"); + expect(source).toContain("name: 'BlogArticlePreview'"); + expect(source).toContain('articleId: row.id'); + }); + + it('wires Tiptap editor mode switching without dropping current content', () => { + expect(source).toContain( + "import { KtTiptapHtmlEditor } from '#/components/richText';", + ); + expect(source).toContain('createBlogArticleEditorModeSchema'); + expect(source).toContain( + 'void setArticleEditorMode(mode, { preserveContent: true });', + ); + expect(source).toContain( + 'contentFormat: getContentFormatForEditorMode(mode)', + ); + expect(source).toContain('editorMode: mode'); + }); +}); diff --git a/apps/web-antdv-next/src/views/blog/article/list.tsx b/apps/web-antdv-next/src/views/blog/article/list.tsx index a92863e..878e0cb 100644 --- a/apps/web-antdv-next/src/views/blog/article/list.tsx +++ b/apps/web-antdv-next/src/views/blog/article/list.tsx @@ -1,5 +1,10 @@ import type { TableColumnType } from 'antdv-next'; +import type { + BlogArticleEditorMode, + BlogArticleFormValues, +} from '../modules/article-form'; + import type { WordpressBlogApi } from '#/api/blog'; import type { KtTableApi, @@ -9,6 +14,7 @@ import type { } from '#/components/ktTable'; import { computed, defineComponent, onActivated, onMounted, ref } from 'vue'; +import { useRouter } from 'vue-router'; import { Page, useVbenModal } from '@vben/common-ui'; import { Plus, SvgDownloadIcon } from '@vben/icons'; @@ -27,9 +33,24 @@ import { } from '#/api/blog'; import { KtTable, useKtTable } from '#/components/ktTable'; import { KtMilkdownEditor } from '#/components/markdown'; +import { KtTiptapHtmlEditor } from '#/components/richText'; +import { + BLOG_ARTICLE_FORM_CLASS, + BLOG_ARTICLE_MODAL_CLASS, + BLOG_ARTICLE_MODAL_CONTENT_CLASS, + buildBlogArticleSubmitPayload, + createBlogArticleContentSchema, + createBlogArticleEditorModeSchema, + getBlogArticleCreateFormDefaults, + getBlogArticleEditFormValues, + getContentFormatForEditorMode, + getRenderedText, +} from '../modules/article-form'; import { consumeBlogArticleFilters } from '../modules/use-article-filters'; +import './list.scss'; + type TermOption = { label: string; value: string; @@ -54,7 +75,9 @@ const articleStatusOptions = [ export default defineComponent({ name: 'BlogArticleList', setup() { + const router = useRouter(); const editingId = ref(); + const contentEditMode = ref('markdown'); const categoryOptions = ref([]); const tagOptions = ref([]); @@ -118,14 +141,13 @@ export default defineComponent({ fieldName: 'excerpt', label: '摘要', }, + createBlogArticleEditorModeSchema(handleArticleEditorModeChange), { - component: KtMilkdownEditor, - componentProps: { - minHeight: 460, - placeholder: '请输入 Markdown 正文', - }, - fieldName: 'content', - label: '内容', + ...createBlogArticleContentSchema( + 'markdown', + KtMilkdownEditor, + KtTiptapHtmlEditor, + ), }, { component: 'Switch', @@ -141,7 +163,8 @@ export default defineComponent({ editingId.value ? '编辑文章' : '新建文章', ); const [ArticleModal, articleModalApi] = useVbenModal({ - class: 'w-[760px]', + class: BLOG_ARTICLE_MODAL_CLASS, + contentClass: BLOG_ARTICLE_MODAL_CONTENT_CLASS, fullscreenButton: false, async onConfirm() { await submitArticle(); @@ -149,9 +172,9 @@ export default defineComponent({ onOpenChange(isOpen: boolean) { if (!isOpen) return; const { values } = articleModalApi.getData<{ - values?: WordpressBlogApi.ArticleBody; + values?: BlogArticleFormValues; }>(); - void resetArticleForm(values || getArticleFormDefaults()); + void resetArticleForm(values || getBlogArticleCreateFormDefaults()); }, }); const columns: Array> = [ @@ -205,6 +228,12 @@ export default defineComponent({ const rowActions: Array< KtTableRowAction > = [ + { + key: 'preview', + label: '预览', + onClick: openPreview, + permissionCodes: ['Blog:Article:Preview'], + }, { key: 'edit', label: '编辑', @@ -284,29 +313,6 @@ export default defineComponent({ ]; } - function getRenderedText(value?: string | WordpressBlogApi.RenderedField) { - if (!value) return ''; - if (typeof value === 'string') return stripHtml(value); - return stripHtml(value.raw || value.rendered || ''); - } - - function getEditableContent( - value?: string | WordpressBlogApi.RenderedField, - markdown?: string, - ) { - if (markdown) return markdown; - if (!value) return ''; - if (typeof value === 'string') return value; - return value.raw || value.rendered || ''; - } - - function stripHtml(value: string) { - return value - .replaceAll(/<[^>]+>/g, '') - .replaceAll(' ', ' ') - .trim(); - } - function getStatusOption(status?: string) { return ( articleStatusOptions.find((item) => item.value === status) || @@ -385,28 +391,66 @@ export default defineComponent({ }); } - function getArticleFormDefaults( - searchValues: ArticleSearchValues = {}, - ): WordpressBlogApi.ArticleBody { - return { - categories: [...(searchValues.categories || [])], - content: '', - contentFormat: 'markdown', - excerpt: '', - slug: '', - status: 'draft', - sticky: false, - tags: [...(searchValues.tags || [])], - title: '', - }; - } - - async function resetArticleForm(values: WordpressBlogApi.ArticleBody) { + /** + * Resets modal form state before applying create or edit values. + * @param values Article values selected for the current modal session. + */ + async function resetArticleForm(values: BlogArticleFormValues) { await articleFormApi.resetForm(); await articleFormApi.setValues(values); await articleFormApi.resetValidate(); } + /** + * Handles editor mode changes from the form control without clearing the current content draft. + * @param mode Editor mode selected by the user. + */ + function handleArticleEditorModeChange(mode: BlogArticleEditorMode) { + void setArticleEditorMode(mode, { preserveContent: true }); + } + + /** + * Switches the content field between Markdown, rich HTML, and raw WordPress HTML editing. + * @param mode Editor mode selected for the current modal session. + * @param options Whether the current content draft should be reapplied after schema replacement. + * @param options.preserveContent Reapplies the existing content value after the editor component changes. + */ + async function setArticleEditorMode( + mode: BlogArticleEditorMode, + options: { preserveContent?: boolean } = {}, + ) { + const currentValues = options.preserveContent + ? await articleFormApi.getValues() + : undefined; + + contentEditMode.value = mode; + await articleFormApi.updateSchema([ + createBlogArticleEditorModeSchema(handleArticleEditorModeChange), + createBlogArticleContentSchema( + mode, + KtMilkdownEditor, + KtTiptapHtmlEditor, + ), + ]); + + const nextValues: Partial = { + contentFormat: getContentFormatForEditorMode(mode), + editorMode: mode, + }; + if ( + options.preserveContent && + currentValues && + 'content' in currentValues + ) { + nextValues.content = currentValues.content; + } + await articleFormApi.setValues(nextValues); + } + + /** + * Opens the article modal in Markdown authoring mode with current table filters as defaults. + * @param context Optional table context supplied by a toolbar button. + */ async function openCreate( context?: KtTableContext, ) { @@ -415,37 +459,42 @@ export default defineComponent({ : await tableApi.getSearchValues(); editingId.value = undefined; - articleModalApi - .setData({ values: getArticleFormDefaults(searchValues) }) - .open(); + const values = getBlogArticleCreateFormDefaults(searchValues); + await setArticleEditorMode(values.editorMode || 'markdown'); + articleModalApi.setData({ values }).open(); } - function openEdit(row: WordpressBlogApi.Article) { + /** + * Opens the article modal using raw HTML mode for imported WordPress/Argon content. + * @param row Article row selected from the table. + */ + async function openEdit(row: WordpressBlogApi.Article) { editingId.value = `${row.id}`; - articleModalApi - .setData({ - values: { - categories: row.categories || [], - content: getEditableContent(row.content, row.contentMarkdown), - contentFormat: 'markdown', - excerpt: getRenderedText(row.excerpt), - id: row.id, - slug: row.slug || '', - status: row.status || 'draft', - sticky: !!row.sticky, - tags: row.tags || [], - title: getRenderedText(row.title), - }, - }) - .open(); + const values = getBlogArticleEditFormValues(row); + await setArticleEditorMode(values.editorMode || 'markdown'); + articleModalApi.setData({ values }).open(); } + /** + * @param row 文章列表当前行,用其数据库 ID 打开专用 Blog Web 预览页。 + */ + function openPreview(row: WordpressBlogApi.Article) { + void router.push({ + name: 'BlogArticlePreview', + params: { + articleId: row.id, + }, + }); + } + + /** + * Validates and submits the article form with the active content format preserved. + */ async function submitArticle() { const { valid } = await articleFormApi.validate(); if (!valid) return; - const values = - await articleFormApi.getValues(); + const values = await articleFormApi.getValues(); const title = values.title?.trim(); if (!title) { message.warning('请填写文章标题'); @@ -455,9 +504,11 @@ export default defineComponent({ articleModalApi.lock(); try { const payload = { - ...values, - contentFormat: 'markdown' as const, - id: editingId.value, + ...buildBlogArticleSubmitPayload( + values, + editingId.value, + contentEditMode.value, + ), title, }; await (editingId.value @@ -559,7 +610,7 @@ export default defineComponent({ /> - + ); diff --git a/apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts b/apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts new file mode 100644 index 0000000..4601a25 --- /dev/null +++ b/apps/web-antdv-next/src/views/blog/modules/article-form.spec.ts @@ -0,0 +1,138 @@ +/* @vitest-environment happy-dom */ + +import { describe, expect, it } from 'vitest'; + +import { + BLOG_ARTICLE_CONTENT_FIELD_CLASS, + BLOG_ARTICLE_FORM_CLASS, + BLOG_ARTICLE_HTML_RICH_CLASS, + BLOG_ARTICLE_HTML_TEXTAREA_CLASS, + buildBlogArticleSubmitPayload, + createBlogArticleContentSchema, + getBlogArticleCreateFormDefaults, + getBlogArticleEditFormValues, + getContentFormatForEditorMode, +} from './article-form'; + +describe('blog article form helpers', () => { + it('edits WordPress Argon HTML articles as raw HTML to preserve codeblocks', () => { + const values = getBlogArticleEditFormValues({ + categories: ['技术'], + contentHtml: + '
select 1;
', + contentMarkdown: '```sql\nselect 1;\n```', + excerpt: '摘要', + id: '50', + slug: 'wordpress-post', + status: 'publish', + tags: ['WordPress'], + title: 'WordPress 文章', + }); + + expect(values.editorMode).toBe('html-source'); + expect(values.contentFormat).toBe('html'); + expect(values.content).toContain('hljs-codeblock'); + }); + + it('edits plain HTML articles with the rich HTML editor by default', () => { + const values = getBlogArticleEditFormValues({ + categories: [], + contentHtml: '

标题

正文

', + excerpt: '', + id: '51', + slug: 'plain-html-post', + status: 'publish', + tags: [], + title: 'Plain HTML', + }); + + expect(values.editorMode).toBe('html-rich'); + expect(values.contentFormat).toBe('html'); + expect(values.content).toBe('

标题

正文

'); + }); + + it('keeps new local articles in markdown editor mode', () => { + const values = getBlogArticleCreateFormDefaults(); + + expect(values.editorMode).toBe('markdown'); + expect(values.contentFormat).toBe('markdown'); + }); + + it('keeps markdown as the default editing mode for new local articles', () => { + const payload = buildBlogArticleSubmitPayload( + { + content: '# 标题', + contentFormat: 'markdown', + editorMode: 'markdown', + status: 'draft', + title: '新文章', + }, + undefined, + 'markdown', + ); + + expect(payload).toMatchObject({ + content: '# 标题', + contentFormat: 'markdown', + title: '新文章', + }); + }); + + it('keeps html format in update payloads instead of forcing markdown', () => { + const payload = buildBlogArticleSubmitPayload( + { + content: '
',
+        contentFormat: 'markdown',
+        editorMode: 'html-source',
+        status: 'publish',
+        title: 'WordPress 文章',
+      },
+      '50',
+      'html-source',
+    );
+
+    expect(payload).toMatchObject({
+      contentFormat: 'html',
+      id: '50',
+    });
+    expect(payload).not.toHaveProperty('editorMode');
+  });
+
+  it('maps both html editor modes to the persisted html content format', () => {
+    expect(getContentFormatForEditorMode('html-rich')).toBe('html');
+    expect(getContentFormatForEditorMode('html-source')).toBe('html');
+    expect(getContentFormatForEditorMode('markdown')).toBe('markdown');
+  });
+
+  it('uses the rich HTML component and class for html-rich mode', () => {
+    const schema = createBlogArticleContentSchema(
+      'html-rich',
+      'MarkdownEditor',
+      'RichHtmlEditor',
+    );
+
+    expect(BLOG_ARTICLE_FORM_CLASS).toBe('blog-article-form');
+    expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
+    expect(schema.component).toBe('RichHtmlEditor');
+    expect(schema.componentProps).toMatchObject({
+      class: BLOG_ARTICLE_HTML_RICH_CLASS,
+      minHeight: 560,
+      placeholder: '请输入 HTML 富文本正文',
+    });
+  });
+
+  it('uses a full-width content form item and a tall raw html textarea', () => {
+    const schema = createBlogArticleContentSchema(
+      'html-source',
+      'MarkdownEditor',
+      'RichHtmlEditor',
+    );
+
+    expect(schema.formItemClass).toContain(BLOG_ARTICLE_CONTENT_FIELD_CLASS);
+    expect(schema.component).toBe('Textarea');
+    expect(schema.componentProps).toMatchObject({
+      autoSize: { maxRows: 30, minRows: 18 },
+      class: BLOG_ARTICLE_HTML_TEXTAREA_CLASS,
+    });
+  });
+});
diff --git a/apps/web-antdv-next/src/views/blog/modules/article-form.ts b/apps/web-antdv-next/src/views/blog/modules/article-form.ts
new file mode 100644
index 0000000..d98614d
--- /dev/null
+++ b/apps/web-antdv-next/src/views/blog/modules/article-form.ts
@@ -0,0 +1,268 @@
+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_HTML_PATTERN =
+  /\b(?:wp-block-|hljs-codeblock|hljs-ln|hljs-control|fancybox-wrapper|lazyload|collapse-block)/;
+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 imported Argon HTML through Tiptap or Markdown.
+ * @param article Article row returned by the local blog API.
+ * @returns Source HTML for Argon runtime snapshots, rich HTML for plain tags, otherwise Markdown.
+ */
+export function getBlogArticleEditorMode(
+  article?: Partial,
+): BlogArticleEditorMode {
+  const html = getRenderedValue(article?.contentHtml || article?.content);
+  if (!html) return 'markdown';
+  if (ARGON_HTML_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,
+): 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 || '';
+}
+
+/**
+ * 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();
+}