| 1 | import * as cheerio from 'cheerio' |
| 2 | import type { Element } from 'domhandler' |
| 3 | import { |
| 4 | getLayoutMasterTemplate, |
| 5 | type LayoutMasterTemplate, |
| 6 | type LayoutSlot |
| 7 | } from '@shared/layout-master' |
| 8 | import type { LayoutIntent } from '@shared/layout-intent' |
| 9 | |
| 10 | export type LayoutSlotValidationInput = { |
| 11 | html: string |
| 12 | layoutIntent?: LayoutIntent | null |
| 13 | layoutId?: string | null |
| 14 | layoutContractVersion?: number | null |
| 15 | } |
| 16 | |
| 17 | type PageLayoutSourceInput = Omit<LayoutSlotValidationInput, 'html'> |
| 18 | |
| 19 | export type LayoutSlotValidationResult = { |
| 20 | valid: boolean |
| 21 | errors: string[] |
| 22 | skipped: boolean |
| 23 | diagnostic?: 'layout-contract-incompatible' | 'layout-source-missing' |
| 24 | } |
| 25 | |
| 26 | export type RetainedPageLayoutSource = { |
| 27 | layoutIntent: LayoutIntent | null |
| 28 | layoutId: string | null |
| 29 | layoutContractVersion: number | null |
| 30 | validation: LayoutSlotValidationResult |
| 31 | } |
| 32 | |
| 33 | const SLOT_FORBIDDEN_TAGS = new Set(['script', 'style', 'svg']) |
| 34 | |
| 35 | const resolveTemplate = (input: PageLayoutSourceInput): LayoutMasterTemplate | null => { |
| 36 | if (!input.layoutId || !input.layoutIntent) return null |
| 37 | const template = getLayoutMasterTemplate(input.layoutId) |
| 38 | if (!template || template.intent !== input.layoutIntent) return null |
| 39 | return template |
| 40 | } |
| 41 | |
| 42 | export const hasCompatiblePageLayoutSource = (input: PageLayoutSourceInput): boolean => { |
| 43 | const template = resolveTemplate(input) |
| 44 | return Boolean(template && input.layoutContractVersion === template.layoutContractVersion) |
| 45 | } |
| 46 | |
| 47 | const validateSlotElement = ( |
| 48 | node: Element, |
| 49 | slot: LayoutSlot, |
| 50 | errors: string[] |
| 51 | ): void => { |
| 52 | const tagName = node.tagName.toLowerCase() |
| 53 | if (SLOT_FORBIDDEN_TAGS.has(tagName)) { |
| 54 | errors.push(`Slot ${slot.id} cannot be placed on <${tagName}>.`) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | export const validateLayoutSlots = ( |
| 59 | input: LayoutSlotValidationInput |
| 60 | ): LayoutSlotValidationResult => { |
| 61 | const template = resolveTemplate(input) |
| 62 | if (!input.layoutId || !input.layoutIntent) { |
| 63 | return { valid: true, errors: [], skipped: true, diagnostic: 'layout-source-missing' } |
| 64 | } |
| 65 | if (!template || !hasCompatiblePageLayoutSource(input)) { |
| 66 | return { valid: true, errors: [], skipped: true, diagnostic: 'layout-contract-incompatible' } |
| 67 | } |
| 68 | |
| 69 | const $ = cheerio.load(input.html, { scriptingEnabled: false }) |
| 70 | const content = $('main[data-role="content"]').first() |
| 71 | if (content.length === 0) { |
| 72 | return { |
| 73 | valid: false, |
| 74 | errors: ['Layout slot validation requires main[data-role="content"].'], |
| 75 | skipped: false |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | const errors: string[] = [] |
| 80 | const declaredSlots = new Map(template.slots.map((slot) => [slot.id, slot])) |
| 81 | const seen = new Set<string>() |
| 82 | const contentSlots = content.find('[data-ppt-slot]').addBack('[data-ppt-slot]') |
| 83 | const allSlots = $('[data-ppt-slot]') |
| 84 | |
| 85 | allSlots.each((_index, node) => { |
| 86 | if (contentSlots.toArray().includes(node)) return |
| 87 | errors.push('data-ppt-slot may only appear inside main[data-role="content"].') |
| 88 | }) |
| 89 | |
| 90 | contentSlots.each((_index, node) => { |
| 91 | if (!('tagName' in node)) { |
| 92 | errors.push('data-ppt-slot must be attached to an HTML element.') |
| 93 | return |
| 94 | } |
| 95 | const element = node as Element |
| 96 | const slotId = ($(element).attr('data-ppt-slot') || '').trim() |
| 97 | const slot = declaredSlots.get(slotId) |
| 98 | if (!slot) { |
| 99 | errors.push(`Unknown layout slot: ${slotId || '(empty)'}.`) |
| 100 | return |
| 101 | } |
| 102 | if (seen.has(slotId)) { |
| 103 | errors.push(`Layout slot ${slotId} appears more than once.`) |
| 104 | return |
| 105 | } |
| 106 | seen.add(slotId) |
| 107 | validateSlotElement(element, slot, errors) |
| 108 | }) |
| 109 | |
| 110 | for (const slot of template.slots) { |
| 111 | if (slot.required && !seen.has(slot.id)) { |
| 112 | errors.push(`Required layout slot is missing: ${slot.id}.`) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | return { valid: errors.length === 0, errors, skipped: false } |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Full-page rewrites may redesign the DOM, but they must not leave a stale M3b |
| 121 | * source snapshot behind when the rendered slot contract no longer matches it. |
| 122 | */ |
| 123 | export const resolveRetainedPageLayoutSource = ( |
| 124 | input: LayoutSlotValidationInput |
| 125 | ): RetainedPageLayoutSource => { |
| 126 | const hasCompleteSource = Boolean( |
| 127 | input.layoutIntent && input.layoutId && input.layoutContractVersion |
| 128 | ) |
| 129 | const validation = validateLayoutSlots(input) |
| 130 | if (!hasCompleteSource || !validation.valid) { |
| 131 | return { |
| 132 | layoutIntent: null, |
| 133 | layoutId: null, |
| 134 | layoutContractVersion: null, |
| 135 | validation |
| 136 | } |
| 137 | } |
| 138 | return { |
| 139 | layoutIntent: input.layoutIntent || null, |
| 140 | layoutId: input.layoutId || null, |
| 141 | layoutContractVersion: input.layoutContractVersion || null, |
| 142 | validation |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | export const LAYOUT_SLOT_PRESERVATION_REQUIREMENT = |
| 147 | 'M3b structure contract: preserve every existing data-ppt-slot attribute, its exact value, and its attachment to the same semantic content role. You may redesign the composition, but do not drop, duplicate, rename, or move slot attributes onto decorative, script, style, or SVG nodes.' |
| 148 |