@fecommunity/reactpress 4.0.0-beta.15 → 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.
- package/admin-dist/assets/{admin-bamoE2Tn.js → admin-CF4LIXsB.js} +1 -1
- package/admin-dist/assets/{admin-CrL6Xc9b.js → admin-DYBZ60Ac.js} +1 -1
- package/admin-dist/assets/{cssMode-J2cfgFkq.js → cssMode-BJqbvgLG.js} +1 -1
- package/admin-dist/assets/{freemarker2-L_gr_Wq4.js → freemarker2-CYGOmtYQ.js} +1 -1
- package/admin-dist/assets/{handlebars-D81kzUDC.js → handlebars-qhE4jgvZ.js} +1 -1
- package/admin-dist/assets/{html-Cung-Yr0.js → html-U5gI8Ly8.js} +1 -1
- package/admin-dist/assets/{htmlMode-C6QemWl2.js → htmlMode-DQd5Eb2D.js} +1 -1
- package/admin-dist/assets/{index-DXTkTQ21.js → index-BKNoDtfb.js} +178 -178
- package/admin-dist/assets/{javascript-WUcGtCKt.js → javascript-DU3Ebk7L.js} +1 -1
- package/admin-dist/assets/{jsonMode-Dp0R9P1F.js → jsonMode-D1w8Vhjb.js} +1 -1
- package/admin-dist/assets/{liquid-OFb9c-6B.js → liquid-BPVbnl_K.js} +1 -1
- package/admin-dist/assets/{mdx-CUfjLxA3.js → mdx-CMSYE9vh.js} +1 -1
- package/admin-dist/assets/{python-zxEYY77U.js → python-Dbpms-Xp.js} +1 -1
- package/admin-dist/assets/{razor-DJrSr4ly.js → razor-CWQdYrqP.js} +1 -1
- package/admin-dist/assets/{tsMode-Dtxx1cRn.js → tsMode-DAsZhwU2.js} +1 -1
- package/admin-dist/assets/{typescript-DT11gl4K.js → typescript-7mrVr_47.js} +1 -1
- package/admin-dist/assets/{xml-BIbLvdwx.js → xml-DbPOrhNN.js} +1 -1
- package/admin-dist/assets/{yaml-BEeUZskE.js → yaml-6hDyKyrt.js} +1 -1
- package/admin-dist/index.html +1 -1
- package/out/bin/reactpress.js +4 -0
- package/out/core/services/local-site.js +68 -0
- package/out/lib/i18n/strings.js +2 -0
- package/package.json +2 -1
- package/plugins/hello-world/README.md +104 -0
- package/plugins/hello-world/dist/config.d.ts +4 -0
- package/plugins/hello-world/dist/config.js +17 -0
- package/plugins/hello-world/dist/index.d.ts +10 -0
- package/plugins/hello-world/dist/index.js +15 -0
- package/plugins/hello-world/dist/summary.d.ts +3 -0
- package/plugins/hello-world/dist/summary.js +33 -0
- package/plugins/hello-world/dist/types.d.ts +16 -0
- package/plugins/hello-world/dist/types.js +2 -0
- package/plugins/hello-world/locales/en.json +24 -0
- package/plugins/hello-world/locales/zh.json +24 -0
- package/plugins/hello-world/package.json +20 -0
- package/plugins/hello-world/plugin.json +61 -0
- package/plugins/hello-world/tsconfig.json +16 -0
- package/plugins/image-optimizer/README.md +43 -0
- package/plugins/image-optimizer/admin/OptimizeDashboard.tsx +249 -0
- package/plugins/image-optimizer/admin/api.ts +133 -0
- package/plugins/image-optimizer/admin/index.ts +16 -0
- package/plugins/image-optimizer/admin/locale.ts +68 -0
- package/plugins/image-optimizer/admin/optimize-dashboard.module.css +80 -0
- package/plugins/image-optimizer/dist/config.d.ts +8 -0
- package/plugins/image-optimizer/dist/config.js +13 -0
- package/plugins/image-optimizer/dist/index.d.ts +10 -0
- package/plugins/image-optimizer/dist/index.js +13 -0
- package/plugins/image-optimizer/locales/en.json +51 -0
- package/plugins/image-optimizer/locales/zh.json +53 -0
- package/plugins/image-optimizer/package.json +20 -0
- package/plugins/image-optimizer/plugin.json +58 -0
- package/plugins/image-optimizer/tsconfig.json +16 -0
- package/plugins/package.json +14 -0
- package/plugins/seo/README.md +60 -0
- package/plugins/seo/admin/ArticleEditorSeoPanel.tsx +67 -0
- package/plugins/seo/admin/article-editor-seo-panel.module.css +74 -0
- package/plugins/seo/admin/index.ts +18 -0
- package/plugins/seo/dist/config.d.ts +4 -0
- package/plugins/seo/dist/config.js +19 -0
- package/plugins/seo/dist/index.d.ts +10 -0
- package/plugins/seo/dist/index.js +23 -0
- package/plugins/seo/dist/seo.d.ts +3 -0
- package/plugins/seo/dist/seo.js +79 -0
- package/plugins/seo/dist/slug.d.ts +2 -0
- package/plugins/seo/dist/slug.js +13 -0
- package/plugins/seo/dist/types.d.ts +23 -0
- package/plugins/seo/dist/types.js +2 -0
- package/plugins/seo/locales/en.json +32 -0
- package/plugins/seo/locales/zh.json +32 -0
- package/plugins/seo/package.json +20 -0
- package/plugins/seo/plugin.json +76 -0
- package/plugins/seo/tsconfig.json +16 -0
- package/server/package-lock.json +3 -3
- package/server/package.json +1 -1
- package/toolkit/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# hello-world (Auto Summary)
|
|
2
|
+
|
|
3
|
+
Official example plugin — **auto-fill article summary on publish** when empty. Template for custom ReactPress plugins.
|
|
4
|
+
|
|
5
|
+
> Part of the ReactPress publishing platform · [plugin system docs](../README.md)
|
|
6
|
+
|
|
7
|
+
- **Plugin id**: `hello-world` (historical id, unchanged)
|
|
8
|
+
- **Display name**: Auto Summary
|
|
9
|
+
- **Version**: see [`plugin.json`](./plugin.json)
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
| Scenario | Behavior |
|
|
14
|
+
| :--- | :--- |
|
|
15
|
+
| Summary already set on publish | No change |
|
|
16
|
+
| Summary empty, body has content | Strip Markdown, truncate, write `summary` |
|
|
17
|
+
| Summary and body both empty | Use title if `fallbackToTitle` is enabled |
|
|
18
|
+
|
|
19
|
+
## Hooks
|
|
20
|
+
|
|
21
|
+
Subscribes to a single hook with a focused responsibility:
|
|
22
|
+
|
|
23
|
+
| Hook | Type | Description |
|
|
24
|
+
| :--- | :--- | :--- |
|
|
25
|
+
| `article.beforePublish` | filter | Fill in `summary` before publish |
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
Declared via `plugin.json` → `settings.schema`, stored in `globalSetting.plugins.entries.hello-world.config`.
|
|
30
|
+
|
|
31
|
+
Admin UI strings (plugin name, descriptions, field labels) live in [`locales/`](./locales/) (`locales/{locale}.json`), following the same convention as themes; Chinese strings in `plugin.json` serve as fallback.
|
|
32
|
+
|
|
33
|
+
| Field | Type | Default | Description |
|
|
34
|
+
| :--- | :--- | :--- | :--- |
|
|
35
|
+
| `enabled` | boolean | `true` | Enable auto summary |
|
|
36
|
+
| `maxLength` | number | `160` | Max length (40–500) |
|
|
37
|
+
| `suffix` | string | `…` | Suffix when truncated |
|
|
38
|
+
| `fallbackToTitle` | boolean | `true` | Use title when body is empty |
|
|
39
|
+
|
|
40
|
+
Update config (admin login required):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl -X PUT http://localhost:3002/api/extension/plugins/hello-world/config \
|
|
44
|
+
-H "Authorization: Bearer <token>" \
|
|
45
|
+
-H "Content-Type: application/json" \
|
|
46
|
+
-d '{"config":{"enabled":true,"maxLength":120}}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Config save triggers an automatic plugin reload — no server restart needed.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
1. `pnpm dev` (plugins are compiled automatically)
|
|
54
|
+
2. Admin → **Plugins** → install **hello-world** → **Enable**
|
|
55
|
+
3. Click **Settings** on the plugin row (or visit `/admin/plugins/hello-world/settings`)
|
|
56
|
+
4. Leave summary empty when writing → publish → summary field should be auto-filled
|
|
57
|
+
|
|
58
|
+
Example server logs:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
[PluginService] [hello-world] registered (enabled=true, maxLength=160)
|
|
62
|
+
[PluginLoaderService] Loaded plugin "hello-world" v1.2.0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Directory structure
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
hello-world/
|
|
69
|
+
├── plugin.json
|
|
70
|
+
├── package.json
|
|
71
|
+
├── tsconfig.json
|
|
72
|
+
├── README.md
|
|
73
|
+
└── src/
|
|
74
|
+
├── index.ts # register() entry
|
|
75
|
+
├── config.ts # defaults and merge
|
|
76
|
+
├── summary.ts # Markdown strip + truncate
|
|
77
|
+
└── types.ts
|
|
78
|
+
↓ tsc
|
|
79
|
+
dist/
|
|
80
|
+
└── index.js # runtime loads this file
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# from monorepo root
|
|
87
|
+
pnpm --filter @reactpress/plugin-hello-world run build
|
|
88
|
+
pnpm --filter @reactpress/plugin-hello-world run typecheck
|
|
89
|
+
|
|
90
|
+
# after editing src, rebuild and deactivate → reactivate in admin
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Dependencies:
|
|
94
|
+
|
|
95
|
+
- `@fecommunity/reactpress-toolkit/plugin/server` — `HookService`, `PluginContext` types
|
|
96
|
+
|
|
97
|
+
## Use as a new plugin template
|
|
98
|
+
|
|
99
|
+
1. Copy this directory to `plugins/my-plugin/`
|
|
100
|
+
2. Update `plugin.json` `id`, `name`, `hooks.subscribe`
|
|
101
|
+
3. Rewrite `src/index.ts` and business modules
|
|
102
|
+
4. Add the id to [`plugins/package.json`](../package.json) → `local` list
|
|
103
|
+
|
|
104
|
+
See [`../README.md`](../README.md) for the plugin system overview.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { PluginContext } from '@fecommunity/reactpress-toolkit/plugin/server';
|
|
2
|
+
import type { SummaryPluginConfig } from './types';
|
|
3
|
+
export declare const DEFAULT_CONFIG: SummaryPluginConfig;
|
|
4
|
+
export declare function resolveConfig(ctx: PluginContext): SummaryPluginConfig;
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
maxLength: 160,
|
|
8
|
+
suffix: '…',
|
|
9
|
+
fallbackToTitle: true,
|
|
10
|
+
};
|
|
11
|
+
function resolveConfig(ctx) {
|
|
12
|
+
const patch = ctx.config;
|
|
13
|
+
return {
|
|
14
|
+
...exports.DEFAULT_CONFIG,
|
|
15
|
+
...patch,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HookService, PluginContext } from '@fecommunity/reactpress-toolkit/plugin/server';
|
|
2
|
+
/**
|
|
3
|
+
* hello-world:自动摘要插件。
|
|
4
|
+
* 发布前若 summary 为空,从 Markdown 正文(或标题)生成摘要。
|
|
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,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.register = register;
|
|
4
|
+
const config_1 = require("./config");
|
|
5
|
+
const summary_1 = require("./summary");
|
|
6
|
+
/**
|
|
7
|
+
* hello-world:自动摘要插件。
|
|
8
|
+
* 发布前若 summary 为空,从 Markdown 正文(或标题)生成摘要。
|
|
9
|
+
*/
|
|
10
|
+
function register(hooks, ctx) {
|
|
11
|
+
const config = () => (0, config_1.resolveConfig)(ctx);
|
|
12
|
+
hooks.addFilter('article.beforePublish', async (article) => (0, summary_1.applyAutoSummary)(article, config()), { priority: 10, pluginId: ctx.id });
|
|
13
|
+
ctx.logger.log(`registered (enabled=${config().enabled}, maxLength=${config().maxLength})`);
|
|
14
|
+
}
|
|
15
|
+
exports.default = { register };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyAutoSummary = applyAutoSummary;
|
|
4
|
+
function stripMarkdown(text) {
|
|
5
|
+
return String(text || '')
|
|
6
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
7
|
+
.replace(/`[^`]+`/g, ' ')
|
|
8
|
+
.replace(/!\[[^\]]*]\([^)]+\)/g, ' ')
|
|
9
|
+
.replace(/\[([^\]]*)]\([^)]+\)/g, '$1')
|
|
10
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
11
|
+
.replace(/^>\s?/gm, '')
|
|
12
|
+
.replace(/[*_~`]/g, '')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
/** 摘要为空时从正文(或标题)生成 summary。 */
|
|
17
|
+
function applyAutoSummary(article, config) {
|
|
18
|
+
if (!config.enabled)
|
|
19
|
+
return article;
|
|
20
|
+
const existing = String(article.summary ?? '').trim();
|
|
21
|
+
if (existing)
|
|
22
|
+
return article;
|
|
23
|
+
const content = stripMarkdown(String(article.content ?? article.html ?? ''));
|
|
24
|
+
const title = String(article.title ?? '').trim();
|
|
25
|
+
const source = content || (config.fallbackToTitle ? title : '');
|
|
26
|
+
if (!source)
|
|
27
|
+
return article;
|
|
28
|
+
const max = Math.max(40, config.maxLength);
|
|
29
|
+
let summary = source.slice(0, max);
|
|
30
|
+
if (source.length > max)
|
|
31
|
+
summary += config.suffix;
|
|
32
|
+
return { ...article, summary };
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** 插件 Hook 使用的文章字段子集。 */
|
|
2
|
+
export interface ArticleDraft {
|
|
3
|
+
title?: string;
|
|
4
|
+
summary?: string | null;
|
|
5
|
+
content?: string | null;
|
|
6
|
+
html?: string | null;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface SummaryPluginConfig {
|
|
10
|
+
/** 发布时摘要为空则自动生成 */
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
maxLength: number;
|
|
13
|
+
suffix: string;
|
|
14
|
+
/** 正文为空时是否回退到标题 */
|
|
15
|
+
fallbackToTitle: boolean;
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"name": "Auto Summary",
|
|
4
|
+
"description": "When publishing, if the summary is empty, generate one from the body or title."
|
|
5
|
+
},
|
|
6
|
+
"settings": {
|
|
7
|
+
"enabled": {
|
|
8
|
+
"title": "Enable auto summary",
|
|
9
|
+
"description": "When disabled, summary is not auto-filled on publish."
|
|
10
|
+
},
|
|
11
|
+
"maxLength": {
|
|
12
|
+
"title": "Maximum length",
|
|
13
|
+
"description": "Max characters taken from the body (40–500)."
|
|
14
|
+
},
|
|
15
|
+
"suffix": {
|
|
16
|
+
"title": "Truncation suffix",
|
|
17
|
+
"description": "Appended when content exceeds max length, e.g. …"
|
|
18
|
+
},
|
|
19
|
+
"fallbackToTitle": {
|
|
20
|
+
"title": "Use title when body is empty",
|
|
21
|
+
"description": "When stripped Markdown body is empty, use the article title as summary."
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"name": "自动摘要",
|
|
4
|
+
"description": "发布文章时,若摘要为空,从正文或标题自动生成 summary。"
|
|
5
|
+
},
|
|
6
|
+
"settings": {
|
|
7
|
+
"enabled": {
|
|
8
|
+
"title": "启用自动摘要",
|
|
9
|
+
"description": "关闭后,发布时不再自动写入 summary。"
|
|
10
|
+
},
|
|
11
|
+
"maxLength": {
|
|
12
|
+
"title": "摘要最大长度",
|
|
13
|
+
"description": "从正文截取的最大字符数(40–500)。"
|
|
14
|
+
},
|
|
15
|
+
"suffix": {
|
|
16
|
+
"title": "截断后缀",
|
|
17
|
+
"description": "超出最大长度时追加在末尾,例如 …"
|
|
18
|
+
},
|
|
19
|
+
"fallbackToTitle": {
|
|
20
|
+
"title": "正文为空时使用标题",
|
|
21
|
+
"description": "Markdown 正文去格式后为空时,用文章标题作为摘要。"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fecommunity/reactpress-plugin-hello-world",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Auto summary 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,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../plugin.manifest.schema.json",
|
|
3
|
+
"id": "hello-world",
|
|
4
|
+
"name": "自动摘要",
|
|
5
|
+
"version": "1.2.0",
|
|
6
|
+
"description": "发布文章时,若摘要为空,从正文或标题自动生成 summary。",
|
|
7
|
+
"author": "ReactPress",
|
|
8
|
+
"requires": ">=3.5.0",
|
|
9
|
+
"server": {
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"hooks": {
|
|
12
|
+
"subscribe": ["article.beforePublish"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"admin": {
|
|
16
|
+
"menu": {
|
|
17
|
+
"title": "自动摘要",
|
|
18
|
+
"path": "/plugins/hello-world/settings",
|
|
19
|
+
"permission": "extension:manage",
|
|
20
|
+
"sort": 10
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"settings": {
|
|
24
|
+
"schema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"properties": {
|
|
28
|
+
"enabled": {
|
|
29
|
+
"type": "boolean",
|
|
30
|
+
"title": "启用自动摘要",
|
|
31
|
+
"description": "关闭后,发布时不再自动写入 summary。",
|
|
32
|
+
"default": true
|
|
33
|
+
},
|
|
34
|
+
"maxLength": {
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"title": "摘要最大长度",
|
|
37
|
+
"description": "从正文截取的最大字符数(40–500)。",
|
|
38
|
+
"minimum": 40,
|
|
39
|
+
"maximum": 500,
|
|
40
|
+
"default": 160
|
|
41
|
+
},
|
|
42
|
+
"suffix": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"title": "截断后缀",
|
|
45
|
+
"description": "超出最大长度时追加在末尾,例如 …",
|
|
46
|
+
"default": "…"
|
|
47
|
+
},
|
|
48
|
+
"fallbackToTitle": {
|
|
49
|
+
"type": "boolean",
|
|
50
|
+
"title": "正文为空时使用标题",
|
|
51
|
+
"description": "Markdown 正文去格式后为空时,用文章标题作为摘要。",
|
|
52
|
+
"default": true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"permissions": ["article:read"],
|
|
58
|
+
"capabilities": {
|
|
59
|
+
"headless": true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -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,43 @@
|
|
|
1
|
+
# image-optimizer (Image Asset Optimization)
|
|
2
|
+
|
|
3
|
+
Official plugin — **analyze legacy uploads and batch-compress to WebP multi-size variants**, with optional content URL rewrite.
|
|
4
|
+
|
|
5
|
+
> Improve Lighthouse and page weight without leaving Admin
|
|
6
|
+
|
|
7
|
+
- **Plugin id**: `image-optimizer`
|
|
8
|
+
- **Version**: see [`plugin.json`](./plugin.json)
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
| Capability | Description |
|
|
13
|
+
| :--- | :--- |
|
|
14
|
+
| Batch optimization | Process historical uploads via Core `FileOptimizationService` |
|
|
15
|
+
| WebP variants | Generate compressed multi-size WebP outputs |
|
|
16
|
+
| URL rewrite | Optionally replace old image URLs in article/page content after batch run |
|
|
17
|
+
| Admin dashboard | Full UI at **Plugins → Image Optimization** |
|
|
18
|
+
|
|
19
|
+
Batch compression is executed by the core file optimization service; this plugin registers configuration and exposes the admin dashboard (`admin/OptimizeDashboard`).
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
| Field | Type | Default | Description |
|
|
24
|
+
| :--- | :--- | :--- | :--- |
|
|
25
|
+
| `batchSize` | integer | `50` | Max images per batch (1–200) |
|
|
26
|
+
| `skipGif` | boolean | `true` | Skip GIF (WebP conversion loses animation) |
|
|
27
|
+
| `rewriteContent` | boolean | `false` | Rewrite article/page image URLs after optimization |
|
|
28
|
+
| `cleanupOriginals` | boolean | `false` | Delete original files after optimization (irreversible) |
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
1. `pnpm dev` (plugins are compiled automatically)
|
|
33
|
+
2. Admin → **Plugins** → install **Image Optimization** → **Enable**
|
|
34
|
+
3. Open **Plugins → Image Optimization** to run batch jobs and review results
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pnpm --filter @fecommunity/reactpress-plugin-image-optimizer run build
|
|
40
|
+
pnpm --filter @fecommunity/reactpress-plugin-image-optimizer run typecheck
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
See [`../README.md`](../README.md) for the plugin system overview.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { PluginSettingsPanelProps } from "@fecommunity/reactpress-toolkit/plugin/admin";
|
|
2
|
+
import { App, Button, Progress, Spin } from "antd";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
fetchOptimizeJob,
|
|
7
|
+
fetchOptimizeReport,
|
|
8
|
+
readApiErrorMessage,
|
|
9
|
+
startOptimizeJob,
|
|
10
|
+
type ImageOptimizeReport,
|
|
11
|
+
type OptimizeJob,
|
|
12
|
+
} from "./api";
|
|
13
|
+
import { usePluginDashboardText } from "./locale";
|
|
14
|
+
|
|
15
|
+
import styles from "./optimize-dashboard.module.css";
|
|
16
|
+
|
|
17
|
+
function formatBytes(bytes: number): string {
|
|
18
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
19
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
20
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveConfig(config: Record<string, unknown>) {
|
|
24
|
+
const batchSize = Number(config.batchSize);
|
|
25
|
+
return {
|
|
26
|
+
batchSize: Number.isFinite(batchSize) ? Math.min(Math.max(batchSize, 1), 200) : 50,
|
|
27
|
+
skipGif: config.skipGif !== false,
|
|
28
|
+
rewriteContent: config.rewriteContent === true,
|
|
29
|
+
cleanupOriginals: config.cleanupOriginals === true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function OptimizeDashboard({ pluginId, pluginActive, config }: PluginSettingsPanelProps) {
|
|
34
|
+
const { message } = App.useApp();
|
|
35
|
+
const t = usePluginDashboardText(pluginId);
|
|
36
|
+
|
|
37
|
+
const [report, setReport] = useState<ImageOptimizeReport | null>(null);
|
|
38
|
+
const [job, setJob] = useState<OptimizeJob | null>(null);
|
|
39
|
+
const [loadingReport, setLoadingReport] = useState(false);
|
|
40
|
+
const [startingJob, setStartingJob] = useState(false);
|
|
41
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
42
|
+
|
|
43
|
+
const stopPolling = useCallback(() => {
|
|
44
|
+
if (pollRef.current) {
|
|
45
|
+
clearInterval(pollRef.current);
|
|
46
|
+
pollRef.current = null;
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const loadReport = useCallback(async () => {
|
|
51
|
+
setLoadingReport(true);
|
|
52
|
+
try {
|
|
53
|
+
const data = await fetchOptimizeReport();
|
|
54
|
+
setReport(data);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
message.error(readApiErrorMessage(err, t("analyzeFailed", "分析失败")));
|
|
57
|
+
} finally {
|
|
58
|
+
setLoadingReport(false);
|
|
59
|
+
}
|
|
60
|
+
}, [message, t]);
|
|
61
|
+
|
|
62
|
+
const pollJob = useCallback(
|
|
63
|
+
(jobId: string) => {
|
|
64
|
+
stopPolling();
|
|
65
|
+
|
|
66
|
+
const refresh = () => {
|
|
67
|
+
void fetchOptimizeJob(jobId)
|
|
68
|
+
.then((next) => {
|
|
69
|
+
setJob(next);
|
|
70
|
+
if (next.status === "completed" || next.status === "failed") {
|
|
71
|
+
stopPolling();
|
|
72
|
+
void loadReport();
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.catch((err) => {
|
|
76
|
+
stopPolling();
|
|
77
|
+
message.error(readApiErrorMessage(err, t("jobPollFailed", "获取任务进度失败")));
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
refresh();
|
|
82
|
+
pollRef.current = setInterval(refresh, 800);
|
|
83
|
+
},
|
|
84
|
+
[loadReport, message, stopPolling, t],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (pluginActive) {
|
|
89
|
+
void loadReport();
|
|
90
|
+
}
|
|
91
|
+
}, [loadReport, pluginActive]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => () => stopPolling(), [stopPolling]);
|
|
94
|
+
|
|
95
|
+
const runJob = async (dryRun: boolean) => {
|
|
96
|
+
if (!pluginActive) return;
|
|
97
|
+
const opts = resolveConfig(config);
|
|
98
|
+
setStartingJob(true);
|
|
99
|
+
try {
|
|
100
|
+
const started = await startOptimizeJob({
|
|
101
|
+
dryRun,
|
|
102
|
+
batchSize: opts.batchSize,
|
|
103
|
+
limit: opts.batchSize,
|
|
104
|
+
skipGif: opts.skipGif,
|
|
105
|
+
rewriteContent: opts.rewriteContent,
|
|
106
|
+
cleanupOriginals: opts.cleanupOriginals,
|
|
107
|
+
});
|
|
108
|
+
setJob(started);
|
|
109
|
+
if (started.status === "completed" || started.status === "failed") {
|
|
110
|
+
void loadReport();
|
|
111
|
+
if (started.total === 0) {
|
|
112
|
+
message.info(t("jobEmpty", "没有待优化的图片(可能已全部优化,或原文件已不存在)。"));
|
|
113
|
+
} else if (dryRun) {
|
|
114
|
+
message.success(t("dryRunDone", "试运行完成(未写入数据库)。"));
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
pollJob(started.id);
|
|
118
|
+
if (dryRun) {
|
|
119
|
+
message.info(t("dryRun", "试运行(不写入)"));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
message.error(readApiErrorMessage(err, t("runFailed", "启动任务失败")));
|
|
124
|
+
} finally {
|
|
125
|
+
setStartingJob(false);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (!pluginActive) {
|
|
130
|
+
return (
|
|
131
|
+
<div className={styles.warn} data-testid="image-optimizer-inactive">
|
|
132
|
+
{t("inactiveHint", "请先在插件列表中启用本插件,才能执行分析与优化。")}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const jobRunning = job?.status === "pending" || job?.status === "running";
|
|
138
|
+
const jobDone = job?.status === "completed" || job?.status === "failed";
|
|
139
|
+
const jobPercent =
|
|
140
|
+
job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : undefined;
|
|
141
|
+
const failedItems = job?.items.filter((item) => item.status === "failed").slice(0, 5) ?? [];
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={styles.optimizeDashboard} data-testid="image-optimizer-dashboard">
|
|
145
|
+
<h2 className={styles.sectionTitle}>{t("title", "资源分析")}</h2>
|
|
146
|
+
|
|
147
|
+
<div className={styles.actions}>
|
|
148
|
+
<Button loading={loadingReport} onClick={() => void loadReport()}>
|
|
149
|
+
{t("analyze", "分析现状")}
|
|
150
|
+
</Button>
|
|
151
|
+
<Button loading={startingJob} onClick={() => void runJob(true)}>
|
|
152
|
+
{t("dryRun", "试运行(不写入)")}
|
|
153
|
+
</Button>
|
|
154
|
+
<Button type="primary" loading={startingJob || jobRunning} onClick={() => void runJob(false)}>
|
|
155
|
+
{t("run", "开始批量优化")}
|
|
156
|
+
</Button>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{loadingReport && !report ? (
|
|
160
|
+
<Spin />
|
|
161
|
+
) : report ? (
|
|
162
|
+
<div className={styles.statsGrid}>
|
|
163
|
+
<div className={styles.statCard}>
|
|
164
|
+
<p className={styles.statLabel}>{t("total", "素材总数")}</p>
|
|
165
|
+
<p className={styles.statValue}>{report.total}</p>
|
|
166
|
+
</div>
|
|
167
|
+
<div className={styles.statCard}>
|
|
168
|
+
<p className={styles.statLabel}>{t("optimized", "已优化")}</p>
|
|
169
|
+
<p className={styles.statValue}>{report.alreadyOptimized}</p>
|
|
170
|
+
</div>
|
|
171
|
+
<div className={styles.statCard}>
|
|
172
|
+
<p className={styles.statLabel}>{t("pending", "待优化")}</p>
|
|
173
|
+
<p className={styles.statValue}>{report.needsOptimization}</p>
|
|
174
|
+
</div>
|
|
175
|
+
<div className={styles.statCard}>
|
|
176
|
+
<p className={styles.statLabel}>{t("estimatedSaved", "预估可节省")}</p>
|
|
177
|
+
<p className={styles.statValue}>{formatBytes(report.storageBytes.estimatedSaved)}</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div className={styles.statCard}>
|
|
180
|
+
<p className={styles.statLabel}>{t("contentRefs", "内容引用")}</p>
|
|
181
|
+
<p className={styles.statValue}>
|
|
182
|
+
{report.contentRefs.articles} {t("articles", "文章")} / {report.contentRefs.pages}{" "}
|
|
183
|
+
{t("pages", "页面")}
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
<div className={styles.statCard}>
|
|
187
|
+
<p className={styles.statLabel}>{t("skipped", "已跳过")}</p>
|
|
188
|
+
<p className={styles.statValue}>
|
|
189
|
+
{report.skipped.nonImage + report.skipped.svg + report.skipped.gif + report.skipped.missing}
|
|
190
|
+
</p>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
) : null}
|
|
194
|
+
|
|
195
|
+
{job ? (
|
|
196
|
+
<div className={styles.progressBlock}>
|
|
197
|
+
<Progress
|
|
198
|
+
percent={jobPercent}
|
|
199
|
+
status={
|
|
200
|
+
job.status === "failed"
|
|
201
|
+
? "exception"
|
|
202
|
+
: jobRunning
|
|
203
|
+
? "active"
|
|
204
|
+
: job.total === 0
|
|
205
|
+
? "normal"
|
|
206
|
+
: "success"
|
|
207
|
+
}
|
|
208
|
+
showInfo={job.total > 0}
|
|
209
|
+
/>
|
|
210
|
+
<p className={styles.hint}>
|
|
211
|
+
{job.status === "failed"
|
|
212
|
+
? `${t("jobFailed", "任务失败")}: ${job.error ?? ""}`
|
|
213
|
+
: jobRunning
|
|
214
|
+
? job.total === 0
|
|
215
|
+
? t("jobScanning", "正在扫描待优化素材…")
|
|
216
|
+
: t("jobRunning", "优化任务进行中…")
|
|
217
|
+
: t("jobDone", "任务已完成")}
|
|
218
|
+
{" · "}
|
|
219
|
+
{t("processed", "已处理")} {job.processed}/{job.total} · {t("succeeded", "成功")}{" "}
|
|
220
|
+
{job.succeeded} · {t("failed", "失败")} {job.failed}
|
|
221
|
+
{job.savedBytes > 0 ? ` · ${t("savedBytes", "已节省空间")} ${formatBytes(job.savedBytes)}` : ""}
|
|
222
|
+
</p>
|
|
223
|
+
{jobDone && job.total === 0 ? (
|
|
224
|
+
<p className={styles.emptyJob}>
|
|
225
|
+
{t("jobEmpty", "没有待优化的图片(可能已全部优化,或原文件已不存在)。")}
|
|
226
|
+
</p>
|
|
227
|
+
) : null}
|
|
228
|
+
{job.contentRewrite ? (
|
|
229
|
+
<p className={styles.hint}>
|
|
230
|
+
URL 回写:{job.contentRewrite.articles} {t("articles", "文章")}、{job.contentRewrite.pages}{" "}
|
|
231
|
+
{t("pages", "页面")}、{job.contentRewrite.revisions} 修订
|
|
232
|
+
</p>
|
|
233
|
+
) : null}
|
|
234
|
+
{failedItems.length > 0 ? (
|
|
235
|
+
<ul className={styles.errorList}>
|
|
236
|
+
{failedItems.map((item) => (
|
|
237
|
+
<li key={item.fileId}>
|
|
238
|
+
{item.originalname}: {item.error}
|
|
239
|
+
</li>
|
|
240
|
+
))}
|
|
241
|
+
</ul>
|
|
242
|
+
) : null}
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { OptimizeDashboard as SettingsPanel };
|