mockup-parse.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. /**
  2. * Parse UNA `mockup` block JSON into a render tree.
  3. *
  4. * Envelope (page JSON):
  5. * block.content[] = { type: 'mockup', data: <tree> }
  6. *
  7. * `data` may be the tree, `{ tree }`, `{ node }`, or a JSON string.
  8. * Node types: view, row, text, image, icon, button.
  9. */
  10. import { pickUnaDisplaySrc } from 'app/lib/image-helpers'
  11. export const MOCKUP_MAX_DEPTH = 24
  12. export const MOCKUP_NODE_TYPES: Record<string, any> = {
  13. view: true,
  14. row: true,
  15. text: true,
  16. image: true,
  17. icon: true,
  18. button: true,
  19. }
  20. const BUTTON_STYLE: Record<string, any> = {
  21. primary: 'borderedProminent',
  22. secondary: 'bordered',
  23. outline: 'bordered',
  24. bordered: 'bordered',
  25. borderedProminent: 'borderedProminent',
  26. plain: 'plain',
  27. link: 'link',
  28. glass: 'glass',
  29. glassProminent: 'glassProminent',
  30. borderless: 'borderless',
  31. }
  32. export function parseJsonValue(value: any) {
  33. if (typeof value !== 'string') return value
  34. const trimmed = value.trim()
  35. if (!trimmed) return null
  36. try {
  37. return JSON.parse(trimmed)
  38. } catch {
  39. return null
  40. }
  41. }
  42. function isNodeLike(value: any) {
  43. if (!value || typeof value !== 'object' || Array.isArray(value)) return false
  44. return typeof value.type === 'string'
  45. || Array.isArray(value.children)
  46. || typeof value.text === 'string'
  47. }
  48. function unwrapPayload(payload: any, hops = 0) {
  49. if (hops > 8 || payload == null) return null
  50. const parsed = parseJsonValue(payload)
  51. if (parsed !== payload) return unwrapPayload(parsed, hops + 1)
  52. if (typeof parsed !== 'object') return null
  53. if (Array.isArray(parsed)) {
  54. return { type: 'view', children: parsed }
  55. }
  56. const itemType = parsed.type || parsed.content_type
  57. if (itemType === 'mockup') {
  58. return unwrapPayload(parsed.data ?? parsed.content ?? parsed.tree ?? parsed.node, hops + 1)
  59. }
  60. if (isNodeLike(parsed) && itemType !== 'service') return parsed
  61. const nested = parsed.data ?? parsed.content ?? parsed.tree ?? parsed.node
  62. if (nested != null && nested !== parsed) return unwrapPayload(nested, hops + 1)
  63. return parsed
  64. }
  65. function asClassName(value: any) {
  66. return typeof value === 'string' ? value : ''
  67. }
  68. function asChildren(value: any) {
  69. if (Array.isArray(value)) return value
  70. if (value == null || value === '') return []
  71. return [value]
  72. }
  73. function normalizeButtonStyle(value: any) {
  74. const key = String(value || 'primary')
  75. return BUTTON_STYLE[key] || BUTTON_STYLE.primary
  76. }
  77. function normalizeNode(raw: any, depth: number): any {
  78. if (depth > MOCKUP_MAX_DEPTH || raw == null) return null
  79. const parsed = parseJsonValue(raw)
  80. if (typeof parsed === 'string') {
  81. const text = parsed.trim()
  82. return text ? { type: 'text', className: '', text } : null
  83. }
  84. if (!parsed || typeof parsed !== 'object') return null
  85. if (Array.isArray(parsed)) {
  86. const children = parsed.map((child) => normalizeNode(child, depth + 1)).filter(Boolean)
  87. return children.length ? { type: 'view', className: '', children } : null
  88. }
  89. let type = parsed.type
  90. if (!type) {
  91. type = parsed.text != null && !parsed.children ? 'text' : 'view'
  92. } else if (!MOCKUP_NODE_TYPES[type]) {
  93. return null
  94. }
  95. const className = asClassName(parsed.className)
  96. const children = asChildren(parsed.children)
  97. .map((child) => normalizeNode(child, depth + 1))
  98. .filter(Boolean)
  99. if (type === 'text') {
  100. const text = parsed.text == null ? '' : String(parsed.text)
  101. if (!text) return null
  102. return { type, className, text }
  103. }
  104. if (type === 'icon') {
  105. const icon = String(parsed.icon || '')
  106. if (!icon) return null
  107. const size = Number(parsed.size)
  108. return { type, className, icon, size: Number.isFinite(size) && size > 0 ? size : 24 }
  109. }
  110. if (type === 'image') {
  111. const src = pickUnaDisplaySrc(parsed.src || parsed)
  112. if (!src) return null
  113. return { type, className, src, alt: parsed.alt == null ? '' : String(parsed.alt) }
  114. }
  115. if (type === 'button') {
  116. const label = String(parsed.label || parsed.text || '')
  117. if (!label) return null
  118. const href = String(parsed.href || '')
  119. return { type, className, label, href, style: normalizeButtonStyle(parsed.style || parsed.variant) }
  120. }
  121. if (!children.length) return null
  122. return { type, className, children }
  123. }
  124. /** @returns {object|null} Normalized tree, or null if the payload is empty/invalid. */
  125. export function parseMockup(payload: any) {
  126. return normalizeNode(unwrapPayload(payload), 0)
  127. }
  128. export function isMockupEmpty(payload: any) {
  129. return !parseMockup(payload)
  130. }