diff --git a/.prettierignore b/.prettierignore
index bef11bb0..49f29142 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -26,3 +26,4 @@ plugins/pdf-reader/assets/
plugins/packages/
docs/designs/b-prototype/site/
plugins/fsrs-optimizer/assets/
+plugins/static-publish/assets/
diff --git a/build/external-plugins.ts b/build/external-plugins.ts
index 6e8c40fb..d504940c 100644
--- a/build/external-plugins.ts
+++ b/build/external-plugins.ts
@@ -19,6 +19,9 @@ export function externalPlugins(desktop: boolean): Plugin {
'fsrs-optimizer',
'study-statistics',
'assistant',
+ 'static-publish',
+ 'wordpress-publish',
+ 'discourse-publish',
]
: [
'review-history',
@@ -32,6 +35,7 @@ export function externalPlugins(desktop: boolean): Plugin {
'fsrs-optimizer',
'study-statistics',
]
+ if (!desktop) ids.push('static-publish')
const files = new Map()
const catalog = ids.map((id) => {
const bundle = JSON.parse(
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index dd98efa9..dcab018b 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -14,6 +14,8 @@ Repeaty 在运行时加载外置插件。官方答题历史、LeetCode、复习
## 页面与界面扩展边界
+SDK 1.14.0 增加[公开发布投影和受确认的网站连接](PLUGIN_PUBLISHING.md)。独立官方静态站、WordPress 与 Discourse 发布插件从插件页安装,配置注册到统一设置页,发布绑定随学习库保存,设备凭据不随库移动。操作方式见[发布学习材料](PUBLISHING.md)。
+
SDK 1.13.0 增加带宿主授权、预览、事务及幂等收据的[自动化接口](PLUGIN_AUTOMATION.md),以及设备模型连接。独立官方[学习助手](../plugins/assistant/README.md)提供会话、授权材料问答和可审查的整理建议;通过插件页面安装、启停和卸载。
SDK 1.7.0 支持通过 `registerPage()` 注册独立页面及对应主侧栏入口,通过 `registerWidget()` 在学习概览的指定位置嵌入组件。题目插件、复习面板插件都可注册;只提供页面或嵌入组件的插件导出 `type: 'extension'` 的 `ExtensionPlugin`,激活时至少注册一项页面或组件。现有题目、答案、编辑器、历史记录渲染以及 `registerSettings()` 保持原接口。
diff --git a/docs/PLUGIN_PUBLISHING.md b/docs/PLUGIN_PUBLISHING.md
new file mode 100644
index 00000000..6cc11dcd
--- /dev/null
+++ b/docs/PLUGIN_PUBLISHING.md
@@ -0,0 +1,71 @@
+# 插件发布接口
+
+SDK 1.14.0 提供公开内容投影 `publishing` 与受授权的原生网络 `network`。静态站、WordPress、Discourse 官方插件使用同一接口,独立构建和按学习库安装,源码不进入本体构建。
+
+## 选择与预览
+
+```ts
+import { publishing } from '@repeaty/plugin-api'
+
+const index = publishing.list()
+const snapshot = await publishing.prepare(
+ {
+ sourceIds: selectedIds,
+ expectedRevision: index.revision,
+ publicFields: ['author', 'tags'],
+ },
+ signal,
+)
+
+for (const source of snapshot.sources) {
+ // Only these explicitly named fields are part of the public projection.
+ // id/versionId are local binding identities; do not pass them into templates.
+ for (const asset of source.assets) {
+ const blob = await publishing.asset(snapshot.id, asset.id, signal)
+ // Rewrite asset.reference to the generated public asset URL.
+ }
+}
+publishing.validate(snapshot.id) // Before export or remote mutation.
+publishing.release(snapshot.id)
+```
+
+`list()` 仅返回当前库的标题、身份及修订,用于用户选择。`prepare()` 只读取指定的材料,不自动包含子材料;最多 1,000 份、正文合计 32 MiB。草稿或标记为私有的材料会拒绝生成。正文开头的 YAML frontmatter 不直接输出;字段只允许明确指定的标量或字符串数组,拒绝内部身份、私有和凭据字段。未知嵌套属性不会默默转为公开 JSON。
+
+明确传入空选择会生成空快照,可用于移除静态站中的全部文章。`links` 仅解析正文实际引用的材料身份,目标可以不在本次选择中,方便连接器查找先前的公开 URL;不会附带目标正文、字段或附件。内部 ID 不应直接输出到公开内容,尚无公开地址的链接需要明确处理或阻止发布。
+
+快照的附件句柄仅指向这些正文实际引用的资源,不能传任意媒体 ID 读取原件或其他附件。附件 MIME 有白名单,单件不超过 25 MiB。原始 PDF/EPUB、注释、调度、历史、草稿、设备路径、凭据和插件私有数据不自动加入投影。正文中用户自行写入的内容仍需在发布前审查。
+
+快照最多同时保留四份,有效期十分钟。准备时检查调用方提供的库修订;之后所选材料内容、属性、关联附件或链接解析身份变化时失效。纯学习时长或评分保存不会让未变化的公开预览失效,校验也不会重新读取正文。学习库替换、插件停用或卸载使快照失效;返回后还会复核作用域,不能用迟到读取继续发布。插件保存生成物和最终提交远端前都应重新验证;失败保留上一个完整结果,不把半份文件集标记为可发布。
+
+## 网络与凭据
+
+固定网站可沿用 manifest 的 `permissions.origins`。用户指定的网站声明 `permissions.network: true`,先调用 `network.authorize('https://example.org')`;宿主展示实际插件与完整 HTTPS origin,用户同意后才能访问。该授权仅保留在当前库的本次启用实例中,切库或重新启用后重新确认。普通 `nativePlugin` 不能直接调用内部的 `network.authorize` 操作。
+
+```ts
+import { network } from '@repeaty/plugin-api'
+await network.authorize(new URL(site).origin)
+const response = await network.request(
+ {
+ url: `${site}/api/posts`,
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(publicPost),
+ secret: { slot: 'site-account', headers: { Authorization: 'Bearer {{token}}' } },
+ },
+ signal,
+)
+```
+
+密钥通过已有 `nativePlugin('secrets', …)` 写入设备私有存储,不能放入 `pluginData`。原生宿主将凭据绑定到保存时的网站 origin,在发送时插入指定头;不跟随重定向。XML-RPC 可以使用同一 `secret` 的 `body: {template, encoding:'xml'}`,其中凭据插值由宿主进行 XML 转义;不能同时传普通 body。公开凭据状态不回传密钥。
+
+XML 模板中的 `{{field}}` 专用于凭据插槽。文章、标题等用户内容需先进行 XML 转义,并将字面 `{`、`}` 编码为 `{`、`}`,再与凭据插槽组装模板,防止正文中的同形文字被当作凭据插值。插值在每次追加前检查输出限额。
+
+网络请求支持字符串、base64 二进制或 multipart,完整类型见 [publishing.d.ts](../packages/plugin-api/publishing.d.ts)。请求体最多 32 MiB,multipart 单项最多 25 MiB,响应最多 8 MiB;超限明确拒绝。插件调用绑定本次启用实例,AbortSignal、停用、卸载和切库会取消相关请求;更改凭据会停止使用旧凭据的请求。不会因此中断其他插件实例。
+
+## 发布结果和恢复
+
+取消等待、超时和网络异常不代表远端回滚。连接器应在第一次远端写入前持久保存操作意图,以稳定的本地材料身份关联远端 ID。结果不明时先读取并核对远端,禁止直接重新创建。更新前再次核对内容、目标和修订;删除必须是用户明确选择的独立动作。
+
+发布绑定和待核对记录保存到按库的 `pluginData`,随完整工作目录备份保留;凭据仍留在设备。恢复本地备份或撤销学习操作不会撤回已经发生的外部发布,必须重新核对远端。模板、模型输出和文章内容均不能授予网络访问权限。
+
+插件仍是用户信任的 JavaScript;这些接口约束公开发布路径和资源生命周期,不是任意恶意插件代码的强沙箱。网页可以生成、预览和导出静态站;原生网络和设备凭据需要桌面版。
diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md
new file mode 100644
index 00000000..f0c7e3e1
--- /dev/null
+++ b/docs/PUBLISHING.md
@@ -0,0 +1,33 @@
+# 发布学习材料
+
+在「插件」中按需安装 **静态发布**、**WordPress 发布** 或 **Discourse 发布**。安装后侧栏出现对应页面。静态站的主题与 Cloudflare 配置位于「设置 → 工具 → 发布」,WordPress 和 Discourse 连接位于「设置 → 连接」的对应分类。三个插件都独立加载;停用或卸载不会删除学习材料、发布关联或远端内容。
+
+## 静态站点
+
+1. 在设置中填写站点名称、说明和公开地址,选择需要公开的自定义字段;可编辑页面、归档、CSS 和 JavaScript 模板。
+2. 在「静态发布」中明确勾选材料并生成预览。检查正文、图片、归档、分类、标签及搜索;草稿和私有材料会阻止生成。
+3. 导出 ZIP 后,可把解压后的完整文件集放到静态托管服务。站点包含 RSS 与站点地图。重新生成时保留文章地址和首次发布时间,取消选择会从下一版站点移除文章。
+4. 桌面版也可在设置中连接 Cloudflare Pages,创建目标项目,再从发布页确认部署。完整文件集替换所选分支的站点;清空选择可生成不含文章的站点。删除整个 Cloudflare 项目是设置里的独立操作,需要再次确认。
+
+主题预览允许站内导航和默认搜索,但不执行用户主题脚本,也不访问外部链接。导出站点保留主题脚本。模板出错或生成失败会保留上次完整站点;重启后可以查看已有预览,导出或发布前需要重新生成并核对当前材料。
+
+网页支持生成、预览和导出 ZIP;使用 Cloudflare 凭据和原生联网需要桌面版。
+
+## WordPress 和 Discourse
+
+在统一设置中填写 HTTPS 网站并连接账号。首次连接用户指定的网站时,Repeaty 会显示完整网站来源供确认。凭据只保存在当前设备,发布关联随工作目录保存。
+
+- WordPress 支持 REST 应用密码,以及启用 XML-RPC 的 WordPress / WordPress.com 站点。连接方式及 Blog ID 取决于网站配置。
+- Discourse 使用站点提供的 User API 授权。授权页面生成的加密结果由插件在本地解密,私钥仅保留在本次授权流程中。
+
+从发布页面选择材料,调整文章标题、分类或标签,准备预览后再确认提交。已有发布关联会更新同一篇文章或主题。删除远端文章也是独立的预览和确认操作,不会删除本地学习材料。
+
+这两个连接器不会上传本地图片或附件。含本地资源的正文需要先改用已公开的 HTTPS 地址,或选择静态站点发布。材料之间的链接需要目标已有可核对的公开地址;无法转换的内部链接会明确阻止发布,避免输出内部材料身份。
+
+## 中断、冲突与备份
+
+停止等待不等于撤回远端操作。超时或断网后如果不能确定远端结果,先使用「核对远端状态」,不要重复创建文章。远端在预览后被编辑时会停止更新,要求重新预览;已经发布的内容不会因为学习库撤销或恢复本地备份而自动撤回。
+
+移动完整工作目录后,插件设置、站点产物和发布关联仍在,但新设备需要重新连接账号。卸载插件不会撤下已经公开的站点或文章;需要先执行相应的远端删除操作。
+
+插件包内 README 提供各连接器的具体权限、限制和操作说明。SDK 开发接口见 [插件发布接口](PLUGIN_PUBLISHING.md)。
diff --git a/package-lock.json b/package-lock.json
index 69df3b72..0c99ecfd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,7 +22,11 @@
"plugins/readwise",
"plugins/fsrs-optimizer",
"plugins/study-statistics",
- "plugins/assistant"
+ "plugins/assistant",
+ "packages/publishing-client",
+ "plugins/static-publish",
+ "plugins/wordpress-publish",
+ "plugins/discourse-publish"
],
"dependencies": {
"@iconify-json/lucide": "^1.2.147",
@@ -1235,6 +1239,18 @@
"@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nuxt/devtools-kit": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@nuxt/devtools-kit/-/devtools-kit-3.4.4.tgz",
@@ -1633,6 +1649,10 @@
"resolved": "plugins/assistant",
"link": true
},
+ "node_modules/@repeaty/plugin-discourse-publish": {
+ "resolved": "plugins/discourse-publish",
+ "link": true
+ },
"node_modules/@repeaty/plugin-document-reader": {
"resolved": "plugins/document-reader",
"link": true
@@ -1669,6 +1689,10 @@
"resolved": "plugins/review-history",
"link": true
},
+ "node_modules/@repeaty/plugin-static-publish": {
+ "resolved": "plugins/static-publish",
+ "link": true
+ },
"node_modules/@repeaty/plugin-study-statistics": {
"resolved": "plugins/study-statistics",
"link": true
@@ -1681,6 +1705,14 @@
"resolved": "plugins/visual-occlusion",
"link": true
},
+ "node_modules/@repeaty/plugin-wordpress-publish": {
+ "resolved": "plugins/wordpress-publish",
+ "link": true
+ },
+ "node_modules/@repeaty/publishing-client": {
+ "resolved": "packages/publishing-client",
+ "link": true
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
@@ -5330,6 +5362,35 @@
"integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==",
"license": "MIT"
},
+ "node_modules/liquidjs": {
+ "version": "10.30.1",
+ "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.1.tgz",
+ "integrity": "sha512-P1Iz14WUHZdfbOYwHOIhtl/iAWIOYk1ks5l9rPZlDM6vZo+6BLeYIuWHAPkIz+U8Mm8HKZKITIrjX7DQJO2fqA==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^10.0.0"
+ },
+ "bin": {
+ "liquid": "bin/liquid.js",
+ "liquidjs": "bin/liquid.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/liquidjs"
+ }
+ },
+ "node_modules/liquidjs/node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
@@ -7438,7 +7499,7 @@
},
"packages/plugin-api": {
"name": "@repeaty/plugin-api",
- "version": "1.13.0",
+ "version": "1.14.0",
"dependencies": {
"@vitejs/plugin-vue": "^6.0.10",
"vite": "^8.3.4",
@@ -7454,6 +7515,20 @@
"vue": "^3.5.0"
}
},
+ "packages/publishing-client": {
+ "name": "@repeaty/publishing-client",
+ "version": "1.0.0",
+ "dependencies": {
+ "markdown-it": "^14.3.3"
+ },
+ "devDependencies": {
+ "@types/markdown-it": "^14.1.0"
+ },
+ "peerDependencies": {
+ "@repeaty/plugin-api": "^1.14.0",
+ "vue": "^3.5.0"
+ }
+ },
"plugins/assistant": {
"name": "@repeaty/plugin-assistant",
"version": "1.0.0",
@@ -7470,6 +7545,25 @@
"vue": "^3.5.0"
}
},
+ "plugins/discourse-publish": {
+ "name": "@repeaty/plugin-discourse-publish",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@repeaty/plugin-api": "file:../../packages/plugin-api",
+ "@repeaty/publishing-client": "file:../../packages/publishing-client",
+ "@types/markdown-it": "^14.1.0",
+ "@types/node": "^24.0.0",
+ "@vitejs/plugin-vue": "^6.0.10",
+ "markdown-it": "^14.3.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.3.4",
+ "vitest": "^5.0.5",
+ "vue-tsc": "^3.3.13"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
"plugins/document-reader": {
"name": "@repeaty/plugin-document-reader",
"version": "1.0.0",
@@ -7637,6 +7731,33 @@
"vue": "^3.5.0"
}
},
+ "plugins/static-publish": {
+ "name": "@repeaty/plugin-static-publish",
+ "version": "1.0.0",
+ "dependencies": {
+ "@noble/hashes": "^2.0.0",
+ "@zip.js/zip.js": "^2.24.0",
+ "highlight.js": "^11.12.0",
+ "katex": "^0.19.0",
+ "liquidjs": "^10.30.0",
+ "markdown-it": "^14.3.3",
+ "parse5": "^8.0.1"
+ },
+ "devDependencies": {
+ "@repeaty/plugin-api": "file:../../packages/plugin-api",
+ "@types/katex": "^0.16.8",
+ "@types/markdown-it": "^14.2.0",
+ "@types/node": "^24.0.0",
+ "@vitejs/plugin-vue": "^6.0.10",
+ "typescript": "~5.9.3",
+ "vite": "^8.3.4",
+ "vitest": "^5.0.5",
+ "vue-tsc": "^3.3.13"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
"plugins/study-statistics": {
"name": "@repeaty/plugin-study-statistics",
"version": "1.0.0",
@@ -7692,6 +7813,25 @@
"peerDependencies": {
"vue": "^3.5.0"
}
+ },
+ "plugins/wordpress-publish": {
+ "name": "@repeaty/plugin-wordpress-publish",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@repeaty/plugin-api": "file:../../packages/plugin-api",
+ "@repeaty/publishing-client": "file:../../packages/publishing-client",
+ "@types/markdown-it": "^14.1.0",
+ "@types/node": "^24.0.0",
+ "@vitejs/plugin-vue": "^6.0.10",
+ "markdown-it": "^14.3.0",
+ "typescript": "~5.9.3",
+ "vite": "^8.3.4",
+ "vitest": "^5.0.5",
+ "vue-tsc": "^3.3.13"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index b8c8c1ee..d94192c9 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,11 @@
"plugins/readwise",
"plugins/fsrs-optimizer",
"plugins/study-statistics",
- "plugins/assistant"
+ "plugins/assistant",
+ "packages/publishing-client",
+ "plugins/static-publish",
+ "plugins/wordpress-publish",
+ "plugins/discourse-publish"
],
"description": "Desktop flashcards and spaced repetition, with your learning library in one folder.",
"engines": {
diff --git a/packages/plugin-api/build.mjs b/packages/plugin-api/build.mjs
index ca6f180f..b7a6c77f 100644
--- a/packages/plugin-api/build.mjs
+++ b/packages/plugin-api/build.mjs
@@ -25,6 +25,8 @@ const runtimeExports = [
'materials',
'study',
'automation',
+ 'publishing',
+ 'network',
'tasks',
'resources',
'practiceContentRevision',
diff --git a/packages/plugin-api/index.d.ts b/packages/plugin-api/index.d.ts
index 30e614ca..5e56b400 100644
--- a/packages/plugin-api/index.d.ts
+++ b/packages/plugin-api/index.d.ts
@@ -13,6 +13,7 @@ export * from './collection'
export * from './study'
export * from './automation'
export * from './providers'
+export * from './publishing'
export const automation: import('./automation').AutomationApi
import type { MaterialLocator } from './materials'
@@ -230,7 +231,13 @@ export interface PluginManifest {
dataVersion: number
kind: 'frontend' | 'local'
description?: string
- permissions?: { origins?: string[]; secrets?: boolean; files?: boolean; models?: boolean }
+ permissions?: {
+ origins?: string[]
+ secrets?: boolean
+ files?: boolean
+ models?: boolean
+ network?: boolean
+ }
}
export declare const locale: DeepReadonly[>
export declare function t(
diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json
index bae2230f..97f371ac 100644
--- a/packages/plugin-api/package.json
+++ b/packages/plugin-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@repeaty/plugin-api",
- "version": "1.13.0",
+ "version": "1.14.0",
"scripts": {
"test": "node --test build.test.mjs"
},
@@ -21,6 +21,7 @@
"study.d.ts",
"automation.d.ts",
"providers.d.ts",
+ "publishing.d.ts",
"build.d.mts",
"build.mjs"
],
diff --git a/packages/plugin-api/publishing.d.ts b/packages/plugin-api/publishing.d.ts
new file mode 100644
index 00000000..f8dc5b23
--- /dev/null
+++ b/packages/plugin-api/publishing.d.ts
@@ -0,0 +1,87 @@
+/** Explicit public projection. Internal identity is for local bindings, never template variables. */
+export type PublishingField = string | number | boolean | string[]
+export interface PublishingAsset {
+ id: string
+ reference: string
+ type: string
+ size: number
+}
+export interface PublishingSource {
+ id: string
+ versionId: string
+ title: string
+ body: string
+ format: 'plain' | 'markdown'
+ createdAt: string
+ updatedAt: string
+ fields: Record]
+ assets: PublishingAsset[]
+ /** Resolved references may target unselected materials; never includes their content or fields. */
+ links: { reference: string; sourceId: string; fragment?: string }[]
+}
+export interface PublishingSnapshot {
+ id: string
+ workspaceScope: string
+ revision: number
+ createdAt: string
+ sources: PublishingSource[]
+}
+export interface PublishingApi {
+ /** Titles only, for the explicit material picker. No bodies, metadata values or private paths. */
+ list(): {
+ workspaceScope: string
+ revision: number
+ sources: { id: string; title: string; versionId: string }[]
+ }
+ /** Exact selected IDs (including an explicit empty selection); never silently includes descendants. Only named scalar fields are public.
+ * Draft/private materials are rejected; frontmatter is stripped. Max 1000 materials / 32 MiB text.
+ */
+ prepare(
+ input: { sourceIds: string[]; expectedRevision: number; publicFields?: string[] },
+ signal?: AbortSignal,
+ ): Promise
+ /** Revalidate before export/upload; edits, expiry, replacement, disposal and library changes fail. */
+ validate(snapshotId: string): void
+ /** Only attachment references found in this snapshot. Never arbitrary library media or originals. */
+ asset(snapshotId: string, assetId: string, signal?: AbortSignal): Promise
+ release(snapshotId: string): void
+}
+export declare const publishing: PublishingApi
+
+export interface PluginHttpSecret {
+ slot: string
+ expectedRevision?: number
+ headers?: Record
+ body?: { template: string; encoding: 'xml' }
+}
+export type PluginHttpBody =
+ | string
+ | { base64: string }
+ | {
+ multipart: (
+ | { name: string; text: string }
+ | { name: string; base64: string; filename: string; type: string }
+ )[]
+ }
+export interface PluginHttpRequest {
+ url: string
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
+ headers?: Record
+ body?: PluginHttpBody
+ secret?: PluginHttpSecret
+}
+export interface PluginHttpResponse {
+ status: number
+ headers: Record
+ body: string
+ credentialRevision: number | null
+ currentCredentialRevision: number | null
+}
+export declare const network: {
+ /** Host-owned confirmation, HTTPS origin only; requires manifest network:true.
+ * Grant lasts for this plugin activation/current library, not the saved configuration.
+ */
+ authorize(origin: string): Promise
+ /** Native transport. Cancellation does not imply a remote write was rolled back. */
+ request(input: PluginHttpRequest, signal?: AbortSignal): Promise
+}
diff --git a/packages/publishing-client/package.json b/packages/publishing-client/package.json
new file mode 100644
index 00000000..08c5a2d7
--- /dev/null
+++ b/packages/publishing-client/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@repeaty/publishing-client",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./ui": "./src/PublishPage.vue",
+ "./settings": "./src/ConnectionSettings.vue"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0",
+ "@repeaty/plugin-api": "^1.14.0"
+ },
+ "dependencies": {
+ "markdown-it": "^14.3.3"
+ },
+ "devDependencies": {
+ "@types/markdown-it": "^14.1.0"
+ },
+ "scripts": {
+ "test": "vitest run --config vitest.config.ts",
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
+ }
+}
diff --git a/packages/publishing-client/src/ConnectionSettings.vue b/packages/publishing-client/src/ConnectionSettings.vue
new file mode 100644
index 00000000..44738a51
--- /dev/null
+++ b/packages/publishing-client/src/ConnectionSettings.vue
@@ -0,0 +1,319 @@
+
+
+
+
+ {{
+ configured ? t('已保存凭据', 'Credentials saved') : t('尚未连接', 'Not connected')
+ }}
+
+
+
+
+
+
+
+ {{ t('保存连接', 'Save connection') }}
+
+
+
+
+ {{ t('开始论坛授权', 'Authorize with forum') }}
+
+
+ {{ t('打开论坛授权页面', 'Open forum authorization') }}
+
+ {{
+ t('验证并保存', 'Verify and save')
+ }}
+
+
+ {{
+ t('已有 User API key', 'Already have a User API key')
+ }}
+ {{ t('保存密钥', 'Save key') }}
+
+
+
+ {{ t('测试连接', 'Test connection') }}
+ {{
+ t('移除凭据', 'Remove credentials')
+ }}
+
+ {{ error }} {{ message }}
+
+
+
+
diff --git a/packages/publishing-client/src/PublishPage.vue b/packages/publishing-client/src/PublishPage.vue
new file mode 100644
index 00000000..dd1cab92
--- /dev/null
+++ b/packages/publishing-client/src/PublishPage.vue
@@ -0,0 +1,746 @@
+
+
+
+
+ {{
+ t('准备预览', 'Prepare preview')
+ }}
+ {{
+ t(
+ `先在设置 → 连接 → ${providerLabel} 中连接站点。`,
+ `Connect a site in Settings → Connections → ${providerLabel}.`,
+ )
+ }}
+ {{ error }} {{ message }}
+
+
+
+
+
+ {{
+ plan.action === 'delete'
+ ? t('删除远端文章', 'Delete remote post')
+ : plan.action === 'create'
+ ? t('新建文章', 'Create post')
+ : t('更新原文章', 'Update existing post')
+ }} {{ providerLabel }}
+
+ {{ plan.draft.title }}
+
+ {{
+ t(
+ '将删除下方已读回的远端文章。本地内容不会被发送或删除。',
+ 'Delete the remote post read back below. No local content is sent or deleted.',
+ )
+ }}
+
+ {{
+ t('查看远端当前正文', 'View current remote content')
+ }}
+
+ {{ plan.previous.content }}
+
+
+
+
+
+
{{ t('发送前,先看一遍', 'Review before sending') }}
+
+ {{
+ t(
+ '这里会显示实际发送的正文、站点和新建或更新操作。发布需要再次确认。',
+ 'Review the exact content, destination and create/update action here, then confirm publishing.',
+ )
+ }}
+
+
{{ t('准备预览', 'Prepare preview') }} {{
+ t('停止等待', 'Stop waiting')
+ }}
+
+
+
+
+
+
+
diff --git a/packages/publishing-client/src/engine.test.ts b/packages/publishing-client/src/engine.test.ts
new file mode 100644
index 00000000..01c076a0
--- /dev/null
+++ b/packages/publishing-client/src/engine.test.ts
@@ -0,0 +1,235 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createPublisher, emptyLedger } from './engine'
+import { marked } from './util'
+import { RemoteRejected, PartialMutation } from './http'
+import type { Adapter, Draft, Ledger, Remote } from './types'
+
+it('revalidates after pending persistence so newly private content is never sent', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ let privateNow = false
+ const save = f.store.save
+ f.store.save = vi.fn(async (state) => {
+ await save(state)
+ privateNow = true
+ })
+ const controller = new AbortController()
+ const plan = await engine.preview(draft, controller.signal)
+ await expect(
+ engine.apply(plan, controller.signal, async () => {
+ if (privateNow) throw new Error('source-private')
+ }),
+ ).rejects.toThrow('source-private')
+ expect(f.adapter.create).not.toHaveBeenCalled()
+ expect(Object.values(f.state().bindings)).toEqual([])
+})
+
+const draft: Draft = {
+ sourceId: 'source',
+ versionId: 'v1',
+ title: '间隔复习',
+ content: '正文',
+ status: 'draft',
+ category: '',
+ tags: [],
+}
+function fixture() {
+ let state: Ledger = emptyLedger()
+ let remote: Remote | null = null
+ const adapter: Adapter = {
+ provider: 'wordpress',
+ connection: { site: 'https://example.com', adapter: 'rest', slot: 'account' },
+ verify: vi.fn(),
+ catalog: vi.fn(),
+ find: vi.fn(async () => structuredClone(remote)),
+ read: vi.fn(async () => structuredClone(remote)),
+ create: vi.fn(async (value, marker) => {
+ remote = {
+ id: '7',
+ url: 'https://example.com/post',
+ title: value.title,
+ content: marked(value.content, marker),
+ status: value.status,
+ category: value.category,
+ tags: value.tags,
+ revision: 'one',
+ }
+ return structuredClone(remote)
+ }),
+ update: vi.fn(async (_old, value, marker) => {
+ remote = {
+ ...remote!,
+ title: value.title,
+ content: marked(value.content, marker),
+ revision: 'two',
+ }
+ return structuredClone(remote)
+ }),
+ remove: vi.fn(async () => {
+ remote = null
+ }),
+ }
+ const store = {
+ load: vi.fn(async () => structuredClone(state)),
+ save: vi.fn(async (value: Ledger) => {
+ state = structuredClone(value)
+ }),
+ }
+ return {
+ adapter,
+ store,
+ state: () => state,
+ setRemote: (value: Remote | null) => {
+ remote = value
+ },
+ remote: () => remote,
+ }
+}
+const signal = () => new AbortController().signal
+describe('durable publication transactions', () => {
+ it('writes intent before sending, then updates the original after reloading', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ const create = f.adapter.create
+ f.adapter.create = vi.fn(async (...args: Parameters) => {
+ expect(Object.values(f.state().bindings)[0]?.state).toBe('pending')
+ return create(...args)
+ })
+ await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined)
+ expect(Object.values(f.state().bindings)[0]?.remote?.content).toBe('')
+ const restarted = createPublisher(f.adapter, f.store)
+ const plan = await restarted.preview({ ...draft, content: '更新' }, signal())
+ expect(plan.action).toBe('update')
+ await restarted.apply(plan, signal(), async () => undefined)
+ expect(f.adapter.create).toHaveBeenCalledTimes(1)
+ expect(f.adapter.update).toHaveBeenCalledTimes(1)
+ })
+ it('never sends after an intent save fails', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ const plan = await engine.preview(draft, signal())
+ f.store.save.mockRejectedValueOnce(new Error('disk-full'))
+ await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow('disk-full')
+ expect(f.adapter.create).not.toHaveBeenCalled()
+ })
+ it('keeps unknown after a lost create reply, then reconciles without another create', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ const create = f.adapter.create
+ f.adapter.create = vi.fn(async (...args: Parameters) => {
+ await create(...args)
+ throw new Error('timeout')
+ })
+ await expect(
+ engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined),
+ ).rejects.toThrow('timeout')
+ expect(Object.values(f.state().bindings)[0]?.state).toBe('unknown')
+ const restarted = createPublisher(f.adapter, f.store)
+ await expect(restarted.preview(draft, signal())).rejects.toThrow('reconcile-required')
+ expect((await restarted.reconcile('source', signal())).remote?.id).toBe('7')
+ expect((await restarted.preview(draft, signal())).action).toBe('update')
+ expect(f.adapter.create).toHaveBeenCalledTimes(1)
+ })
+ it('restores retryability for cancellation before send and a definite first-write rejection', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ const controller = new AbortController()
+ const plan = await engine.preview(draft, signal())
+ const save = f.store.save.getMockImplementation()!
+ f.store.save.mockImplementationOnce(async (state) => {
+ await save(state)
+ controller.abort()
+ })
+ await expect(engine.apply(plan, controller.signal, async () => undefined)).rejects.toThrow()
+ expect(Object.keys(f.state().bindings)).toHaveLength(0)
+ expect(f.adapter.create).not.toHaveBeenCalled()
+ f.adapter.create = vi.fn(async () => {
+ throw new RemoteRejected(401)
+ })
+ await expect(
+ engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined),
+ ).rejects.toThrow('remote-http-401')
+ expect(Object.keys(f.state().bindings)).toHaveLength(0)
+ expect((await engine.preview(draft, signal())).action).toBe('create')
+ })
+ it('keeps successful-create followed by a rejected read uncertain and blocks a second create', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ f.adapter.create = vi.fn(async () => {
+ throw new PartialMutation()
+ })
+ await expect(
+ engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined),
+ ).rejects.toThrow('remote-result-unknown')
+ await expect(engine.preview(draft, signal())).rejects.toThrow('reconcile-required')
+ await expect(engine.reconcile('source', signal())).rejects.toThrow('remote-unconfirmed')
+ expect(f.adapter.create).toHaveBeenCalledTimes(1)
+ })
+ it('refuses stale remote content and stale source revisions before a mutation', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined)
+ const plan = await engine.preview({ ...draft, content: 'edited' }, signal())
+ f.setRemote({ ...f.remote()!, title: 'Changed in WordPress' })
+ await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow(
+ 'remote-changed',
+ )
+ const newer = await engine.preview(draft, signal())
+ await expect(
+ engine.apply(newer, signal(), async () => {
+ throw new Error('source-changed')
+ }),
+ ).rejects.toThrow('source-changed')
+ expect(f.adapter.update).not.toHaveBeenCalled()
+ })
+ it('retains durable pending on scope loss and never writes results into the new library', async () => {
+ const f = fixture()
+ let active = true
+ const engine = createPublisher(f.adapter, f.store, () => active)
+ const create = f.adapter.create
+ f.adapter.create = vi.fn(async (...args: Parameters) => {
+ const result = await create(...args)
+ active = false
+ return result
+ })
+ await expect(
+ engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined),
+ ).rejects.toThrow('workspace-changed')
+ expect(f.store.save).toHaveBeenCalledTimes(1)
+ expect(Object.values(f.state().bindings)[0]?.state).toBe('pending')
+ })
+ it('recovers a lost deletion reply by reading the known ID and leaves local data intact', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined)
+ f.adapter.remove = vi.fn(async () => {
+ f.setRemote(null)
+ throw new Error('timeout')
+ })
+ await expect(
+ engine.apply(await engine.preview(draft, signal(), true), signal(), async () => undefined),
+ ).rejects.toThrow('timeout')
+ expect((await engine.reconcile('source', signal())).state).toBe('deleted')
+ expect(draft.content).toBe('正文')
+ })
+ it('rejects mismatched ownership, changed previews and concurrent writes', async () => {
+ const f = fixture()
+ const engine = createPublisher(f.adapter, f.store)
+ const plan = await engine.preview(draft, signal())
+ plan.draft.content = 'tampered'
+ await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow(
+ 'preview-changed',
+ )
+ f.setRemote({
+ id: '7',
+ url: 'https://example.com/other',
+ content: 'other post',
+ title: 'other',
+ status: 'publish',
+ category: '',
+ tags: [],
+ revision: 'a',
+ })
+ await expect(engine.preview(draft, signal())).rejects.toThrow('remote-binding-mismatch')
+ })
+})
diff --git a/packages/publishing-client/src/engine.ts b/packages/publishing-client/src/engine.ts
new file mode 100644
index 00000000..f96771b8
--- /dev/null
+++ b/packages/publishing-client/src/engine.ts
@@ -0,0 +1,185 @@
+import type { Adapter, Binding, Draft, Ledger, Preview, Remote, Store } from './types'
+import { fingerprint, hasMarker, publicationMarker } from './util'
+import { RemoteRejected } from './http'
+
+export const emptyLedger = (): Ledger => ({ version: 1, bindings: {} })
+const compactRemote = (remote: Remote): Remote => ({ ...remote, content: '' })
+export function createPublisher(
+ adapter: Adapter,
+ store: Store,
+ current: () => boolean = () => true,
+) {
+ let busy = false
+ const key = (sourceId: string) => `${adapter.connection.site}\n${sourceId}`
+ function active(signal: AbortSignal) {
+ signal.throwIfAborted()
+ if (!current()) throw new Error('workspace-changed')
+ }
+ function owned(remote: Remote, marker: string) {
+ if (!hasMarker(remote.content, marker)) throw new Error('remote-binding-mismatch')
+ return remote
+ }
+ async function lock(signal: AbortSignal, action: () => Promise): Promise {
+ active(signal)
+ if (busy) throw new Error('publication-busy')
+ busy = true
+ try {
+ return await action()
+ } finally {
+ busy = false
+ }
+ }
+ async function lookup(binding: Binding | undefined, marker: string, signal: AbortSignal) {
+ const remote = binding?.remote
+ ? await adapter.read(binding.remote.id, signal)
+ : await adapter.find(marker, signal)
+ active(signal)
+ if (remote) owned(remote, marker)
+ return remote
+ }
+ async function preview(draft: Draft, signal: AbortSignal, remove = false): Promise {
+ return lock(signal, async () => {
+ const state = await store.load()
+ active(signal)
+ const binding = state.bindings[key(draft.sourceId)]
+ if (binding && binding.adapter !== adapter.connection.adapter)
+ throw new Error('remote-binding-mismatch')
+ if (binding?.state === 'pending' || binding?.state === 'unknown')
+ throw new Error('reconcile-required')
+ const marker = await publicationMarker(adapter.connection.site, draft.sourceId)
+ const found = await lookup(binding, marker, signal)
+ const previous =
+ binding?.state === 'deleted' && found && ['trash', 'deleted'].includes(found.status)
+ ? null
+ : found
+ if (binding?.remote && !previous && binding.state !== 'deleted')
+ throw new Error('remote-missing')
+ if (remove && !previous) throw new Error('remote-missing')
+ const action = remove ? 'delete' : previous ? 'update' : 'create'
+ const connection = structuredClone(adapter.connection)
+ const stableDraft = structuredClone(draft)
+ const hash = await fingerprint([connection, stableDraft, previous, action])
+ active(signal)
+ return {
+ connection,
+ draft: stableDraft,
+ previous,
+ marker,
+ fingerprint: hash,
+ action,
+ at: Date.now(),
+ }
+ })
+ }
+ async function apply(plan: Preview, signal: AbortSignal, validateSource: () => Promise) {
+ return lock(signal, async () => {
+ if (Date.now() - plan.at > 10 * 60_000 || plan.at > Date.now() + 1_000)
+ throw new Error('preview-expired')
+ if (
+ (await fingerprint([adapter.connection, plan.draft, plan.previous, plan.action])) !==
+ plan.fingerprint
+ )
+ throw new Error('preview-changed')
+ const state = await store.load()
+ active(signal)
+ if (Object.keys(state.bindings).length >= 500 && !state.bindings[key(plan.draft.sourceId)])
+ throw new Error('binding-limit')
+ const existing = state.bindings[key(plan.draft.sourceId)]
+ if (existing?.state === 'pending' || existing?.state === 'unknown')
+ throw new Error('reconcile-required')
+ const found = await lookup(existing, plan.marker, signal)
+ const latest =
+ existing?.state === 'deleted' && found && ['trash', 'deleted'].includes(found.status)
+ ? null
+ : found
+ if ((await fingerprint(latest)) !== (await fingerprint(plan.previous)))
+ throw new Error('remote-changed')
+ await validateSource()
+ active(signal)
+ const binding: Binding = {
+ sourceId: plan.draft.sourceId,
+ site: adapter.connection.site,
+ adapter: adapter.connection.adapter,
+ marker: plan.marker,
+ ...(latest ? { remote: compactRemote(latest) } : {}),
+ sourceVersion: plan.draft.versionId,
+ state: 'pending',
+ pending: {
+ id: crypto.randomUUID(),
+ action: plan.action,
+ fingerprint: plan.fingerprint,
+ at: new Date().toISOString(),
+ },
+ }
+ // Publish intent durably before the first remote mutation. A failed save sends nothing.
+ state.bindings[key(binding.sourceId)] = binding
+ let mutationStarted = false
+ try {
+ await store.save(state)
+ active(signal)
+ // Intent persistence can yield while external material changes are accepted.
+ // Recheck the public projection immediately before the first remote write.
+ await validateSource()
+ active(signal)
+ mutationStarted = true
+ const remote =
+ plan.action === 'delete'
+ ? (await adapter.remove(latest!, signal), null)
+ : plan.action === 'create'
+ ? await adapter.create(plan.draft, plan.marker, signal)
+ : await adapter.update(latest!, plan.draft, plan.marker, signal)
+ active(signal)
+ if (remote) owned(remote, plan.marker)
+ const committed: Binding = {
+ ...binding,
+ state: remote ? 'ready' : 'deleted',
+ pending: undefined,
+ ...(remote ? { remote: compactRemote(remote) } : {}),
+ }
+ state.bindings[key(binding.sourceId)] = committed
+ await store.save(state)
+ active(signal)
+ return committed
+ } catch (error) {
+ // If this save also fails, the durable pending state still blocks unsafe retries.
+ if (current()) {
+ if (!mutationStarted || error instanceof RemoteRejected) {
+ if (existing) state.bindings[key(binding.sourceId)] = existing
+ else delete state.bindings[key(binding.sourceId)]
+ } else state.bindings[key(binding.sourceId)] = { ...binding, state: 'unknown' }
+ await store.save(state).catch(() => undefined)
+ }
+ throw error
+ }
+ })
+ }
+ async function reconcile(sourceId: string, signal: AbortSignal, remoteId?: string) {
+ return lock(signal, async () => {
+ const state = await store.load()
+ active(signal)
+ const binding = state.bindings[key(sourceId)]
+ if (!binding) throw new Error('binding-missing')
+ const remote = remoteId
+ ? await adapter.read(remoteId, signal)
+ : await lookup(binding, binding.marker, signal)
+ active(signal)
+ if (remote) owned(remote, binding.marker)
+ if (!remote || ['trash', 'deleted'].includes(remote.status)) {
+ if (binding.pending?.action !== 'delete' && binding.state !== 'deleted')
+ throw new Error('remote-unconfirmed')
+ state.bindings[key(sourceId)] = { ...binding, state: 'deleted', pending: undefined }
+ } else {
+ state.bindings[key(sourceId)] = {
+ ...binding,
+ remote: compactRemote(remote),
+ state: 'ready',
+ pending: undefined,
+ }
+ }
+ await store.save(state)
+ active(signal)
+ return state.bindings[key(sourceId)]!
+ })
+ }
+ return { preview, apply, reconcile, key }
+}
diff --git a/packages/publishing-client/src/http.test.ts b/packages/publishing-client/src/http.test.ts
new file mode 100644
index 00000000..a9ad0f00
--- /dev/null
+++ b/packages/publishing-client/src/http.test.ts
@@ -0,0 +1,65 @@
+import { expect, it, vi } from 'vitest'
+import { createTransport } from './http'
+import type { Connection } from './types'
+const connection: Connection = { site: 'https://site.example', adapter: 'rest', slot: 'account' }
+const state = { slots: [{ slot: 'account', origin: 'https://site.example', revision: 4 }] }
+it('uses device credential templates and rejects changed credentials without exposing body text', async () => {
+ const native = vi.fn(async (operation: string) =>
+ operation === 'secrets'
+ ? state
+ : {
+ status: 401,
+ body: 'sensitive server error',
+ headers: {},
+ credentialRevision: 4,
+ currentCredentialRevision: 4,
+ },
+ )
+ await expect(
+ createTransport(native, connection).json(
+ '/wp-json/wp/v2/posts',
+ 'GET',
+ new AbortController().signal,
+ ),
+ ).rejects.toThrow('remote-http-401')
+ expect(native.mock.calls[1]?.[0]).toBe('http')
+ native.mockImplementation(async (operation: string) =>
+ operation === 'secrets'
+ ? state
+ : {
+ status: 200,
+ body: '{}',
+ headers: {},
+ credentialRevision: 4,
+ currentCredentialRevision: 5,
+ },
+ )
+ await expect(
+ createTransport(native, connection).json('/posts', 'GET', new AbortController().signal),
+ ).rejects.toThrow('credentials-changed')
+})
+it('cancels the native request when the view aborts and drops its late response', async () => {
+ let finish!: (value: unknown) => void
+ const native = vi.fn(async (operation: string) =>
+ operation === 'secrets'
+ ? state
+ : operation === 'http.cancel'
+ ? null
+ : new Promise((resolve) => {
+ finish = resolve
+ }),
+ )
+ const controller = new AbortController()
+ const pending = createTransport(native, connection).json('/posts', 'GET', controller.signal)
+ await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
+ controller.abort()
+ finish({
+ status: 200,
+ body: '{}',
+ headers: {},
+ credentialRevision: 4,
+ currentCredentialRevision: 4,
+ })
+ await expect(pending).rejects.toThrow()
+ expect(native.mock.calls.some(([operation]) => operation === 'http.cancel')).toBe(true)
+})
diff --git a/packages/publishing-client/src/http.ts b/packages/publishing-client/src/http.ts
new file mode 100644
index 00000000..51f3b42d
--- /dev/null
+++ b/packages/publishing-client/src/http.ts
@@ -0,0 +1,100 @@
+import type { Connection, Native } from './types'
+import { record } from './util'
+export class RemoteRejected extends Error {
+ constructor(readonly status: number) {
+ super(`remote-http-${status}`)
+ }
+}
+/** A mutation may already have succeeded; failures in follow-up reads are never safe create retries. */
+export class PartialMutation extends Error {
+ constructor() {
+ super('remote-result-unknown')
+ }
+}
+export function responseError(status: number): Error {
+ return [400, 401, 403, 404, 405, 409, 412, 413, 415, 422, 429].includes(status)
+ ? new RemoteRejected(status)
+ : new Error(`remote-http-${status}`)
+}
+
+export function createTransport(native: Native, connection: Connection) {
+ let credentialRevision: number | undefined
+ async function request(
+ path: string,
+ method: string,
+ signal: AbortSignal,
+ data?: unknown,
+ xml?: string,
+ ) {
+ signal.throwIfAborted()
+ const url = new URL(`${connection.site}${path}`)
+ if (url.origin !== new URL(connection.site).origin) throw new Error('invalid-site')
+ if (credentialRevision === undefined) {
+ const state = record(await native('secrets', { action: 'state' }))
+ const slot = Array.isArray(state.slots)
+ ? state.slots
+ .map(record)
+ .find((item) => item.slot === connection.slot && item.origin === url.origin)
+ : undefined
+ if (!slot || typeof slot.revision !== 'number') throw new Error('credential-unavailable')
+ credentialRevision = slot.revision
+ }
+ signal.throwIfAborted()
+ const requestId = crypto.randomUUID()
+ const stop = () => {
+ void native('http.cancel', { requestId }).catch(() => undefined)
+ }
+ signal.addEventListener('abort', stop, { once: true })
+ try {
+ const result = record(
+ await native(
+ 'http',
+ {
+ requestId,
+ url: url.href,
+ method,
+ headers: { 'Content-Type': xml ? 'text/xml; charset=utf-8' : 'application/json' },
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
+ secret: {
+ slot: connection.slot,
+ expectedRevision: credentialRevision,
+ ...(xml
+ ? { body: { template: xml, encoding: 'xml' } }
+ : {
+ headers:
+ connection.adapter === 'user-api'
+ ? { 'User-Api-Key': '{{key}}' }
+ : { Authorization: '{{authorization}}' },
+ }),
+ },
+ },
+ signal,
+ ),
+ )
+ signal.throwIfAborted()
+ if (
+ result.credentialRevision !== credentialRevision ||
+ result.currentCredentialRevision !== credentialRevision
+ )
+ throw new Error('credentials-changed')
+ if (typeof result.status !== 'number' || typeof result.body !== 'string')
+ throw new Error('invalid-response')
+ return { status: result.status, body: result.body, headers: record(result.headers) }
+ } finally {
+ signal.removeEventListener('abort', stop)
+ }
+ }
+ return {
+ request,
+ async json(path: string, method: string, signal: AbortSignal, data?: unknown) {
+ const result = await request(path, method, signal, data)
+ if (result.status === 404 && method === 'GET') return null
+ if (result.status < 200 || result.status >= 300) throw responseError(result.status)
+ try {
+ return result.body ? (JSON.parse(result.body) as unknown) : {}
+ } catch {
+ throw new Error('invalid-response')
+ }
+ },
+ }
+}
diff --git a/packages/publishing-client/src/index.ts b/packages/publishing-client/src/index.ts
new file mode 100644
index 00000000..ead6fdaa
--- /dev/null
+++ b/packages/publishing-client/src/index.ts
@@ -0,0 +1,5 @@
+export * from './types'
+export * from './util'
+export * from './http'
+export * from './engine'
+export * from './xml'
diff --git a/packages/publishing-client/src/render.test.ts b/packages/publishing-client/src/render.test.ts
new file mode 100644
index 00000000..2d2cb5b1
--- /dev/null
+++ b/packages/publishing-client/src/render.test.ts
@@ -0,0 +1,100 @@
+import { expect, it } from 'vitest'
+import type { PublishingSource } from '@repeaty/plugin-api'
+import { emptyLedger } from './engine'
+import { publicMarkdown, renderMarkdown } from './render'
+import type { Ledger } from './types'
+const site = 'https://example.com'
+function source(body: string): PublishingSource {
+ return {
+ id: 'selected',
+ versionId: 'version',
+ title: '公开材料',
+ body,
+ format: 'markdown',
+ createdAt: '',
+ updatedAt: '',
+ assets: [],
+ fields: {},
+ links: [
+ { reference: '学习', sourceId: 'linked' },
+ { reference: 'source:linked', sourceId: 'linked' },
+ { reference: 'repeaty-source:note.md#section', sourceId: 'linked', fragment: 'section' },
+ ],
+ }
+}
+const ledger: Ledger = {
+ version: 1,
+ bindings: {
+ [`${site}\nlinked`]: {
+ sourceId: 'linked',
+ site,
+ adapter: 'rest',
+ marker: '',
+ state: 'ready',
+ remote: {
+ id: '7',
+ url: `${site}/public`,
+ title: '',
+ content: '',
+ status: 'publish',
+ category: '',
+ tags: [],
+ revision: '',
+ },
+ },
+ },
+}
+it('rewrites only Wiki and Markdown destinations, preserving ordinary words and code', () => {
+ const body =
+ '学习是日常任务。[[学习|去学习]] 和 [[source:linked|已发布文章]]\n\n`学习 [[学习]]`\n\n```ts\nconst 学习 = "[[学习]]"\n```\n\n[文章](repeaty-source:note.md#section)'
+ const output = publicMarkdown(source(body), ledger, site)
+ expect(output).toContain('学习是日常任务。[去学习]()')
+ expect(output).toContain('[已发布文章]()')
+ expect(output).toContain('`学习 [[学习]]`')
+ expect(output).toContain('const 学习 = "[[学习]]"')
+ expect(output).toContain('[文章]()')
+ expect(output).not.toContain('source:linked')
+})
+it('does not expose unmapped or unpublished internal identities', () => {
+ for (const body of [
+ '[[source:secret|未选择]]',
+ '[内部](repeaty-source:private.md)',
+ '[[学习]]',
+ '`[[source:secret|代码]]`',
+ ])
+ expect(() => publicMarkdown(source(body), emptyLedger(), site)).toThrow('local-links')
+})
+it('audits disallowed destinations even when the display renderer would turn them into text', () => {
+ for (const body of [
+ '[文件](file:///C:/private)',
+ '',
+ '[笔记](notes/private.md)',
+ '[脚本](javascript:alert(1))',
+ ' ',
+ '[引用][local]\n\n[local]: ../private',
+ '[unused]: /private',
+ 'C:\\Users\\private',
+ '\\\\server\\share',
+ ])
+ expect(() => publicMarkdown(source(body), ledger, site)).toThrow('local-links')
+ expect(() =>
+ publicMarkdown(
+ {
+ ...source('正文'),
+ assets: [{ id: 'media', reference: 'assets/a.png', size: 1, type: 'image/png' }],
+ },
+ ledger,
+ site,
+ ),
+ ).toThrow('local-assets')
+})
+it('preserves public Markdown, tables, language-labelled code and formula text without executing HTML', () => {
+ const body =
+ '# 标题\n\n**中文加粗**\n\n|A|B|\n|-|-|\n|1|2|\n\n```ts\nconst a = 1\n```\n\n$x^2$\n\n'
+ const html = renderMarkdown(publicMarkdown(source(body), ledger, site))
+ expect(html).toContain('中文加粗 ')
+ expect(html).toContain('')
+ expect(html).toContain('language-ts')
+ expect(html).toContain('$x^2$')
+ expect(html).not.toContain('
+
+
+
+
+ {{
+ t('停止', 'Stop')
+ }}
+ {{ build ? t('更新预览', 'Update preview') : t('生成预览', 'Generate preview') }}
+
+ {{ error }}
+ {{ notice }}
+
+
+
+
+
{{ t('公开材料', 'Public materials') }}
+ {{ selected.length }} / {{ choices.length }}
+
+
+
+ item.id)"
+ >{{ t('选择搜索结果', 'Select results') }} {{
+ t('清空', 'Clear')
+ }}
+
+
+
toggle(source.id, value)"
+ />
+
+ {{
+ loading ? t('正在读取…', 'Loading…') : t('没有匹配的材料', 'No matching materials')
+ }}
+
+
+
+ {{
+ t(
+ '只公开勾选的材料。未勾选材料的内部链接显示为普通文字。站点与主题在设置页调整。',
+ 'Only checked materials are public. Links to other materials become plain text. Configure the site and theme in Settings.',
+ )
+ }}
+
+
+
+
+
+ show(String(value))"
+ />{{ build.files.length }} {{ t('个文件', 'files') }}
+
+
+
+
+ {{
+ !fresh
+ ? t(
+ '这是上次保存的站点。请更新预览后导出或发布。',
+ 'This is the last saved site. Update the preview before export or publication.',
+ )
+ : t(
+ '预览中的内容将进入导出包或远端站点。',
+ 'The previewed content will be included in the archive or remote site.',
+ )
+ }}
+
+
{{ t('导出 ZIP', 'Export ZIP') }} {{ t('发布到 Cloudflare', 'Publish to Cloudflare') }}
+
+
+
+
+
{{ t('把值得分享的内容放在一起', 'Bring shareable ideas together') }}
+
+ {{
+ t(
+ '勾选左侧材料后生成预览。生成不会上传内容,也不会修改学习进度。',
+ 'Select materials on the left and generate a preview. Generation does not upload content or change study progress.',
+ )
+ }}
+
+
+
+
+
+
+
{{ t('确认公开到', 'Publish publicly to') }} {{ targetLabel }}
+
+ {{ t('本次公开', 'Public in this build:') }} {{ scope?.sources.length ?? 0 }}
+ {{
+ t(
+ '篇材料;比更新前的本地站点移除',
+ 'materials; removed from the previous local build:',
+ )
+ }}
+ {{ removedFromPreview }}
+ {{
+ t(
+ '篇。远端此分支将完整替换为当前预览。',
+ 'articles. This branch on the remote will be replaced by the preview.',
+ )
+ }}
+
+
+ {{
+ t(
+ '将上传当前预览的全部文件,并更新此项目对应分支的站点。',
+ 'Upload all files in this preview and update the site on the selected project branch.',
+ )
+ }}
+
+
+ {{
+ t('确认发布', 'Confirm publication')
+ }} {{
+ t('取消', 'Cancel')
+ }}
+
+
+
+
{{ t('最近部署', 'Latest deployment') }}
+
{{ remote.targetKey }}
+
{{
+ remoteLabel
+ }}
+
+ {{ remote.url }}
+
+
{{ message(new Error(remote.error)) }}
+
+ {{
+ t('核对远端状态', 'Check remote status')
+ }}
+
+
+
+
+
+
diff --git a/plugins/static-publish/src/PublishSettings.vue b/plugins/static-publish/src/PublishSettings.vue
new file mode 100644
index 00000000..c242429b
--- /dev/null
+++ b/plugins/static-publish/src/PublishSettings.vue
@@ -0,0 +1,510 @@
+
+
+
+
+
{{ error }}
+
{{ notice }}
+
+
+
+
+
+
+
+ {{ t('恢复默认主题', 'Reset theme') }}
+
+
+
+ {{
+ t(
+ 'Liquid 模板仅能读取公开的 site 和 page。正文使用 page.content | raw;不能读取文件或执行模板代码。',
+ 'Liquid templates can only read public site and page data. Use page.content | raw for content; templates cannot access files or execute code.',
+ )
+ }}
+
+
+
+ {{
+ t(
+ '将替换四份主题文件;学习材料和上次生成的站点仍保留。',
+ 'Replace all four theme files. Materials and the previous site remain intact.',
+ )
+ }}
+
+
{{
+ t('恢复主题', 'Reset theme')
+ }} {{
+ t('取消', 'Cancel')
+ }}
+
+
+
+ {{ t('保存站点与主题', 'Save site and theme') }}
+
+
+
+
+
+
+
+ {{ t('保存连接', 'Save connection') }} {{ t('检查项目', 'Check project') }} {{ t('创建项目', 'Create project') }}
+
+
+
+ {{
+ t(
+ '确认在已保存的账号下创建 Pages 项目?现有同名项目会先核对,不重复创建。',
+ 'Create the Pages project in the saved account? An existing project with this name is checked first.',
+ )
+ }}
+ {{ confirmedTarget.accountId }} / {{ confirmedTarget.projectName }} /
+ {{ confirmedTarget.branch }}
+
+
{{
+ t('确认创建', 'Confirm creation')
+ }} {{
+ t('取消', 'Cancel')
+ }}
+
+
+ {{ t('删除远端项目…', 'Delete remote project…') }}
+
+
+
+ {{ confirmedTarget.accountId }} / {{ confirmedTarget.projectName }} /
+ {{ confirmedTarget.branch }}
+
+
{{
+ t(
+ '这会删除远端项目及其全部部署,不只是当前页面。本地材料和站点不受影响。输入项目名称后确认。',
+ 'This deletes the remote project and all its deployments, not only the current page. Local materials and the site are kept. Type the project name to confirm.',
+ )
+ }} {{ t('确认删除远端项目', 'Delete remote project') }} {{ t('取消', 'Cancel') }}
+
+
+
+
+
+
diff --git a/plugins/static-publish/src/cloudflare/assets.ts b/plugins/static-publish/src/cloudflare/assets.ts
new file mode 100644
index 00000000..5d632ab9
--- /dev/null
+++ b/plugins/static-publish/src/cloudflare/assets.ts
@@ -0,0 +1,33 @@
+import { blake3 } from '@noble/hashes/blake3.js'
+import { validPath, type SiteFile } from '../model'
+
+export interface UploadAsset {
+ path: string
+ type: string
+ hash: string
+ base64: string
+}
+export function prepareAssets(files: readonly SiteFile[]): UploadAsset[] {
+ if (!files.length || files.length > 20_000) throw new Error('cloudflare-file-limit')
+ const paths = new Set()
+ let total = 0
+ return files.map((file) => {
+ if (!validPath(file.path) || paths.has(file.path)) throw new Error('cloudflare-invalid-path')
+ paths.add(file.path)
+ if (file.bytes.byteLength > 23 * 1024 * 1024) throw new Error('cloudflare-file-limit')
+ total += file.bytes.byteLength
+ if (total > 64 * 1024 * 1024) throw new Error('cloudflare-total-limit')
+ let binary = ''
+ for (let offset = 0; offset < file.bytes.length; offset += 16_384)
+ binary += String.fromCharCode(...file.bytes.subarray(offset, offset + 16_384))
+ const base64 = btoa(binary)
+ const name = file.path.split('/').at(-1)!
+ const extension = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : ''
+ // Pages Direct Upload uses the same key as Wrangler: BLAKE3(base64 + extension).
+ const hash = [...blake3(new TextEncoder().encode(base64 + extension))]
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('')
+ .slice(0, 32)
+ return { path: file.path, type: file.type, hash, base64 }
+ })
+}
diff --git a/plugins/static-publish/src/cloudflare/client.test.ts b/plugins/static-publish/src/cloudflare/client.test.ts
new file mode 100644
index 00000000..0563db4d
--- /dev/null
+++ b/plugins/static-publish/src/cloudflare/client.test.ts
@@ -0,0 +1,252 @@
+import { describe, expect, it, vi } from 'vitest'
+vi.mock('@repeaty/plugin-api', () => ({ resources: {} }))
+import {
+ createProject,
+ deploy,
+ reconcile,
+ removeProject,
+ targetKey,
+ type CloudflarePorts,
+ type CloudflareTarget,
+ type DeploymentState,
+} from './client'
+import { prepareAssets } from './assets'
+import { emptyIndex, file, type SiteBuild } from '../model'
+import type { PluginHttpRequest } from '@repeaty/plugin-api'
+
+const target: CloudflareTarget = {
+ accountId: 'a'.repeat(32),
+ projectName: 'learning',
+ branch: 'main',
+ credentialSlot: 'cloudflare',
+}
+const build: SiteBuild = {
+ files: [file('index.html', 'text/html', '学习 ')],
+ fingerprint: 'f'.repeat(64),
+ index: emptyIndex(),
+ generatedAt: '2026-10-30T00:00:00Z',
+ pages: [],
+}
+function response(result: unknown, status = 200) {
+ return {
+ status,
+ headers: {},
+ body: JSON.stringify({ success: status < 400, result }),
+ credentialRevision: null,
+ currentCredentialRevision: null,
+ }
+}
+function state(phase: DeploymentState['phase'] = 'submitting'): DeploymentState {
+ return {
+ version: 1,
+ operationId: 'operation',
+ targetKey: targetKey(target),
+ fingerprint: build.fingerprint,
+ phase,
+ startedAt: build.generatedAt,
+ }
+}
+function fixture() {
+ const saved: DeploymentState[] = []
+ const calls: PluginHttpRequest[] = []
+ let failSubmission = false
+ let remote: Record | undefined
+ const ports: CloudflarePorts = {
+ prepare: async (files) => prepareAssets(files),
+ save: async (value) => {
+ saved.push(structuredClone(value))
+ },
+ request: async (input) => {
+ calls.push(input)
+ if (input.url.endsWith('/upload-token')) return response({ jwt: 'short-lived-secret' })
+ if (input.url.endsWith('/check-missing'))
+ return response(prepareAssets(build.files).map((file) => file.hash))
+ if (input.url.endsWith('/assets/upload')) return response(null)
+ if (input.url.endsWith('/deployments') && input.method === 'POST') {
+ expect(saved.at(-1)?.phase).toBe('submitting')
+ const multipart = (input.body as { multipart: { name: string; text: string }[] }).multipart
+ remote = {
+ id: 'deployment-one',
+ url: 'https://learning.pages.dev/',
+ latest_stage: { status: 'success' },
+ deployment_trigger: {
+ metadata: {
+ commit_message: multipart.find((part) => part.name === 'commit_message')!.text,
+ branch: 'main',
+ },
+ },
+ }
+ if (failSubmission) throw new Error('connection closed after server commit')
+ return response(remote)
+ }
+ if (input.url.includes('/deployments?')) return response(remote ? [remote] : [])
+ if (input.url.includes('/deployments/')) return response(remote)
+ return response({ subdomain: 'learning.pages.dev' })
+ },
+ }
+ return {
+ ports,
+ saved,
+ calls,
+ failSubmission() {
+ failSubmission = true
+ },
+ }
+}
+describe('Cloudflare Pages publication journal', () => {
+ it('uploads content-addressed assets and a manifest without persisting credentials or local IDs', async () => {
+ const { ports, saved, calls } = fixture()
+ const result = await deploy(build, target, undefined, ports)
+ expect(result.phase).toBe('published')
+ expect(result.deploymentId).toBe('deployment-one')
+ expect(JSON.stringify(saved)).not.toContain('short-lived-secret')
+ const submission = calls.find((call) => call.url.endsWith('/deployments'))!
+ expect(submission.secret?.headers?.authorization).toBe('Bearer {{token}}')
+ expect(JSON.stringify(submission.body)).toContain('/index.html')
+ const before = calls.filter((call) => call.method === 'POST').length
+ await deploy(build, target, result, ports)
+ expect(calls.filter((call) => call.method === 'POST')).toHaveLength(before)
+ })
+ it('recovers a committed deployment after a lost response and never reposts an unresolved operation', async () => {
+ const fixtureA = fixture()
+ fixtureA.failSubmission()
+ const unknown = await deploy(build, target, undefined, fixtureA.ports)
+ expect(unknown.phase).toBe('unknown')
+ const recovered = await deploy(build, target, unknown, fixtureA.ports)
+ expect(recovered.phase).toBe('published')
+ expect(
+ fixtureA.calls.filter((call) => call.url.endsWith('/deployments') && call.method === 'POST'),
+ ).toHaveLength(1)
+ const fixtureB = fixture()
+ const absent = await deploy(build, target, state(), fixtureB.ports)
+ expect(absent.phase).toBe('unknown')
+ expect(fixtureB.calls.every((call) => !call.method || call.method === 'GET')).toBe(true)
+ })
+ it('does not send remote writes when the durable submission journal cannot be saved', async () => {
+ const { ports, calls } = fixture()
+ ports.save = async (value) => {
+ if (value.phase === 'submitting') throw new Error('disk-full')
+ }
+ const result = await deploy(build, target, undefined, ports)
+ expect(result.phase).toBe('failed')
+ expect(calls.some((call) => call.url.endsWith('/deployments') && call.method === 'POST')).toBe(
+ false,
+ )
+ })
+ it('revalidates the source after asset uploads and before the final non-idempotent submission', async () => {
+ const { ports, calls } = fixture()
+ let changed = false
+ const request = ports.request
+ ports.request = async (input, signal) => {
+ const result = await request(input, signal)
+ if (input.url.endsWith('/assets/upload')) changed = true
+ return result
+ }
+ ports.validate = () => {
+ if (changed) throw new Error('publishing-library-changed')
+ }
+ const result = await deploy(build, target, undefined, ports)
+ expect(result.phase).toBe('failed')
+ expect(result.error).toBe('publishing-library-changed')
+ expect(calls.some((call) => call.url.endsWith('/deployments') && call.method === 'POST')).toBe(
+ false,
+ )
+ changed = false
+ ports.request = request
+ expect((await deploy(build, target, result, ports)).phase).toBe('published')
+ })
+ it('keeps an uncertain remote result if recording the acknowledged deployment fails', async () => {
+ const { ports, calls } = fixture()
+ const save = ports.save
+ ports.save = async (value) => {
+ if (value.phase === 'published') throw new Error('disk-full')
+ await save(value)
+ }
+ expect((await deploy(build, target, undefined, ports)).phase).toBe('unknown')
+ expect(
+ calls.filter((call) => call.url.endsWith('/deployments') && call.method === 'POST'),
+ ).toHaveLength(1)
+ })
+ it('allows retry after cancellation before submission or an explicit authentication rejection', async () => {
+ const cancelled = fixture()
+ const abort = new AbortController()
+ const save = cancelled.ports.save
+ cancelled.ports.save = async (value) => {
+ await save(value)
+ if (value.phase === 'submitting') abort.abort()
+ }
+ expect((await deploy(build, target, undefined, cancelled.ports, abort.signal)).phase).toBe(
+ 'failed',
+ )
+ expect(
+ cancelled.calls.some((call) => call.url.endsWith('/deployments') && call.method === 'POST'),
+ ).toBe(false)
+
+ const rejected = fixture()
+ const request = rejected.ports.request
+ let deny = true
+ rejected.ports.request = async (input, signal) => {
+ if (deny && input.url.endsWith('/deployments') && input.method === 'POST')
+ return response(null, 403)
+ return request(input, signal)
+ }
+ const failed = await deploy(build, target, undefined, rejected.ports)
+ expect(failed.phase).toBe('failed')
+ expect(failed.error).toBe('cloudflare-auth-failed')
+ deny = false
+ expect((await deploy(build, target, failed, rejected.ports)).phase).toBe('published')
+ })
+ it('rejects target changes while an unresolved operation exists and honors cancellation before upload', async () => {
+ const { ports, calls } = fixture()
+ await expect(
+ deploy(build, { ...target, projectName: 'other' }, state(), ports),
+ ).rejects.toThrow('cloudflare-pending-other-target')
+ const abort = new AbortController()
+ abort.abort()
+ await expect(deploy(build, target, undefined, ports, abort.signal)).rejects.toThrow()
+ expect(calls).toHaveLength(0)
+ await expect(reconcile({ ...target, branch: 'other' }, state(), ports)).rejects.toThrow(
+ 'cloudflare-state-mismatch',
+ )
+ })
+ it('reconciles project create conflicts and project deletion without implicit retries', async () => {
+ let exists = false
+ let creates = 0
+ const saved: DeploymentState[] = []
+ const ports: CloudflarePorts = {
+ save: async (value) => {
+ saved.push(value)
+ },
+ request: async (input) => {
+ if (input.method === 'POST') {
+ creates++
+ exists = true
+ return response(null, 409)
+ }
+ if (input.method === 'DELETE') {
+ exists = false
+ throw new Error('reply lost')
+ }
+ return response(exists ? { subdomain: 'learning.pages.dev' } : null, exists ? 200 : 404)
+ },
+ }
+ expect((await createProject(target, ports)).exists).toBe(true)
+ expect(creates).toBe(1)
+ const deleting = await removeProject(target, state('published'), ports)
+ expect(deleting.phase).toBe('deleting')
+ expect((await reconcile(target, deleting, ports)).phase).toBe('deleted')
+ })
+ it('hashes extension and bytes deterministically, rejecting duplicate and unsafe output paths', () => {
+ expect(prepareAssets([file('index.html', 'text/html', 'Hello ')])[0]!.hash).toBe(
+ '8deb79e268ae1932009657bb3e496c87',
+ )
+ const first = prepareAssets(build.files)[0]!
+ expect(first.hash).toMatch(/^[a-f0-9]{32}$/u)
+ expect(first.hash).toBe(prepareAssets(build.files)[0]!.hash)
+ expect(prepareAssets([{ ...build.files[0]!, path: 'index.txt' }])[0]!.hash).not.toBe(first.hash)
+ expect(() => prepareAssets([...build.files, ...build.files])).toThrow('cloudflare-invalid-path')
+ expect(() => prepareAssets([{ ...build.files[0]!, path: '../private' }])).toThrow(
+ 'cloudflare-invalid-path',
+ )
+ })
+})
diff --git a/plugins/static-publish/src/cloudflare/client.ts b/plugins/static-publish/src/cloudflare/client.ts
new file mode 100644
index 00000000..dc3a7db3
--- /dev/null
+++ b/plugins/static-publish/src/cloudflare/client.ts
@@ -0,0 +1,449 @@
+import type { PluginHttpRequest, PluginHttpResponse } from '@repeaty/plugin-api'
+import { resources } from '@repeaty/plugin-api'
+import type { SiteBuild, SiteFile } from '../model'
+import type { UploadAsset } from './assets'
+
+export const cloudflareOrigin = 'https://api.cloudflare.com'
+const api = cloudflareOrigin + '/client/v4'
+export interface CloudflareTarget {
+ accountId: string
+ projectName: string
+ branch: string
+ credentialSlot: string
+ credentialRevision?: number
+}
+export interface DeploymentState {
+ version: 1
+ operationId: string
+ targetKey: string
+ fingerprint: string
+ phase:
+ | 'prepared'
+ | 'uploading'
+ | 'submitting'
+ | 'unknown'
+ | 'deploying'
+ | 'published'
+ | 'failed'
+ | 'deleting'
+ | 'deleted'
+ deploymentId?: string
+ url?: string
+ startedAt: string
+ error?: string
+}
+export interface CloudflarePorts {
+ request(input: PluginHttpRequest, signal?: AbortSignal): Promise
+ save(state: DeploymentState): Promise
+ validate?(): void
+ prepare?(files: SiteFile[], signal?: AbortSignal): Promise
+}
+interface Deployment {
+ id?: string
+ url?: string
+ latest_stage?: { status?: string }
+ deployment_trigger?: {
+ metadata?: { commit_message?: string; commit_hash?: string; branch?: string }
+ }
+}
+export class CloudflareError extends Error {
+ constructor(
+ readonly code: string,
+ readonly status?: number,
+ ) {
+ super(code)
+ }
+}
+function check(signal?: AbortSignal) {
+ signal?.throwIfAborted()
+}
+export function targetKey(target: CloudflareTarget) {
+ if (
+ !/^[a-f0-9]{32}$/iu.test(target.accountId) ||
+ !/^[a-z0-9](?:[a-z0-9-]{0,56}[a-z0-9])?$/u.test(target.projectName) ||
+ !target.branch ||
+ target.branch.length > 100 ||
+ /[\u0000-\u001f]/u.test(target.branch) ||
+ !/^[a-zA-Z0-9._-]{1,128}$/u.test(target.credentialSlot)
+ )
+ throw new CloudflareError('cloudflare-invalid-target')
+ return `${target.accountId.toLowerCase()}/${target.projectName}/${target.branch}`
+}
+function endpoint(target: CloudflareTarget) {
+ targetKey(target)
+ return `${api}/accounts/${target.accountId}/pages/projects/${target.projectName}`
+}
+function marker(state: DeploymentState) {
+ return `repeaty:${state.operationId}:${state.fingerprint}`
+}
+function secret(target: CloudflareTarget) {
+ return {
+ slot: target.credentialSlot,
+ expectedRevision: target.credentialRevision,
+ headers: { authorization: 'Bearer {{token}}' },
+ }
+}
+function safeUrl(value: unknown) {
+ if (typeof value !== 'string') return undefined
+ try {
+ const url = new URL(value)
+ return url.protocol === 'https:' && !url.username && !url.password ? url.href : undefined
+ } catch {
+ return undefined
+ }
+}
+async function request(
+ ports: CloudflarePorts,
+ input: PluginHttpRequest,
+ signal?: AbortSignal,
+): Promise {
+ check(signal)
+ let response: PluginHttpResponse
+ try {
+ response = await ports.request(input, signal)
+ } catch {
+ check(signal)
+ throw new CloudflareError('cloudflare-network-unknown')
+ }
+ check(signal)
+ if (response.credentialRevision !== response.currentCredentialRevision)
+ throw new CloudflareError('cloudflare-credentials-changed')
+ let envelope: { success?: boolean; result?: T }
+ try {
+ envelope = JSON.parse(response.body)
+ } catch {
+ throw new CloudflareError('cloudflare-invalid-response', response.status)
+ }
+ if (response.status < 200 || response.status >= 300 || envelope.success === false)
+ throw new CloudflareError(
+ response.status === 401 || response.status === 403
+ ? 'cloudflare-auth-failed'
+ : 'cloudflare-request-failed',
+ response.status,
+ )
+ if (!Object.hasOwn(envelope, 'result') && envelope.success !== true)
+ throw new CloudflareError('cloudflare-invalid-response', response.status)
+ return envelope.result as T
+}
+export async function inspectProject(
+ target: CloudflareTarget,
+ ports: CloudflarePorts,
+ signal?: AbortSignal,
+): Promise<{ exists: boolean; subdomain?: string }> {
+ try {
+ const result = await request<{ subdomain?: string }>(
+ ports,
+ { url: endpoint(target), secret: secret(target) },
+ signal,
+ )
+ return {
+ exists: true,
+ subdomain: typeof result?.subdomain === 'string' ? result.subdomain : undefined,
+ }
+ } catch (error) {
+ if (error instanceof CloudflareError && error.status === 404) return { exists: false }
+ throw error
+ }
+}
+export async function createProject(
+ target: CloudflareTarget,
+ ports: CloudflarePorts,
+ signal?: AbortSignal,
+) {
+ const current = await inspectProject(target, ports, signal)
+ if (current.exists) return current
+ try {
+ await request(
+ ports,
+ {
+ url: `${api}/accounts/${target.accountId}/pages/projects`,
+ method: 'POST',
+ secret: secret(target),
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name: target.projectName, production_branch: target.branch }),
+ },
+ signal,
+ )
+ } catch (error) {
+ // Names are unique remotely. An interrupted create is reconciled by GET, never repeated here.
+ if (
+ error instanceof CloudflareError &&
+ (error.status === 409 || error.code === 'cloudflare-network-unknown')
+ ) {
+ const found = await inspectProject(target, ports, signal)
+ if (found.exists) return found
+ }
+ throw error
+ }
+ return inspectProject(target, ports, signal)
+}
+async function prepare(files: SiteFile[], signal?: AbortSignal): Promise {
+ check(signal)
+ const source = resources.read('cloudflare-worker.js')
+ if (!source) throw new CloudflareError('cloudflare-worker-missing')
+ const scope = resources.createScope()
+ const abort = () => scope.dispose()
+ signal?.addEventListener('abort', abort, { once: true })
+ try {
+ const worker = scope.createWorker(await source.text())
+ check(signal)
+ return await new Promise((resolve, reject) => {
+ const cancelled = () => reject(new CloudflareError('cloudflare-cancelled'))
+ scope.signal.addEventListener('abort', cancelled, { once: true })
+ worker.onmessage = (event: MessageEvent) => {
+ scope.signal.removeEventListener('abort', cancelled)
+ if (event.data.error) reject(new CloudflareError('cloudflare-assets-invalid'))
+ else resolve(event.data.assets)
+ }
+ worker.onerror = () => reject(new CloudflareError('cloudflare-assets-invalid'))
+ worker.postMessage({ files })
+ })
+ } finally {
+ signal?.removeEventListener('abort', abort)
+ scope.dispose()
+ }
+}
+async function persist(
+ ports: CloudflarePorts,
+ state: DeploymentState,
+ patch: Partial,
+) {
+ const next = { ...state, ...patch }
+ await ports.save(next)
+ return next
+}
+function deploymentState(state: DeploymentState, deployment: Deployment): DeploymentState {
+ if (
+ !deployment ||
+ typeof deployment.id !== 'string' ||
+ !/^[a-zA-Z0-9-]{1,100}$/u.test(deployment.id)
+ )
+ throw new CloudflareError('cloudflare-invalid-response')
+ const status = deployment.latest_stage?.status
+ return {
+ ...state,
+ deploymentId: deployment.id,
+ url: safeUrl(deployment.url),
+ phase:
+ status === 'success'
+ ? 'published'
+ : ['failure', 'canceled'].includes(status ?? '')
+ ? 'failed'
+ : 'deploying',
+ error: undefined,
+ }
+}
+function verifyState(target: CloudflareTarget, state: DeploymentState) {
+ if (
+ state.version !== 1 ||
+ state.targetKey !== targetKey(target) ||
+ !/^[a-zA-Z0-9-]{1,100}$/u.test(state.operationId) ||
+ !/^[a-f0-9]{64}$/u.test(state.fingerprint)
+ )
+ throw new CloudflareError('cloudflare-state-mismatch')
+}
+export async function reconcile(
+ target: CloudflareTarget,
+ state: DeploymentState,
+ ports: CloudflarePorts,
+ signal?: AbortSignal,
+): Promise {
+ verifyState(target, state)
+ if (state.phase === 'deleting' || state.phase === 'deleted') {
+ const project = await inspectProject(target, ports, signal)
+ return persist(ports, state, { phase: project.exists ? 'deleting' : 'deleted' })
+ }
+ let found: Deployment | undefined
+ if (state.deploymentId) {
+ try {
+ found = await request(
+ ports,
+ {
+ url: `${endpoint(target)}/deployments/${encodeURIComponent(state.deploymentId)}`,
+ secret: secret(target),
+ },
+ signal,
+ )
+ } catch (error) {
+ if (!(error instanceof CloudflareError && error.status === 404)) throw error
+ }
+ } else {
+ for (let page = 1; page <= 20; page++) {
+ const deployments = await request(
+ ports,
+ { url: `${endpoint(target)}/deployments?per_page=25&page=${page}`, secret: secret(target) },
+ signal,
+ )
+ if (!Array.isArray(deployments)) throw new CloudflareError('cloudflare-invalid-response')
+ found = deployments.find(
+ (item) =>
+ item.deployment_trigger?.metadata?.commit_message === marker(state) &&
+ item.deployment_trigger?.metadata?.branch === target.branch,
+ )
+ if (found || deployments.length < 25) break
+ }
+ }
+ if (!found)
+ return persist(ports, state, { phase: 'unknown', error: 'cloudflare-result-unconfirmed' })
+ const next = deploymentState(state, found)
+ await ports.save(next)
+ return next
+}
+export async function deploy(
+ build: SiteBuild,
+ target: CloudflareTarget,
+ previous: DeploymentState | undefined,
+ ports: CloudflarePorts,
+ signal?: AbortSignal,
+): Promise {
+ check(signal)
+ const key = targetKey(target)
+ if (
+ previous &&
+ previous.targetKey !== key &&
+ ['submitting', 'unknown', 'deploying', 'deleting'].includes(previous.phase)
+ )
+ throw new CloudflareError('cloudflare-pending-other-target')
+ if (
+ previous &&
+ previous.targetKey === key &&
+ ['submitting', 'unknown', 'deploying', 'deleting'].includes(previous.phase)
+ )
+ return reconcile(target, previous, ports, signal)
+ if (
+ previous?.targetKey === key &&
+ previous.phase === 'published' &&
+ previous.fingerprint === build.fingerprint
+ )
+ return reconcile(target, previous, ports, signal)
+ if (!/^[a-f0-9]{64}$/u.test(build.fingerprint))
+ throw new CloudflareError('cloudflare-invalid-build')
+ let state: DeploymentState = {
+ version: 1,
+ operationId: crypto.randomUUID(),
+ targetKey: key,
+ fingerprint: build.fingerprint,
+ phase: 'prepared',
+ startedAt: new Date().toISOString(),
+ }
+ await ports.save(state)
+ let submissionStarted = false
+ try {
+ ports.validate?.()
+ if (!(await inspectProject(target, ports, signal)).exists)
+ throw new CloudflareError('cloudflare-project-missing')
+ const files = await (ports.prepare ?? prepare)(build.files, signal)
+ check(signal)
+ ports.validate?.()
+ const token = await request<{ jwt: string }>(
+ ports,
+ { url: `${endpoint(target)}/upload-token`, secret: secret(target) },
+ signal,
+ )
+ if (!token || typeof token.jwt !== 'string' || !token.jwt || token.jwt.length > 8192)
+ throw new CloudflareError('cloudflare-invalid-response')
+ const headers = { authorization: `Bearer ${token.jwt}`, 'content-type': 'application/json' }
+ const missing = await request(
+ ports,
+ {
+ url: `${api}/pages/assets/check-missing`,
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ hashes: files.map((file) => file.hash) }),
+ },
+ signal,
+ )
+ if (
+ !Array.isArray(missing) ||
+ missing.some((hash) => !files.some((file) => file.hash === hash))
+ )
+ throw new CloudflareError('cloudflare-invalid-response')
+ state = await persist(ports, state, { phase: 'uploading' })
+ for (const file of files.filter((file) => missing.includes(file.hash))) {
+ ports.validate?.()
+ await request(
+ ports,
+ {
+ url: `${api}/pages/assets/upload`,
+ method: 'POST',
+ headers,
+ body: JSON.stringify([
+ {
+ key: file.hash,
+ value: file.base64,
+ metadata: { contentType: file.type },
+ base64: true,
+ },
+ ]),
+ },
+ signal,
+ )
+ }
+ // The durable uncertain state precedes the only non-idempotent write.
+ state = await persist(ports, state, { phase: 'submitting' })
+ check(signal)
+ ports.validate?.()
+ submissionStarted = true
+ const deployment = await request(
+ ports,
+ {
+ url: `${endpoint(target)}/deployments`,
+ method: 'POST',
+ secret: secret(target),
+ body: {
+ multipart: [
+ { name: 'branch', text: target.branch },
+ {
+ name: 'manifest',
+ text: JSON.stringify(
+ Object.fromEntries(files.map((file) => [`/${file.path}`, file.hash])),
+ ),
+ },
+ { name: 'commit_message', text: marker(state) },
+ { name: 'commit_hash', text: state.fingerprint.slice(0, 40) },
+ { name: 'commit_dirty', text: 'false' },
+ ],
+ },
+ },
+ signal,
+ )
+ const next = deploymentState(state, deployment)
+ await ports.save(next)
+ return next
+ } catch (error) {
+ const rejected = error instanceof CloudflareError && error.code === 'cloudflare-auth-failed'
+ const uncertain = submissionStarted && !rejected
+ return persist(ports, state, {
+ phase: uncertain ? 'unknown' : 'failed',
+ error: uncertain
+ ? 'cloudflare-result-unconfirmed'
+ : error instanceof CloudflareError
+ ? error.code
+ : error instanceof Error && /^(publishing-[a-z-]+|publication-stale)$/.test(error.message)
+ ? error.message
+ : signal?.aborted
+ ? 'cloudflare-cancelled'
+ : 'cloudflare-request-failed',
+ })
+ }
+}
+export async function removeProject(
+ target: CloudflareTarget,
+ state: DeploymentState,
+ ports: CloudflarePorts,
+ signal?: AbortSignal,
+) {
+ verifyState(target, state)
+ // Caller must explicitly confirm deleting the project and all its deployments.
+ const deleting = await persist(ports, state, { phase: 'deleting' })
+ try {
+ await request(
+ ports,
+ { url: endpoint(target), method: 'DELETE', secret: secret(target) },
+ signal,
+ )
+ } catch (error) {
+ if (!(error instanceof CloudflareError && error.status === 404)) return deleting
+ }
+ return reconcile(target, deleting, ports, signal)
+}
diff --git a/plugins/static-publish/src/cloudflare/worker.ts b/plugins/static-publish/src/cloudflare/worker.ts
new file mode 100644
index 00000000..3ec6c901
--- /dev/null
+++ b/plugins/static-publish/src/cloudflare/worker.ts
@@ -0,0 +1,8 @@
+import { prepareAssets } from './assets'
+self.onmessage = (event: MessageEvent) => {
+ try {
+ self.postMessage({ assets: prepareAssets(event.data.files) })
+ } catch {
+ self.postMessage({ error: 'cloudflare-assets-invalid' })
+ }
+}
diff --git a/plugins/static-publish/src/generate.test.ts b/plugins/static-publish/src/generate.test.ts
new file mode 100644
index 00000000..53f69d2a
--- /dev/null
+++ b/plugins/static-publish/src/generate.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest'
+import { generateSite } from './generate'
+import { defaultTheme } from './theme'
+import { emptyIndex, type PublishSource } from './model'
+import { renderTemplate } from './template'
+const now = '2026-10-30T12:00:00Z'
+const options = {
+ title: '学习札记',
+ description: '让知识互相连接',
+ url: 'https://notes.example.com/study/',
+ pageSize: 1,
+}
+const source = (id = 'private-source-1'): PublishSource => ({
+ id,
+ versionId: 'private-version-1',
+ title: '间隔重复',
+ text: '把记忆留给时间。',
+ format: 'markdown',
+ fields: [{ key: 'tags', values: ['学习', '记忆'] }],
+ resources: [],
+})
+const input = (sources = [source()]) => ({
+ sources,
+ index: emptyIndex(),
+ options,
+ theme: defaultTheme(),
+ now,
+})
+const contents = (result: Awaited>, path: string) =>
+ new TextDecoder().decode(result.files.find((item) => item.path === path)!.bytes)
+
+describe('public site projection', () => {
+ it('creates navigable pages, taxonomy, search, valid RSS and stable public paths without source identity', async () => {
+ const first = source()
+ const second = {
+ ...source('private-source-2'),
+ title: '主动回忆',
+ text: '[[source:private-source-1|间隔重复]]',
+ fields: [{ key: 'category', values: ['学习方法'] }],
+ }
+ const build = await generateSite(input([first, second]))
+ expect(build.pages.map((item) => item.path)).toContain('page/2/index.html')
+ expect(build.pages.map((item) => item.path)).toContain('search/index.html')
+ expect(
+ build.pages.some((item) => item.path.startsWith('tags/') && item.path !== 'tags/index.html'),
+ ).toBe(true)
+ expect(contents(build, 'articles/2/index.html')).toContain('../../articles/1/index.html')
+ expect(contents(build, 'feed.xml')).toContain(
+ 'https://notes.example.com/study/articles/1/index.html',
+ )
+ const exported = build.files
+ .filter((item) => item.type.includes('text') || item.type.includes('xml'))
+ .map((item) => new TextDecoder().decode(item.bytes))
+ .join('\n')
+ expect(exported).not.toContain('private-source-')
+ expect(exported).not.toContain('private-version-')
+ expect(contents(build, 'search-index.js')).toContain('主动回忆')
+ expect(build.fingerprint).toMatch(/^[a-f\d]{64}$/u)
+ })
+ it('preserves first publication and identity across changes, exclusion and republication', async () => {
+ const first = await generateSite(input())
+ const excluded = await generateSite({
+ ...input([]),
+ index: first.index,
+ now: '2026-11-01T12:00:00Z',
+ })
+ expect(excluded.index.bindings[0]?.active).toBe(false)
+ expect(excluded.files.some((item) => item.path.startsWith('articles/'))).toBe(false)
+ const restored = await generateSite({
+ ...input([{ ...source(), text: '新的文字', versionId: 'second' }]),
+ index: excluded.index,
+ now: '2026-11-02T12:00:00Z',
+ })
+ expect(restored.index.bindings[0]).toMatchObject({
+ publicId: 1,
+ publishedAt: now,
+ updatedAt: '2026-11-02T12:00:00Z',
+ active: true,
+ })
+ expect(first.index.bindings[0]).toMatchObject({ active: true, updatedAt: now })
+ })
+ it('projects heading links to public article anchors and deduplicates heading ids', async () => {
+ const first = { ...source(), text: '# 核心 方法\n\n## 核心 方法' }
+ const second = {
+ ...source('second'),
+ text: '[[source:private-source-1#核心 方法|相关段落]] [段落](repeaty-source:private-source-1#核心)',
+ links: [
+ {
+ reference: 'repeaty-source:private-source-1#核心',
+ sourceId: first.id,
+ fragment: '核心 方法',
+ },
+ ],
+ }
+ const build = await generateSite(input([first, second]))
+ expect(contents(build, 'articles/1/index.html')).toContain('id="核心-方法"')
+ expect(contents(build, 'articles/1/index.html')).toContain('id="核心-方法-1"')
+ expect(
+ contents(build, 'articles/2/index.html').match(
+ /articles\/1\/index.html#%E6%A0%B8%E5%BF%83-%E6%96%B9%E6%B3%95/gu,
+ ),
+ ).toHaveLength(2)
+ })
+ it('is deterministic for unchanged public content and does not change update dates on rebuild', async () => {
+ const first = await generateSite(input())
+ const again = await generateSite({
+ ...input(),
+ index: first.index,
+ now: '2026-11-01T12:00:00Z',
+ })
+ expect(again.fingerprint).toBe(first.fingerprint)
+ expect(again.index.bindings[0]?.updatedAt).toBe(now)
+ })
+ it('exports only explicitly projected resources, blocks unsafe HTML/links and renders code and math', async () => {
+ const item = source()
+ item.text =
+ ' \n\n [未知](repeaty-source:private-source-99)\n\n```js\nconst n = 1\n```\n\n$x^2$ **中文加粗**'
+ item.resources.push({
+ reference: 'repeaty-resource:assets/a.png',
+ file: { path: 'assets/image.png', type: 'image/png', bytes: new Uint8Array([1, 2, 3]) },
+ })
+ const build = await generateSite(input([item]))
+ const html = contents(build, 'articles/1/index.html')
+ expect(html).toContain('../../assets/image.png')
+ expect(html).not.toContain('bad()')
+ expect(html).not.toContain('file:///')
+ expect(html).not.toContain('repeaty-source:')
+ expect(html).toContain('hljs-keyword')
+ expect(html).toContain('中文加粗')
+ })
+ it('rejects conflicting asset paths and oversized or corrupt data before replacing a build', async () => {
+ await expect(
+ generateSite(input([{ ...source(), text: '' }])),
+ ).rejects.toThrow('外部图片或媒体')
+ const a = source(),
+ b = source('two')
+ a.resources = [
+ {
+ reference: 'one',
+ file: { path: 'assets/x.png', type: 'image/png', bytes: new Uint8Array([1]) },
+ },
+ ]
+ b.resources = [
+ {
+ reference: 'two',
+ file: { path: 'assets/x.png', type: 'image/png', bytes: new Uint8Array([2]) },
+ },
+ ]
+ await expect(generateSite(input([a, b]))).rejects.toThrow('冲突')
+ await expect(
+ generateSite({
+ ...input(),
+ index: { version: 1, nextId: 1, bindings: [{ sourceId: 'x', publicId: 2 } as never] },
+ }),
+ ).rejects.toThrow('发布记录')
+ await expect(
+ generateSite({ ...input(), theme: { ...defaultTheme(), css: 'a'.repeat(256 * 1024 + 1) } }),
+ ).rejects.toThrow('大小')
+ })
+})
+describe('theme boundary', () => {
+ it('escapes values, permits deliberate raw HTML and reports source locations', () => {
+ expect(
+ renderTemplate(
+ '{{ page.title }} {{ page.content | raw }}',
+ { page: { title: 'x ', content: '正文 ' } },
+ 'page.html',
+ ),
+ ).toBe('<b>x</b> 正文 ')
+ expect(() => renderTemplate('a\n{{ private }}', {}, 'page.html')).toThrow(/page.html 第 2 行/u)
+ expect(() =>
+ renderTemplate('{{ page.title | wrong_filter }}', { page: { title: 'x' } }, 'page.html'),
+ ).toThrow('wrong_filter')
+ })
+ it('denies files and prototype reads, with bounded rendering', () => {
+ expect(() => renderTemplate('{% render "secret" %}', {}, 'page.html')).toThrow('引入')
+ expect(() => renderTemplate('{{ page.constructor }}', { page: {} }, 'page.html')).toThrow()
+ expect(() =>
+ renderTemplate('{% for i in (1..999999999) %}x{% endfor %}', {}, 'page.html'),
+ ).toThrow()
+ })
+})
diff --git a/plugins/static-publish/src/generate.ts b/plugins/static-publish/src/generate.ts
new file mode 100644
index 00000000..9f0bd52a
--- /dev/null
+++ b/plugins/static-publish/src/generate.ts
@@ -0,0 +1,372 @@
+import { parseFragment, type DefaultTreeAdapterMap } from 'parse5'
+import {
+ file,
+ hash,
+ escape,
+ publicUrl,
+ validPath,
+ limits,
+ encoder,
+ type PublishSource,
+ type SiteBuild,
+ type SiteIndex,
+ type SiteOptions,
+ type SiteTheme,
+ type PublicField,
+} from './model'
+import { renderSource } from './render'
+import { renderTemplate } from './template'
+
+function text(html: string) {
+ let result = ''
+ const walk = (node: DefaultTreeAdapterMap['node']) => {
+ if ('tagName' in node && node.tagName === 'annotation') return
+ if (node.nodeName === '#text' && 'value' in node) result += node.value + ' '
+ if ('childNodes' in node) node.childNodes.forEach(walk)
+ }
+ walk(parseFragment(html))
+ return result.replace(/\s+/gu, ' ').trim()
+}
+interface Topic {
+ id: string
+ path: string
+ title: string
+ published_at: string
+ updated_at: string
+ content: string
+ excerpt: string
+ fields: PublicField[]
+ searchText: string
+}
+function safeFields(input: PublicField[]) {
+ if (input.length > 64) throw new Error('公开字段不能超过 64 项。')
+ return input.map(({ key, values }) => {
+ if (
+ !key ||
+ key.length > 100 ||
+ ['__proto__', 'constructor', 'prototype'].includes(key) ||
+ values.length > 100 ||
+ values.some((value) => typeof value !== 'string' || value.length > 4000)
+ )
+ throw new Error('公开字段格式无效。')
+ return { key, values: [...values] }
+ })
+}
+export async function generateSite(
+ input: {
+ sources: PublishSource[]
+ index: SiteIndex
+ options: SiteOptions
+ theme: SiteTheme
+ now?: string
+ },
+ progress: (completed: number, total: number) => void = () => {},
+): Promise {
+ const now = input.now ?? new Date().toISOString()
+ if (!Number.isFinite(Date.parse(now))) throw new Error('生成时间无效。')
+ if (
+ input.sources.length > limits.sources ||
+ new Set(input.sources.map((item) => item.id)).size !== input.sources.length
+ )
+ throw new Error('单个站点最多发布 500 篇不同材料。')
+ const options = input.options
+ if (
+ !options.title.trim() ||
+ options.title.length > 160 ||
+ options.description.length > 1000 ||
+ !Number.isInteger(options.pageSize) ||
+ options.pageSize < 1 ||
+ options.pageSize > 50
+ )
+ throw new Error('请检查站点标题、说明和每页数量。')
+ const address = publicUrl(options.url)
+ const theme = input.theme
+ for (const [key, value] of Object.entries(theme))
+ if (typeof value !== 'string' || encoder.encode(value).length > limits.themeBytes)
+ throw new Error(`${key} 超过主题大小限制。`)
+ const index: SiteIndex = structuredClone(input.index)
+ if (
+ index.version !== 1 ||
+ !Number.isSafeInteger(index.nextId) ||
+ index.nextId < 1 ||
+ index.bindings.length > 10_000 ||
+ new Set(index.bindings.map((item) => item.publicId)).size !== index.bindings.length ||
+ new Set(index.bindings.map((item) => item.sourceId)).size !== index.bindings.length ||
+ index.bindings.some(
+ (item) =>
+ !Number.isSafeInteger(item.publicId) ||
+ item.publicId < 1 ||
+ item.publicId >= index.nextId ||
+ !Number.isFinite(Date.parse(item.publishedAt)) ||
+ !Number.isFinite(Date.parse(item.updatedAt)),
+ )
+ )
+ throw new Error('本地发布记录无效,请保留数据并恢复之前的备份。')
+ for (const binding of index.bindings) binding.active = false
+ const files = new Map>()
+ let total = 0
+ const put = (item: ReturnType) => {
+ if (!validPath(item.path) || item.bytes.length > limits.fileBytes || files.size >= limits.files)
+ throw new Error('生成文件的路径或大小超出限制。')
+ const previous = files.get(item.path)
+ if (previous) {
+ if (
+ previous.type !== item.type ||
+ previous.bytes.length !== item.bytes.length ||
+ previous.bytes.some((byte, i) => byte !== item.bytes[i])
+ )
+ throw new Error('发布附件的目标路径冲突。')
+ return
+ }
+ total += item.bytes.length
+ if (total > limits.totalBytes) throw new Error('站点超过 64 MiB,请减少材料或附件。')
+ files.set(item.path, item)
+ }
+ const sourceMap = new Map(input.sources.map((item) => [item.id, item]))
+ const titles = new Map()
+ const ambiguous = new Set()
+ for (const source of input.sources) {
+ if (encoder.encode(source.text).length > limits.sourceBytes)
+ throw new Error(`「${source.title}」正文超过 4 MiB。`)
+ if (titles.has(source.title)) ambiguous.add(source.title)
+ else titles.set(source.title, source.id)
+ let binding = index.bindings.find((item) => item.sourceId === source.id)
+ const contentHash = await hash(
+ JSON.stringify({
+ title: source.title,
+ text: source.text,
+ format: source.format,
+ fields: safeFields(source.fields),
+ resources: await Promise.all(
+ source.resources.map(async (item) => ({
+ reference: item.reference,
+ hash: await hash(item.file.bytes),
+ })),
+ ),
+ links: source.links ?? [],
+ }),
+ )
+ if (!binding) {
+ binding = {
+ sourceId: source.id,
+ publicId: index.nextId++,
+ versionId: source.versionId,
+ contentHash,
+ publishedAt: now,
+ updatedAt: now,
+ title: source.title,
+ active: true,
+ }
+ index.bindings.push(binding)
+ if (index.bindings.length > 10_000) throw new Error('站点历史文章记录超过 10000 条限制。')
+ } else {
+ if (binding.contentHash !== contentHash) binding.updatedAt = now
+ binding.versionId = source.versionId
+ binding.contentHash = contentHash
+ binding.title = source.title
+ binding.active = true
+ }
+ for (const resource of source.resources) {
+ if (
+ !resource.file.path.startsWith('assets/') ||
+ !/^(image\/(png|jpeg|gif|webp|avif)|audio\/[a-z0-9.+-]+|video\/[a-z0-9.+-]+|application\/pdf)$/iu.test(
+ resource.file.type,
+ )
+ )
+ throw new Error('只允许发布选定材料引用的图片、音视频和 PDF 附件。')
+ put(resource.file)
+ }
+ }
+ for (const title of ambiguous) titles.delete(title)
+ const active = index.bindings
+ .filter((item) => item.active)
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.publicId - a.publicId)
+ const paths = new Map(
+ active.map((item) => [item.sourceId, `articles/${item.publicId}/index.html`]),
+ )
+ const topics: Topic[] = active.map((binding) => {
+ const source = sourceMap.get(binding.sourceId)!
+ const content = renderSource(source, paths, titles, '../../')
+ const searchText = text(content)
+ return {
+ id: String(binding.publicId),
+ path: paths.get(binding.sourceId)!,
+ title: source.title,
+ published_at: binding.publishedAt,
+ updated_at: binding.updatedAt,
+ content,
+ excerpt: searchText.slice(0, 180),
+ fields: safeFields(source.fields),
+ searchText,
+ }
+ })
+ const publicTopics = topics.map(({ searchText: _searchText, ...topic }) => topic)
+ const site = {
+ title: options.title,
+ description: options.description,
+ url: address,
+ home_url: 'index.html',
+ archive_url: 'archive/index.html',
+ rss_url: 'feed.xml',
+ tags_url: 'tags/index.html',
+ categories_url: 'categories/index.html',
+ search_url: 'search/index.html',
+ topics: publicTopics,
+ }
+ const pages: SiteBuild['pages'] = []
+ const page = (path: string, title: string, values: Record) => {
+ const depth = '../'.repeat(path.split('/').length - 1)
+ const scope = {
+ site,
+ page: {
+ kind: 'topic',
+ view: 'home',
+ id: null,
+ title,
+ depth,
+ is_home: path === 'index.html',
+ content: '',
+ published_at: null,
+ updated_at: null,
+ fields: [],
+ has_visible_fields: false,
+ topics: [],
+ groups: [],
+ terms: [],
+ newer: null,
+ older: null,
+ newer_url: null,
+ older_url: null,
+ previous_page_url: null,
+ next_page_url: null,
+ home_url: depth + site.home_url,
+ archive_url: depth + site.archive_url,
+ rss_url: depth + site.rss_url,
+ tags_url: depth + site.tags_url,
+ categories_url: depth + site.categories_url,
+ search_url: depth + site.search_url,
+ ...values,
+ },
+ }
+ put(
+ file(
+ path,
+ 'text/html; charset=utf-8',
+ renderTemplate(
+ values.view === 'archive' ? theme.archive : theme.page,
+ scope,
+ values.view === 'archive' ? 'archive.html' : 'page.html',
+ ),
+ ),
+ )
+ pages.push({ path, title })
+ progress(pages.length, topics.length + Math.ceil(topics.length / options.pageSize) + 6)
+ }
+ const pageCount = Math.max(1, Math.ceil(topics.length / options.pageSize))
+ const home = (number: number) => (number === 1 ? 'index.html' : `page/${number}/index.html`)
+ for (let i = 1; i <= pageCount; i++) {
+ const depth = i === 1 ? '' : '../../'
+ page(home(i), options.title, {
+ topics: publicTopics.slice((i - 1) * options.pageSize, i * options.pageSize),
+ previous_page_url: i > 1 ? depth + home(i - 1) : null,
+ next_page_url: i < pageCount ? depth + home(i + 1) : null,
+ })
+ }
+ topics.forEach((topic, i) =>
+ page(topic.path, topic.title, {
+ ...publicTopics[i],
+ view: 'article',
+ has_visible_fields: topic.fields.some((field) => field.values.length),
+ newer: topics[i - 1]
+ ? { title: topics[i - 1]!.title, url: '../../' + topics[i - 1]!.path }
+ : null,
+ older: topics[i + 1]
+ ? { title: topics[i + 1]!.title, url: '../../' + topics[i + 1]!.path }
+ : null,
+ newer_url: topics[i - 1] ? '../../' + topics[i - 1]!.path : null,
+ older_url: topics[i + 1] ? '../../' + topics[i + 1]!.path : null,
+ }),
+ )
+ page('archive/index.html', '文章归档', {
+ kind: 'archive',
+ view: 'archive',
+ topics: publicTopics,
+ groups: [...new Set(topics.map((item) => item.updated_at.slice(0, 4)))].map((year) => ({
+ label: year,
+ topics: publicTopics.filter((item) => item.updated_at.startsWith(year)),
+ })),
+ })
+ for (const [key, directory, label] of [
+ ['tags', 'tags', '标签'],
+ ['category', 'categories', '分类'],
+ ] as const) {
+ const terms = new Map<
+ string,
+ { name: string; path: string; count: number; topics: typeof publicTopics }
+ >()
+ for (const topic of publicTopics)
+ for (const value of new Set(
+ topic.fields
+ .filter((field) => field.key.toLowerCase() === key)
+ .flatMap((field) => field.values),
+ )) {
+ const folded = value.trim().normalize('NFKC').toLocaleLowerCase()
+ if (!folded) continue
+ const term = terms.get(folded) ?? {
+ name: value,
+ path: `${directory}/${(await hash(folded)).slice(0, 20)}/index.html`,
+ count: 0,
+ topics: [],
+ }
+ term.count++
+ term.topics.push(topic)
+ terms.set(folded, term)
+ }
+ const ordered = [...terms.values()].sort((a, b) => a.name.localeCompare(b.name))
+ page(`${directory}/index.html`, label, {
+ view: directory,
+ terms: ordered.map(({ topics: _topics, ...term }) => term),
+ })
+ for (const term of ordered)
+ page(term.path, term.name, { view: key === 'tags' ? 'tag' : 'category', topics: term.topics })
+ }
+ page('search/index.html', '搜索文章', { view: 'search' })
+ const search = topics.map((topic) => ({
+ title: topic.title,
+ path: topic.path,
+ text: topic.searchText,
+ fields: topic.fields.flatMap((item) => [item.key, ...item.values]).join(' '),
+ }))
+ put(
+ file(
+ 'search-index.js',
+ 'text/javascript; charset=utf-8',
+ `window.__REPEATY_PUBLIC_SEARCH__=${JSON.stringify(search).replaceAll('<', '\\u003c').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029')};`,
+ ),
+ )
+ put(file('style.css', 'text/css; charset=utf-8', theme.css))
+ put(file('site.js', 'text/javascript; charset=utf-8', theme.script))
+ put(
+ file(
+ 'feed.xml',
+ 'application/rss+xml; charset=utf-8',
+ `${escape(options.title)} ${escape(address)}${escape(options.description)} ${topics.map((topic) => `${escape(topic.title)} ${escape(new URL(topic.path, address).href)}${escape(new URL(topic.path, address).href)} ${new Date(topic.published_at).toUTCString()} ${escape(topic.excerpt)} `).join('')} `,
+ ),
+ )
+ put(
+ file(
+ 'sitemap.xml',
+ 'application/xml; charset=utf-8',
+ `${pages.map((item) => `${escape(new URL(item.path, address).href)} `).join('')} `,
+ ),
+ )
+ const orderedFiles = [...files.values()].sort((a, b) => a.path.localeCompare(b.path))
+ const fingerprint = await hash(
+ JSON.stringify(
+ await Promise.all(
+ orderedFiles.map(async (item) => [item.path, item.type, await hash(item.bytes)]),
+ ),
+ ),
+ )
+ return { files: orderedFiles, index, pages, generatedAt: now, fingerprint }
+}
diff --git a/plugins/static-publish/src/generator.worker.ts b/plugins/static-publish/src/generator.worker.ts
new file mode 100644
index 00000000..504b8790
--- /dev/null
+++ b/plugins/static-publish/src/generator.worker.ts
@@ -0,0 +1,33 @@
+import { ZipWriter, Uint8ArrayWriter, Uint8ArrayReader } from '@zip.js/zip.js'
+import { generateSite } from './generate'
+import type { SiteFile } from './model'
+const scope = globalThis as unknown as {
+ onmessage: (event: MessageEvent) => void
+ postMessage(value: unknown, transfer?: Transferable[]): void
+}
+scope.onmessage = async ({ data }) => {
+ try {
+ if (data.type === 'zip') {
+ const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false })
+ for (const file of data.files as SiteFile[])
+ await writer.add(file.path, new Uint8ArrayReader(file.bytes), {
+ lastModDate: new Date('2026-01-01T00:00:00Z'),
+ })
+ const bytes = await writer.close()
+ scope.postMessage({ type: 'zip', bytes }, [bytes.buffer])
+ } else {
+ const build = await generateSite(data.input, (completed, total) =>
+ scope.postMessage({ type: 'progress', completed, total }),
+ )
+ scope.postMessage(
+ { type: 'done', build },
+ build.files.map((file) => file.bytes.buffer),
+ )
+ }
+ } catch (error) {
+ scope.postMessage({
+ type: 'error',
+ message: error instanceof Error ? error.message : '站点生成失败。',
+ })
+ }
+}
diff --git a/plugins/static-publish/src/index.ts b/plugins/static-publish/src/index.ts
new file mode 100644
index 00000000..878f8512
--- /dev/null
+++ b/plugins/static-publish/src/index.ts
@@ -0,0 +1,62 @@
+import { h } from 'vue'
+import { registerPage, registerSettings, type ExtensionPlugin } from '@repeaty/plugin-api'
+import PublishPage from './PublishPage.vue'
+import PublishSettings from './PublishSettings.vue'
+import { t } from './text'
+const icon = () =>
+ h(
+ 'svg',
+ {
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ 'stroke-width': 1.8,
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ 'aria-hidden': true,
+ },
+ [
+ h('rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }),
+ h('path', { d: 'M3 9h18M7 6.5h.01M10 6.5h.01M9 13h6M9 16h4' }),
+ ],
+ )
+export default function activate(): ExtensionPlugin {
+ registerPage({
+ id: 'site',
+ get title() {
+ return t('静态发布', 'Static publishing')
+ },
+ icon,
+ component: PublishPage,
+ })
+ registerSettings({
+ id: 'site',
+ get title() {
+ return t('静态发布', 'Static publishing')
+ },
+ category: [
+ {
+ id: 'tools',
+ get title() {
+ return t('工具', 'Tools')
+ },
+ },
+ {
+ id: 'publishing',
+ get title() {
+ return t('发布', 'Publishing')
+ },
+ },
+ ],
+ component: PublishSettings,
+ })
+ return {
+ id: 'static-publish',
+ kind: 'frontend',
+ type: 'extension',
+ version: 1,
+ get label() {
+ return t('静态发布', 'Static publishing')
+ },
+ }
+}
diff --git a/plugins/static-publish/src/model.ts b/plugins/static-publish/src/model.ts
new file mode 100644
index 00000000..64014f06
--- /dev/null
+++ b/plugins/static-publish/src/model.ts
@@ -0,0 +1,94 @@
+export interface PublicField {
+ key: string
+ values: string[]
+}
+export interface PublishSource {
+ id: string
+ versionId: string
+ title: string
+ text: string
+ format: 'plain' | 'markdown'
+ fields: PublicField[]
+ resources: { reference: string; file: SiteFile }[]
+ links?: { reference: string; sourceId: string; fragment?: string }[]
+}
+export interface SiteFile {
+ path: string
+ type: string
+ bytes: Uint8Array
+}
+export interface SiteBinding {
+ sourceId: string
+ publicId: number
+ versionId: string
+ contentHash: string
+ publishedAt: string
+ updatedAt: string
+ title: string
+ active: boolean
+}
+export interface SiteIndex {
+ version: 1
+ nextId: number
+ bindings: SiteBinding[]
+}
+export interface SiteTheme {
+ page: string
+ archive: string
+ css: string
+ script: string
+}
+export interface SiteOptions {
+ title: string
+ url: string
+ description: string
+ pageSize: number
+}
+export interface SiteBuild {
+ files: SiteFile[]
+ index: SiteIndex
+ pages: { path: string; title: string }[]
+ generatedAt: string
+ fingerprint: string
+}
+export const limits = {
+ sources: 500,
+ sourceBytes: 4 * 1024 * 1024,
+ totalBytes: 64 * 1024 * 1024,
+ fileBytes: 8 * 1024 * 1024,
+ themeBytes: 256 * 1024,
+ files: 5000,
+}
+export const emptyIndex = (): SiteIndex => ({ version: 1, nextId: 1, bindings: [] })
+export const encoder = new TextEncoder()
+export function file(path: string, type: string, text: string): SiteFile {
+ return { path, type, bytes: encoder.encode(text) }
+}
+export function validPath(path: string) {
+ return (
+ path.length > 0 &&
+ path.length <= 240 &&
+ !path.startsWith('/') &&
+ !/[\\\u0000-\u001f?#:]/u.test(path) &&
+ path.split('/').every((part) => part && part !== '.' && part !== '..')
+ )
+}
+export async function hash(value: string | Uint8Array) {
+ const bytes = typeof value === 'string' ? encoder.encode(value) : value
+ return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes)))]
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+}
+export function escape(value: unknown) {
+ return String(value ?? '').replace(
+ /[&<>"']/gu,
+ (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!,
+ )
+}
+export function publicUrl(input: string) {
+ if (!input.trim()) return 'https://example.invalid/'
+ const url = new URL(input)
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
+ throw new Error('站点地址须为不含查询参数的 HTTPS 地址。')
+ return url.href.replace(/\/?$/u, '/')
+}
diff --git a/plugins/static-publish/src/preview.ts b/plugins/static-publish/src/preview.ts
new file mode 100644
index 00000000..8020ed79
--- /dev/null
+++ b/plugins/static-publish/src/preview.ts
@@ -0,0 +1,142 @@
+import { escape, type SiteBuild } from './model'
+const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
+export async function createPreview(build: SiteBuild, signal?: AbortSignal) {
+ const searchSource = build.files.find((file) => file.path === 'search-index.js')
+ let searchItems: { title: string; text: string; fields: string; path: string }[] = []
+ if (searchSource) {
+ try {
+ const raw = text(searchSource.bytes)
+ .replace(/^window\.__REPEATY_PUBLIC_SEARCH__\s*=\s*/u, '')
+ .replace(/;\s*$/u, '')
+ const items = JSON.parse(raw)
+ if (Array.isArray(items))
+ searchItems = items.filter(
+ (item) =>
+ item &&
+ typeof item.title === 'string' &&
+ typeof item.path === 'string' &&
+ build.pages.some((page) => page.path === item.path),
+ )
+ } catch {
+ /* An edited theme can omit the default search index. */
+ }
+ }
+ const urls = new Map()
+ // A sandbox without allow-same-origin has an opaque origin and cannot read the host's
+ // blob URLs. FileReader creates bounded media data URLs asynchronously without weakening
+ // the iframe sandbox or granting the theme access to the application.
+ for (const file of build.files)
+ if (/^(image\/(png|jpeg|gif|webp|avif)|audio\/|video\/|font\/)/u.test(file.type)) {
+ signal?.throwIfAborted()
+ const value = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ const abort = () => reader.abort()
+ signal?.addEventListener('abort', abort, { once: true })
+ reader.onloadend = () => signal?.removeEventListener('abort', abort)
+ reader.onabort = () => reject(new DOMException('预览已取消。', 'AbortError'))
+ reader.onload = () => resolve(String(reader.result))
+ reader.onerror = () => reject(new Error('预览附件无法读取,请重新生成。'))
+ reader.readAsDataURL(new Blob([new Uint8Array(file.bytes)], { type: file.type }))
+ })
+ urls.set(file.path, value)
+ }
+ signal?.throwIfAborted()
+ const nonce = crypto.randomUUID().replaceAll('-', '')
+ const resolve = (page: string, reference: string) => {
+ try {
+ const url = new URL(reference, 'https://preview.invalid/' + page)
+ return url.origin === 'https://preview.invalid'
+ ? decodeURIComponent(url.pathname.slice(1))
+ : undefined
+ } catch {
+ return undefined
+ }
+ }
+ return {
+ render(path: string, fragment = '') {
+ const file = build.files.find(
+ (item) => item.path === path && item.type.startsWith('text/html'),
+ )
+ if (!file) throw new Error('预览页面不存在。')
+ const document = new DOMParser().parseFromString(text(file.bytes), 'text/html')
+ document.querySelectorAll('script,base,meta[http-equiv]').forEach((node) => node.remove())
+ for (const link of document.querySelectorAll('link[rel=stylesheet]')) {
+ const target = resolve(path, link.getAttribute('href') ?? '')
+ const style = build.files.find(
+ (item) => item.path === target && item.type.startsWith('text/css'),
+ )
+ if (style) {
+ const node = document.createElement('style')
+ node.textContent = text(style.bytes).replace(
+ /url\(\s*(['"]?)(.*?)\1\s*\)/giu,
+ (_match, _quote, reference: string) => {
+ const resource = resolve(style.path, reference)
+ return `url("${resource && urls.has(resource) ? urls.get(resource) : ''}")`
+ },
+ )
+ link.replaceWith(node)
+ } else link.remove()
+ }
+ for (const element of document.querySelectorAll('[src],[poster]'))
+ for (const attribute of ['src', 'poster']) {
+ const original = element.getAttribute(attribute)
+ if (!original) continue
+ const target = resolve(path, original),
+ url = target ? urls.get(target) : undefined
+ if (url) element.setAttribute(attribute, url)
+ else element.removeAttribute(attribute)
+ }
+ document
+ .querySelectorAll('img[loading]')
+ .forEach((image) => image.setAttribute('loading', 'eager'))
+ for (const link of document.querySelectorAll('a[href]')) {
+ const original = link.getAttribute('href')!
+ const target = resolve(path, original)
+ link.removeAttribute('target')
+ if (original.startsWith('#')) continue
+ if (target && build.pages.some((item) => item.path === target)) {
+ link.setAttribute('data-preview-page', target)
+ const hash = new URL(original, 'https://preview.invalid/' + path).hash.slice(1)
+ if (hash) {
+ try {
+ link.setAttribute('data-preview-fragment', decodeURIComponent(hash))
+ } catch {
+ /* Invalid fragments do not block the page. */
+ }
+ }
+ link.setAttribute('href', '#')
+ } else link.removeAttribute('href')
+ }
+ // The theme's scripts never execute in the application. Only this navigation bridge does.
+ document
+ .querySelectorAll('#site-search button')
+ .forEach((button) => button.setAttribute('type', 'button'))
+ const policy = document.createElement('meta')
+ policy.httpEquiv = 'Content-Security-Policy'
+ policy.content = `default-src 'none'; img-src data:; media-src data:; style-src 'unsafe-inline'; font-src data:; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'`
+ document.head.prepend(policy)
+ const script = document.createElement('script')
+ script.setAttribute('nonce', nonce)
+ const publicSearch = JSON.stringify(searchItems).replaceAll('<', '\\u003c')
+ script.textContent = `document.addEventListener('click',e=>{const a=e.target.closest('[data-preview-page]');if(a){e.preventDefault();parent.postMessage({type:'repeaty-site-preview',page:a.dataset.previewPage},'*')}});const items=${publicSearch};document.addEventListener('submit',e=>{e.preventDefault();if(e.target.id!=='site-search')return;const query=document.getElementById('query'),list=document.getElementById('search-results'),status=document.getElementById('search-status');if(!query||!list||!status)return;const term=query.value.trim().toLocaleLowerCase();const results=term?items.filter(item=>(item.title+' '+item.text+' '+item.fields).toLocaleLowerCase().includes(term)).slice(0,100):[];list.replaceChildren();status.textContent=term?results.length+' 篇文章':'输入关键词搜索';for(const item of results){const row=document.createElement('li'),a=document.createElement('a');a.textContent=item.title;a.href='#';a.dataset.previewPage=item.path;row.append(a);list.append(row)}});`
+ // Serialize the detached script before attaching it: browsers hide the nonce attribute
+ // after insertion, which would otherwise make the navigation bridge fail CSP.
+ script.textContent += `document.addEventListener('click',e=>{if(e.target.closest('#site-search button')){e.preventDefault();document.getElementById('site-search').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))}});document.addEventListener('keydown',e=>{if(e.key==='Enter'&&e.target.closest('#site-search')){e.preventDefault();document.getElementById('site-search').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))}});`
+ script.textContent += `document.addEventListener('click',e=>{const a=e.target.closest('a[href^="#"]');if(a&&!a.dataset.previewPage){e.preventDefault();try{document.getElementById(decodeURIComponent(a.getAttribute('href').slice(1)))?.scrollIntoView()}catch{}}});document.getElementById(${JSON.stringify(fragment).replaceAll('<', '\\u003c')})?.scrollIntoView();`
+ script.textContent = script.textContent.replace(
+ "page:a.dataset.previewPage},'*'",
+ "page:a.dataset.previewPage,fragment:a.dataset.previewFragment},'*'",
+ )
+ const bridge = script.outerHTML
+ return (
+ '' +
+ document.documentElement.outerHTML.replace('
${escape(label)}
', bridge + '')
+ )
+ },
+ dispose() {
+ urls.clear()
+ },
+ }
+}
+export const emptyPreview = (label: string) =>
+ `