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 @@ + + + + + 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 @@ + + + + + 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)', + '![图片](../assets/a.png)', + '[笔记](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![图](https://example.com/image.png)' + 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(' + +