dsh-plugin 1.1.0 → 1.1.1
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/README.md +3 -3
- package/client/client.js +547 -178
- package/client/client.js.map +1 -1
- package/lib/services/loader.js +12 -5
- package/lib/services/task-queue.js +8 -3
- package/lib/types/services/install-types.d.ts +2 -0
- package/lib/types/services/loader.d.ts +6 -4
- package/package.json +1 -1
- package/src/client/components/AboutModal.tsx +72 -0
- package/src/client/components/CatalogHeader.tsx +30 -5
- package/src/client/components/HubUpdateModal.tsx +84 -0
- package/src/client/components/NotificationsModal.tsx +21 -8
- package/src/client/components/PluginCard.tsx +3 -0
- package/src/client/components/PluginHubSection.tsx +62 -6
- package/src/client/components/modals.tsx +31 -10
- package/src/client/hooks/useCatalog.ts +82 -14
- package/src/client/hooks/useTaskQueue.ts +39 -26
- package/src/client/lib/catalog.ts +32 -0
- package/src/client/lib/renderMarkdown.ts +116 -0
- package/src/client/locales.ts +41 -0
- package/src/client/styles/Header.module.css +83 -3
- package/src/client/styles/Modal.module.css +274 -0
- package/src/client/types.ts +2 -0
- package/src/server/services/install-types.ts +2 -0
- package/src/server/services/loader.ts +12 -5
- package/src/server/services/task-queue.ts +8 -3
|
@@ -13,14 +13,18 @@ import { ProgressView } from './ProgressView.tsx'
|
|
|
13
13
|
import { installCommandOf, pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../lib/catalog.ts'
|
|
14
14
|
import { classifyFailure } from '../lib/failures.ts'
|
|
15
15
|
|
|
16
|
-
/** 完成结果视图:绿色对勾 +
|
|
16
|
+
/** 完成结果视图:绿色对勾 + 标题/描述;
|
|
17
|
+
* needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
|
|
18
|
+
* 通知中心待重启条目常驻(服务端登记,内存态),直到用户点「立即重启」真正重启后才消失;
|
|
19
|
+
* needsRestart=false(卸载已即时生效)→ 仅「完成」关闭。 */
|
|
17
20
|
function ResultView({
|
|
18
|
-
title, desc, t, restarting, onRestart, onClose,
|
|
21
|
+
title, desc, t, restarting, needsRestart, onRestart, onClose,
|
|
19
22
|
}: {
|
|
20
23
|
title: string
|
|
21
24
|
desc: string
|
|
22
25
|
t: Translate
|
|
23
26
|
restarting: boolean
|
|
27
|
+
needsRestart: boolean
|
|
24
28
|
onRestart: () => void
|
|
25
29
|
onClose: () => void
|
|
26
30
|
}) {
|
|
@@ -43,12 +47,17 @@ function ResultView({
|
|
|
43
47
|
),
|
|
44
48
|
h('div', { className: styles.resultTitle }, title),
|
|
45
49
|
desc ? h('div', { className: styles.resultDesc }, desc) : null,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
needsRestart
|
|
51
|
+
? h('div', null, [
|
|
52
|
+
h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
|
|
53
|
+
h('div', { className: styles.modalActions },
|
|
54
|
+
h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
|
|
55
|
+
h('button', { className: styles.restartNow, onClick: onRestart, disabled: restarting },
|
|
56
|
+
restarting ? t('restarting') : t('restartNow')),
|
|
57
|
+
),
|
|
58
|
+
])
|
|
59
|
+
: h('div', { className: styles.modalActions },
|
|
60
|
+
h('button', { className: styles.restartNow, onClick: onClose }, t('done'))),
|
|
52
61
|
)
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -61,8 +70,12 @@ export interface InstallModalProps {
|
|
|
61
70
|
restarting: boolean
|
|
62
71
|
/** 已安装插件的覆盖更新(走同一条 add 命令原位重装,文案区分安装/更新) */
|
|
63
72
|
update: boolean
|
|
73
|
+
/** 仅命令行插件(webInstallable=false):不提供一键安装,只展示命令供复制到 dsh 终端 */
|
|
74
|
+
cliOnly: boolean
|
|
64
75
|
/** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
|
|
65
76
|
submitting: boolean
|
|
77
|
+
/** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false → 仅「完成」 */
|
|
78
|
+
needsRestart: boolean
|
|
66
79
|
onClose: () => void
|
|
67
80
|
onCopy: () => void
|
|
68
81
|
onInstall: () => void
|
|
@@ -74,7 +87,7 @@ export interface InstallModalProps {
|
|
|
74
87
|
* 只在本任务执行中展示实时进度;完成后切换为结果视图,与卸载一致。
|
|
75
88
|
*/
|
|
76
89
|
export function InstallModal(props: InstallModalProps) {
|
|
77
|
-
const { plugin, done, task, t, langPath, restarting, submitting, update, onClose, onCopy, onInstall, onRestart } = props
|
|
90
|
+
const { plugin, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, onClose, onCopy, onInstall, onRestart } = props
|
|
78
91
|
const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
|
|
79
92
|
// 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分
|
|
80
93
|
const name = plugin.displayName ?? plugin.slug
|
|
@@ -112,6 +125,7 @@ export function InstallModal(props: InstallModalProps) {
|
|
|
112
125
|
desc: update ? t('updateResultDesc') : t('installResultDesc'),
|
|
113
126
|
t,
|
|
114
127
|
restarting,
|
|
128
|
+
needsRestart,
|
|
115
129
|
onRestart,
|
|
116
130
|
onClose,
|
|
117
131
|
})
|
|
@@ -145,8 +159,12 @@ export function InstallModal(props: InstallModalProps) {
|
|
|
145
159
|
h('span', { className: styles.modalCmdText }, installCommandOf(plugin)),
|
|
146
160
|
h('span', { className: styles.modalCmdCopy }, h(CopyIcon), t('copyCmdLabel')),
|
|
147
161
|
),
|
|
162
|
+
// AI 识别可能需要命令行辅助的插件(webInstallable=false):提示但不拦截,仍允许尝试一键安装
|
|
163
|
+
cliOnly ? h('div', { className: styles.cliOnlyHint }, t('cliOnlyHint')) : null,
|
|
148
164
|
task && task.status === 'pending' ? h('div', { className: styles.queuedHint }, t('queuedHint')) : null,
|
|
149
165
|
task ? h(ProgressView, { task }) : null,
|
|
166
|
+
// 安装失败:引导用户复制上方命令到 dsh 终端手动安装(如 node-pty 等原生依赖构建脚本需人工放行)
|
|
167
|
+
task && task.status === 'failed' ? h('div', { className: styles.failedCopyHint }, t('failedCopyHint')) : null,
|
|
150
168
|
h('div', { className: styles.modalActions },
|
|
151
169
|
h('button', {
|
|
152
170
|
className: styles.modalCopy,
|
|
@@ -177,6 +195,8 @@ export interface UninstallModalProps {
|
|
|
177
195
|
restarting: boolean
|
|
178
196
|
/** 卸载请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
|
|
179
197
|
submitting: boolean
|
|
198
|
+
/** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false(loader 已即时移除)→ 仅「完成」 */
|
|
199
|
+
needsRestart: boolean
|
|
180
200
|
onClose: () => void
|
|
181
201
|
onCancel: () => void
|
|
182
202
|
onCopyCommand: () => void
|
|
@@ -186,7 +206,7 @@ export interface UninstallModalProps {
|
|
|
186
206
|
|
|
187
207
|
/** 卸载确认弹窗:确认/进行中(后台队列,可关闭);完成后切换为结果视图(成功即生效,仅「完成」关闭)。 */
|
|
188
208
|
export function UninstallModal(props: UninstallModalProps) {
|
|
189
|
-
const { plugin, done, task, t, langPath, restarting, submitting, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
|
|
209
|
+
const { plugin, done, task, t, langPath, restarting, submitting, needsRestart, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
|
|
190
210
|
const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
|
|
191
211
|
// 进行中标题带上插件名,与安装弹窗一致,并用状态色区分
|
|
192
212
|
const name = plugin.displayName ?? plugin.slug
|
|
@@ -221,6 +241,7 @@ export function UninstallModal(props: UninstallModalProps) {
|
|
|
221
241
|
desc: t('uninstallResultDesc'),
|
|
222
242
|
t,
|
|
223
243
|
restarting,
|
|
244
|
+
needsRestart,
|
|
224
245
|
onRestart,
|
|
225
246
|
onClose,
|
|
226
247
|
})
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useMemo, useState } from 'react'
|
|
9
9
|
import type { HubPlugin, LocaleId } from '../types.ts'
|
|
10
|
-
import { normalize } from '../lib/catalog.ts'
|
|
10
|
+
import { HUB_ABOUT_URL, HUB_REPO, HUB_UPDATE_URL, normalize, PLUGIN_VERSION } from '../lib/catalog.ts'
|
|
11
|
+
import type { HubAboutInfo, HubUpdateInfo } from '../lib/catalog.ts'
|
|
11
12
|
import type { SortKey } from '../lib/catalog.ts'
|
|
12
13
|
|
|
13
14
|
const PLUGINS_URL = (lang: LocaleId) => `https://dsh-plugin.org/api/plugins.${lang}.json`
|
|
14
15
|
const STATS_URL = 'https://dsh-plugin.org/api/stats.json'
|
|
16
|
+
/** 插件市场自身仓库:DSH-Plugin Hub 不显示在目录里(自己不进自己的插件列表) */
|
|
17
|
+
const SELF_REPO = 'dshplugin/dsh-plugin-hub'
|
|
15
18
|
|
|
16
19
|
export function useCatalog(lang: LocaleId) {
|
|
17
20
|
/** 目录插件(仅保留人工验证通过的条目) */
|
|
@@ -30,6 +33,10 @@ export function useCatalog(lang: LocaleId) {
|
|
|
30
33
|
/** 安装时记录的目录信号:repo(小写) -> { version, updatedAt }(来自宿主本地路由);
|
|
31
34
|
* npmPackage 为 npm 优先通道反查命中的包名映射(目录数据未下发时客户端靠它把依赖 key 匹配回仓库) */
|
|
32
35
|
const [versions, setVersions] = useState<Record<string, { version: string; updatedAt: string; npmPackage?: string }>>({})
|
|
36
|
+
/** Hub 自我更新信息:来自 CF Worker 版本控制中心(hub.dsh-plugin.org),与目录数据解耦 */
|
|
37
|
+
const [hubUpdateInfo, setHubUpdateInfo] = useState<HubUpdateInfo | null>(null)
|
|
38
|
+
/** 头部「关注我们」弹窗内容(平台介绍 + 反馈群二维码):来自同款 Worker /about,Markdown 推送非写死 */
|
|
39
|
+
const [hubAboutInfo, setHubAboutInfo] = useState<HubAboutInfo | null>(null)
|
|
33
40
|
|
|
34
41
|
// 拉取目录 + 统计。在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
|
|
35
42
|
useEffect(() => {
|
|
@@ -42,19 +49,29 @@ export function useCatalog(lang: LocaleId) {
|
|
|
42
49
|
if (!res.ok) throw new Error(String(res.status))
|
|
43
50
|
return res.json()
|
|
44
51
|
})
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
// 目录数据直拉:hub 自身版本由独立 Worker 版本控制中心管理,不再依赖主站版本号接口;
|
|
53
|
+
// 数据接口 CDN 缓存 1 小时,发布新数据后最长 1 小时全网生效。
|
|
54
|
+
const load = () =>
|
|
55
|
+
Promise.all([fetchData(PLUGINS_URL(lang)), fetchData(STATS_URL)])
|
|
56
|
+
load()
|
|
57
|
+
.then(([data, s]) => {
|
|
58
|
+
if (cancelled) return
|
|
59
|
+
const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
|
|
60
|
+
// 插件市场不显示自己:DSH-Plugin Hub 从目录里排除自身条目,
|
|
61
|
+
// 防止「自己出现在自己的插件列表里、还能自己安装自己」;统计计数同步减 1 保持与列表一致。
|
|
62
|
+
// 主站数据构建侧已同步防止(registry/API 不再含自身),此处为已发布旧数据/CDN 缓存期的兜底。
|
|
63
|
+
const hadSelf = list.some((p) => p.source?.repo === SELF_REPO)
|
|
64
|
+
setPlugins(list.filter((p) => p.compatibility?.status === 'verified' && p.source?.repo !== SELF_REPO))
|
|
65
|
+
const stats = s as { total?: number; verified?: number }
|
|
66
|
+
if (stats && typeof stats.total === 'number' && typeof stats.verified === 'number') {
|
|
67
|
+
setStats(hadSelf
|
|
68
|
+
? { total: stats.total - 1, verified: stats.verified - 1 }
|
|
69
|
+
: { total: stats.total, verified: stats.verified })
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
if (!cancelled) setFailed(true)
|
|
74
|
+
})
|
|
58
75
|
return () => { cancelled = true }
|
|
59
76
|
// lang 必须进依赖:切换界面语言要重新拉对应语言的数据文件,
|
|
60
77
|
// 否则英文模式仍停留在 plugins.zh.json,描述/名称全是中文
|
|
@@ -76,6 +93,42 @@ export function useCatalog(lang: LocaleId) {
|
|
|
76
93
|
// 首次进入拉取已安装表(依赖宿主 webServer 服务)
|
|
77
94
|
useEffect(() => { refreshInstalled() }, [])
|
|
78
95
|
|
|
96
|
+
// Hub 自身版本检查走独立 Worker(版本控制中心):发新版后写一次 Worker KV,
|
|
97
|
+
// 所有已装用户立即看到「可更新」+ Markdown 变更记录,不再依赖主站目录数据管道。
|
|
98
|
+
// 失败静默降级为 null(徽标不出现,不影响目录本身加载)。
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
let cancelled = false
|
|
101
|
+
fetch(HUB_UPDATE_URL, { cache: 'no-store' })
|
|
102
|
+
.then((res): Promise<HubUpdateInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
|
|
103
|
+
.then((data) => {
|
|
104
|
+
if (cancelled || !data) { if (!cancelled) setHubUpdateInfo(null); return }
|
|
105
|
+
const version = typeof data.version === 'string' && data.version.length > 0 ? data.version : null
|
|
106
|
+
setHubUpdateInfo(version === null
|
|
107
|
+
? null
|
|
108
|
+
: { version, publishedAt: data.publishedAt ?? null, notes: data.notes ?? null })
|
|
109
|
+
})
|
|
110
|
+
.catch(() => { if (!cancelled) setHubUpdateInfo(null) })
|
|
111
|
+
return () => { cancelled = true }
|
|
112
|
+
}, [reloadKey])
|
|
113
|
+
|
|
114
|
+
// 「关注我们」内容走同一套 Worker:作者写好 Markdown(平台介绍 + 反馈群二维码)推送 /admin/about,
|
|
115
|
+
// 客户端下次进入拉 /about 即可看到最新,非写死。失败静默为 null(弹窗展示兜底文案,不阻塞)。
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
let cancelled = false
|
|
118
|
+
fetch(HUB_ABOUT_URL, { cache: 'no-store' })
|
|
119
|
+
.then((res): Promise<HubAboutInfo | null> => (res.ok ? res.json() : Promise.resolve(null)))
|
|
120
|
+
.then((data) => {
|
|
121
|
+
if (cancelled) return
|
|
122
|
+
if (!data || data.content === null || data.content === undefined) { setHubAboutInfo(null); return }
|
|
123
|
+
const content = typeof data.content === 'string' || typeof data.content === 'object' ? data.content : null
|
|
124
|
+
setHubAboutInfo(content === null
|
|
125
|
+
? null
|
|
126
|
+
: { content, updatedAt: data.updatedAt ?? null })
|
|
127
|
+
})
|
|
128
|
+
.catch(() => { if (!cancelled) setHubAboutInfo(null) })
|
|
129
|
+
return () => { cancelled = true }
|
|
130
|
+
}, [reloadKey])
|
|
131
|
+
|
|
79
132
|
/** 插件是否已安装:匹配 installed spec 中的 `github:<owner>/<repo>`,或 npm 通道安装的依赖包名;命中返回 npm 包名。 */
|
|
80
133
|
const installedName = (p: HubPlugin): string | null => {
|
|
81
134
|
const repo = p.source?.repo
|
|
@@ -119,6 +172,18 @@ export function useCatalog(lang: LocaleId) {
|
|
|
119
172
|
return Boolean(current && rec.updatedAt && current > rec.updatedAt)
|
|
120
173
|
}
|
|
121
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Hub 自身是否有可用更新:Hub 就是当前运行的应用(始终已安装,无需 installedName 命中)。
|
|
177
|
+
* 以 Worker 版本控制中心返回的「最新版本」为准,与当前运行的版本号比对——
|
|
178
|
+
* 构建时注入的 PLUGIN_VERSION 是运行 bundle 的真实版本;测试/异常场景缺失时
|
|
179
|
+
* 回退到安装时记录的版本。版本号不等即新版(版本号只在发版时变更,绝无降级场景)。
|
|
180
|
+
*/
|
|
181
|
+
const hubHasUpdate = (() => {
|
|
182
|
+
if (!hubUpdateInfo) return false
|
|
183
|
+
const current = PLUGIN_VERSION || versions[HUB_REPO]?.version || null
|
|
184
|
+
return current !== null && hubUpdateInfo.version !== current
|
|
185
|
+
})()
|
|
186
|
+
|
|
122
187
|
/** 当前分类下的插件(「全部」时为整个目录)。 */
|
|
123
188
|
const categoryPlugins = useMemo(() => {
|
|
124
189
|
if (!plugins) return []
|
|
@@ -184,6 +249,9 @@ export function useCatalog(lang: LocaleId) {
|
|
|
184
249
|
installedName,
|
|
185
250
|
installedVersion,
|
|
186
251
|
hasUpdate,
|
|
252
|
+
hubHasUpdate,
|
|
253
|
+
hubUpdateInfo,
|
|
254
|
+
hubAboutInfo,
|
|
187
255
|
refreshInstalled,
|
|
188
256
|
category,
|
|
189
257
|
setCategory,
|
|
@@ -33,6 +33,10 @@ export interface QueueTask {
|
|
|
33
33
|
command?: string
|
|
34
34
|
/** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败时 issue 预填一并贴给作者 */
|
|
35
35
|
attempts?: string[]
|
|
36
|
+
/** 完成结果是否需要重启才生效(服务端任务终态带出;默认 true 保持老行为:弹窗给重启选项)。
|
|
37
|
+
* 卸载时 loader 已即时移除 → false,结果视图只显示「完成」;true 时弹窗给「稍后重启/立即重启」,
|
|
38
|
+
* 通知中心待重启条目(服务端登记)同步常驻,直到用户点「立即重启」。 */
|
|
39
|
+
needsRestart: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
|
|
@@ -49,9 +53,10 @@ export interface PendingRestart {
|
|
|
49
53
|
export interface TaskQueueOptions {
|
|
50
54
|
t: Translate
|
|
51
55
|
refreshInstalled: () => void
|
|
52
|
-
/** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
/** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录;
|
|
57
|
+
* needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」)。 */
|
|
58
|
+
onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
|
|
59
|
+
onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
|
|
55
60
|
/** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)。 */
|
|
56
61
|
onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[]) => void
|
|
57
62
|
/** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
|
|
@@ -168,13 +173,13 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
168
173
|
if (q.kind === 'uninstall') {
|
|
169
174
|
// 卸载成功:清掉本地版本记录
|
|
170
175
|
void syncInstalledVersion(q.repo, undefined, undefined)
|
|
171
|
-
onUninstallDone(modalTaskRef.current === q.id, q.repo)
|
|
176
|
+
onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
|
|
172
177
|
} else {
|
|
173
178
|
// 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
|
|
174
179
|
// 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
|
|
175
180
|
void syncInstalledVersion(q.repo, q.version, q.updatedAt)
|
|
176
181
|
pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
|
|
177
|
-
onInstallDone(modalTaskRef.current === q.id, q.repo)
|
|
182
|
+
onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
|
|
178
183
|
}
|
|
179
184
|
} else {
|
|
180
185
|
// 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
|
|
@@ -220,25 +225,31 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
220
225
|
try {
|
|
221
226
|
const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
|
|
222
227
|
if (res.ok) {
|
|
223
|
-
const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[] } }
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
228
|
+
const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown } }
|
|
229
|
+
const task = data.task
|
|
230
|
+
if (task !== undefined) {
|
|
231
|
+
status = task.status ?? 'failed'
|
|
232
|
+
lines = task.lines ?? []
|
|
233
|
+
attempts = Array.isArray(task.attempts) ? task.attempts : undefined
|
|
234
|
+
// 终态带出的「是否需要重启」覆盖本地(服务端 done 时按 loader 即时移除情况设置)
|
|
235
|
+
const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
|
|
236
|
+
if (status === 'done') {
|
|
237
|
+
const settled = { ...q, needsRestart }
|
|
238
|
+
// 卸载成功走进度过渡动画;安装成功直接收尾
|
|
239
|
+
if (q.kind === 'uninstall') settleDone(settled, lines)
|
|
240
|
+
else finishQueueTask(true, settled, lines)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (status === 'failed') {
|
|
244
|
+
// 服务端终态带出的尝试记录覆盖本地:以实际执行为准
|
|
245
|
+
finishQueueTask(false, { ...q, attempts }, lines)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
}
|
|
227
249
|
}
|
|
228
250
|
} catch { /* 服务端不可达:按失败处理 */ }
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (q.kind === 'uninstall') settleDone(q, lines)
|
|
232
|
-
else finishQueueTask(true, q, lines)
|
|
233
|
-
}
|
|
234
|
-
else if (status === 'failed') {
|
|
235
|
-
// 服务端终态带出的尝试记录覆盖本地:以实际执行为准
|
|
236
|
-
finishQueueTask(false, { ...q, attempts }, lines)
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
|
|
240
|
-
maybeStopPoll()
|
|
241
|
-
}
|
|
251
|
+
applyQueue((prev) => prev.filter((x) => x.id !== q.id)) // cancelled / 未知
|
|
252
|
+
maybeStopPoll()
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
/** 轮询整个队列:合并服务端 /active(running 在前、pending 在后),
|
|
@@ -250,7 +261,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
250
261
|
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
251
262
|
if (!res.ok) throw new Error(`active ${res.status}`)
|
|
252
263
|
const data = await res.json() as {
|
|
253
|
-
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
|
|
264
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
|
|
254
265
|
pendingRestarts?: unknown
|
|
255
266
|
}
|
|
256
267
|
const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
|
|
@@ -279,6 +290,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
279
290
|
progress: typeof a.progress === 'number' ? a.progress : prevTask?.progress ?? 0,
|
|
280
291
|
lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
|
|
281
292
|
attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
|
|
293
|
+
needsRestart: typeof a.needsRestart === 'boolean' ? a.needsRestart : (prevTask?.needsRestart ?? true),
|
|
282
294
|
})
|
|
283
295
|
}
|
|
284
296
|
// 保留乐观条目:服务端尚未登记(preflight 中),无真实 id,不能因 active 缺它就被合并吞掉
|
|
@@ -319,7 +331,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
319
331
|
const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
|
|
320
332
|
if (!res.ok) return
|
|
321
333
|
const data = await res.json() as {
|
|
322
|
-
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown }[]
|
|
334
|
+
tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown }[]
|
|
323
335
|
pendingRestarts?: unknown
|
|
324
336
|
}
|
|
325
337
|
if (cancelled) return
|
|
@@ -343,6 +355,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
343
355
|
progress: typeof a.progress === 'number' ? a.progress : 0,
|
|
344
356
|
lines: Array.isArray(a.lines) ? (a.lines as string[]) : [],
|
|
345
357
|
attempts: Array.isArray((a as { attempts?: unknown }).attempts) ? ((a as { attempts: string[] }).attempts) : [],
|
|
358
|
+
needsRestart: typeof (a as { needsRestart?: unknown }).needsRestart === 'boolean' ? (a as { needsRestart: boolean }).needsRestart : true,
|
|
346
359
|
}
|
|
347
360
|
})
|
|
348
361
|
if (items.length > 0 || pendingRef.current.length > 0) {
|
|
@@ -382,7 +395,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
382
395
|
status: 'running', progress: 0, lines: [], optimistic: true,
|
|
383
396
|
command: installCommandOf(p, true),
|
|
384
397
|
// 服务端登记后 /active 会带回真实尝试记录(npm 反查 + 执行命令),先占位空列表
|
|
385
|
-
attempts: [],
|
|
398
|
+
attempts: [], needsRestart: true,
|
|
386
399
|
}])
|
|
387
400
|
setSubmitting(true)
|
|
388
401
|
try {
|
|
@@ -442,7 +455,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
442
455
|
}
|
|
443
456
|
const taskId = data.task
|
|
444
457
|
modalTaskRef.current = taskId
|
|
445
|
-
applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [] }])
|
|
458
|
+
applyQueue((prev) => [...prev, { id: taskId, kind: 'uninstall', target: name, desc: p.description, repo, status: 'pending', progress: 0, lines: [], needsRestart: true }])
|
|
446
459
|
// 摘要条常驻顶部(含实时进度),明细面板保持折叠,想看时再点开
|
|
447
460
|
pollQueue()
|
|
448
461
|
} catch {
|
|
@@ -11,6 +11,36 @@ export const SITE_URL = 'https://dsh-plugin.org/'
|
|
|
11
11
|
/** 插件中心源码仓库:头部右上角 GitHub 图标的跳转地址 */
|
|
12
12
|
export const GITHUB_URL = 'https://github.com/dshplugin/dsh-plugin-hub'
|
|
13
13
|
|
|
14
|
+
/** Hub 自身在目录数据里的仓库标识:头部版本号旁的「可更新」徽标按它定位自己。 */
|
|
15
|
+
export const HUB_REPO = 'dshplugin/dsh-plugin-hub'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hub 自我更新版本控制中心(Cloudflare Worker:dsh-update)。
|
|
19
|
+
* 发新版后向 Worker 管理接口写入最新版本 + Markdown 变更记录(见
|
|
20
|
+
* dsh-update-worker 的 POST /admin/hub),所有已装用户的「可更新」徽标即时出现,
|
|
21
|
+
* 不再依赖主站目录数据管道(每日刷新 + 重建站点)。
|
|
22
|
+
* 响应:{ version: string | null, publishedAt: string | null, notes: string | {zh,en} | null }
|
|
23
|
+
*/
|
|
24
|
+
export const HUB_UPDATE_URL = 'https://hub.dsh-plugin.org/hub'
|
|
25
|
+
|
|
26
|
+
/** Worker 版本控制中心返回的 Hub 自我更新信息(notes 为 Markdown 变更记录)。 */
|
|
27
|
+
export interface HubUpdateInfo {
|
|
28
|
+
version: string
|
|
29
|
+
publishedAt?: string | null
|
|
30
|
+
/** Markdown 变更记录;可传字符串,或 { zh, en } 双语言对象按界面语言取 */
|
|
31
|
+
notes?: string | Record<string, string> | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Worker「关注我们」内容接口(同款 Worker,key = hub:about,Markdown 推送非写死)。 */
|
|
35
|
+
export const HUB_ABOUT_URL = 'https://hub.dsh-plugin.org/about'
|
|
36
|
+
|
|
37
|
+
/** Worker 返回的「关注我们」内容(content 为 Markdown,可字符串或 {zh,en} 双语言对象)。 */
|
|
38
|
+
export interface HubAboutInfo {
|
|
39
|
+
/** Markdown 平台介绍 + 反馈群二维码;可传字符串,或 { zh, en } 双语言对象按界面语言取 */
|
|
40
|
+
content?: string | Record<string, string> | null
|
|
41
|
+
updatedAt?: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
/** 构建时由 tsdown 从 package.json 注入的插件版本号(见 tsdown.config.ts define)。 */
|
|
15
45
|
declare const __PLUGIN_VERSION__: string
|
|
16
46
|
|
|
@@ -173,6 +203,8 @@ export function normalize(raw: Record<string, unknown>): HubPlugin {
|
|
|
173
203
|
npmPackage: typeof (raw.r as { npmPackage?: unknown }).npmPackage === 'string' ? (raw.r as { npmPackage: string }).npmPackage : undefined,
|
|
174
204
|
}
|
|
175
205
|
: undefined,
|
|
206
|
+
// 能否网页一键安装:API 仅在 false 时下发 wi(缺省视为 true),据此禁用一键安装、只提示命令行
|
|
207
|
+
install: raw.wi === false ? { webInstallable: false } : undefined,
|
|
176
208
|
compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
|
|
177
209
|
dates: {
|
|
178
210
|
repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量 Markdown 渲染器:仅用于 Hub 自我更新的变更记录(Worker 下发的 notes)。
|
|
3
|
+
*
|
|
4
|
+
* 内容由插件作者自己编写、属可信内容,但渲染端仍先整体 HTML 转义,再按
|
|
5
|
+
* Markdown 语法输出结构,杜绝任何未转义的原始 HTML 注入;链接仅放行 http(s)。
|
|
6
|
+
*
|
|
7
|
+
* 支持:标题(#~####)、有序/无序列表、代码块(```)、行内代码、加粗、斜体、
|
|
8
|
+
* 链接、图片(,如反馈群二维码)、引用(>)、段落;其余语法一律按
|
|
9
|
+
* 纯文本处理,不引入外部依赖。图片仅放行 http(s),限定最大宽度不撑破弹窗。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** HTML 转义:所有文本先进这里,再进入结构变换,保证输出里没有未转义的用户输入。 */
|
|
13
|
+
function escapeHtml(s: string): string {
|
|
14
|
+
return s
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 行内变换:链接 / 加粗 / 斜体 / 行内代码(代码先占位保护,最后还原)。 */
|
|
23
|
+
function inline(s: string): string {
|
|
24
|
+
let out = escapeHtml(s)
|
|
25
|
+
// 行内代码先占位:避免其中的 * [ ] 被后续链接/加粗/斜体规则破坏
|
|
26
|
+
const codeSpans: string[] = []
|
|
27
|
+
out = out.replace(/`([^`]+)`/g, (_m, code) => {
|
|
28
|
+
codeSpans.push(String(code))
|
|
29
|
+
return `\u0000${codeSpans.length - 1}\u0000`
|
|
30
|
+
})
|
|
31
|
+
// 图片 :仅放行 http(s),用于展示反馈群二维码等;limited by CSS
|
|
32
|
+
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) =>
|
|
33
|
+
/^https?:\/\//i.test(String(url)) && !/[\u0000]/.test(String(url))
|
|
34
|
+
? `<img src="${String(url)}" alt="${String(alt)}" loading="lazy">`
|
|
35
|
+
: m,
|
|
36
|
+
)
|
|
37
|
+
// 链接 [text](url):仅放行 http(s),新窗口打开
|
|
38
|
+
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, url) =>
|
|
39
|
+
/^https?:\/\//i.test(String(url)) && !/[\u0000]/.test(String(url))
|
|
40
|
+
? `<a href="${String(url)}" target="_blank" rel="noopener noreferrer">${String(text)}</a>`
|
|
41
|
+
: m,
|
|
42
|
+
)
|
|
43
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
44
|
+
out = out.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
45
|
+
return out.replace(/\u0000(\d+)\u0000/g, (_m, idx) => `<code>${codeSpans[Number(idx)]}</code>`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 把变更记录 Markdown 渲染为安全的 HTML 字符串(由弹窗用 dangerouslySetInnerHTML 注入)。 */
|
|
49
|
+
export function renderMarkdown(md: string): string {
|
|
50
|
+
const lines = md.replace(/\r\n?/g, '\n').split('\n')
|
|
51
|
+
const blocks: string[] = []
|
|
52
|
+
let i = 0
|
|
53
|
+
while (i < lines.length) {
|
|
54
|
+
const line = lines[i]
|
|
55
|
+
// 围栏代码块
|
|
56
|
+
if (/^\s*```/.test(line)) {
|
|
57
|
+
const buf: string[] = []
|
|
58
|
+
i++
|
|
59
|
+
while (i < lines.length && !/^\s*```/.test(lines[i])) {
|
|
60
|
+
buf.push(lines[i])
|
|
61
|
+
i++
|
|
62
|
+
}
|
|
63
|
+
i++ // 跳过闭合围栏
|
|
64
|
+
blocks.push(`<pre><code>${escapeHtml(buf.join('\n'))}</code></pre>`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
// 空行:段落分隔符
|
|
68
|
+
if (line.trim() === '') {
|
|
69
|
+
i++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
// 标题:# 到 #### 映射为 h2~h5(弹窗标题用 modalTitle,不与其冲突)
|
|
73
|
+
const heading = line.match(/^(#{1,4})\s+(.*)/)
|
|
74
|
+
if (heading) {
|
|
75
|
+
const level = heading[1].length + 1
|
|
76
|
+
blocks.push(`<h${level}>${inline(heading[2])}</h${level}>`)
|
|
77
|
+
i++
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
// 有序 / 无序列表(连续收集为一个列表块)
|
|
81
|
+
if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+[.)]\s+/.test(line)) {
|
|
82
|
+
const ordered = /^\s*\d+[.)]\s+/.test(line)
|
|
83
|
+
const items: string[] = []
|
|
84
|
+
while (i < lines.length && (/^\s*[-*+]\s+/.test(lines[i]) || /^\s*\d+[.)]\s+/.test(lines[i]))) {
|
|
85
|
+
items.push(`<li>${inline(lines[i].replace(/^\s*[-*+]|\s*\d+[.)]\s*/, '').trim())}</li>`)
|
|
86
|
+
i++
|
|
87
|
+
}
|
|
88
|
+
blocks.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// 引用
|
|
92
|
+
if (/^\s*>\s?/.test(line)) {
|
|
93
|
+
const buf: string[] = []
|
|
94
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
|
95
|
+
buf.push(inline(lines[i].replace(/^\s*>\s?/, '')))
|
|
96
|
+
i++
|
|
97
|
+
}
|
|
98
|
+
blocks.push(`<blockquote>${buf.join('<br>')}</blockquote>`)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
// 普通段落:收集连续非空、非特殊开头的行
|
|
102
|
+
const buf: string[] = []
|
|
103
|
+
while (
|
|
104
|
+
i < lines.length
|
|
105
|
+
&& lines[i].trim() !== ''
|
|
106
|
+
&& !/^\s*```/.test(lines[i])
|
|
107
|
+
&& !/^#{1,4}\s+/.test(lines[i])
|
|
108
|
+
&& !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])
|
|
109
|
+
) {
|
|
110
|
+
buf.push(inline(lines[i]))
|
|
111
|
+
i++
|
|
112
|
+
}
|
|
113
|
+
blocks.push(`<p>${buf.join('<br>')}</p>`)
|
|
114
|
+
}
|
|
115
|
+
return blocks.join('')
|
|
116
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -21,6 +21,9 @@ export const zh = {
|
|
|
21
21
|
failedDesc: '请稍后重试,或点击右上角按钮在浏览器中打开插件中心。',
|
|
22
22
|
retry: '重试',
|
|
23
23
|
install: '安装',
|
|
24
|
+
// AI 识别可能需要命令行辅助的插件(webInstallable=false):仍可尝试一键安装,失败时复制命令去 dsh 终端
|
|
25
|
+
cliOnlyHint: '此插件可能需命令行辅助,失败时复制上方命令到 dsh 终端',
|
|
26
|
+
failedCopyHint: '安装失败。请复制上方安装命令到 dsh 终端手动执行(原生依赖构建脚本可能需要人工放行,如 pnpm approve-builds)',
|
|
24
27
|
toastCopied: '安装命令已复制,去 dsh 终端粘贴即可安装',
|
|
25
28
|
installed: '已安装',
|
|
26
29
|
notInstalled: '未安装',
|
|
@@ -65,6 +68,7 @@ export const zh = {
|
|
|
65
68
|
restartLater: '稍后重启',
|
|
66
69
|
restarting: '正在重启…',
|
|
67
70
|
restartHint: '部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
|
|
71
|
+
done: '完成',
|
|
68
72
|
// 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
|
|
69
73
|
restartPendingHint: '安装成功,重启后生效',
|
|
70
74
|
restartPendingHintUninstall: '卸载成功,重启后移除',
|
|
@@ -120,6 +124,22 @@ export const zh = {
|
|
|
120
124
|
update: '可更新',
|
|
121
125
|
updateAvailable: '有更新',
|
|
122
126
|
updateAvailableHint: '检测到更新:有版本号的仓库比对版本号,无版本号的仓库比对仓库最近更新时间',
|
|
127
|
+
hubUpdateHint: '检测到 DSH-Plugin Hub 新版本,点击查看更新内容并更新',
|
|
128
|
+
hubUpdateTitle: 'DSH-Plugin Hub 有新版本',
|
|
129
|
+
hubUpdateDesc: 'v{version} 已发布,点击下方按钮即可更新,完成后需重启宿主生效。',
|
|
130
|
+
hubUpdatePublished: '发布于',
|
|
131
|
+
hubUpdateNotes: '更新记录',
|
|
132
|
+
hubUpdateLater: '稍后再说',
|
|
133
|
+
versionHint: '查看版本信息与更新记录',
|
|
134
|
+
hubCurrentTitle: '当前版本',
|
|
135
|
+
hubCurrentDesc: '你正在使用 v{version},已是最新版本。',
|
|
136
|
+
hubUpToDate: '已是最新版本',
|
|
137
|
+
// 头部「关注我们」弹窗:平台介绍 + 用户反馈群二维码(内容由 Worker /about Markdown 推送,非写死)
|
|
138
|
+
followUs: '关注我们',
|
|
139
|
+
aboutTitle: '关注我们',
|
|
140
|
+
aboutDesc: '了解 DSH-Plugin Hub 是做什么的,加入用户反馈群随时交流。',
|
|
141
|
+
aboutEmpty: '内容暂未发布,敬请期待。',
|
|
142
|
+
aboutUpdated: '更新于',
|
|
123
143
|
empty: '该分类暂无插件',
|
|
124
144
|
dataFrom: '数据源 dsh-plugin.org · 每日人工更新',
|
|
125
145
|
}
|
|
@@ -146,6 +166,10 @@ export const en = {
|
|
|
146
166
|
failedDesc: 'Please retry, or open the hub in your browser with the button above.',
|
|
147
167
|
retry: 'Retry',
|
|
148
168
|
install: 'Install',
|
|
169
|
+
// AI-flagged plugins that may need command-line help (webInstallable=false): one-click install still allowed,
|
|
170
|
+
// on failure the user is guided to copy the command into the dsh terminal
|
|
171
|
+
cliOnlyHint: 'This plugin may need command-line help; if it fails, copy the command above and run it in your dsh terminal',
|
|
172
|
+
failedCopyHint: 'Install failed. Copy the command above and run it manually in your dsh terminal (native build scripts may need manual approval, e.g. pnpm approve-builds)',
|
|
149
173
|
toastCopied: 'Install command copied — paste it in your dsh terminal',
|
|
150
174
|
installed: 'Installed',
|
|
151
175
|
notInstalled: 'Not installed',
|
|
@@ -190,6 +214,7 @@ export const en = {
|
|
|
190
214
|
restartLater: 'Later',
|
|
191
215
|
restarting: 'Restarting…',
|
|
192
216
|
restartHint: 'Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
|
|
217
|
+
done: 'Done',
|
|
193
218
|
restartPendingHint: 'Installed — takes effect after restart',
|
|
194
219
|
restartPendingHintUninstall: 'Removed — cleaned up after restart',
|
|
195
220
|
sectionPendingRestart: 'Pending restart',
|
|
@@ -243,6 +268,22 @@ export const en = {
|
|
|
243
268
|
update: 'Update',
|
|
244
269
|
updateAvailable: 'Update available',
|
|
245
270
|
updateAvailableHint: 'An update was detected: repos with a release are compared by tag, repos without one by last push time',
|
|
271
|
+
hubUpdateHint: 'A new DSH-Plugin Hub version is available — click to view the changelog and update',
|
|
272
|
+
hubUpdateTitle: 'A new DSH-Plugin Hub version is available',
|
|
273
|
+
hubUpdateDesc: 'v{version} has been released — click below to update, then restart the host to apply it.',
|
|
274
|
+
hubUpdatePublished: 'Published',
|
|
275
|
+
hubUpdateNotes: "What's new",
|
|
276
|
+
hubUpdateLater: 'Later',
|
|
277
|
+
versionHint: 'View version info and changelog',
|
|
278
|
+
hubCurrentTitle: 'Current version',
|
|
279
|
+
hubCurrentDesc: 'You are on v{version} — this is the latest version.',
|
|
280
|
+
hubUpToDate: 'Up to date',
|
|
281
|
+
// Header "Follow us" modal: platform intro + user feedback group QR (content pushed via Worker /about Markdown)
|
|
282
|
+
followUs: 'Follow us',
|
|
283
|
+
aboutTitle: 'Follow us',
|
|
284
|
+
aboutDesc: 'Learn what DSH-Plugin Hub does and join our user feedback group.',
|
|
285
|
+
aboutEmpty: 'Content not published yet — stay tuned.',
|
|
286
|
+
aboutUpdated: 'Updated',
|
|
246
287
|
empty: 'No plugins in this category yet',
|
|
247
288
|
dataFrom: 'Data from dsh-plugin.org · curated daily',
|
|
248
289
|
}
|