@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,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,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,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,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,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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../plugin.manifest.schema.json",
|
|
3
|
+
"id": "seo",
|
|
4
|
+
"name": "SEO 增强",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "为文章提供 URL 别名、SEO 关键词与 meta 描述,发布时可自动补全。",
|
|
7
|
+
"author": "ReactPress",
|
|
8
|
+
"requires": ">=3.5.0",
|
|
9
|
+
"server": {
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"hooks": {
|
|
12
|
+
"subscribe": ["article.beforeCreate", "article.beforePublish"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"admin": {
|
|
16
|
+
"slots": {
|
|
17
|
+
"subscribe": ["article.editor.meta.afterSummary"]
|
|
18
|
+
},
|
|
19
|
+
"menu": {
|
|
20
|
+
"title": "SEO 增强",
|
|
21
|
+
"path": "/plugins/seo/settings",
|
|
22
|
+
"permission": "extension:manage",
|
|
23
|
+
"sort": 20
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"settings": {
|
|
27
|
+
"schema": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"enabled": {
|
|
32
|
+
"type": "boolean",
|
|
33
|
+
"title": "启用 SEO 自动补全",
|
|
34
|
+
"description": "关闭后,发布时不再自动写入别名、关键词或描述。",
|
|
35
|
+
"default": true
|
|
36
|
+
},
|
|
37
|
+
"autoSlug": {
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"title": "自动生成 URL 别名",
|
|
40
|
+
"description": "别名为空时,从文章标题生成 slug。",
|
|
41
|
+
"default": true
|
|
42
|
+
},
|
|
43
|
+
"autoDescription": {
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"title": "自动生成 SEO 描述",
|
|
46
|
+
"description": "SEO 描述为空时,优先使用摘要,否则从正文截取。",
|
|
47
|
+
"default": true
|
|
48
|
+
},
|
|
49
|
+
"autoKeywords": {
|
|
50
|
+
"type": "boolean",
|
|
51
|
+
"title": "自动生成 SEO 关键词",
|
|
52
|
+
"description": "关键词为空时,使用文章标题与标签名称。",
|
|
53
|
+
"default": true
|
|
54
|
+
},
|
|
55
|
+
"descriptionMaxLength": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"title": "描述最大长度",
|
|
58
|
+
"description": "从正文截取 SEO 描述的最大字符数(80–320)。",
|
|
59
|
+
"minimum": 80,
|
|
60
|
+
"maximum": 320,
|
|
61
|
+
"default": 160
|
|
62
|
+
},
|
|
63
|
+
"descriptionSuffix": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"title": "描述截断后缀",
|
|
66
|
+
"description": "超出最大长度时追加在末尾,例如 …",
|
|
67
|
+
"default": "…"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"permissions": ["article:read"],
|
|
73
|
+
"capabilities": {
|
|
74
|
+
"headless": true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -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
|
+
}
|
package/server/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fecommunity/reactpress-server",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.16",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@fecommunity/reactpress-server",
|
|
9
|
-
"version": "4.0.0-beta.
|
|
9
|
+
"version": "4.0.0-beta.16",
|
|
10
10
|
"deprecated": "Bundled in @fecommunity/reactpress CLI from v3.1. Run `npx @fecommunity/reactpress init` for new projects.",
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
"../toolkit": {
|
|
93
93
|
"name": "@fecommunity/reactpress-toolkit",
|
|
94
|
-
"version": "4.0.0-beta.
|
|
94
|
+
"version": "4.0.0-beta.16",
|
|
95
95
|
"dependencies": {
|
|
96
96
|
"ajv": "^8.17.1",
|
|
97
97
|
"axios": "^1.12.2",
|
package/server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fecommunity/reactpress-server",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.17",
|
|
4
4
|
"deprecated": "Bundled in @fecommunity/reactpress CLI from v3.1. Run `npx @fecommunity/reactpress init` for new projects.",
|
|
5
5
|
"description": "ReactPress NestJS REST API (deprecated). Bundled in @fecommunity/reactpress CLI from v3.1. Headless CMS for articles, media, plugins, and themes",
|
|
6
6
|
"author": "fecommunity",
|