@teleporthq/teleport-plugin-common 0.43.0-alpha.0 → 0.43.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/__tests__/builders/html-builders.ts +9 -2
  2. package/__tests__/node-handlers/node-to-html/index.ts +2 -0
  3. package/__tests__/node-handlers/node-to-jsx/index.ts +165 -1
  4. package/__tests__/node-handlers/node-to-jsx/url-search-params-expression.ts +59 -0
  5. package/__tests__/node-handlers/node-to-jsx/utils.ts +233 -3
  6. package/__tests__/utils/ast-utils.ts +135 -0
  7. package/__tests__/utils/content-property-sanitiser.ts +156 -0
  8. package/__tests__/utils/url-search-params.ts +48 -0
  9. package/dist/cjs/builders/ast-builders.d.ts +2 -0
  10. package/dist/cjs/builders/ast-builders.d.ts.map +1 -1
  11. package/dist/cjs/builders/ast-builders.js +27 -3
  12. package/dist/cjs/builders/ast-builders.js.map +1 -1
  13. package/dist/cjs/builders/hast-builders.d.ts +1 -0
  14. package/dist/cjs/builders/hast-builders.d.ts.map +1 -1
  15. package/dist/cjs/builders/hast-builders.js +11 -1
  16. package/dist/cjs/builders/hast-builders.js.map +1 -1
  17. package/dist/cjs/builders/style-builders.d.ts +9 -2
  18. package/dist/cjs/builders/style-builders.d.ts.map +1 -1
  19. package/dist/cjs/builders/style-builders.js +73 -19
  20. package/dist/cjs/builders/style-builders.js.map +1 -1
  21. package/dist/cjs/index.d.ts +2 -1
  22. package/dist/cjs/index.d.ts.map +1 -1
  23. package/dist/cjs/index.js +3 -1
  24. package/dist/cjs/index.js.map +1 -1
  25. package/dist/cjs/node-handlers/node-to-html/constants.d.ts.map +1 -1
  26. package/dist/cjs/node-handlers/node-to-html/constants.js +2 -0
  27. package/dist/cjs/node-handlers/node-to-html/constants.js.map +1 -1
  28. package/dist/cjs/node-handlers/node-to-html/index.d.ts.map +1 -1
  29. package/dist/cjs/node-handlers/node-to-html/index.js +69 -1
  30. package/dist/cjs/node-handlers/node-to-html/index.js.map +1 -1
  31. package/dist/cjs/node-handlers/node-to-html/types.d.ts +5 -1
  32. package/dist/cjs/node-handlers/node-to-html/types.d.ts.map +1 -1
  33. package/dist/cjs/node-handlers/node-to-html/utils.d.ts +2 -1
  34. package/dist/cjs/node-handlers/node-to-html/utils.d.ts.map +1 -1
  35. package/dist/cjs/node-handlers/node-to-html/utils.js +139 -12
  36. package/dist/cjs/node-handlers/node-to-html/utils.js.map +1 -1
  37. package/dist/cjs/node-handlers/node-to-jsx/index.d.ts.map +1 -1
  38. package/dist/cjs/node-handlers/node-to-jsx/index.js +1024 -63
  39. package/dist/cjs/node-handlers/node-to-jsx/index.js.map +1 -1
  40. package/dist/cjs/node-handlers/node-to-jsx/types.d.ts +44 -6
  41. package/dist/cjs/node-handlers/node-to-jsx/types.d.ts.map +1 -1
  42. package/dist/cjs/node-handlers/node-to-jsx/utils.d.ts +52 -7
  43. package/dist/cjs/node-handlers/node-to-jsx/utils.d.ts.map +1 -1
  44. package/dist/cjs/node-handlers/node-to-jsx/utils.js +557 -49
  45. package/dist/cjs/node-handlers/node-to-jsx/utils.js.map +1 -1
  46. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  47. package/dist/cjs/utils/ast-utils.d.ts +98 -11
  48. package/dist/cjs/utils/ast-utils.d.ts.map +1 -1
  49. package/dist/cjs/utils/ast-utils.js +677 -65
  50. package/dist/cjs/utils/ast-utils.js.map +1 -1
  51. package/dist/cjs/utils/hast-utils.d.ts +8 -6
  52. package/dist/cjs/utils/hast-utils.d.ts.map +1 -1
  53. package/dist/cjs/utils/hast-utils.js +28 -4
  54. package/dist/cjs/utils/hast-utils.js.map +1 -1
  55. package/dist/cjs/utils/style-utils.d.ts +1 -0
  56. package/dist/cjs/utils/style-utils.d.ts.map +1 -1
  57. package/dist/cjs/utils/style-utils.js +155 -2
  58. package/dist/cjs/utils/style-utils.js.map +1 -1
  59. package/dist/cjs/utils/types.d.ts +3 -0
  60. package/dist/cjs/utils/types.d.ts.map +1 -0
  61. package/dist/cjs/utils/types.js +3 -0
  62. package/dist/cjs/utils/types.js.map +1 -0
  63. package/dist/cjs/utils/url-search-params.d.ts +48 -0
  64. package/dist/cjs/utils/url-search-params.d.ts.map +1 -0
  65. package/dist/cjs/utils/url-search-params.js +109 -0
  66. package/dist/cjs/utils/url-search-params.js.map +1 -0
  67. package/dist/esm/builders/ast-builders.d.ts +2 -0
  68. package/dist/esm/builders/ast-builders.d.ts.map +1 -1
  69. package/dist/esm/builders/ast-builders.js +25 -3
  70. package/dist/esm/builders/ast-builders.js.map +1 -1
  71. package/dist/esm/builders/hast-builders.d.ts +1 -0
  72. package/dist/esm/builders/hast-builders.d.ts.map +1 -1
  73. package/dist/esm/builders/hast-builders.js +9 -0
  74. package/dist/esm/builders/hast-builders.js.map +1 -1
  75. package/dist/esm/builders/style-builders.d.ts +9 -2
  76. package/dist/esm/builders/style-builders.d.ts.map +1 -1
  77. package/dist/esm/builders/style-builders.js +70 -18
  78. package/dist/esm/builders/style-builders.js.map +1 -1
  79. package/dist/esm/index.d.ts +2 -1
  80. package/dist/esm/index.d.ts.map +1 -1
  81. package/dist/esm/index.js +2 -1
  82. package/dist/esm/index.js.map +1 -1
  83. package/dist/esm/node-handlers/node-to-html/constants.d.ts.map +1 -1
  84. package/dist/esm/node-handlers/node-to-html/constants.js +2 -0
  85. package/dist/esm/node-handlers/node-to-html/constants.js.map +1 -1
  86. package/dist/esm/node-handlers/node-to-html/index.d.ts.map +1 -1
  87. package/dist/esm/node-handlers/node-to-html/index.js +71 -3
  88. package/dist/esm/node-handlers/node-to-html/index.js.map +1 -1
  89. package/dist/esm/node-handlers/node-to-html/types.d.ts +5 -1
  90. package/dist/esm/node-handlers/node-to-html/types.d.ts.map +1 -1
  91. package/dist/esm/node-handlers/node-to-html/utils.d.ts +2 -1
  92. package/dist/esm/node-handlers/node-to-html/utils.d.ts.map +1 -1
  93. package/dist/esm/node-handlers/node-to-html/utils.js +135 -12
  94. package/dist/esm/node-handlers/node-to-html/utils.js.map +1 -1
  95. package/dist/esm/node-handlers/node-to-jsx/index.d.ts.map +1 -1
  96. package/dist/esm/node-handlers/node-to-jsx/index.js +1026 -65
  97. package/dist/esm/node-handlers/node-to-jsx/index.js.map +1 -1
  98. package/dist/esm/node-handlers/node-to-jsx/types.d.ts +44 -6
  99. package/dist/esm/node-handlers/node-to-jsx/types.d.ts.map +1 -1
  100. package/dist/esm/node-handlers/node-to-jsx/utils.d.ts +52 -7
  101. package/dist/esm/node-handlers/node-to-jsx/utils.d.ts.map +1 -1
  102. package/dist/esm/node-handlers/node-to-jsx/utils.js +552 -48
  103. package/dist/esm/node-handlers/node-to-jsx/utils.js.map +1 -1
  104. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  105. package/dist/esm/utils/ast-utils.d.ts +98 -11
  106. package/dist/esm/utils/ast-utils.d.ts.map +1 -1
  107. package/dist/esm/utils/ast-utils.js +660 -65
  108. package/dist/esm/utils/ast-utils.js.map +1 -1
  109. package/dist/esm/utils/hast-utils.d.ts +8 -6
  110. package/dist/esm/utils/hast-utils.d.ts.map +1 -1
  111. package/dist/esm/utils/hast-utils.js +26 -3
  112. package/dist/esm/utils/hast-utils.js.map +1 -1
  113. package/dist/esm/utils/style-utils.d.ts +1 -0
  114. package/dist/esm/utils/style-utils.d.ts.map +1 -1
  115. package/dist/esm/utils/style-utils.js +153 -1
  116. package/dist/esm/utils/style-utils.js.map +1 -1
  117. package/dist/esm/utils/types.d.ts +3 -0
  118. package/dist/esm/utils/types.d.ts.map +1 -0
  119. package/dist/esm/utils/types.js +2 -0
  120. package/dist/esm/utils/types.js.map +1 -0
  121. package/dist/esm/utils/url-search-params.d.ts +48 -0
  122. package/dist/esm/utils/url-search-params.d.ts.map +1 -0
  123. package/dist/esm/utils/url-search-params.js +81 -0
  124. package/dist/esm/utils/url-search-params.js.map +1 -0
  125. package/package.json +4 -4
  126. package/src/builders/ast-builders.ts +62 -2
  127. package/src/builders/hast-builders.ts +11 -0
  128. package/src/builders/style-builders.ts +115 -27
  129. package/src/index.ts +12 -1
  130. package/src/node-handlers/node-to-html/constants.ts +2 -0
  131. package/src/node-handlers/node-to-html/index.ts +126 -6
  132. package/src/node-handlers/node-to-html/types.ts +6 -0
  133. package/src/node-handlers/node-to-html/utils.ts +167 -12
  134. package/src/node-handlers/node-to-jsx/index.ts +1585 -91
  135. package/src/node-handlers/node-to-jsx/types.ts +58 -32
  136. package/src/node-handlers/node-to-jsx/utils.ts +751 -54
  137. package/src/utils/ast-utils.ts +936 -111
  138. package/src/utils/hast-utils.ts +37 -9
  139. package/src/utils/style-utils.ts +157 -1
  140. package/src/utils/types.ts +25 -0
  141. package/src/utils/url-search-params.ts +122 -0
@@ -3,7 +3,7 @@ import { createHTMLNode, createTextNode } from '../../src/builders/hast-builders
3
3
  describe('HTML Builders', () => {
4
4
  describe('createHTMLNode', () => {
5
5
  it('should create html node', () => {
6
- const htmlNode = createHTMLNode('span', ['span'])
6
+ const htmlNode = createHTMLNode('span', [createHTMLNode('span', [])])
7
7
 
8
8
  expect(htmlNode).toHaveProperty('type')
9
9
  expect(htmlNode).toHaveProperty('tagName')
@@ -12,7 +12,14 @@ describe('HTML Builders', () => {
12
12
  expect(htmlNode.type).toBe('element')
13
13
  expect(htmlNode.tagName).toBe('span')
14
14
  expect(htmlNode.properties).toEqual({})
15
- expect(htmlNode.children).toEqual(['span'])
15
+ expect(htmlNode.children).toEqual([
16
+ {
17
+ children: [],
18
+ tagName: 'span',
19
+ type: 'element',
20
+ properties: {},
21
+ },
22
+ ])
16
23
  })
17
24
  })
18
25
  describe('createTextNode', () => {
@@ -12,6 +12,8 @@ describe('generateHTMLTemplateSyntax', () => {
12
12
  dataObject: {},
13
13
  methodsObject: {},
14
14
  templateLookup: {},
15
+ propDefinitions: {},
16
+ stateDefinitions: {},
15
17
  }
16
18
 
17
19
  describe('uidl node', () => {
@@ -16,7 +16,13 @@ describe('generateJSXSyntax', () => {
16
16
  dependencies: {},
17
17
  propDefinitions: uidl.propDefinitions || {},
18
18
  stateDefinitions: uidl.stateDefinitions || {},
19
+ globalStateDefinitions: {},
19
20
  nodesLookup: {},
21
+ windowImports: {},
22
+ localeReferences: [],
23
+ globalReferences: [],
24
+ globalStateReferences: [],
25
+ hoistedConstants: [],
20
26
  }
21
27
 
22
28
  const options: JSXGenerationOptions = {
@@ -24,7 +30,6 @@ describe('generateJSXSyntax', () => {
24
30
  prop: 'props',
25
31
  state: '',
26
32
  local: '',
27
- ctx: '',
28
33
  },
29
34
  }
30
35
 
@@ -104,4 +109,163 @@ describe('generateJSXSyntax', () => {
104
109
  expect((nameAttr.value as types.StringLiteral).value).toBe('hole')
105
110
  })
106
111
  })
112
+
113
+ describe('renderingConditions on elements', () => {
114
+ it('wraps the element with a logical `&&` when renderingConditions are set', () => {
115
+ const ownerContainer = elementNode('container', {}, [])
116
+ ownerContainer.content.renderingConditions = {
117
+ reference: {
118
+ type: 'dynamic',
119
+ content: {
120
+ referenceType: 'state',
121
+ id: 'profileViewer',
122
+ refPath: ['currentUserId'],
123
+ },
124
+ },
125
+ condition: {
126
+ conditions: [
127
+ {
128
+ operation: '===',
129
+ operand: {
130
+ type: 'dynamic',
131
+ content: {
132
+ referenceType: 'local',
133
+ id: 'id',
134
+ refPath: ['id'],
135
+ },
136
+ },
137
+ },
138
+ ],
139
+ },
140
+ } as never
141
+
142
+ const wrappingParent = elementNode('container', {}, [ownerContainer])
143
+ const detailsParams: JSXGenerationParams = {
144
+ ...params,
145
+ globalReferences: [],
146
+ detailsPageExposeAsName: 'User',
147
+ }
148
+ const result = generateJSXSyntax(wrappingParent, detailsParams, options)
149
+
150
+ const expressionChild = result.children[0] as types.JSXExpressionContainer
151
+ expect(expressionChild.type).toBe('JSXExpressionContainer')
152
+
153
+ const logical = expressionChild.expression as types.LogicalExpression
154
+ expect(logical.type).toBe('LogicalExpression')
155
+ expect(logical.operator).toBe('&&')
156
+
157
+ // The wrapped content is the original <container/>.
158
+ const wrappedContent = logical.right as types.JSXElement
159
+ expect(wrappedContent.type).toBe('JSXElement')
160
+ expect((wrappedContent.openingElement.name as types.JSXIdentifier).name).toBe('container')
161
+ })
162
+ })
163
+
164
+ describe('navlink href via differentiatorValue', () => {
165
+ // The navlink attribute flow is exercised end-to-end in the resolver tests;
166
+ // here we only confirm that an `expr`-typed attribute which mentions
167
+ // `currentUser` registers a global reference so useGlobalContext() is wired
168
+ // up downstream.
169
+ it('tracks currentUser as a global reference when emitted via an expr attr', () => {
170
+ const linkWithDifferentiatorHref = elementNode('a', {
171
+ href: {
172
+ type: 'expr',
173
+ content: '`/profile/' + '$' + '{' + 'currentUser?.id}' + '`',
174
+ },
175
+ })
176
+ const localParams: JSXGenerationParams = {
177
+ ...params,
178
+ globalReferences: [],
179
+ }
180
+ generateJSXSyntax(linkWithDifferentiatorHref, localParams, options)
181
+ expect(localParams.globalReferences).toContain('currentUser')
182
+ })
183
+ })
184
+
185
+ describe('plan v15 Layer 4 — codegen safety net for junk attrs and corrupt values', () => {
186
+ // Suppress the console.warn that fires when junk attrs are dropped so
187
+ // the test runner output stays clean. Reset between tests.
188
+ let warnSpy: jest.SpyInstance | null = null
189
+ beforeEach(() => {
190
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
191
+ })
192
+ afterEach(() => {
193
+ warnSpy?.mockRestore()
194
+ })
195
+
196
+ const attributeNames = (el: types.JSXElement): string[] =>
197
+ el.openingElement.attributes
198
+ .filter((a): a is types.JSXAttribute => a.type === 'JSXAttribute')
199
+ .map((a) => (a.name as types.JSXIdentifier).name)
200
+
201
+ it('drops junk-name attribute true="true"', () => {
202
+ const node = elementNode('div', {
203
+ true: { type: 'static', content: 'true' },
204
+ class: { type: 'static', content: 'kept' },
205
+ } as never)
206
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
207
+ const names = attributeNames(result)
208
+ expect(names).not.toContain('true')
209
+ expect(names).toContain('class')
210
+ })
211
+
212
+ it('drops junk-name attribute false="false"', () => {
213
+ const node = elementNode('div', {
214
+ false: { type: 'static', content: 'false' },
215
+ class: { type: 'static', content: 'kept' },
216
+ } as never)
217
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
218
+ expect(attributeNames(result)).not.toContain('false')
219
+ })
220
+
221
+ it('drops numeric-name attributes (e.g. 0="x")', () => {
222
+ const node = elementNode('div', {
223
+ '0': { type: 'static', content: 'x' },
224
+ class: { type: 'static', content: 'kept' },
225
+ } as never)
226
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
227
+ expect(attributeNames(result)).not.toContain('0')
228
+ })
229
+
230
+ it('drops null and undefined name attrs', () => {
231
+ const node = elementNode('div', {
232
+ null: { type: 'static', content: 'x' },
233
+ undefined: { type: 'static', content: 'y' },
234
+ class: { type: 'static', content: 'kept' },
235
+ } as never)
236
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
237
+ const names = attributeNames(result)
238
+ expect(names).not.toContain('null')
239
+ expect(names).not.toContain('undefined')
240
+ })
241
+
242
+ it('drops entity-escaped binding markers in static attr values (&#123;)', () => {
243
+ const node = elementNode('tq-if', {
244
+ condition: { type: 'static', content: '&#123;&#123;state.X&#125;&#125;' },
245
+ } as never)
246
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
247
+ expect(attributeNames(result)).not.toContain('condition')
248
+ })
249
+
250
+ it('drops static attr values with truncated `{{` (no closing `}}`)', () => {
251
+ const node = elementNode('tq-if', {
252
+ condition: { type: 'static', content: '{{state.isMobileMenuOpen' },
253
+ } as never)
254
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
255
+ expect(attributeNames(result)).not.toContain('condition')
256
+ })
257
+
258
+ it('preserves well-formed static attrs (no false positives)', () => {
259
+ const node = elementNode('div', {
260
+ class: { type: 'static', content: 'foo' },
261
+ 'data-config': { type: 'static', content: '{"k":1}' },
262
+ condition: { type: 'static', content: '{{state.X}}' },
263
+ } as never)
264
+ const result = generateJSXSyntax(node, params, options) as types.JSXElement
265
+ const names = attributeNames(result)
266
+ expect(names).toContain('class')
267
+ expect(names).toContain('data-config')
268
+ expect(names).toContain('condition')
269
+ })
270
+ })
107
271
  })
@@ -0,0 +1,59 @@
1
+ import generator from '@babel/generator'
2
+ import { createDynamicValueExpression } from '../../../src/node-handlers/node-to-jsx/utils'
3
+ import type { JSXGenerationOptions } from '../../../src/node-handlers/node-to-jsx/types'
4
+ import type { UIDLDynamicReference } from '@teleporthq/teleport-types'
5
+
6
+ const gen = (ast: unknown): string => generator(ast as never).code
7
+
8
+ const buildOptions = (
9
+ prefixMap: Record<string, string> = { urlSearchParams: 'router.query' },
10
+ registry?: Record<string, { defaultValue?: string }>
11
+ ): JSXGenerationOptions => {
12
+ return {
13
+ dynamicReferencePrefixMap: prefixMap,
14
+ dependencyHandling: 'import',
15
+ stateHandling: 'mutation',
16
+ slotHandling: 'native',
17
+ ...(registry ? { urlSearchParamsRegistry: registry } : {}),
18
+ } as unknown as JSXGenerationOptions
19
+ }
20
+
21
+ const buildRef = (key: string): UIDLDynamicReference => ({
22
+ type: 'dynamic',
23
+ content: {
24
+ referenceType: 'urlSearchParams',
25
+ id: key,
26
+ refPath: [key],
27
+ },
28
+ })
29
+
30
+ describe('createDynamicValueExpression — urlSearchParams', () => {
31
+ it('emits router.query.<key> for Next.js generators', () => {
32
+ const ast = createDynamicValueExpression(buildRef('category'), buildOptions())
33
+ expect(gen(ast)).toBe('router.query?.category')
34
+ })
35
+
36
+ it('appends a nullish-coalescing default from the registry', () => {
37
+ const ast = createDynamicValueExpression(
38
+ buildRef('category'),
39
+ buildOptions({ urlSearchParams: 'router.query' }, { category: { defaultValue: 'food' } })
40
+ )
41
+ expect(gen(ast)).toBe('router.query?.category ?? "food"')
42
+ })
43
+
44
+ it('honors a custom prefix for static generators', () => {
45
+ const ast = createDynamicValueExpression(
46
+ buildRef('category'),
47
+ buildOptions({ urlSearchParams: '__urlSearchParams' })
48
+ )
49
+ expect(gen(ast)).toBe('__urlSearchParams?.category')
50
+ })
51
+
52
+ it('uses refPath[0] when no registry default is set and key has no fallback', () => {
53
+ const ast = createDynamicValueExpression(
54
+ buildRef('minPrice'),
55
+ buildOptions({ urlSearchParams: 'router.query' })
56
+ )
57
+ expect(gen(ast)).toBe('router.query?.minPrice')
58
+ })
59
+ })
@@ -1,7 +1,10 @@
1
+ import generate from '@babel/generator'
1
2
  import {
2
3
  createBinaryExpression,
3
4
  createConditionIdentifier,
5
+ createStateChangeStatement,
4
6
  } from '../../../src/node-handlers/node-to-jsx/utils'
7
+ import { UIDLStateDefinition, UIDLStateModifierEvent } from '@teleporthq/teleport-types'
5
8
  import { dynamicNode } from '@teleporthq/teleport-uidl-builders'
6
9
  import {
7
10
  JSXGenerationParams,
@@ -122,8 +125,14 @@ describe('createConditionIdentifier', () => {
122
125
  defaultValue: false,
123
126
  },
124
127
  },
128
+ globalStateDefinitions: {},
125
129
  dependencies: {},
126
130
  nodesLookup: {},
131
+ windowImports: {},
132
+ localeReferences: [],
133
+ globalReferences: [],
134
+ globalStateReferences: [],
135
+ hoistedConstants: [],
127
136
  }
128
137
 
129
138
  const options: JSXGenerationOptions = {
@@ -144,10 +153,10 @@ describe('createConditionIdentifier', () => {
144
153
  })
145
154
 
146
155
  it('works on member expressions', () => {
147
- const node = dynamicNode('prop', 'fields.title')
156
+ const node = dynamicNode('prop', 'fields', ['title'])
148
157
  const result = createConditionIdentifier(node, params, options)
149
158
 
150
- expect(result.key).toBe('fields.title')
159
+ expect(result.key).toBe("fields?.['title']")
151
160
  expect(result.prefix).toBe('this.props')
152
161
  expect(result.type).toBe('object')
153
162
  })
@@ -161,8 +170,229 @@ describe('createConditionIdentifier', () => {
161
170
  expect(result.type).toBe('boolean')
162
171
  })
163
172
 
173
+ it('works with a local reference', () => {
174
+ const node = dynamicNode('local', 'item', ['type'])
175
+ const optionsWithLocalIdentifier = {
176
+ ...options,
177
+ localIdentifier: 'item',
178
+ }
179
+ const result = createConditionIdentifier(node, params, optionsWithLocalIdentifier)
180
+
181
+ expect(result.key).toBe('type')
182
+ expect(result.prefix).toBe('item')
183
+ expect(result.type).toBe('string')
184
+ })
185
+
186
+ it('resolves a page-root local reference against the details-page exposeAs name', () => {
187
+ const node = dynamicNode('local', 'id', ['id'])
188
+ const detailsOptions = {
189
+ ...options,
190
+ dynamicReferencePrefixMap: {
191
+ prop: 'props',
192
+ state: '',
193
+ local: '',
194
+ },
195
+ detailsPageExposeAsName: 'User',
196
+ }
197
+ const result = createConditionIdentifier(node, params, detailsOptions)
198
+
199
+ expect(result.key).toBe('User.id')
200
+ expect(result.prefix).toBe('props')
201
+ expect(result.type).toBe('string')
202
+ })
203
+
204
+ it('keeps repeater semantics for local refs inside a loop', () => {
205
+ const node = dynamicNode('local', 'item', ['type'])
206
+ const detailsInsideLoopOptions = {
207
+ ...options,
208
+ localIdentifier: 'item',
209
+ detailsPageExposeAsName: 'User',
210
+ }
211
+ const result = createConditionIdentifier(node, params, detailsInsideLoopOptions)
212
+
213
+ expect(result.key).toBe('type')
214
+ expect(result.prefix).toBe('item')
215
+ })
216
+
164
217
  it('throws an error for unknown reference type', () => {
165
- const node = dynamicNode('local', 'title')
218
+ const node = dynamicNode('locale', 'title')
166
219
  expect(() => createConditionIdentifier(node, params, options)).toThrowError()
167
220
  })
168
221
  })
222
+
223
+ describe('createStateChangeStatement — plan v14 type-aware emitter', () => {
224
+ // We render each AST back to source so the assertions read like the code
225
+ // the React component would actually contain.
226
+ const render = (stmt: ReturnType<typeof createStateChangeStatement>): string => {
227
+ if (!stmt) {
228
+ return ''
229
+ }
230
+ return generate(stmt).code.replace(/[\s;]+$/g, '')
231
+ }
232
+
233
+ const hooksOptions: JSXGenerationOptions = {
234
+ stateHandling: 'hooks',
235
+ dynamicReferencePrefixMap: { prop: '', state: '', local: '' },
236
+ }
237
+
238
+ const stateDefs = (
239
+ overrides: Record<string, UIDLStateDefinition>
240
+ ): Record<string, UIDLStateDefinition> => ({
241
+ isOpen: { type: 'boolean', defaultValue: false },
242
+ activeStep: { type: 'number', defaultValue: 1 },
243
+ quizAnswers: { type: 'object', defaultValue: {} },
244
+ tags: { type: 'array', defaultValue: [] },
245
+ selectedSkinType: { type: 'string', defaultValue: '' },
246
+ ...overrides,
247
+ })
248
+
249
+ it('toggles a boolean state with !x (existing behaviour preserved)', () => {
250
+ const event: UIDLStateModifierEvent = {
251
+ type: 'stateChange',
252
+ modifies: 'isOpen',
253
+ newState: '$toggle',
254
+ }
255
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
256
+ expect(render(stmt)).toBe('setIsOpen(!isOpen)')
257
+ })
258
+
259
+ it('refuses to toggle a numeric state (no setter emitted)', () => {
260
+ const event: UIDLStateModifierEvent = {
261
+ type: 'stateChange',
262
+ modifies: 'activeStep',
263
+ newState: '$toggle',
264
+ }
265
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
266
+ expect(stmt).toBeNull()
267
+ })
268
+
269
+ it('refuses to toggle an object state (no setter emitted)', () => {
270
+ const event: UIDLStateModifierEvent = {
271
+ type: 'stateChange',
272
+ modifies: 'quizAnswers',
273
+ newState: '$toggle',
274
+ }
275
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
276
+ expect(stmt).toBeNull()
277
+ })
278
+
279
+ it('refuses to overwrite object state with a primitive', () => {
280
+ const event: UIDLStateModifierEvent = {
281
+ type: 'stateChange',
282
+ modifies: 'quizAnswers',
283
+ newState: 'oily',
284
+ }
285
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
286
+ expect(stmt).toBeNull()
287
+ })
288
+
289
+ it('writes a literal value to a numeric state', () => {
290
+ const event: UIDLStateModifierEvent = {
291
+ type: 'stateChange',
292
+ modifies: 'activeStep',
293
+ newState: 2,
294
+ }
295
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
296
+ expect(render(stmt)).toBe('setActiveStep(2)')
297
+ })
298
+
299
+ it('writes a literal value to a string state', () => {
300
+ const event: UIDLStateModifierEvent = {
301
+ type: 'stateChange',
302
+ modifies: 'selectedSkinType',
303
+ newState: 'Oily',
304
+ }
305
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
306
+ expect(render(stmt)).toBe('setSelectedSkinType("Oily")')
307
+ })
308
+
309
+ it('emits a functional setter for $increment on numeric state', () => {
310
+ const event: UIDLStateModifierEvent = {
311
+ type: 'stateChange',
312
+ modifies: 'activeStep',
313
+ newState: { type: '$increment' },
314
+ }
315
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
316
+ expect(render(stmt)).toBe('setActiveStep(prev => prev + 1)')
317
+ })
318
+
319
+ it('emits a functional setter for $decrement with custom delta', () => {
320
+ const event: UIDLStateModifierEvent = {
321
+ type: 'stateChange',
322
+ modifies: 'activeStep',
323
+ newState: { type: '$decrement', delta: 2 },
324
+ }
325
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
326
+ expect(render(stmt)).toBe('setActiveStep(prev => prev - 2)')
327
+ })
328
+
329
+ it('honors negative delta on $increment as a decrement', () => {
330
+ const event: UIDLStateModifierEvent = {
331
+ type: 'stateChange',
332
+ modifies: 'activeStep',
333
+ newState: { type: '$increment', delta: -2 },
334
+ }
335
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
336
+ expect(render(stmt)).toBe('setActiveStep(prev => prev - 2)')
337
+ })
338
+
339
+ it('refuses $increment on non-numeric state', () => {
340
+ const event: UIDLStateModifierEvent = {
341
+ type: 'stateChange',
342
+ modifies: 'selectedSkinType',
343
+ newState: { type: '$increment' },
344
+ }
345
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
346
+ expect(stmt).toBeNull()
347
+ })
348
+
349
+ it('emits a $patch setter for an object state with a path', () => {
350
+ const event: UIDLStateModifierEvent = {
351
+ type: 'stateChange',
352
+ modifies: 'quizAnswers',
353
+ newState: { type: '$patch', path: 'skinType', value: 'Oily' },
354
+ }
355
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
356
+ expect(render(stmt)).toBe('setQuizAnswers(prev => ({\n ...prev,\n skinType: "Oily"\n}))')
357
+ })
358
+
359
+ it('refuses $patch when the state is not declared as object', () => {
360
+ const event: UIDLStateModifierEvent = {
361
+ type: 'stateChange',
362
+ modifies: 'activeStep',
363
+ newState: { type: '$patch', path: 'foo', value: 'bar' },
364
+ }
365
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
366
+ expect(stmt).toBeNull()
367
+ })
368
+
369
+ it('emits an $append setter for an array state', () => {
370
+ const event: UIDLStateModifierEvent = {
371
+ type: 'stateChange',
372
+ modifies: 'tags',
373
+ newState: { type: '$append', value: 'sale' },
374
+ }
375
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
376
+ expect(render(stmt)).toBe('setTags(prev => [...prev, "sale"])')
377
+ })
378
+
379
+ it('refuses $append when the state is not declared as array', () => {
380
+ const event: UIDLStateModifierEvent = {
381
+ type: 'stateChange',
382
+ modifies: 'quizAnswers',
383
+ newState: { type: '$append', value: 'x' },
384
+ }
385
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
386
+ expect(stmt).toBeNull()
387
+ })
388
+
389
+ it('refuses a stateChange targeting an undeclared state', () => {
390
+ const event: UIDLStateModifierEvent = {
391
+ type: 'stateChange',
392
+ modifies: 'doesNotExist',
393
+ newState: 1,
394
+ }
395
+ const stmt = createStateChangeStatement(event, stateDefs({}), hooksOptions)
396
+ expect(stmt).toBeNull()
397
+ })
398
+ })
@@ -8,7 +8,11 @@ import {
8
8
  convertValueToLiteral,
9
9
  objectToObjectExpression,
10
10
  addRawAttributeToJSXTag,
11
+ collectGlobalReferencesFromObjectStates,
12
+ createStateHookAST,
13
+ sanitizeExprContent,
11
14
  } from '../../src/utils/ast-utils'
15
+ import generator from '@babel/generator'
12
16
  import ParsedASTNode from '../../src/utils/parsed-ast'
13
17
  import { createJSXTag } from '../../src/builders/ast-builders'
14
18
  import * as types from '@babel/types'
@@ -284,3 +288,134 @@ describe('objectToObjectExpression', () => {
284
288
  })
285
289
  })
286
290
  })
291
+
292
+ describe('collectGlobalReferencesFromObjectStates', () => {
293
+ it('resolves refPath-style global references (no explicit id) via GLOBAL_REF_ID_MAP', () => {
294
+ const stateDefinitions = {
295
+ profileViewer: {
296
+ type: 'object',
297
+ defaultValue: {
298
+ currentUserId: {
299
+ type: 'dynamic',
300
+ content: {
301
+ referenceType: 'global',
302
+ refPath: ['Current User', 'id'],
303
+ },
304
+ },
305
+ },
306
+ },
307
+ }
308
+ const refs = collectGlobalReferencesFromObjectStates(stateDefinitions as never)
309
+ expect(refs).toContain('currentUser')
310
+ })
311
+
312
+ it('resolves id-style global references untouched', () => {
313
+ const stateDefinitions = {
314
+ cartViewer: {
315
+ type: 'object',
316
+ defaultValue: {
317
+ itemCount: {
318
+ type: 'dynamic',
319
+ content: { referenceType: 'global', id: 'cart', refPath: ['items', 'length'] },
320
+ },
321
+ },
322
+ },
323
+ }
324
+ const refs = collectGlobalReferencesFromObjectStates(stateDefinitions as never)
325
+ expect(refs).toContain('cart')
326
+ })
327
+ })
328
+
329
+ describe('createStateHookAST – urlSearchParamBinding', () => {
330
+ const codeOf = (node: types.Node) => generator(node).code
331
+
332
+ it('emits plain useState(defaultValue) when no binding is declared', () => {
333
+ const decl = createStateHookAST('openPanel', {
334
+ type: 'boolean',
335
+ defaultValue: false,
336
+ })
337
+ expect(codeOf(decl)).toBe('const [openPanel, setOpenPanel] = useState(false);')
338
+ })
339
+
340
+ it('seeds useState from window.location.search falling back to the declared default', () => {
341
+ // Deliberately uses `window.location.search` rather than `router.query`
342
+ // because on SSG pages `router.query` is empty on the first render and
343
+ // only hydrates after `router.isReady` flips — past that, `useState`'s
344
+ // initializer has already captured the fallback default. Direct reads
345
+ // from `window.location.search` are synchronously available on both
346
+ // direct loads and client-side navigations.
347
+ const decl = createStateHookAST('products_detail_panel_item_id', {
348
+ type: 'string',
349
+ defaultValue: '',
350
+ urlSearchParamBinding: { key: 'products_detail_panel_item_id' },
351
+ })
352
+ expect(codeOf(decl)).toBe(
353
+ 'const [products_detail_panel_item_id, setProductsDetailPanelItemId] = useState((typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("products_detail_panel_item_id") : null) ?? "");'
354
+ )
355
+ })
356
+
357
+ it('falls back to a non-empty static default when the param is absent', () => {
358
+ const decl = createStateHookAST('category', {
359
+ type: 'string',
360
+ defaultValue: 'all',
361
+ urlSearchParamBinding: { key: 'category' },
362
+ })
363
+ expect(codeOf(decl)).toBe(
364
+ 'const [category, setCategory] = useState((typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("category") : null) ?? "all");'
365
+ )
366
+ })
367
+
368
+ it('ignores urlSearchParamBinding with an empty key (defensive)', () => {
369
+ const decl = createStateHookAST('foo', {
370
+ type: 'string',
371
+ defaultValue: 'x',
372
+ urlSearchParamBinding: { key: '' },
373
+ })
374
+ expect(codeOf(decl)).toBe('const [foo, setFoo] = useState("x");')
375
+ })
376
+
377
+ it('prefers dataSourceBinding over urlSearchParamBinding when both are declared', () => {
378
+ const decl = createStateHookAST('mixed', {
379
+ type: 'string',
380
+ defaultValue: 'fallback',
381
+ dataSourceBinding: { dataSourceId: 'ds', refPath: [] },
382
+ urlSearchParamBinding: { key: 'mixed' },
383
+ })
384
+ // dataSourceBinding path short-circuits: props.mixed !== undefined ? props.mixed : "fallback"
385
+ expect(codeOf(decl)).toBe(
386
+ 'const [mixed, setMixed] = useState(props.mixed !== undefined ? props.mixed : "fallback");'
387
+ )
388
+ })
389
+ })
390
+
391
+ describe('sanitizeExprContent', () => {
392
+ const originalWarn = console.warn
393
+ beforeEach(() => {
394
+ console.warn = jest.fn()
395
+ })
396
+ afterEach(() => {
397
+ console.warn = originalWarn
398
+ })
399
+
400
+ it('passes through well-formed expressions unchanged', () => {
401
+ const expr = '`/profile/' + '$' + '{' + 'currentUser?.id}' + '`'
402
+ expect(sanitizeExprContent(expr, 'href')).toBe(expr)
403
+ expect((console.warn as jest.Mock).mock.calls.length).toBe(0)
404
+ })
405
+
406
+ it('repairs the /profile/${} My Profile navlink pattern', () => {
407
+ const expr = '`/profile/' + '$' + '{}' + '`'
408
+ expect(sanitizeExprContent(expr, 'href')).toBe(
409
+ '`/profile/' + '$' + '{' + 'currentUser?.id}' + '`'
410
+ )
411
+ expect((console.warn as jest.Mock).mock.calls.length).toBe(1)
412
+ })
413
+
414
+ it('repairs generic empty ${} with an empty-string placeholder', () => {
415
+ const expr = '`/unknown/' + '$' + '{}' + '/edit`'
416
+ expect(sanitizeExprContent(expr, 'href')).toBe(
417
+ '`/unknown/' + '$' + '{' + "'" + "'" + '}' + '/edit`'
418
+ )
419
+ expect((console.warn as jest.Mock).mock.calls.length).toBe(1)
420
+ })
421
+ })