diff --git a/packages/adapter-wordpress/src/adapter.ts b/packages/adapter-wordpress/src/adapter.ts new file mode 100644 index 0000000..f2b7ba9 --- /dev/null +++ b/packages/adapter-wordpress/src/adapter.ts @@ -0,0 +1,170 @@ +// 适配器全链组装(Plan 2 Task 6):parseWxrStream → mapItem → sanitizeBodyHtml 串联为 +// AsyncGenerator。三段件均为既有纯函数/纯流,本模块只做组装与计时, +// 不引入新的业务判定。 +// +// 事件契约(spec §7.7 / Plan 2 移交): +// - term 旁路直通:terms 是表数据不是内容行,不经映射不经清洗,原样转发; +// - item 经 mapItem 三分支:row → 组装层追加清洗(bodyHtml 为空时跳过清洗零 diff); +// unsupported / error 原样透传(error 分支补 sourceType 报告锚点,见下); +// - changed 语义(Task 5 审查移交钉死):`html !== input` 字符串比较——diffs.length===0 +// 不等于未变更(img 自闭合规范化等零 diff 等价变形),报告层"无变化 Skipped"以此为准。 +// +// 计时分段(Task 5 移交:解析与清洗分开计时):手动泵 parser.next(),await 窗口内的 +// 时间全部归属解析(parseWxrStream 是拉取式生成器,其全部工作都发生在 next() 期间; +// map/sanitize 发生在两次 next() 之间的循环体内),sanitizeBodyHtml 调用单独累计。 +// 经可选 timings 累计对象侧信道输出(消费完成后读取——生成器无法附带返回元数据)。 + +import type { Readable } from 'node:stream' +import { isUniversalRow, type UniversalRow } from '@sf/contracts' +import { parseWxrStream, type WxrEvent, type WxrItem, type WxrStreamOptions, type WxrTerm } from './parse/wxr-stream.js' +import { mapItem, type MapConfig } from './map/item-to-row.js' +import { sanitizeBodyHtml, type SanitizeDiff } from './sanitize/html.js' +import type { WxrLimits } from './limits.js' + +/** row 事件:映射产物 + 清洗结果(issues 为 mapItem issues 与契约复检 issues 的并集,文档序) */ +export interface RowResult { + kind: 'row' + row: UniversalRow + /** 非致命问题清单(不阻碍 row 产出;报告层逐条收录为行级错误) */ + issues: string[] + /** 清洗差异(文档序);bodyHtml 为空(跳过清洗)时恒为空数组 */ + sanitizeDiffs: SanitizeDiff[] + /** `html !== input`:清洗输出与输入原文的字符串比较(≠ diffs.length>0) */ + changed: boolean +} + +/** + * 全链事件判别联合。unsupported/error 分支与 mapItem 的 MappedResult 同形,仅 error + * 分支补 `sourceType`(`wp:${postType}` 报告锚点:post_id 缺失时 mapItem 拒猜身份, + * 但 post_type 本身仍可作计数归桶依据——命名约定同 unsupported 分支,仅为报告可读性)。 + */ +export type AdapterEvent = + | RowResult + | { kind: 'unsupported'; sourceType: string; reason: string; rawLocation: string } + | { kind: 'error'; sourceType: string; issue: string; rawLocation: string } + | { kind: 'term'; term: WxrTerm } + +/** 分段计时累计(可注入对象,运行中被本模块累加;消费完成后读取终值) */ +export interface AdapterTimings { + /** 解析耗时(parser.next() await 窗口累计,毫秒) */ + parseMs: number + /** 清洗耗时(sanitizeBodyHtml 调用累计,毫秒;不含映射) */ + sanitizeMs: number +} + +/** runWxrAdapter 选项 */ +export interface AdapterOptions { + /** 透传 parseWxrStream 的限额部分覆盖 */ + limits?: Partial + /** 透传 parseWxrStream 的可注入时钟(time 预算测试用;非计时用途) */ + now?: () => number + /** mapItem 配置;缺省 { locale: '' }(locale 空串与否由调用方自证) */ + mapConfig?: MapConfig + /** 计时累计对象(缺省内部自建;报告层注入以在消费后读取分段耗时) */ + timings?: AdapterTimings +} + +/** + * 契约复检(Plan 1 移交的"加法式"检查):复用 isUniversalRow 守卫 + 浅层不变量复检, + * 返回 issue 清单(空数组=通过)。不修改 contracts 包、不猜修复——发现即报告。 + * 与 mapItem 内部守卫的关系:mapItem 产出时自证(测试钉死),本函数在组装层再复检, + * 防御未来映射层变更引入的回归(两道闸,加法不替代)。 + */ +export function validateUniversalRow(row: UniversalRow): string[] { + const issues: string[] = [] + if (!isUniversalRow(row)) { + issues.push('契约守卫 isUniversalRow 未通过(row 结构不完整)') + return issues + } + if (row.sourceType === '') issues.push('sourceType 为空串') + if (row.sourceId === '') issues.push('sourceId 为空串') + if (row.rawLocation === '') issues.push('rawLocation 为空串') + row.taxonomyRefs.forEach((ref, i) => { + if (ref.taxonomy === '' || ref.sourceTermId === '') { + issues.push(`taxonomyRefs[${i}] 含空锚点(taxonomy/sourceTermId)`) + } + }) + row.attachmentRefs.forEach((ref, i) => { + if (ref.role === '') issues.push(`attachmentRefs[${i}] role 为空串`) + if (ref.sourceId !== undefined && ref.sourceId === '') { + issues.push(`attachmentRefs[${i}] sourceId 为空串`) + } + if (ref.sourceId === undefined && ref.sourceUrl === undefined) { + issues.push(`attachmentRefs[${i}] 无 sourceId/sourceUrl 锚点`) + } + }) + return issues +} + +/** 单个 item 事件的组装:映射 → 契约复检 → 清洗 → RowResult */ +function assembleItem( + item: WxrItem, + mapConfig: MapConfig, + timings: AdapterTimings, +): AdapterEvent { + const mapped = mapItem(item, mapConfig) + if (mapped.kind === 'unsupported') return mapped + if (mapped.kind === 'error') { + return { ...mapped, sourceType: `wp:${item.postType}` } + } + + const { row } = mapped + const issues = [...mapped.issues, ...validateUniversalRow(row)] + + // bodyHtml 为空(缺席或空串)→ 跳过清洗零 diff:attachment 无正文是常态, + // 空串调用 sanitizeBodyHtml 虽无害但会虚增清洗计时 + let sanitizeDiffs: SanitizeDiff[] = [] + let changed = false + if (row.bodyHtml !== undefined && row.bodyHtml !== '') { + const input = row.bodyHtml + const t0 = performance.now() + const result = sanitizeBodyHtml(input) + timings.sanitizeMs += performance.now() - t0 + row.bodyHtml = result.html + sanitizeDiffs = result.diffs + changed = result.html !== input + } + return { kind: 'row', row, issues, sanitizeDiffs, changed } +} + +/** + * WXR 全链适配(组装层):Readable(WXR 字节流)→ AsyncGenerator。 + * + * 事件按文档序交错产出(row/unsupported/error/term);解析的限额/结构错误 + * (WxrLimitError/WxrParseError)原样向上抛——错误前已产出的事件不撤回 + * (交付确定性由 parseWxrStream 保证,本层不吞不补)。消费方提前 break 时 + * 经 finally 关闭解析器(级联 destroy 上游流)。 + */ +export async function* runWxrAdapter( + readable: Readable, + opts: AdapterOptions = {}, +): AsyncGenerator { + const timings = opts.timings ?? { parseMs: 0, sanitizeMs: 0 } + const mapConfig = opts.mapConfig ?? { locale: '' } + // exactOptionalPropertyTypes:显式缺席键(不传 undefined 值),与 WxrStreamOptions 形态对齐 + const streamOpts: WxrStreamOptions = {} + if (opts.limits !== undefined) streamOpts.limits = opts.limits + if (opts.now !== undefined) streamOpts.now = opts.now + const parser = parseWxrStream(readable, streamOpts) + + try { + // 手动泵而非 for-await:await 窗口 = 解析器自身工作时间(拉取式生成器的全部 + // 工作都发生在 next() 内),与循环体内的映射/清洗天然分段 + while (true) { + const t0 = performance.now() + const res: IteratorResult = await parser.next() + timings.parseMs += performance.now() - t0 + if (res.done) break + const ev = res.value + if (ev.kind === 'term') { + yield { kind: 'term', term: ev.term } + } else { + yield assembleItem(ev.item, mapConfig, timings) + } + } + } finally { + // 正常完结(幂等无害)/ 消费方提前退出 / 解析抛错后:级联关闭解析器, + // 触发其 finally(destroy 上游流) + await parser.return() + } +} diff --git a/packages/adapter-wordpress/src/index.ts b/packages/adapter-wordpress/src/index.ts index cb02e08..62aefe6 100644 --- a/packages/adapter-wordpress/src/index.ts +++ b/packages/adapter-wordpress/src/index.ts @@ -1,5 +1,8 @@ +export * from './versions.js' export * from './limits.js' export * from './parse/php-unserialize.js' export * from './parse/wxr-stream.js' export * from './map/item-to-row.js' export * from './sanitize/html.js' +export * from './adapter.js' +export * from './report.js' diff --git a/packages/adapter-wordpress/src/report.ts b/packages/adapter-wordpress/src/report.ts new file mode 100644 index 0000000..88766b5 --- /dev/null +++ b/packages/adapter-wordpress/src/report.ts @@ -0,0 +1,261 @@ +// 导入前报告(Plan 2 Task 6,spec §7.7 对齐):消费 runWxrAdapter 全链事件流并聚合为 +// 可 JSON 序列化的报告对象(WxrAdapterReport),另提供 HTML 渲染(renderReportHtml)。 +// +// 对象层与渲染层职责分离: +// - 报告对象保留**原文**(excerpt/message 未转义)——JSON 归档、diff、机器消费的语义层; +// - renderReportHtml 对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本 +// 来自不可信 WXR 内容(清洗差异 excerpt 含 `' }), + ) + const events = await collect(runWxrAdapter(fromXml(xml), { mapConfig: { locale: 'zh-CN' } })) + const ev = soleRow(events) + expect(ev.row.bodyHtml).toBe('

ok

') // script 内文随 nonTextTags 语义一并丢弃 + expect(ev.changed).toBe(true) + expect(ev.sanitizeDiffs).toStrictEqual([ + { type: 'removed-tag', tag: 'script', excerpt: '

ok

' }), + ) + const report = await runWxrAdapterReport(fromXml(xml), { mapConfig: { locale: 'zh-CN' } }) + // 报告对象内 excerpt 未转义(JSON 语义层保留原文,转义是渲染层职责) + const entry = report.rowErrors.find((e) => e.excerpt !== undefined) + expect(entry).toMatchObject({ rawLocation: 'wxr://item/1', excerpt: '