| 1 |
// Patch schema from the block's real `supports` — the model never hand-authors HTML. |
| 2 |
// Structure only: the backend overlays the model-facing field descriptions. |
| 3 |
|
| 4 |
const str = { type: 'string' }; |
| 5 |
const obj = (properties) => ({ |
| 6 |
type: 'object', |
| 7 |
additionalProperties: false, |
| 8 |
properties, |
| 9 |
}); |
| 10 |
|
| 11 |
// Color channels default on once color support is present, unless opted out. |
| 12 |
const colorChannel = (color, channel) => { |
| 13 |
if (color === true) return true; |
| 14 |
if (!color || typeof color !== 'object') return false; |
| 15 |
return color[channel] !== false; |
| 16 |
}; |
| 17 |
|
| 18 |
// Border/spacing/typography/dimensions are opt-in: `true`, or a truthy per-feature key. |
| 19 |
const feat = (support, key) => |
| 20 |
support === true || |
| 21 |
(!!support && support[key] != null && support[key] !== false); |
| 22 |
|
| 23 |
const prune = (properties) => { |
| 24 |
const out = {}; |
| 25 |
for (const [key, value] of Object.entries(properties)) { |
| 26 |
if (value == null) continue; |
| 27 |
if (value.type === 'object' && !Object.keys(value.properties).length) |
| 28 |
continue; |
| 29 |
out[key] = value; |
| 30 |
} |
| 31 |
return out; |
| 32 |
}; |
| 33 |
|
| 34 |
const styleSchema = (supports) => { |
| 35 |
const border = supports.border ?? supports.__experimentalBorder; |
| 36 |
const spacing = supports.spacing; |
| 37 |
const typography = supports.typography; |
| 38 |
const dimensions = supports.dimensions; |
| 39 |
|
| 40 |
const borderStyle = prune({ |
| 41 |
color: feat(border, 'color') ? str : null, |
| 42 |
width: feat(border, 'width') ? str : null, |
| 43 |
style: feat(border, 'style') ? str : null, |
| 44 |
radius: feat(border, 'radius') ? str : null, |
| 45 |
}); |
| 46 |
|
| 47 |
const spacingStyle = prune({ |
| 48 |
padding: feat(spacing, 'padding') ? str : null, |
| 49 |
margin: feat(spacing, 'margin') ? str : null, |
| 50 |
blockGap: feat(spacing, 'blockGap') ? str : null, |
| 51 |
}); |
| 52 |
|
| 53 |
const typographyStyle = prune({ |
| 54 |
textAlign: |
| 55 |
feat(typography, 'textAlign') || |
| 56 |
feat(typography, '__experimentalTextAlign') |
| 57 |
? str |
| 58 |
: null, |
| 59 |
lineHeight: feat(typography, 'lineHeight') ? str : null, |
| 60 |
fontFamily: feat(typography, '__experimentalFontFamily') ? str : null, |
| 61 |
fontWeight: feat(typography, '__experimentalFontWeight') ? str : null, |
| 62 |
fontStyle: feat(typography, '__experimentalFontStyle') ? str : null, |
| 63 |
textTransform: feat(typography, '__experimentalTextTransform') ? str : null, |
| 64 |
textDecoration: feat(typography, '__experimentalTextDecoration') |
| 65 |
? str |
| 66 |
: null, |
| 67 |
letterSpacing: feat(typography, '__experimentalLetterSpacing') ? str : null, |
| 68 |
}); |
| 69 |
|
| 70 |
const dimensionsStyle = prune({ |
| 71 |
minHeight: feat(dimensions, 'minHeight') ? str : null, |
| 72 |
aspectRatio: feat(dimensions, 'aspectRatio') ? str : null, |
| 73 |
}); |
| 74 |
|
| 75 |
const style = prune({ |
| 76 |
border: Object.keys(borderStyle).length ? obj(borderStyle) : null, |
| 77 |
spacing: Object.keys(spacingStyle).length ? obj(spacingStyle) : null, |
| 78 |
typography: Object.keys(typographyStyle).length |
| 79 |
? obj(typographyStyle) |
| 80 |
: null, |
| 81 |
dimensions: Object.keys(dimensionsStyle).length |
| 82 |
? obj(dimensionsStyle) |
| 83 |
: null, |
| 84 |
shadow: supports.shadow ? str : null, |
| 85 |
// The arbitrary-CSS escape hatch — always offered. |
| 86 |
css: str, |
| 87 |
}); |
| 88 |
|
| 89 |
return obj(style); |
| 90 |
}; |
| 91 |
|
| 92 |
const ALIGN_OPTIONS = ['left', 'center', 'right', 'wide', 'full']; |
| 93 |
|
| 94 |
// supports.align is `true` (everything) or the block's own subset. |
| 95 |
const alignSchema = (align) => { |
| 96 |
if (!align) return null; |
| 97 |
return { |
| 98 |
type: 'string', |
| 99 |
enum: align === true ? ALIGN_OPTIONS : align, |
| 100 |
}; |
| 101 |
}; |
| 102 |
|
| 103 |
// Positioning knobs only — allowedBlocks/default/allowEditing are editor config. |
| 104 |
const layoutSchema = (supports) => { |
| 105 |
const layout = supports.layout ?? supports.__experimentalLayout; |
| 106 |
if (!layout || layout.allowEditing === false) return null; |
| 107 |
return obj({ |
| 108 |
type: str, |
| 109 |
contentSize: str, |
| 110 |
wideSize: str, |
| 111 |
justifyContent: str, |
| 112 |
orientation: str, |
| 113 |
flexWrap: str, |
| 114 |
verticalAlignment: str, |
| 115 |
}); |
| 116 |
}; |
| 117 |
|
| 118 |
// Identity attributes (ref, id, className, lock, metadata) stay out — |
| 119 |
// bad values there break the block, not just its look. |
| 120 |
const SIMPLE_ATTRIBUTES = [ |
| 121 |
'level', |
| 122 |
'ordered', |
| 123 |
'reversed', |
| 124 |
'start', |
| 125 |
'width', |
| 126 |
'height', |
| 127 |
'verticalAlignment', |
| 128 |
'isStackedOnMobile', |
| 129 |
// media-text's image side is an attribute, not child order. |
| 130 |
'mediaPosition', |
| 131 |
]; |
| 132 |
|
| 133 |
const simpleAttrSchema = (definition) => { |
| 134 |
if (!definition) return null; |
| 135 |
const schema = {}; |
| 136 |
if (definition.type) schema.type = definition.type; |
| 137 |
if (definition.enum) schema.enum = definition.enum; |
| 138 |
return Object.keys(schema).length ? schema : str; |
| 139 |
}; |
| 140 |
|
| 141 |
export const buildBlockSchema = (blockType) => { |
| 142 |
if (!blockType) return null; |
| 143 |
const attributes = blockType.attributes ?? {}; |
| 144 |
const supports = blockType.supports ?? {}; |
| 145 |
const typography = supports.typography; |
| 146 |
|
| 147 |
const simple = Object.fromEntries( |
| 148 |
SIMPLE_ATTRIBUTES.map((name) => [name, simpleAttrSchema(attributes[name])]), |
| 149 |
); |
| 150 |
|
| 151 |
const properties = prune({ |
| 152 |
...simple, |
| 153 |
align: attributes.align ? alignSchema(supports.align) : null, |
| 154 |
layout: attributes.layout ? layoutSchema(supports) : null, |
| 155 |
// Rich text lives in `text` (button) or `content` (paragraph, heading, …); |
| 156 |
// the model always sees `text` — the patcher maps it to the real attribute. |
| 157 |
text: attributes.text || attributes.content ? str : null, |
| 158 |
url: attributes.url ? str : null, |
| 159 |
backgroundColor: |
| 160 |
attributes.backgroundColor && colorChannel(supports.color, 'background') |
| 161 |
? str |
| 162 |
: null, |
| 163 |
textColor: |
| 164 |
attributes.textColor && colorChannel(supports.color, 'text') ? str : null, |
| 165 |
// Gradients omitted: the theme's preset slugs are too opaque for the model |
| 166 |
// to pick reliably, so gradients go through style.css instead. |
| 167 |
fontSize: attributes.fontSize && feat(typography, 'fontSize') ? str : null, |
| 168 |
fontFamily: |
| 169 |
attributes.fontFamily && feat(typography, '__experimentalFontFamily') |
| 170 |
? str |
| 171 |
: null, |
| 172 |
style: styleSchema(supports), |
| 173 |
}); |
| 174 |
|
| 175 |
if (!Object.keys(properties).length) return null; |
| 176 |
return obj(properties); |
| 177 |
}; |
| 178 |
|