@teleporthq/teleport-uidl-resolver 0.43.44 → 0.43.47

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 (55) hide show
  1. package/__tests__/abilities/utils.ts +108 -0
  2. package/__tests__/unbound-expressions/expression-fallback.ts +64 -0
  3. package/__tests__/unbound-expressions/expression-identifiers.ts +47 -4
  4. package/__tests__/unbound-expressions/index.ts +216 -24
  5. package/dist/cjs/resolvers/abilities/utils.js +48 -1
  6. package/dist/cjs/resolvers/abilities/utils.js.map +1 -1
  7. package/dist/cjs/resolvers/unbound-expressions/ambient-identifiers.d.ts +32 -10
  8. package/dist/cjs/resolvers/unbound-expressions/ambient-identifiers.d.ts.map +1 -1
  9. package/dist/cjs/resolvers/unbound-expressions/ambient-identifiers.js +34 -27
  10. package/dist/cjs/resolvers/unbound-expressions/ambient-identifiers.js.map +1 -1
  11. package/dist/cjs/resolvers/unbound-expressions/expression-fallback.d.ts +22 -0
  12. package/dist/cjs/resolvers/unbound-expressions/expression-fallback.d.ts.map +1 -0
  13. package/dist/cjs/resolvers/unbound-expressions/expression-fallback.js +129 -0
  14. package/dist/cjs/resolvers/unbound-expressions/expression-fallback.js.map +1 -0
  15. package/dist/cjs/resolvers/unbound-expressions/expression-identifiers.d.ts +30 -16
  16. package/dist/cjs/resolvers/unbound-expressions/expression-identifiers.d.ts.map +1 -1
  17. package/dist/cjs/resolvers/unbound-expressions/expression-identifiers.js +130 -144
  18. package/dist/cjs/resolvers/unbound-expressions/expression-identifiers.js.map +1 -1
  19. package/dist/cjs/resolvers/unbound-expressions/expression-lexer.d.ts +41 -0
  20. package/dist/cjs/resolvers/unbound-expressions/expression-lexer.d.ts.map +1 -0
  21. package/dist/cjs/resolvers/unbound-expressions/expression-lexer.js +158 -0
  22. package/dist/cjs/resolvers/unbound-expressions/expression-lexer.js.map +1 -0
  23. package/dist/cjs/resolvers/unbound-expressions/index.d.ts.map +1 -1
  24. package/dist/cjs/resolvers/unbound-expressions/index.js +119 -61
  25. package/dist/cjs/resolvers/unbound-expressions/index.js.map +1 -1
  26. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  27. package/dist/esm/resolvers/abilities/utils.js +49 -2
  28. package/dist/esm/resolvers/abilities/utils.js.map +1 -1
  29. package/dist/esm/resolvers/unbound-expressions/ambient-identifiers.d.ts +32 -10
  30. package/dist/esm/resolvers/unbound-expressions/ambient-identifiers.d.ts.map +1 -1
  31. package/dist/esm/resolvers/unbound-expressions/ambient-identifiers.js +33 -26
  32. package/dist/esm/resolvers/unbound-expressions/ambient-identifiers.js.map +1 -1
  33. package/dist/esm/resolvers/unbound-expressions/expression-fallback.d.ts +22 -0
  34. package/dist/esm/resolvers/unbound-expressions/expression-fallback.d.ts.map +1 -0
  35. package/dist/esm/resolvers/unbound-expressions/expression-fallback.js +125 -0
  36. package/dist/esm/resolvers/unbound-expressions/expression-fallback.js.map +1 -0
  37. package/dist/esm/resolvers/unbound-expressions/expression-identifiers.d.ts +30 -16
  38. package/dist/esm/resolvers/unbound-expressions/expression-identifiers.d.ts.map +1 -1
  39. package/dist/esm/resolvers/unbound-expressions/expression-identifiers.js +125 -139
  40. package/dist/esm/resolvers/unbound-expressions/expression-identifiers.js.map +1 -1
  41. package/dist/esm/resolvers/unbound-expressions/expression-lexer.d.ts +41 -0
  42. package/dist/esm/resolvers/unbound-expressions/expression-lexer.d.ts.map +1 -0
  43. package/dist/esm/resolvers/unbound-expressions/expression-lexer.js +151 -0
  44. package/dist/esm/resolvers/unbound-expressions/expression-lexer.js.map +1 -0
  45. package/dist/esm/resolvers/unbound-expressions/index.d.ts.map +1 -1
  46. package/dist/esm/resolvers/unbound-expressions/index.js +121 -63
  47. package/dist/esm/resolvers/unbound-expressions/index.js.map +1 -1
  48. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +5 -5
  50. package/src/resolvers/abilities/utils.ts +57 -2
  51. package/src/resolvers/unbound-expressions/ambient-identifiers.ts +33 -26
  52. package/src/resolvers/unbound-expressions/expression-fallback.ts +141 -0
  53. package/src/resolvers/unbound-expressions/expression-identifiers.ts +160 -138
  54. package/src/resolvers/unbound-expressions/expression-lexer.ts +156 -0
  55. package/src/resolvers/unbound-expressions/index.ts +134 -65
@@ -208,6 +208,114 @@ describe('insertLink', () => {
208
208
  })
209
209
  })
210
210
 
211
+ it('resolves a navlink whose route value carries a folder prefix', () => {
212
+ // A details page's route value is folder-qualified (`add-event/Add-Event`)
213
+ // while the navlink still names the page (`Add-Event`). Matching only on
214
+ // the whole value missed every one of them and silently fabricated
215
+ // `/add-event` — a path with no file behind it.
216
+ const node = elementNode('container')
217
+ const navlink = navlinkMockedDefinition()
218
+ navlink.content.routeName = { type: 'static', content: 'Guild-Details' }
219
+ node.content.abilities = { link: navlink }
220
+
221
+ const result = insertLinks(
222
+ node,
223
+ {
224
+ projectRouteDefinition: {
225
+ type: 'route',
226
+ defaultValue: 'home',
227
+ values: [{ value: 'guild-details/Guild-Details', pageOptions: { navLink: '/guilds' } }],
228
+ },
229
+ },
230
+ false
231
+ )
232
+
233
+ expect(result.content.attrs.transitionTo.content).toBe('/guilds')
234
+ })
235
+
236
+ it('does not guess when two folders hold a page of the same name', () => {
237
+ const node = elementNode('container')
238
+ const navlink = navlinkMockedDefinition()
239
+ navlink.content.routeName = { type: 'static', content: 'Details' }
240
+ node.content.abilities = { link: navlink }
241
+
242
+ const result = insertLinks(
243
+ node,
244
+ {
245
+ projectRouteDefinition: {
246
+ type: 'route',
247
+ defaultValue: 'home',
248
+ values: [
249
+ { value: 'events/Details', pageOptions: { navLink: '/events/detail' } },
250
+ { value: 'guilds/Details', pageOptions: { navLink: '/guilds/detail' } },
251
+ ],
252
+ },
253
+ },
254
+ false
255
+ )
256
+
257
+ // Ambiguous → the pre-existing fallback, not an arbitrary pick.
258
+ expect(result.content.attrs.transitionTo.content).toBe('/details')
259
+ })
260
+
261
+ it('refuses to link a dynamic route that has no record id to fill it', () => {
262
+ // `/rsvp-event/[id]` is a TEMPLATE. Without a differentiator there is no id
263
+ // to substitute, so neither the template (a literal `[id]` in the address
264
+ // bar) nor the bare prefix (`/rsvp-event`, which matches no file) is
265
+ // navigable. Run 798e2775 shipped the bare prefix in the global footer of
266
+ // all 17 pages.
267
+ const node = elementNode('container')
268
+ const navlink = navlinkMockedDefinition()
269
+ navlink.content.routeName = { type: 'static', content: 'Add-Event' }
270
+ node.content.abilities = { link: navlink }
271
+
272
+ const result = insertLinks(
273
+ node,
274
+ {
275
+ projectRouteDefinition: {
276
+ type: 'route',
277
+ defaultValue: 'home',
278
+ values: [{ value: 'add-event/Add-Event', pageOptions: { navLink: '/add-event/[id]' } }],
279
+ },
280
+ },
281
+ false
282
+ )
283
+
284
+ expect(result.content.attrs.transitionTo.content).toBe('#')
285
+ })
286
+
287
+ it('still builds the record URL for a dynamic route WITH a differentiator', () => {
288
+ // The row-scoped case must keep working — it is the reason dynamic routes
289
+ // exist, and it is what the guard above must not touch.
290
+ const node = elementNode('container')
291
+ const navlink = navlinkMockedDefinition()
292
+ navlink.content.routeName = { type: 'static', content: 'Event-Details' }
293
+ navlink.content.differentiatorValue = {
294
+ type: 'dynamic',
295
+ content: { referenceType: 'global', refPath: ['Current User', 'id'] },
296
+ } as never
297
+ node.content.abilities = { link: navlink }
298
+
299
+ const result = insertLinks(
300
+ node,
301
+ {
302
+ projectRouteDefinition: {
303
+ type: 'route',
304
+ defaultValue: 'home',
305
+ values: [
306
+ { value: 'event-details/Event-Details', pageOptions: { navLink: '/event-details' } },
307
+ ],
308
+ },
309
+ },
310
+ false
311
+ )
312
+
313
+ expect(result.content.attrs.transitionTo).toEqual({
314
+ type: 'expr',
315
+ content: '`/event-details/' + '$' + '{' + 'currentUser?.id}' + '`',
316
+ })
317
+ })
318
+
211
319
  it('marks the link wrapper display:contents when the flex parent uses a project-referenced style', () => {
212
320
  const child = elementNode('container')
213
321
  child.content.abilities = { link: navlinkMockedDefinition() }
@@ -0,0 +1,64 @@
1
+ import { extractFallbackLiteral } from '../../src/resolvers/unbound-expressions/expression-fallback'
2
+
3
+ describe('extractFallbackLiteral', () => {
4
+ it('returns the string literal an expression falls back to', () => {
5
+ expect(extractFallbackLiteral('company?.name || "N/A"')).toBe('N/A')
6
+ expect(extractFallbackLiteral("deal?.stage || 'Unassigned'")).toBe('Unassigned')
7
+ expect(extractFallbackLiteral('company?.logo ?? "https://cdn/placeholder.png"')).toBe(
8
+ 'https://cdn/placeholder.png'
9
+ )
10
+ })
11
+
12
+ it('returns the last fallback of a chain', () => {
13
+ expect(extractFallbackLiteral('a || b || "last"')).toBe('last')
14
+ expect(extractFallbackLiteral('a ?? b ?? "last"')).toBe('last')
15
+ })
16
+
17
+ it('returns numeric fallbacks verbatim', () => {
18
+ expect(extractFallbackLiteral('parseFloat(item.price) || 0')).toBe('0')
19
+ expect(extractFallbackLiteral('item.ratio || 1.5')).toBe('1.5')
20
+ expect(extractFallbackLiteral('item.delta || -1')).toBe('-1')
21
+ expect(extractFallbackLiteral('item.delta || +1')).toBe('1')
22
+ })
23
+
24
+ it('accepts a template literal with no interpolation', () => {
25
+ expect(extractFallbackLiteral('item.label || `Untitled`')).toBe('Untitled')
26
+ })
27
+
28
+ it('rejects a template literal that still reads scope', () => {
29
+ // tslint:disable-next-line no-invalid-template-strings
30
+ expect(extractFallbackLiteral('item.label || `${item.id}`')).toBeNull()
31
+ })
32
+
33
+ it('ignores operators that live inside literals', () => {
34
+ expect(extractFallbackLiteral(`company?.size || "size || '-'"`)).toBe(`size || '-'`)
35
+ expect(extractFallbackLiteral(`"a || b"`)).toBeNull()
36
+ })
37
+
38
+ it('ignores operators nested inside parentheses, brackets or braces', () => {
39
+ expect(extractFallbackLiteral('fn(a || "inner")')).toBeNull()
40
+ expect(extractFallbackLiteral('map[a || "inner"]')).toBeNull()
41
+ expect(extractFallbackLiteral('{ key: a || "inner" }')).toBeNull()
42
+ })
43
+
44
+ it('rejects a fallback that is not a standalone literal', () => {
45
+ expect(extractFallbackLiteral('company?.name || buildName(company)')).toBeNull()
46
+ expect(extractFallbackLiteral(`company?.name || (a ? 'x' : 'y')`)).toBeNull()
47
+ expect(extractFallbackLiteral('company?.flag || true')).toBeNull()
48
+ })
49
+
50
+ it('does not confuse optional chaining or a ternary with a nullish fallback', () => {
51
+ expect(extractFallbackLiteral(`company?.name`)).toBeNull()
52
+ expect(extractFallbackLiteral(`company ? 'yes' : 'no'`)).toBeNull()
53
+ })
54
+
55
+ it('decodes escape sequences in the literal', () => {
56
+ expect(extractFallbackLiteral(`a || 'It\\'s here'`)).toBe("It's here")
57
+ expect(extractFallbackLiteral('a || "line\\nbreak"')).toBe('line\nbreak')
58
+ })
59
+
60
+ it('returns null for empty or non-string input', () => {
61
+ expect(extractFallbackLiteral('')).toBeNull()
62
+ expect(extractFallbackLiteral(undefined as unknown as string)).toBeNull()
63
+ })
64
+ })
@@ -1,9 +1,11 @@
1
- import { extractRootIdentifiers } from '../../src/resolvers/unbound-expressions/expression-identifiers'
1
+ import { analyzeExpressionScope } from '../../src/resolvers/unbound-expressions/expression-identifiers'
2
2
 
3
3
  const roots = (expression: string): string[] =>
4
- Array.from(extractRootIdentifiers(expression)).sort()
4
+ Array.from(analyzeExpressionScope(expression).freeIdentifiers).sort()
5
5
 
6
- describe('extractRootIdentifiers', () => {
6
+ const isResolvable = (expression: string): boolean => analyzeExpressionScope(expression).resolvable
7
+
8
+ describe('analyzeExpressionScope', () => {
7
9
  it('returns the leading identifier of a member expression', () => {
8
10
  expect(roots('cat.name')).toEqual(['cat'])
9
11
  expect(roots('loc.location_id')).toEqual(['loc'])
@@ -56,6 +58,47 @@ describe('extractRootIdentifiers', () => {
56
58
 
57
59
  it('returns an empty set for empty or non-string input', () => {
58
60
  expect(roots('')).toEqual([])
59
- expect(Array.from(extractRootIdentifiers(undefined as unknown as string))).toEqual([])
61
+ expect(
62
+ Array.from(analyzeExpressionScope(undefined as unknown as string).freeIdentifiers)
63
+ ).toEqual([])
64
+ })
65
+
66
+ it('subtracts identifiers bound by an arrow function parameter list', () => {
67
+ expect(roots('(event) => event.target.value')).toEqual([])
68
+ expect(roots('item => item.id')).toEqual([])
69
+ expect(roots('rows.map((row, i) => row.name + i)').sort()).toEqual(['rows'])
70
+ expect(roots('(a, b) => a + b + outer').sort()).toEqual(['outer'])
71
+ })
72
+
73
+ it('subtracts identifiers bound by a function expression', () => {
74
+ expect(roots('(function (a) { return a })')).toEqual([])
75
+ expect(roots('(function total(a) { return total(a) })')).toEqual([])
76
+ expect(roots('(function (a) { return a + outer })')).toEqual(['outer'])
77
+ })
78
+
79
+ it('keeps nested arrow scopes independent of the outer expression', () => {
80
+ // tslint:disable-next-line no-invalid-template-strings
81
+ expect(roots('groups.map((group) => group.items.map((entry) => entry.id))')).toEqual(['groups'])
82
+ })
83
+
84
+ it('still sees references that only appear outside a callback', () => {
85
+ expect(roots('outer.list.filter((x) => x.ok)').sort()).toEqual(['outer'])
86
+ })
87
+
88
+ it('reports block-scoped declarations as unresolvable rather than guessing', () => {
89
+ expect(isResolvable('(() => { const total = 1; return total })()')).toBe(false)
90
+ expect(isResolvable('(() => { let total = 1; return total })()')).toBe(false)
91
+ expect(isResolvable('(() => { var total = 1; return total })()')).toBe(false)
92
+ expect(isResolvable('props.value')).toBe(true)
93
+ expect(isResolvable('(event) => event.target.value')).toBe(true)
94
+ })
95
+
96
+ it('does not mistake a property named like a keyword for a declaration', () => {
97
+ expect(isResolvable('props.constant')).toBe(true)
98
+ expect(roots('props.constant')).toEqual(['props'])
99
+ // Reserved words are legal property names.
100
+ expect(isResolvable('props.const')).toBe(true)
101
+ expect(roots('props.const')).toEqual(['props'])
102
+ expect(roots('props.function + Math.max(a, b)').sort()).toEqual(['Math', 'a', 'b', 'props'])
60
103
  })
61
104
  })
@@ -24,6 +24,23 @@ const buildComponent = (node: UIDLElementNode): ComponentUIDL =>
24
24
  },
25
25
  } as unknown as ComponentUIDL)
26
26
 
27
+ const containerWith = (children: UIDLNode[]): UIDLElementNode => ({
28
+ type: 'element',
29
+ content: { elementType: 'container', children },
30
+ })
31
+
32
+ // `console.warn` is the resolver's diagnostic channel; silence it so the suite
33
+ // output stays readable while still exercising the code path.
34
+ let warnSpy: jest.SpyInstance
35
+
36
+ beforeEach(() => {
37
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
38
+ })
39
+
40
+ afterEach(() => {
41
+ warnSpy.mockRestore()
42
+ })
43
+
27
44
  describe('resolveUnboundExpressions', () => {
28
45
  it('neutralises an orphaned option expression that references an unbound iterator', () => {
29
46
  const option = optionWith(expr('cat.name'))
@@ -54,16 +71,65 @@ describe('resolveUnboundExpressions', () => {
54
71
  },
55
72
  },
56
73
  } as UIDLNode
57
- const uidl = buildComponent({
58
- type: 'element',
59
- content: { elementType: 'container', children: [repeater] },
60
- })
74
+ const uidl = buildComponent(containerWith([repeater]))
61
75
 
62
76
  resolveUnboundExpressions(uidl)
63
77
 
64
78
  expect(boundExpr).toEqual({ type: 'expr', content: 'item?.id' })
65
79
  })
66
80
 
81
+ it('keeps the `index` a list repeater binds alongside its render prop', () => {
82
+ const indexExpr = expr('index + 1')
83
+ const repeater: UIDLNode = {
84
+ type: 'cms-list-repeater',
85
+ content: {
86
+ elementType: 'fragment',
87
+ name: 'list',
88
+ key: 'list',
89
+ renderPropIdentifier: 'item',
90
+ nodes: {
91
+ list: {
92
+ type: 'element',
93
+ content: { elementType: 'div', children: [indexExpr] },
94
+ },
95
+ },
96
+ },
97
+ } as UIDLNode
98
+ const uidl = buildComponent(containerWith([repeater]))
99
+
100
+ resolveUnboundExpressions(uidl)
101
+
102
+ expect(indexExpr).toEqual({ type: 'expr', content: 'index + 1' })
103
+ })
104
+
105
+ it('keeps `index` inside a repeat that opts into it and drops it otherwise', () => {
106
+ const indexed: UIDLElementNode = {
107
+ type: 'element',
108
+ content: { elementType: 'div', children: [expr('index')] },
109
+ }
110
+ const notIndexed: UIDLElementNode = {
111
+ type: 'element',
112
+ content: { elementType: 'div', children: [expr('index')] },
113
+ }
114
+ const repeatNode = (child: UIDLElementNode, useIndex: boolean): UIDLNode =>
115
+ ({
116
+ type: 'repeat',
117
+ content: {
118
+ node: child,
119
+ dataSource: { type: 'dynamic', content: { referenceType: 'prop', id: 'inventoryItem' } },
120
+ meta: { iteratorName: 'row', useIndex },
121
+ },
122
+ } as UIDLNode)
123
+ const uidl = buildComponent(
124
+ containerWith([repeatNode(indexed, true), repeatNode(notIndexed, false)])
125
+ )
126
+
127
+ resolveUnboundExpressions(uidl)
128
+
129
+ expect(indexed.content.children[0]).toEqual({ type: 'expr', content: 'index' })
130
+ expect(notIndexed.content.children[0]).toEqual({ type: 'static', content: '' })
131
+ })
132
+
67
133
  it('flags a repeater iterator referenced outside of its subtree', () => {
68
134
  const escaped = optionWith(expr('item?.id'))
69
135
  const repeater: UIDLNode = {
@@ -78,35 +144,49 @@ describe('resolveUnboundExpressions', () => {
78
144
  },
79
145
  },
80
146
  } as UIDLNode
81
- const uidl = buildComponent({
82
- type: 'element',
83
- content: { elementType: 'container', children: [repeater, escaped] },
84
- })
147
+ const uidl = buildComponent(containerWith([repeater, escaped]))
85
148
 
86
149
  resolveUnboundExpressions(uidl)
87
150
 
88
151
  expect(escaped.content.children[0]).toEqual({ type: 'static', content: '' })
89
152
  })
90
153
 
91
- it('keeps expressions that reference props, state, globals and event', () => {
154
+ it('keeps expressions that reference props, state and globals', () => {
92
155
  const propsExpr = expr('props.inventoryItem?.name')
93
156
  const stateExpr = expr('categoryOptions')
94
- const globalExpr = expr('JSON.stringify(params)')
95
- const eventExpr = expr('event.target.value')
96
- const uidl = buildComponent({
97
- type: 'element',
98
- content: {
99
- elementType: 'container',
100
- children: [propsExpr, stateExpr, globalExpr, eventExpr],
101
- },
102
- })
157
+ const globalExpr = expr('JSON.stringify(props.inventoryItem)')
158
+ const uidl = buildComponent(containerWith([propsExpr, stateExpr, globalExpr]))
103
159
 
104
160
  resolveUnboundExpressions(uidl)
105
161
 
106
162
  expect(propsExpr).toEqual({ type: 'expr', content: 'props.inventoryItem?.name' })
107
163
  expect(stateExpr).toEqual({ type: 'expr', content: 'categoryOptions' })
108
- expect(globalExpr).toEqual({ type: 'expr', content: 'JSON.stringify(params)' })
109
- expect(eventExpr).toEqual({ type: 'expr', content: 'event.target.value' })
164
+ expect(globalExpr).toEqual({ type: 'expr', content: 'JSON.stringify(props.inventoryItem)' })
165
+ })
166
+
167
+ it('neutralises the renderer-only `state` object, which generated code never declares', () => {
168
+ // Regression: React state is destructured into bare variables, so a `state`
169
+ // reference that survived the editor export compiles to
170
+ // `ReferenceError: state is not defined` and fails `next build`.
171
+ const container = containerWith([expr('state'), expr('state.addressLine')])
172
+ const uidl = buildComponent(container)
173
+
174
+ resolveUnboundExpressions(uidl)
175
+
176
+ expect(container.content.children).toEqual([
177
+ { type: 'static', content: '' },
178
+ { type: 'static', content: '' },
179
+ ])
180
+ })
181
+
182
+ it('keeps a state named `state` when the component actually declares it', () => {
183
+ const container = containerWith([expr('state')])
184
+ const uidl = buildComponent(container)
185
+ uidl.stateDefinitions.state = { type: 'string', defaultValue: '' }
186
+
187
+ resolveUnboundExpressions(uidl)
188
+
189
+ expect(container.content.children).toEqual([{ type: 'expr', content: 'state' }])
110
190
  })
111
191
 
112
192
  it('neutralises an unbound expression used as an attribute value', () => {
@@ -147,6 +227,79 @@ describe('resolveUnboundExpressions', () => {
147
227
  ])
148
228
  })
149
229
 
230
+ it('keeps the literal fallback an unbound expression was authored with', () => {
231
+ const srcAttr: UIDLElementNode = {
232
+ type: 'element',
233
+ content: {
234
+ elementType: 'image',
235
+ attrs: { src: { type: 'expr', content: `company?.logo ?? 'https://cdn/placeholder.png'` } },
236
+ children: [],
237
+ },
238
+ }
239
+ const container = containerWith([expr(`company?.name || "N/A"`), srcAttr])
240
+ const uidl = buildComponent(container)
241
+
242
+ resolveUnboundExpressions(uidl)
243
+
244
+ expect(container.content.children[0]).toEqual({ type: 'static', content: 'N/A' })
245
+ expect(srcAttr.content.attrs!.src).toEqual({
246
+ type: 'static',
247
+ content: 'https://cdn/placeholder.png',
248
+ })
249
+ })
250
+
251
+ it('does not mistake a fallback-looking literal inside a string for a real fallback', () => {
252
+ const container = containerWith([expr(`company?.size || "size || '-'"`)])
253
+ const uidl = buildComponent(container)
254
+
255
+ resolveUnboundExpressions(uidl)
256
+
257
+ expect(container.content.children[0]).toEqual({ type: 'static', content: `size || '-'` })
258
+ })
259
+
260
+ it('blanks rather than guesses when the fallback is not a plain literal', () => {
261
+ const container = containerWith([expr('company?.name || buildName(company)')])
262
+ const uidl = buildComponent(container)
263
+
264
+ resolveUnboundExpressions(uidl)
265
+
266
+ expect(container.content.children[0]).toEqual({ type: 'static', content: '' })
267
+ })
268
+
269
+ it('keeps an inline callback whose parameter is declared by the expression itself', () => {
270
+ const callbackExpr = expr('(event) => event.target.value')
271
+ // tslint:disable-next-line no-invalid-template-strings
272
+ const arrowExpr = expr('props.inventoryItem.tags.map((tag, i) => `${i}:${tag}`).join()')
273
+ const container = containerWith([callbackExpr, arrowExpr])
274
+ const uidl = buildComponent(container)
275
+
276
+ resolveUnboundExpressions(uidl)
277
+
278
+ expect(container.content.children).toEqual([callbackExpr, arrowExpr])
279
+ })
280
+
281
+ it('leaves an expression alone when it declares bindings the analyser cannot resolve', () => {
282
+ const iife = expr('(() => { const total = 2; return total })()')
283
+ const container = containerWith([iife])
284
+ const uidl = buildComponent(container)
285
+
286
+ resolveUnboundExpressions(uidl)
287
+
288
+ expect(container.content.children).toEqual([iife])
289
+ })
290
+
291
+ it('does not treat quoted JSON payloads as references', () => {
292
+ const filterExpr = expr(
293
+ `JSON.stringify([{ "type": "condition", "source": "id", "operand": "=" }])`
294
+ )
295
+ const container = containerWith([filterExpr])
296
+ const uidl = buildComponent(container)
297
+
298
+ resolveUnboundExpressions(uidl)
299
+
300
+ expect(container.content.children).toEqual([filterExpr])
301
+ })
302
+
150
303
  it('keeps a navlink transitionTo that references a global-context identifier', () => {
151
304
  // Regression: the abilities resolver emits transitionTo exprs like
152
305
  // `/profile/${currentUser?.id}` for details navlinks bound to
@@ -163,10 +316,7 @@ describe('resolveUnboundExpressions', () => {
163
316
  children: [],
164
317
  },
165
318
  }
166
- const uidl = buildComponent({
167
- type: 'element',
168
- content: { elementType: 'container', children: [link] },
169
- })
319
+ const uidl = buildComponent(containerWith([link]))
170
320
 
171
321
  resolveUnboundExpressions(uidl)
172
322
 
@@ -176,4 +326,46 @@ describe('resolveUnboundExpressions', () => {
176
326
  content: '`/profile/${currentUser?.id}`',
177
327
  })
178
328
  })
329
+
330
+ it('neutralises an unbound reference inside a template literal', () => {
331
+ const link: UIDLElementNode = {
332
+ type: 'element',
333
+ content: {
334
+ elementType: 'navlink',
335
+ attrs: {
336
+ // tslint:disable-next-line no-invalid-template-strings
337
+ transitionTo: { type: 'expr', content: '`/deal-details/${deal?.id}`' },
338
+ },
339
+ children: [],
340
+ },
341
+ }
342
+ const uidl = buildComponent(containerWith([link]))
343
+
344
+ resolveUnboundExpressions(uidl)
345
+
346
+ expect(link.content.attrs.transitionTo).toEqual({ type: 'static', content: '' })
347
+ })
348
+
349
+ it('keeps the render prop a data-source node introduces for its children', () => {
350
+ const boundExpr = expr('dataSourceData?.title')
351
+ const dataSource: UIDLNode = {
352
+ type: 'data-source-item',
353
+ content: {
354
+ elementType: 'DataProvider',
355
+ renderPropIdentifier: 'dataSourceData',
356
+ resourceDefinition: {
357
+ type: 'external-data-source',
358
+ dataSourceId: 'ds-1',
359
+ tableName: 'items',
360
+ dataSourceType: 'postgresql',
361
+ },
362
+ children: [{ type: 'element', content: { elementType: 'div', children: [boundExpr] } }],
363
+ },
364
+ } as UIDLNode
365
+ const uidl = buildComponent(containerWith([dataSource]))
366
+
367
+ resolveUnboundExpressions(uidl)
368
+
369
+ expect(boundExpr).toEqual({ type: 'expr', content: 'dataSourceData?.title' })
370
+ })
179
371
  })
@@ -360,6 +360,16 @@ var createLinkAttributes = function (link, options) {
360
360
  transitionTo: buildDifferentiatorTransitionTo(baseRoute, differentiatorValue),
361
361
  };
362
362
  }
363
+ // No differentiator + a dynamic destination = nothing navigable. See
364
+ // UNRESOLVABLE_NAVLINK_HREF. Checked HERE rather than inside
365
+ // `resolveNavlink` so the differentiator branch above still receives the
366
+ // real `/prefix/[id]` template it needs to substitute the row id into.
367
+ if (baseRoute.type === 'static' &&
368
+ teleport_shared_1.RoutePaths.pathHasDynamicSegment(String(baseRoute.content))) {
369
+ return {
370
+ transitionTo: { type: 'static', content: UNRESOLVABLE_NAVLINK_HREF },
371
+ };
372
+ }
363
373
  return {
364
374
  transitionTo: baseRoute,
365
375
  };
@@ -382,6 +392,43 @@ var createLinkAttributes = function (link, options) {
382
392
  throw new Error("createLinkNode called with invalid link type '".concat(link.type, "'"));
383
393
  }
384
394
  };
395
+ /**
396
+ * A route whose `value` is folder-qualified (`add-character/Add-Character`)
397
+ * still answers to its bare page name in a navlink's `routeName`
398
+ * (`Add-Character`) — the folder prefix is part of the ROUTE, not of the page's
399
+ * identity. Matching only on the whole value made every such lookup miss and
400
+ * fall through to the fabricated `/${friendlyURL}` below.
401
+ *
402
+ * Resolved only when the last segment identifies the route UNAMBIGUOUSLY; with
403
+ * two pages of the same name in different folders there is no way to tell which
404
+ * one was meant, so we keep the old miss rather than guess.
405
+ */
406
+ var findRouteByName = function (values, routeName) {
407
+ var exact = values.find(function (routeItem) { return routeItem.value === routeName; });
408
+ if (exact) {
409
+ return exact;
410
+ }
411
+ // `value` is typed `string | number | boolean` — only a string route can carry
412
+ // a folder prefix, so anything else can never match by last segment.
413
+ var byLastSegment = values.filter(function (routeItem) {
414
+ return typeof routeItem.value === 'string' && routeItem.value.split('/').pop() === routeName;
415
+ });
416
+ return byLastSegment.length === 1 ? byLastSegment[0] : undefined;
417
+ };
418
+ /**
419
+ * A DYNAMIC route (`/rsvp-event/[id]`) is not a URL — it is a template that
420
+ * needs one record's id. A navlink with no `differentiatorValue` has no id to
421
+ * put there, so neither spelling is navigable: the template ships a literal
422
+ * `[id]` in the address bar, and the bare prefix (`/rsvp-event`) matches no file
423
+ * and 404s. `#` is the platform's existing representation for a link that
424
+ * cannot be resolved — the element still renders, it just does not navigate,
425
+ * which is strictly better than a link that provably fails.
426
+ *
427
+ * This is the last line of defence, not the fix: a nav or CTA link pointing at a
428
+ * single-record page is a planning defect, and the generator that produced the
429
+ * UIDL is where it should be prevented.
430
+ */
431
+ var UNRESOLVABLE_NAVLINK_HREF = '#';
385
432
  var resolveNavlink = function (route, options) {
386
433
  var _a, _b;
387
434
  if (options.skipNavlinkResolver) {
@@ -397,7 +444,7 @@ var resolveNavlink = function (route, options) {
397
444
  }
398
445
  var friendlyURL = teleport_shared_1.StringUtils.camelCaseToDashCase(teleport_shared_1.StringUtils.removeIllegalCharacters(routeName.toString()));
399
446
  var transitionRoute = options.projectRouteDefinition
400
- ? options.projectRouteDefinition.values.find(function (routeItem) { return routeItem.value === routeName; })
447
+ ? findRouteByName(options.projectRouteDefinition.values, routeName.toString())
401
448
  : null;
402
449
  if (!transitionRoute) {
403
450
  return {