| 1 | /** Synchronize a structured element update into a page HTML file. */ |
| 2 | import * as cheerio from 'cheerio' |
| 3 | import type { AnyNode } from 'domhandler' |
| 4 | import { allocateBlockId, attrEscape } from './shared' |
| 5 | |
| 6 | export const SYNC_ELEMENT_ATTR = 'data-ppt-sync-element-id' |
| 7 | |
| 8 | const SYNC_ID_PREFIX = 'sync-' |
| 9 | |
| 10 | export type ApplySyncElementResult = { |
| 11 | html: string |
| 12 | syncElementId: string |
| 13 | changed: boolean |
| 14 | inserted: boolean |
| 15 | updated: boolean |
| 16 | } |
| 17 | |
| 18 | const normalizeSyncElementId = (value: unknown): string => { |
| 19 | if (typeof value !== 'string') return '' |
| 20 | const text = value.trim() |
| 21 | if (!text || text.length > 80) return '' |
| 22 | return /^[a-zA-Z0-9_-]+$/.test(text) ? text : '' |
| 23 | } |
| 24 | |
| 25 | const createSyncElementId = (): string => `${SYNC_ID_PREFIX}${allocateBlockId().replace(/^select-arcsin1-/, '')}` |
| 26 | |
| 27 | function findFirstElement($: cheerio.CheerioAPI): cheerio.Cheerio<AnyNode> { |
| 28 | const bodyChildren = $('body').children() |
| 29 | const element = bodyChildren.filter((_, node) => { |
| 30 | const tagName = String((node as { tagName?: string }).tagName || '').toLowerCase() |
| 31 | return tagName !== 'style' |
| 32 | }) |
| 33 | if (element.length > 0) return element.first() |
| 34 | return $.root().children().first() |
| 35 | } |
| 36 | |
| 37 | function readSyncIdFromFragment(sourceHtmlFragment: string): string { |
| 38 | const source = cheerio.load(sourceHtmlFragment, { scriptingEnabled: false }) |
| 39 | const element = findFirstElement(source) |
| 40 | return normalizeSyncElementId(element.attr(SYNC_ELEMENT_ATTR)) |
| 41 | } |
| 42 | |
| 43 | function buildSourceElementHtml(args: { |
| 44 | sourceHtmlFragment: string |
| 45 | syncElementId: string |
| 46 | blockId: string |
| 47 | }): string { |
| 48 | const source = cheerio.load(args.sourceHtmlFragment, { scriptingEnabled: false }) |
| 49 | const element = findFirstElement(source) |
| 50 | if (element.length === 0) throw new Error('无法读取要同步的元素') |
| 51 | element.attr(SYNC_ELEMENT_ATTR, args.syncElementId) |
| 52 | element.attr('data-block-id', args.blockId) |
| 53 | return element.toString() |
| 54 | } |
| 55 | |
| 56 | function findElementBySyncId( |
| 57 | $: cheerio.CheerioAPI, |
| 58 | syncElementId: string |
| 59 | ): cheerio.Cheerio<AnyNode> { |
| 60 | let matched: cheerio.Cheerio<AnyNode> | null = null |
| 61 | $(`[${SYNC_ELEMENT_ATTR}]`).each((_, node) => { |
| 62 | if (matched) return |
| 63 | const item = $(node) |
| 64 | if ((item.attr(SYNC_ELEMENT_ATTR) || '').trim() === syncElementId) { |
| 65 | matched = item |
| 66 | } |
| 67 | }) |
| 68 | return matched || $([]) |
| 69 | } |
| 70 | |
| 71 | function resolveInsertParent($: cheerio.CheerioAPI): cheerio.Cheerio<AnyNode> { |
| 72 | const selectors = [ |
| 73 | 'main[data-role="content"]', |
| 74 | '[data-role="content"]', |
| 75 | '.ppt-page-content main', |
| 76 | '.ppt-page-content', |
| 77 | 'body' |
| 78 | ] |
| 79 | for (const selector of selectors) { |
| 80 | const parent = $(selector).first() |
| 81 | if (parent.length > 0) return parent |
| 82 | } |
| 83 | return $.root() |
| 84 | } |
| 85 | |
| 86 | export function applySyncElementToPageHtml(args: { |
| 87 | html: string |
| 88 | sourceHtmlFragment: string |
| 89 | syncElementId?: string |
| 90 | preserveSourceBlockId?: string |
| 91 | }): ApplySyncElementResult { |
| 92 | const requestedSyncId = normalizeSyncElementId(args.syncElementId) |
| 93 | const fragmentSyncId = readSyncIdFromFragment(args.sourceHtmlFragment) |
| 94 | const syncElementId = requestedSyncId || fragmentSyncId || createSyncElementId() |
| 95 | const $ = cheerio.load(args.html, { scriptingEnabled: false }) |
| 96 | const existing = findElementBySyncId($, syncElementId) |
| 97 | const existingBlockId = (existing.attr('data-block-id') || '').trim() |
| 98 | const sourceBlockId = |
| 99 | typeof args.preserveSourceBlockId === 'string' ? args.preserveSourceBlockId.trim() : '' |
| 100 | const blockId = existingBlockId || sourceBlockId || allocateBlockId() |
| 101 | const nextElementHtml = buildSourceElementHtml({ |
| 102 | sourceHtmlFragment: args.sourceHtmlFragment, |
| 103 | syncElementId, |
| 104 | blockId |
| 105 | }) |
| 106 | |
| 107 | let inserted = false |
| 108 | let updated = false |
| 109 | if (existing.length > 0) { |
| 110 | const before = existing.toString() |
| 111 | existing.replaceWith(nextElementHtml) |
| 112 | updated = before !== nextElementHtml |
| 113 | } else { |
| 114 | resolveInsertParent($).append(`\n${nextElementHtml}\n`) |
| 115 | inserted = true |
| 116 | } |
| 117 | |
| 118 | const nextHtml = $.html() |
| 119 | return { |
| 120 | html: nextHtml, |
| 121 | syncElementId, |
| 122 | changed: inserted || updated || nextHtml !== args.html, |
| 123 | inserted, |
| 124 | updated |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | export const syncElementSelector = (syncElementId: string): string => |
| 129 | `[${SYNC_ELEMENT_ATTR}="${attrEscape(syncElementId)}"]` |
| 130 |