返回 oh-my-ppt
layout-slot-validator.ts
根目录 / src / main / generation / layout-slot-validator.ts
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
148 lines TYPESCRIPT