@fecommunity/reactpress 4.0.0-beta.14 → 4.0.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/admin-dist/assets/{admin-bamoE2Tn.js → admin-CF4LIXsB.js} +1 -1
  2. package/admin-dist/assets/{admin-CrL6Xc9b.js → admin-DYBZ60Ac.js} +1 -1
  3. package/admin-dist/assets/{cssMode-J2cfgFkq.js → cssMode-BJqbvgLG.js} +1 -1
  4. package/admin-dist/assets/{freemarker2-L_gr_Wq4.js → freemarker2-CYGOmtYQ.js} +1 -1
  5. package/admin-dist/assets/{handlebars-D81kzUDC.js → handlebars-qhE4jgvZ.js} +1 -1
  6. package/admin-dist/assets/{html-Cung-Yr0.js → html-U5gI8Ly8.js} +1 -1
  7. package/admin-dist/assets/{htmlMode-C6QemWl2.js → htmlMode-DQd5Eb2D.js} +1 -1
  8. package/admin-dist/assets/{index-DXTkTQ21.js → index-BKNoDtfb.js} +178 -178
  9. package/admin-dist/assets/{javascript-WUcGtCKt.js → javascript-DU3Ebk7L.js} +1 -1
  10. package/admin-dist/assets/{jsonMode-Dp0R9P1F.js → jsonMode-D1w8Vhjb.js} +1 -1
  11. package/admin-dist/assets/{liquid-OFb9c-6B.js → liquid-BPVbnl_K.js} +1 -1
  12. package/admin-dist/assets/{mdx-CUfjLxA3.js → mdx-CMSYE9vh.js} +1 -1
  13. package/admin-dist/assets/{python-zxEYY77U.js → python-Dbpms-Xp.js} +1 -1
  14. package/admin-dist/assets/{razor-DJrSr4ly.js → razor-CWQdYrqP.js} +1 -1
  15. package/admin-dist/assets/{tsMode-Dtxx1cRn.js → tsMode-DAsZhwU2.js} +1 -1
  16. package/admin-dist/assets/{typescript-DT11gl4K.js → typescript-7mrVr_47.js} +1 -1
  17. package/admin-dist/assets/{xml-BIbLvdwx.js → xml-DbPOrhNN.js} +1 -1
  18. package/admin-dist/assets/{yaml-BEeUZskE.js → yaml-6hDyKyrt.js} +1 -1
  19. package/admin-dist/index.html +1 -1
  20. package/out/bin/reactpress.js +4 -0
  21. package/out/core/services/local-site.js +68 -0
  22. package/out/lib/i18n/strings.js +2 -0
  23. package/out/lib/theme-api-proxy-patch.js +62 -17
  24. package/package.json +2 -1
  25. package/plugins/hello-world/README.md +104 -0
  26. package/plugins/hello-world/dist/config.d.ts +4 -0
  27. package/plugins/hello-world/dist/config.js +17 -0
  28. package/plugins/hello-world/dist/index.d.ts +10 -0
  29. package/plugins/hello-world/dist/index.js +15 -0
  30. package/plugins/hello-world/dist/summary.d.ts +3 -0
  31. package/plugins/hello-world/dist/summary.js +33 -0
  32. package/plugins/hello-world/dist/types.d.ts +16 -0
  33. package/plugins/hello-world/dist/types.js +2 -0
  34. package/plugins/hello-world/locales/en.json +24 -0
  35. package/plugins/hello-world/locales/zh.json +24 -0
  36. package/plugins/hello-world/package.json +20 -0
  37. package/plugins/hello-world/plugin.json +61 -0
  38. package/plugins/hello-world/tsconfig.json +16 -0
  39. package/plugins/image-optimizer/README.md +43 -0
  40. package/plugins/image-optimizer/admin/OptimizeDashboard.tsx +249 -0
  41. package/plugins/image-optimizer/admin/api.ts +133 -0
  42. package/plugins/image-optimizer/admin/index.ts +16 -0
  43. package/plugins/image-optimizer/admin/locale.ts +68 -0
  44. package/plugins/image-optimizer/admin/optimize-dashboard.module.css +80 -0
  45. package/plugins/image-optimizer/dist/config.d.ts +8 -0
  46. package/plugins/image-optimizer/dist/config.js +13 -0
  47. package/plugins/image-optimizer/dist/index.d.ts +10 -0
  48. package/plugins/image-optimizer/dist/index.js +13 -0
  49. package/plugins/image-optimizer/locales/en.json +51 -0
  50. package/plugins/image-optimizer/locales/zh.json +53 -0
  51. package/plugins/image-optimizer/package.json +20 -0
  52. package/plugins/image-optimizer/plugin.json +58 -0
  53. package/plugins/image-optimizer/tsconfig.json +16 -0
  54. package/plugins/package.json +14 -0
  55. package/plugins/seo/README.md +60 -0
  56. package/plugins/seo/admin/ArticleEditorSeoPanel.tsx +67 -0
  57. package/plugins/seo/admin/article-editor-seo-panel.module.css +74 -0
  58. package/plugins/seo/admin/index.ts +18 -0
  59. package/plugins/seo/dist/config.d.ts +4 -0
  60. package/plugins/seo/dist/config.js +19 -0
  61. package/plugins/seo/dist/index.d.ts +10 -0
  62. package/plugins/seo/dist/index.js +23 -0
  63. package/plugins/seo/dist/seo.d.ts +3 -0
  64. package/plugins/seo/dist/seo.js +79 -0
  65. package/plugins/seo/dist/slug.d.ts +2 -0
  66. package/plugins/seo/dist/slug.js +13 -0
  67. package/plugins/seo/dist/types.d.ts +23 -0
  68. package/plugins/seo/dist/types.js +2 -0
  69. package/plugins/seo/locales/en.json +32 -0
  70. package/plugins/seo/locales/zh.json +32 -0
  71. package/plugins/seo/package.json +20 -0
  72. package/plugins/seo/plugin.json +76 -0
  73. package/plugins/seo/tsconfig.json +16 -0
  74. package/server/package-lock.json +3 -3
  75. package/server/package.json +1 -1
  76. package/toolkit/dist/theme/build/next-config.js +4 -1
  77. package/toolkit/package.json +1 -1
@@ -0,0 +1,53 @@
1
+ {
2
+ "meta": {
3
+ "name": "图片资源优化",
4
+ "description": "分析历史图片素材并批量压缩为 WebP 多尺寸变体,可选回写文章与页面中的图片 URL。"
5
+ },
6
+ "settings": {
7
+ "batchSize": {
8
+ "title": "每批处理数量",
9
+ "description": "单次任务最多处理的图片数量(1–200)。"
10
+ },
11
+ "skipGif": {
12
+ "title": "跳过 GIF",
13
+ "description": "GIF 转 WebP 后会失去动画,默认跳过。"
14
+ },
15
+ "rewriteContent": {
16
+ "title": "优化后回写内容 URL",
17
+ "description": "批量优化完成后,自动替换文章/页面中引用的旧图片地址。"
18
+ },
19
+ "cleanupOriginals": {
20
+ "title": "优化后删除原文件",
21
+ "description": "确认无误后再开启;删除后无法通过旧 URL 访问原图。"
22
+ }
23
+ },
24
+ "dashboard": {
25
+ "title": "资源分析",
26
+ "analyze": "分析现状",
27
+ "dryRun": "试运行(不写入)",
28
+ "dryRunDone": "试运行完成(未写入数据库)",
29
+ "run": "开始批量优化",
30
+ "refresh": "刷新",
31
+ "total": "素材总数",
32
+ "optimized": "已优化",
33
+ "pending": "待优化",
34
+ "estimatedSaved": "预估可节省",
35
+ "contentRefs": "内容引用",
36
+ "articles": "文章",
37
+ "pages": "页面",
38
+ "skipped": "已跳过",
39
+ "jobRunning": "优化任务进行中…",
40
+ "jobScanning": "正在扫描待优化素材…",
41
+ "jobEmpty": "没有待优化的图片(可能已全部优化,或原文件已不存在)。",
42
+ "jobDone": "任务已完成",
43
+ "jobFailed": "任务失败",
44
+ "processed": "已处理",
45
+ "succeeded": "成功",
46
+ "failed": "失败",
47
+ "savedBytes": "已节省空间",
48
+ "inactiveHint": "请先在插件列表中启用本插件,才能执行分析与优化。",
49
+ "analyzeFailed": "分析失败",
50
+ "runFailed": "启动任务失败",
51
+ "jobPollFailed": "获取任务进度失败,请重试"
52
+ }
53
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@fecommunity/reactpress-plugin-image-optimizer",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Legacy image optimization plugin for ReactPress",
6
+ "license": "MIT",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "typecheck": "tsc --noEmit",
12
+ "clean": "rm -rf dist"
13
+ },
14
+ "dependencies": {
15
+ "@fecommunity/reactpress-toolkit": "workspace:*"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "~5.9.3"
19
+ }
20
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "../plugin.manifest.schema.json",
3
+ "id": "image-optimizer",
4
+ "name": "图片资源优化",
5
+ "version": "1.0.0",
6
+ "description": "分析历史图片素材并批量压缩为 WebP 多尺寸变体,可选回写文章与页面中的图片 URL。",
7
+ "author": "ReactPress",
8
+ "requires": ">=3.5.0",
9
+ "server": {
10
+ "module": "./dist/index.js"
11
+ },
12
+ "admin": {
13
+ "menu": {
14
+ "title": "图片优化",
15
+ "path": "/plugins/image-optimizer/settings",
16
+ "permission": "extension:manage",
17
+ "sort": 30
18
+ }
19
+ },
20
+ "settings": {
21
+ "schema": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "properties": {
25
+ "batchSize": {
26
+ "type": "integer",
27
+ "title": "每批处理数量",
28
+ "description": "单次任务最多处理的图片数量(1–200)。",
29
+ "minimum": 1,
30
+ "maximum": 200,
31
+ "default": 50
32
+ },
33
+ "skipGif": {
34
+ "type": "boolean",
35
+ "title": "跳过 GIF",
36
+ "description": "GIF 转 WebP 后会失去动画,默认跳过。",
37
+ "default": true
38
+ },
39
+ "rewriteContent": {
40
+ "type": "boolean",
41
+ "title": "优化后回写内容 URL",
42
+ "description": "批量优化完成后,自动替换文章/页面中引用的旧图片地址。",
43
+ "default": false
44
+ },
45
+ "cleanupOriginals": {
46
+ "type": "boolean",
47
+ "title": "优化后删除原文件",
48
+ "description": "确认无误后再开启;删除后无法通过旧 URL 访问原图。",
49
+ "default": false
50
+ }
51
+ }
52
+ }
53
+ },
54
+ "permissions": ["file:read", "file:write", "article:read"],
55
+ "capabilities": {
56
+ "headless": true
57
+ }
58
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "rootDir": "./src",
7
+ "outDir": "./dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "forceConsistentCasingInFileNames": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@fecommunity/reactpress-plugins",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "ReactPress plugin registry — local and npm plugin sources for the publishing platform",
6
+ "license": "MIT",
7
+ "scripts": {
8
+ "build": "pnpm --filter @fecommunity/reactpress-plugin-hello-world run build && pnpm --filter @fecommunity/reactpress-plugin-seo run build && pnpm --filter @fecommunity/reactpress-plugin-image-optimizer run build",
9
+ "typecheck": "pnpm --filter @fecommunity/reactpress-plugin-hello-world run typecheck && pnpm --filter @fecommunity/reactpress-plugin-seo run typecheck && pnpm --filter @fecommunity/reactpress-plugin-image-optimizer run typecheck"
10
+ },
11
+ "reactpress": {
12
+ "local": ["hello-world", "seo", "image-optimizer"]
13
+ }
14
+ }
@@ -0,0 +1,60 @@
1
+ # seo (SEO Enhancement)
2
+
3
+ Official plugin — **URL slug**, **SEO keywords**, and **meta description** with optional auto-fill and an Admin editor panel.
4
+
5
+ > WordPress-style SEO fields · Headless-friendly meta for Next.js themes
6
+
7
+ - **Plugin id**: `seo`
8
+ - **Version**: see [`plugin.json`](./plugin.json)
9
+
10
+ ## Features
11
+
12
+ | Field | Description |
13
+ | :--- | :--- |
14
+ | `slug` | URL alias, accessible at `/article/{slug}` |
15
+ | `seoKeywords` | meta keywords, comma-separated |
16
+ | `seoDescription` | meta description |
17
+
18
+ | Scenario | Behavior (when plugin enabled and option on) |
19
+ | :--- | :--- |
20
+ | Slug empty | Generate slug from title |
21
+ | SEO description empty | Prefer summary, else truncate body |
22
+ | SEO keywords empty | Use title + tag names |
23
+
24
+ ## Hooks
25
+
26
+ | Hook | Type | Description |
27
+ | :--- | :--- | :--- |
28
+ | `article.beforeCreate` | filter | Fill SEO fields on create |
29
+ | `article.beforePublish` | filter | Fill SEO fields on publish |
30
+
31
+ ## Configuration
32
+
33
+ | Field | Type | Default | Description |
34
+ | :--- | :--- | :--- | :--- |
35
+ | `enabled` | boolean | `true` | Master switch |
36
+ | `autoSlug` | boolean | `true` | Auto-generate slug |
37
+ | `autoDescription` | boolean | `true` | Auto-generate description |
38
+ | `autoKeywords` | boolean | `true` | Auto-generate keywords |
39
+ | `descriptionMaxLength` | number | `160` | Max description length |
40
+ | `descriptionSuffix` | string | `…` | Truncation suffix |
41
+
42
+ ## Usage
43
+
44
+ 1. `pnpm dev` (plugins are compiled automatically)
45
+ 2. Admin → **Plugins** → install **SEO Enhancement** → **Enable**
46
+ 3. Fill slug, keywords, and description in the **SEO Settings** panel (leave empty for auto-fill)
47
+ 4. After publish, check `<meta name="description">` and `<meta name="keywords">` on the theme article page
48
+
49
+ ## Admin UI
50
+
51
+ The **SEO Settings** panel in the article editor is injected via Admin slot `article.editor.meta.afterSummary` (see [`admin/`](./admin/)). The panel unmounts automatically when the plugin is deactivated.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ pnpm --filter @fecommunity/reactpress-plugin-seo run build
57
+ pnpm --filter @fecommunity/reactpress-plugin-seo run typecheck
58
+ ```
59
+
60
+ See [`../README.md`](../README.md) for the plugin system overview.
@@ -0,0 +1,67 @@
1
+ import type {
2
+ ArticleEditorAdminSlotContext,
3
+ PluginAdminSlotComponentProps,
4
+ } from "@fecommunity/reactpress-toolkit/plugin/admin";
5
+ import { useState } from "react";
6
+
7
+ import styles from "./article-editor-seo-panel.module.css";
8
+
9
+ type SeoPanelProps = PluginAdminSlotComponentProps<ArticleEditorAdminSlotContext>;
10
+
11
+ export function ArticleEditorSeoPanel({ context, config }: SeoPanelProps) {
12
+ if (config.enabled === false) return null;
13
+
14
+ const { draft, patch, translate: t } = context;
15
+ const [open, setOpen] = useState(true);
16
+
17
+ return (
18
+ <section className={styles.panel}>
19
+ <header className={styles.header} onClick={() => setOpen((value) => !value)}>
20
+ <span className={styles.title}>{t("article.seoTitle")}</span>
21
+ <span className={styles.toggle} aria-hidden>
22
+ {open ? "▲" : "▼"}
23
+ </span>
24
+ </header>
25
+ {open ? (
26
+ <div className={styles.body}>
27
+ <label className={styles.label} htmlFor="article-seo-slug">
28
+ {t("article.seoSlug")}
29
+ </label>
30
+ <input
31
+ id="article-seo-slug"
32
+ className={styles.input}
33
+ value={draft.slug}
34
+ placeholder={t("article.seoSlugPlaceholder")}
35
+ onChange={(e) => patch("slug", e.target.value)}
36
+ />
37
+ <p className={styles.hint}>{t("article.seoSlugHint")}</p>
38
+
39
+ <label className={styles.label} htmlFor="article-seo-keywords">
40
+ {t("article.seoKeywords")}
41
+ </label>
42
+ <input
43
+ id="article-seo-keywords"
44
+ className={styles.input}
45
+ value={draft.seoKeywords}
46
+ placeholder={t("article.seoKeywordsPlaceholder")}
47
+ onChange={(e) => patch("seoKeywords", e.target.value)}
48
+ />
49
+ <p className={styles.hint}>{t("article.seoKeywordsHint")}</p>
50
+
51
+ <label className={styles.label} htmlFor="article-seo-description">
52
+ {t("article.seoDescription")}
53
+ </label>
54
+ <textarea
55
+ id="article-seo-description"
56
+ className={styles.textarea}
57
+ value={draft.seoDescription}
58
+ rows={4}
59
+ placeholder={t("article.seoDescriptionPlaceholder")}
60
+ onChange={(e) => patch("seoDescription", e.target.value)}
61
+ />
62
+ <p className={styles.hint}>{t("article.seoDescriptionHint")}</p>
63
+ </div>
64
+ ) : null}
65
+ </section>
66
+ );
67
+ }
@@ -0,0 +1,74 @@
1
+ .panel {
2
+ border: 1px solid var(--editor-border, #dcdcde);
3
+ border-radius: 2px;
4
+ background: var(--editor-surface, #fff);
5
+ }
6
+
7
+ .header {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: space-between;
11
+ gap: 8px;
12
+ padding: 10px 12px;
13
+ cursor: pointer;
14
+ user-select: none;
15
+ }
16
+
17
+ .title {
18
+ font-size: 13px;
19
+ font-weight: 600;
20
+ color: var(--editor-text, #1d2327);
21
+ }
22
+
23
+ .toggle {
24
+ display: inline-flex;
25
+ color: var(--editor-text-secondary, #646970);
26
+ }
27
+
28
+ .body {
29
+ padding: 0 12px 12px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ gap: 4px;
33
+ }
34
+
35
+ .label {
36
+ display: block;
37
+ margin-top: 8px;
38
+ margin-bottom: 6px;
39
+ font-size: 13px;
40
+ font-weight: 600;
41
+ color: var(--editor-text, #1d2327);
42
+ }
43
+
44
+ .hint {
45
+ margin: 6px 0 0;
46
+ font-size: 12px;
47
+ color: var(--editor-text-secondary, #646970);
48
+ line-height: 1.5;
49
+ }
50
+
51
+ .input,
52
+ .textarea {
53
+ width: 100%;
54
+ box-sizing: border-box;
55
+ padding: 8px 12px;
56
+ font-size: 14px;
57
+ line-height: 1.5;
58
+ color: var(--editor-text, #1d2327);
59
+ background: var(--editor-surface, #fff);
60
+ border: 1px solid var(--editor-border-secondary, #dcdcde);
61
+ border-radius: 2px;
62
+ }
63
+
64
+ .input:focus,
65
+ .textarea:focus {
66
+ outline: none;
67
+ border-color: var(--editor-accent, #2271b1);
68
+ box-shadow: 0 0 0 1px var(--editor-accent, #2271b1);
69
+ }
70
+
71
+ .textarea {
72
+ min-height: 96px;
73
+ resize: vertical;
74
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ AdminSlotIds,
3
+ type PluginAdminModule,
4
+ type PluginAdminRegistry,
5
+ type PluginAdminContext,
6
+ } from "@fecommunity/reactpress-toolkit/plugin/admin";
7
+
8
+ import { ArticleEditorSeoPanel } from "./ArticleEditorSeoPanel";
9
+
10
+ export function registerAdmin(registry: PluginAdminRegistry, _ctx: PluginAdminContext): void {
11
+ registry.registerSlot(AdminSlotIds.ARTICLE_EDITOR_META_AFTER_SUMMARY, ArticleEditorSeoPanel, {
12
+ priority: 10,
13
+ });
14
+ }
15
+
16
+ const pluginAdmin: PluginAdminModule = { registerAdmin };
17
+
18
+ export default pluginAdmin;
@@ -0,0 +1,4 @@
1
+ import type { PluginContext } from '@fecommunity/reactpress-toolkit/plugin/server';
2
+ import type { SeoPluginConfig } from './types';
3
+ export declare const DEFAULT_CONFIG: SeoPluginConfig;
4
+ export declare function resolveConfig(ctx: PluginContext): SeoPluginConfig;
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_CONFIG = void 0;
4
+ exports.resolveConfig = resolveConfig;
5
+ exports.DEFAULT_CONFIG = {
6
+ enabled: true,
7
+ autoSlug: true,
8
+ autoDescription: true,
9
+ autoKeywords: true,
10
+ descriptionMaxLength: 160,
11
+ descriptionSuffix: '…',
12
+ };
13
+ function resolveConfig(ctx) {
14
+ const patch = ctx.config;
15
+ return {
16
+ ...exports.DEFAULT_CONFIG,
17
+ ...patch,
18
+ };
19
+ }
@@ -0,0 +1,10 @@
1
+ import type { HookService, PluginContext } from '@fecommunity/reactpress-toolkit/plugin/server';
2
+ /**
3
+ * SEO 增强:文章别名(slug)、关键词、meta 描述。
4
+ * 创建/发布前若字段为空,按插件配置自动补全。
5
+ */
6
+ export declare function register(hooks: HookService, ctx: PluginContext): void;
7
+ declare const _default: {
8
+ register: typeof register;
9
+ };
10
+ export default _default;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.register = register;
4
+ const config_1 = require("./config");
5
+ const seo_1 = require("./seo");
6
+ /**
7
+ * SEO 增强:文章别名(slug)、关键词、meta 描述。
8
+ * 创建/发布前若字段为空,按插件配置自动补全。
9
+ */
10
+ function register(hooks, ctx) {
11
+ const config = () => (0, config_1.resolveConfig)(ctx);
12
+ const handler = async (article) => (0, seo_1.applySeoDefaults)(article, config());
13
+ hooks.addFilter('article.beforeCreate', handler, {
14
+ priority: 20,
15
+ pluginId: ctx.id,
16
+ });
17
+ hooks.addFilter('article.beforePublish', handler, {
18
+ priority: 20,
19
+ pluginId: ctx.id,
20
+ });
21
+ ctx.logger.log(`registered (enabled=${config().enabled}, autoSlug=${config().autoSlug}, autoDescription=${config().autoDescription})`);
22
+ }
23
+ exports.default = { register };
@@ -0,0 +1,3 @@
1
+ import type { ArticleDraft, SeoPluginConfig } from './types';
2
+ /** 补全 slug、seoKeywords、seoDescription。 */
3
+ export declare function applySeoDefaults(article: ArticleDraft, config: SeoPluginConfig): ArticleDraft;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applySeoDefaults = applySeoDefaults;
4
+ const slug_1 = require("./slug");
5
+ function stripMarkdown(text) {
6
+ return String(text || '')
7
+ .replace(/```[\s\S]*?```/g, ' ')
8
+ .replace(/`[^`]+`/g, ' ')
9
+ .replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
10
+ .replace(/\[([^\]]*)]\([^)]+\)/g, '$1')
11
+ .replace(/^#{1,6}\s+/gm, '')
12
+ .replace(/^>\s?/gm, '')
13
+ .replace(/[*_~`]/g, '')
14
+ .replace(/\s+/g, ' ')
15
+ .trim();
16
+ }
17
+ function truncate(text, max, suffix) {
18
+ if (text.length <= max)
19
+ return text;
20
+ return text.slice(0, max) + suffix;
21
+ }
22
+ function collectTagLabels(tags) {
23
+ if (!tags)
24
+ return [];
25
+ const list = Array.isArray(tags) ? tags : String(tags).split(',');
26
+ const labels = [];
27
+ for (const item of list) {
28
+ if (typeof item === 'string') {
29
+ const label = item.trim();
30
+ if (label)
31
+ labels.push(label);
32
+ continue;
33
+ }
34
+ if (item && typeof item === 'object') {
35
+ const label = String(item.label ?? item.value ?? '').trim();
36
+ if (label)
37
+ labels.push(label);
38
+ }
39
+ }
40
+ return labels;
41
+ }
42
+ function buildKeywords(title, tags) {
43
+ const parts = [title.trim(), ...collectTagLabels(tags)].filter(Boolean);
44
+ const unique = [...new Set(parts.map((p) => p.replace(/\s+/g, ' ').trim()))];
45
+ return unique.join(', ');
46
+ }
47
+ function buildDescription(article, config) {
48
+ const summary = String(article.summary ?? '').trim();
49
+ if (summary)
50
+ return summary;
51
+ const content = stripMarkdown(String(article.content ?? article.html ?? ''));
52
+ const title = String(article.title ?? '').trim();
53
+ const source = content || title;
54
+ if (!source)
55
+ return '';
56
+ const max = Math.max(80, config.descriptionMaxLength);
57
+ return truncate(source, max, config.descriptionSuffix);
58
+ }
59
+ /** 补全 slug、seoKeywords、seoDescription。 */
60
+ function applySeoDefaults(article, config) {
61
+ if (!config.enabled)
62
+ return article;
63
+ const next = { ...article };
64
+ const title = String(article.title ?? '').trim();
65
+ if (config.autoSlug && !String(article.slug ?? '').trim() && title) {
66
+ next.slug = (0, slug_1.slugify)(title) || null;
67
+ }
68
+ if (config.autoDescription && !String(article.seoDescription ?? '').trim()) {
69
+ const description = buildDescription(article, config);
70
+ if (description)
71
+ next.seoDescription = description;
72
+ }
73
+ if (config.autoKeywords && !String(article.seoKeywords ?? '').trim() && title) {
74
+ const keywords = buildKeywords(title, article.tags);
75
+ if (keywords)
76
+ next.seoKeywords = keywords;
77
+ }
78
+ return next;
79
+ }
@@ -0,0 +1,2 @@
1
+ /** URL-friendly slug from title or custom alias. */
2
+ export declare function slugify(value: string): string;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.slugify = slugify;
4
+ /** URL-friendly slug from title or custom alias. */
5
+ function slugify(value) {
6
+ return String(value || '')
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/\s+/g, '-')
10
+ .replace(/[^\w\u4e00-\u9fff-]/g, '')
11
+ .replace(/-+/g, '-')
12
+ .replace(/^-|-$/g, '');
13
+ }
@@ -0,0 +1,23 @@
1
+ /** 插件 Hook 使用的文章字段子集。 */
2
+ export interface ArticleDraft {
3
+ title?: string;
4
+ summary?: string | null;
5
+ content?: string | null;
6
+ html?: string | null;
7
+ slug?: string | null;
8
+ seoKeywords?: string | null;
9
+ seoDescription?: string | null;
10
+ tags?: Array<{
11
+ label?: string;
12
+ value?: string;
13
+ } | string> | string;
14
+ [key: string]: unknown;
15
+ }
16
+ export interface SeoPluginConfig {
17
+ enabled: boolean;
18
+ autoSlug: boolean;
19
+ autoDescription: boolean;
20
+ autoKeywords: boolean;
21
+ descriptionMaxLength: number;
22
+ descriptionSuffix: string;
23
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,32 @@
1
+ {
2
+ "meta": {
3
+ "name": "SEO",
4
+ "description": "Per-article slug, keywords, and meta description with optional auto-fill on publish."
5
+ },
6
+ "settings": {
7
+ "enabled": {
8
+ "title": "Enable SEO auto-fill",
9
+ "description": "When off, slug, keywords, and description are not auto-generated on publish."
10
+ },
11
+ "autoSlug": {
12
+ "title": "Auto-generate URL slug",
13
+ "description": "Create a slug from the title when slug is empty."
14
+ },
15
+ "autoDescription": {
16
+ "title": "Auto-generate meta description",
17
+ "description": "Use summary first, otherwise excerpt from body when description is empty."
18
+ },
19
+ "autoKeywords": {
20
+ "title": "Auto-generate keywords",
21
+ "description": "Use title and tag names when keywords are empty."
22
+ },
23
+ "descriptionMaxLength": {
24
+ "title": "Description max length",
25
+ "description": "Max characters when excerpting from body (80–320)."
26
+ },
27
+ "descriptionSuffix": {
28
+ "title": "Description truncation suffix",
29
+ "description": "Appended when text exceeds max length, e.g. …"
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "meta": {
3
+ "name": "SEO 增强",
4
+ "description": "为文章提供 URL 别名、SEO 关键词与 meta 描述,发布时可自动补全。"
5
+ },
6
+ "settings": {
7
+ "enabled": {
8
+ "title": "启用 SEO 自动补全",
9
+ "description": "关闭后,发布时不再自动写入别名、关键词或描述。"
10
+ },
11
+ "autoSlug": {
12
+ "title": "自动生成 URL 别名",
13
+ "description": "别名为空时,从文章标题生成 slug。"
14
+ },
15
+ "autoDescription": {
16
+ "title": "自动生成 SEO 描述",
17
+ "description": "SEO 描述为空时,优先使用摘要,否则从正文截取。"
18
+ },
19
+ "autoKeywords": {
20
+ "title": "自动生成 SEO 关键词",
21
+ "description": "关键词为空时,使用文章标题与标签名称。"
22
+ },
23
+ "descriptionMaxLength": {
24
+ "title": "描述最大长度",
25
+ "description": "从正文截取 SEO 描述的最大字符数(80–320)。"
26
+ },
27
+ "descriptionSuffix": {
28
+ "title": "描述截断后缀",
29
+ "description": "超出最大长度时追加在末尾,例如 …"
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@fecommunity/reactpress-plugin-seo",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "SEO plugin for ReactPress — slug, keywords, meta description",
6
+ "license": "MIT",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "typecheck": "tsc --noEmit",
12
+ "clean": "rm -rf dist"
13
+ },
14
+ "dependencies": {
15
+ "@fecommunity/reactpress-toolkit": "workspace:*"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "~5.9.3"
19
+ }
20
+ }