| 1 | // Page fragment normalization is presentation-domain logic shared by generation and editing. |
| 2 | import * as cheerio from 'cheerio' |
| 3 | import type { AnyNode } from 'domhandler' |
| 4 | |
| 5 | const CREATIVE_FRAGMENT_SECTION_CLASS = 'h-full min-h-0 overflow-hidden' |
| 6 | const CREATIVE_FRAGMENT_MAIN_CLASS = 'h-full min-h-0' |
| 7 | const EDITABLE_TEXT_SELECTOR = [ |
| 8 | 'h1', |
| 9 | 'h2', |
| 10 | 'h3', |
| 11 | 'h4', |
| 12 | 'h5', |
| 13 | 'h6', |
| 14 | 'p', |
| 15 | 'li', |
| 16 | 'blockquote', |
| 17 | 'figcaption', |
| 18 | 'td', |
| 19 | 'th' |
| 20 | ].join(',') |
| 21 | const INLINE_TEXT_CANDIDATE_SELECTOR = ['span', 'strong', 'em', 'b', 'i', 'small', 'label', 'button'].join(',') |
| 22 | const VISUAL_BLOCK_SELECTOR = [ |
| 23 | 'article', |
| 24 | 'aside', |
| 25 | 'figure', |
| 26 | 'table', |
| 27 | 'section', |
| 28 | 'div' |
| 29 | ].join(',') |
| 30 | const VISUAL_BLOCK_CLASS_PATTERN = |
| 31 | /(?:^|[-_\s])(card|panel|chart|graph|plot|metric|stat|timeline|diagram|visual|figure|image|media|table|ranking|rank|top|list|item|tile|badge|kpi|summary|callout)(?:$|[-_\s])/i |
| 32 | const mergeClassNames = (current: string | undefined, additions: string[]): string => { |
| 33 | const classes = new Set((current || '').split(/\s+/).filter(Boolean)) |
| 34 | additions.forEach((item) => classes.add(item)) |
| 35 | return Array.from(classes).join(' ') |
| 36 | } |
| 37 | |
| 38 | const normalizeBlockIdBase = (value: string): string => |
| 39 | value |
| 40 | .trim() |
| 41 | .toLowerCase() |
| 42 | .replace(/[^a-z0-9_-]+/g, '-') |
| 43 | .replace(/^-+|-+$/g, '') || 'block' |
| 44 | |
| 45 | const allocateBlockId = (base: string, used: Set<string>): string => { |
| 46 | const normalized = normalizeBlockIdBase(base) |
| 47 | let candidate = normalized |
| 48 | let suffix = 1 |
| 49 | while (used.has(candidate)) { |
| 50 | candidate = `${normalized}-${suffix}` |
| 51 | suffix += 1 |
| 52 | } |
| 53 | used.add(candidate) |
| 54 | return candidate |
| 55 | } |
| 56 | |
| 57 | const directTextContent = (el: cheerio.Cheerio<AnyNode>): string => |
| 58 | el |
| 59 | .contents() |
| 60 | .toArray() |
| 61 | .filter((node) => node.type === 'text') |
| 62 | .map((node) => ('data' in node ? String(node.data || '') : '')) |
| 63 | .join(' ') |
| 64 | .replace(/\s+/g, ' ') |
| 65 | .trim() |
| 66 | |
| 67 | const hasVisualChild = (el: cheerio.Cheerio<AnyNode>): boolean => |
| 68 | el.find('canvas,svg,img,picture,video,table,figure').length > 0 |
| 69 | |
| 70 | const blockIdBaseForTag = (tagName: string, titleAvailable: boolean): string => { |
| 71 | if (/^h[1-6]$/.test(tagName)) return titleAvailable ? 'title' : 'heading' |
| 72 | if (tagName === 'li') return 'item' |
| 73 | if (tagName === 'blockquote') return 'quote' |
| 74 | if (tagName === 'figcaption') return 'caption' |
| 75 | if (tagName === 'td' || tagName === 'th') return 'cell' |
| 76 | return 'text' |
| 77 | } |
| 78 | |
| 79 | const blockIdBaseForVisualElement = ( |
| 80 | tagName: string, |
| 81 | el: cheerio.Cheerio<AnyNode> |
| 82 | ): string => { |
| 83 | if (tagName === 'figure') return 'figure' |
| 84 | if (tagName === 'table') return 'table' |
| 85 | const raw = `${el.attr('data-role') || ''} ${el.attr('class') || ''} ${el.attr('id') || ''}` |
| 86 | const match = raw.match(VISUAL_BLOCK_CLASS_PATTERN) |
| 87 | if (match?.[1]) return match[1] |
| 88 | if (hasVisualChild(el)) return 'visual' |
| 89 | return 'block' |
| 90 | } |
| 91 | |
| 92 | export type NormalizeCreativePageFragmentOptions = { |
| 93 | blockIdMode?: 'assign' | 'strip' |
| 94 | } |
| 95 | |
| 96 | export const normalizeCreativePageFragment = ( |
| 97 | html: string, |
| 98 | options: NormalizeCreativePageFragmentOptions = {} |
| 99 | ): string => { |
| 100 | const shouldAssignBlockIds = options.blockIdMode !== 'strip' |
| 101 | const $ = cheerio.load(html.trim(), { scriptingEnabled: false }, false) |
| 102 | let scaffold: cheerio.Cheerio<AnyNode> = $('section[data-page-scaffold]').first() |
| 103 | |
| 104 | if (!scaffold.length) { |
| 105 | const originalNodes = $.root().contents().toArray() |
| 106 | const section = $('<section></section>') |
| 107 | const main = $('<main></main>') |
| 108 | section.attr('data-page-scaffold', '1') |
| 109 | section.attr('class', CREATIVE_FRAGMENT_SECTION_CLASS) |
| 110 | if (shouldAssignBlockIds) { |
| 111 | main.attr('data-block-id', 'content') |
| 112 | } |
| 113 | main.attr('data-role', 'content') |
| 114 | main.attr('class', CREATIVE_FRAGMENT_MAIN_CLASS) |
| 115 | main.append(originalNodes) |
| 116 | section.append(main) |
| 117 | $.root().empty().append(section) |
| 118 | scaffold = section |
| 119 | } else { |
| 120 | scaffold.attr('data-page-scaffold', '1') |
| 121 | scaffold.attr( |
| 122 | 'class', |
| 123 | mergeClassNames(scaffold.attr('class'), CREATIVE_FRAGMENT_SECTION_CLASS.split(/\s+/)) |
| 124 | ) |
| 125 | } |
| 126 | scaffold.attr('data-ppt-readable-fonts', '1') |
| 127 | |
| 128 | let content: cheerio.Cheerio<AnyNode> = scaffold |
| 129 | .find('main[data-role="content"], main[data-block-id="content"], main') |
| 130 | .first() |
| 131 | if (!content.length) { |
| 132 | const originalNodes = scaffold.contents().toArray() |
| 133 | const main = $('<main></main>') |
| 134 | main.append(originalNodes) |
| 135 | scaffold.empty().append(main) |
| 136 | content = main |
| 137 | } |
| 138 | if (shouldAssignBlockIds && !content.attr('data-block-id')) { |
| 139 | content.attr('data-block-id', 'content') |
| 140 | } |
| 141 | content.attr('data-role', 'content') |
| 142 | content.attr( |
| 143 | 'class', |
| 144 | mergeClassNames(content.attr('class'), CREATIVE_FRAGMENT_MAIN_CLASS.split(/\s+/)) |
| 145 | ) |
| 146 | |
| 147 | const usedBlockIds = new Set<string>() |
| 148 | if (shouldAssignBlockIds) { |
| 149 | $('[data-block-id]').each((_, node) => { |
| 150 | const el = $(node) |
| 151 | const current = (el.attr('data-block-id') || '').trim() |
| 152 | if (!current) return |
| 153 | const normalized = normalizeBlockIdBase(current) |
| 154 | if (usedBlockIds.has(normalized)) { |
| 155 | el.attr('data-block-id', allocateBlockId(current, usedBlockIds)) |
| 156 | } else { |
| 157 | usedBlockIds.add(normalized) |
| 158 | } |
| 159 | }) |
| 160 | } |
| 161 | |
| 162 | let hasTitleRole = $('[data-role="title"]').length > 0 |
| 163 | content.find(EDITABLE_TEXT_SELECTOR).each((_, node) => { |
| 164 | const el = $(node) |
| 165 | if (el.closest('script, style, svg, canvas').length) return |
| 166 | const tagName = (node.type === 'tag' ? node.name : '').toLowerCase() |
| 167 | if (!tagName) return |
| 168 | const directText = directTextContent(el) |
| 169 | const text = /^(h[1-6]|p|li|blockquote|figcaption|td|th)$/.test(tagName) |
| 170 | ? el.text().trim() |
| 171 | : directText |
| 172 | if (!text || text.replace(/\s+/g, '').length === 0) return |
| 173 | if (shouldAssignBlockIds && !el.attr('data-block-id')) { |
| 174 | el.attr( |
| 175 | 'data-block-id', |
| 176 | allocateBlockId(blockIdBaseForTag(tagName, !hasTitleRole), usedBlockIds) |
| 177 | ) |
| 178 | } |
| 179 | if (!hasTitleRole && /^h[1-6]$/.test(tagName)) { |
| 180 | el.attr('data-role', 'title') |
| 181 | hasTitleRole = true |
| 182 | } |
| 183 | }) |
| 184 | |
| 185 | content.find(VISUAL_BLOCK_SELECTOR).each((_, node) => { |
| 186 | const el = $(node) |
| 187 | if (el.closest('script, style').length) return |
| 188 | if (shouldAssignBlockIds && el.attr('data-block-id')) return |
| 189 | if (el.attr('data-role') === 'content') return |
| 190 | const tagName = (node.type === 'tag' ? node.name : '').toLowerCase() |
| 191 | if (!tagName) return |
| 192 | const rawIdentity = `${el.attr('data-role') || ''} ${el.attr('class') || ''} ${ |
| 193 | el.attr('id') || '' |
| 194 | }` |
| 195 | const semanticVisualBlock = |
| 196 | tagName === 'figure' || |
| 197 | tagName === 'table' || |
| 198 | VISUAL_BLOCK_CLASS_PATTERN.test(rawIdentity) || |
| 199 | hasVisualChild(el) |
| 200 | if (!semanticVisualBlock) return |
| 201 | // Skip pure layout containers: div/section with many element children and no direct text |
| 202 | if ((tagName === 'div' || tagName === 'section') && el.children().length > 3) { |
| 203 | const dt = directTextContent(el) |
| 204 | if (!dt || dt.replace(/\s+/g, '').length === 0) return |
| 205 | } |
| 206 | if (shouldAssignBlockIds) { |
| 207 | el.attr('data-block-id', allocateBlockId(blockIdBaseForVisualElement(tagName, el), usedBlockIds)) |
| 208 | } |
| 209 | }) |
| 210 | |
| 211 | // Pass 3: inline leaf text nodes — only add block-id to inline elements that are |
| 212 | // true leaf text nodes (direct text content, no child elements with text). |
| 213 | const BLOCK_TAGS = new Set([ |
| 214 | 'div', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'main', |
| 215 | 'ul', 'ol', 'dl', 'form', 'fieldset', 'details', 'summary', |
| 216 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'blockquote', 'figcaption', 'td', 'th', |
| 217 | 'figure', 'table', 'pre', 'hr' |
| 218 | ]) |
| 219 | const hasOnlyInlineOrTextChildren = (el: cheerio.Cheerio<AnyNode>): boolean => { |
| 220 | const children = el.children().toArray() |
| 221 | return children.every((child) => { |
| 222 | if (child.type !== 'tag') return true |
| 223 | return !BLOCK_TAGS.has((child as { name?: string }).name?.toLowerCase() || '') |
| 224 | }) |
| 225 | } |
| 226 | content.find(INLINE_TEXT_CANDIDATE_SELECTOR).each((_, node) => { |
| 227 | const el = $(node) |
| 228 | if (el.closest('script, style, svg, canvas').length) return |
| 229 | if (shouldAssignBlockIds && el.attr('data-block-id')) return |
| 230 | if (!hasOnlyInlineOrTextChildren(el)) return |
| 231 | const text = el.text().replace(/\s+/g, ' ').trim() |
| 232 | if (!text || text.replace(/\s+/g, '').length === 0) return |
| 233 | const tagName = (node.type === 'tag' ? node.name : '').toLowerCase() |
| 234 | if (shouldAssignBlockIds) { |
| 235 | el.attr('data-block-id', allocateBlockId(blockIdBaseForTag(tagName, !hasTitleRole), usedBlockIds)) |
| 236 | } |
| 237 | }) |
| 238 | |
| 239 | if (!shouldAssignBlockIds) { |
| 240 | $('[data-block-id]').removeAttr('data-block-id') |
| 241 | } |
| 242 | |
| 243 | return ($.root().html() || html).trim() |
| 244 | } |
| 245 |