| 1 | import * as cheerio from 'cheerio' |
| 2 | import type { Element } from 'domhandler' |
| 3 | import { validateDataAnimContract } from '../animation/data-anim-validator' |
| 4 | |
| 5 | export type FinalizationAsset = { |
| 6 | slotId: string |
| 7 | layoutSlotId?: string |
| 8 | layer?: 'background' | 'visual' |
| 9 | relativePath: string |
| 10 | } |
| 11 | |
| 12 | export type FinalizationValidationResult = { |
| 13 | valid: boolean |
| 14 | errors: string[] |
| 15 | usedSlotIds: string[] |
| 16 | unusedSlotIds: string[] |
| 17 | } |
| 18 | |
| 19 | const IMAGE_DRAFT_ATTRIBUTE_PREFIX = 'data-img-' |
| 20 | |
| 21 | const hasImageDraftAttributes = (node: Element): boolean => |
| 22 | Object.keys(node.attribs || {}).some((name) => |
| 23 | name.toLowerCase().startsWith(IMAGE_DRAFT_ATTRIBUTE_PREFIX) |
| 24 | ) |
| 25 | |
| 26 | const hasImageIntentDrafts = ($: cheerio.CheerioAPI): boolean => |
| 27 | $('*').toArray().some((node) => hasImageDraftAttributes(node as Element)) |
| 28 | |
| 29 | const stripImageDraftAttributes = ($: cheerio.CheerioAPI): void => { |
| 30 | $('script[data-img-intent]').remove() |
| 31 | $('*').each((_index, node) => { |
| 32 | const $node = $(node) |
| 33 | Object.keys((node as Element).attribs || {}) |
| 34 | .filter((name) => name.toLowerCase().startsWith(IMAGE_DRAFT_ATTRIBUTE_PREFIX)) |
| 35 | .forEach((name) => $node.removeAttr(name)) |
| 36 | }) |
| 37 | } |
| 38 | |
| 39 | const trimStyleValue = (value: string | undefined): string => (value || '').trim().toLowerCase() |
| 40 | |
| 41 | const classTokens = ($node: cheerio.Cheerio<any>): string[] => |
| 42 | ($node.attr('class') || '') |
| 43 | .trim() |
| 44 | .split(/\s+/) |
| 45 | .filter(Boolean) |
| 46 | |
| 47 | const getStyleValue = ($node: cheerio.Cheerio<any>, property: string): string => { |
| 48 | const declarations = ($node.attr('style') || '').split(';') |
| 49 | for (const declaration of declarations) { |
| 50 | const [rawProperty, ...rawValue] = declaration.split(':') |
| 51 | if (rawProperty?.trim().toLowerCase() === property) { |
| 52 | return trimStyleValue(rawValue.join(':')) |
| 53 | } |
| 54 | } |
| 55 | return '' |
| 56 | } |
| 57 | |
| 58 | const isIncomingAnimation = ($node: cheerio.Cheerio<any>): boolean => { |
| 59 | const animation = ($node.attr('data-anim') || '').trim().toLowerCase() |
| 60 | return Boolean(animation) && !animation.startsWith('exit-') |
| 61 | } |
| 62 | |
| 63 | const hasHiddenUtility = ($node: cheerio.Cheerio<any>): boolean => |
| 64 | classTokens($node).some((token) => token === 'hidden' || token === 'invisible') |
| 65 | |
| 66 | const hasZeroOpacityUtility = ($node: cheerio.Cheerio<any>): boolean => |
| 67 | classTokens($node).includes('opacity-0') |
| 68 | |
| 69 | const isPermanentlyHidden = ($: cheerio.CheerioAPI, node: any): boolean => { |
| 70 | let current = $(node) |
| 71 | while (current.length > 0) { |
| 72 | const display = getStyleValue(current, 'display') |
| 73 | const visibility = getStyleValue(current, 'visibility') |
| 74 | const opacity = getStyleValue(current, 'opacity') |
| 75 | if (display === 'none' || visibility === 'hidden' || hasHiddenUtility(current)) return true |
| 76 | if ((opacity === '0' || hasZeroOpacityUtility(current)) && !isIncomingAnimation(current)) { |
| 77 | return true |
| 78 | } |
| 79 | current = current.parent() |
| 80 | } |
| 81 | return false |
| 82 | } |
| 83 | |
| 84 | const isZeroDimension = (value: string): boolean => |
| 85 | /^0(?:px|rem|%|vh|vw)?(?:\s*!important)?$/i.test(value) |
| 86 | |
| 87 | const hasZeroDimensionUtility = ($node: cheerio.Cheerio<any>): boolean => |
| 88 | classTokens($node).some((token) => |
| 89 | /^(?:(?:max-)?[wh]|size)-0$|^(?:(?:max-)?[wh]|size)-\[0(?:px|rem|%|vh|vw)?\]$/i.test( |
| 90 | token |
| 91 | ) |
| 92 | ) |
| 93 | |
| 94 | const hasZeroDimension = ($: cheerio.CheerioAPI, node: any): boolean => { |
| 95 | let current = $(node) |
| 96 | while (current.length > 0) { |
| 97 | const width = getStyleValue(current, 'width') || trimStyleValue(current.attr('width')) |
| 98 | const height = getStyleValue(current, 'height') || trimStyleValue(current.attr('height')) |
| 99 | if (isZeroDimension(width) || isZeroDimension(height) || hasZeroDimensionUtility(current)) { |
| 100 | return true |
| 101 | } |
| 102 | current = current.parent() |
| 103 | } |
| 104 | return false |
| 105 | } |
| 106 | |
| 107 | const isBackgroundImageContainer = ($node: cheerio.Cheerio<any>): boolean => |
| 108 | Boolean(getStyleValue($node, 'background-image')) |
| 109 | |
| 110 | const hasBackgroundGeometry = ($node: cheerio.Cheerio<any>): boolean => { |
| 111 | const className = ($node.attr('class') || '').toLowerCase() |
| 112 | return Boolean( |
| 113 | getStyleValue($node, 'height') || |
| 114 | getStyleValue($node, 'min-height') || |
| 115 | getStyleValue($node, 'aspect-ratio') || |
| 116 | getStyleValue($node, 'inset') || |
| 117 | (getStyleValue($node, 'position') === 'absolute' && |
| 118 | (getStyleValue($node, 'top') || getStyleValue($node, 'bottom')) && |
| 119 | (getStyleValue($node, 'left') || getStyleValue($node, 'right'))) || |
| 120 | /(?:^|\s)(?:h-|min-h-|aspect-|inset-)/.test(className) |
| 121 | ) |
| 122 | } |
| 123 | |
| 124 | const usesAsset = ($: cheerio.CheerioAPI, path: string): cheerio.Cheerio<any> => { |
| 125 | const image = $(`img[src="${path.replace(/"/g, '\\"')}"]`).first() |
| 126 | if (image.length > 0) return image |
| 127 | return $(`[style*="${path.replace(/"/g, '\\"')}"]`) |
| 128 | .filter((_index, node) => ($(node).attr('style') || '').includes(path)) |
| 129 | .first() |
| 130 | } |
| 131 | |
| 132 | const setInlineStyle = ( |
| 133 | $node: cheerio.Cheerio<any>, |
| 134 | declarations: Record<string, string> |
| 135 | ): void => { |
| 136 | const existing = ($node.attr('style') || '') |
| 137 | .split(';') |
| 138 | .map((declaration) => declaration.trim()) |
| 139 | .filter(Boolean) |
| 140 | const values = new Map<string, string>() |
| 141 | for (const declaration of existing) { |
| 142 | const separator = declaration.indexOf(':') |
| 143 | if (separator <= 0) continue |
| 144 | values.set( |
| 145 | declaration.slice(0, separator).trim().toLowerCase(), |
| 146 | declaration.slice(separator + 1).trim() |
| 147 | ) |
| 148 | } |
| 149 | for (const [property, value] of Object.entries(declarations)) values.set(property, value) |
| 150 | $node.attr( |
| 151 | 'style', |
| 152 | [...values.entries()].map(([property, value]) => `${property}:${value}`).join(';') |
| 153 | ) |
| 154 | } |
| 155 | |
| 156 | const escapeSelectorValue = (value: string): string => value.replace(/(["\\])/g, '\\$1') |
| 157 | |
| 158 | /** |
| 159 | * Fulfills image request markers without asking another model to rewrite the page. |
| 160 | * Layout slots already own the geometry, so the asset is inserted into that exact node. |
| 161 | */ |
| 162 | export const adoptFinalizedImageAssets = ( |
| 163 | html: string, |
| 164 | assets: FinalizationAsset[] |
| 165 | ): { html: string; errors: string[] } => { |
| 166 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 167 | const errors: string[] = [] |
| 168 | |
| 169 | for (const asset of assets) { |
| 170 | const slotId = asset.layoutSlotId || asset.slotId |
| 171 | const selector = `[data-ppt-slot="${escapeSelectorValue(slotId)}"][data-img-request]` |
| 172 | const slot = $(selector).first() |
| 173 | if (slot.length === 0) { |
| 174 | errors.push(`Image asset for slot ${asset.slotId} has no matching image request marker.`) |
| 175 | continue |
| 176 | } |
| 177 | |
| 178 | if ((asset.layer || 'visual') === 'background') { |
| 179 | setInlineStyle(slot, { |
| 180 | 'background-image': `url("${asset.relativePath}")`, |
| 181 | 'background-size': 'cover', |
| 182 | 'background-position': 'center', |
| 183 | 'background-repeat': 'no-repeat' |
| 184 | }) |
| 185 | } else { |
| 186 | slot.empty() |
| 187 | const blockId = `generated-image-${slotId.replace(/[^A-Za-z0-9_-]+/g, '-')}` |
| 188 | slot.append( |
| 189 | `<img data-block-id="${blockId}" data-ppt-generated-image="true" src="${asset.relativePath}" alt="" style="display:block;width:100%;height:100%;object-fit:cover" />` |
| 190 | ) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | if (errors.length === 0) { |
| 195 | stripImageDraftAttributes($) |
| 196 | } |
| 197 | |
| 198 | return { html: $.html(), errors } |
| 199 | } |
| 200 | |
| 201 | export const stripImageIntentDrafts = (html: string): string => { |
| 202 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 203 | stripImageDraftAttributes($) |
| 204 | return $.html() |
| 205 | } |
| 206 | |
| 207 | export const validateFinalizedImageHtml = ( |
| 208 | html: string, |
| 209 | assets: FinalizationAsset[] |
| 210 | ): FinalizationValidationResult => { |
| 211 | const $ = cheerio.load(html, { scriptingEnabled: false }) |
| 212 | const errors = [...validateDataAnimContract(html).errors] |
| 213 | const usedSlotIds: string[] = [] |
| 214 | const unusedSlotIds: string[] = [] |
| 215 | |
| 216 | if (hasImageIntentDrafts($)) { |
| 217 | errors.push('Final HTML must not contain image intent draft attributes or scripts.') |
| 218 | } |
| 219 | if (html.includes('/.staging/') || html.includes('images/.staging/')) { |
| 220 | errors.push('Final HTML must not reference staging image assets.') |
| 221 | } |
| 222 | |
| 223 | for (const asset of assets) { |
| 224 | const node = usesAsset($, asset.relativePath) |
| 225 | if (node.length === 0) { |
| 226 | unusedSlotIds.push(asset.slotId) |
| 227 | continue |
| 228 | } |
| 229 | usedSlotIds.push(asset.slotId) |
| 230 | if (isPermanentlyHidden($, node.get(0))) { |
| 231 | errors.push(`Image asset for slot ${asset.slotId} is permanently hidden.`) |
| 232 | } |
| 233 | if (hasZeroDimension($, node.get(0))) { |
| 234 | errors.push(`Image asset for slot ${asset.slotId} has a zero dimension.`) |
| 235 | } |
| 236 | if (isBackgroundImageContainer(node) && !hasBackgroundGeometry(node)) { |
| 237 | errors.push(`Background image asset for slot ${asset.slotId} has no visible geometry.`) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | return { |
| 242 | valid: errors.length === 0, |
| 243 | errors, |
| 244 | usedSlotIds, |
| 245 | unusedSlotIds |
| 246 | } |
| 247 | } |
| 248 |