odac 1.4.5 → 1.4.7

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.
package/src/View.js CHANGED
@@ -103,6 +103,7 @@ class View {
103
103
  }
104
104
  }
105
105
  #part = {}
106
+ #refresh = new Set()
106
107
  #odac = null
107
108
 
108
109
  constructor(odac) {
@@ -148,10 +149,25 @@ class View {
148
149
  }
149
150
  }
150
151
 
151
- // Render requested elements
152
+ // Build current parts manifest (part name → view path)
153
+ const currentParts = {}
154
+ for (let key in this.#part) {
155
+ if (['all', 'skeleton'].includes(key)) continue
156
+ if (this.#part[key]) currentParts[key] = this.#part[key]
157
+ }
158
+
159
+ // Parse client's previous parts to detect unchanged regions
160
+ const clientParts = this.#odac.Request.clientParts || {}
161
+
162
+ // Render requested elements (skip unchanged parts)
152
163
  let title = null
153
164
  for (let element of this.#odac.Request.ajaxLoad) {
154
165
  if (this.#part[element]) {
166
+ // Skip rendering if the part view path has not changed and refresh is not forced
167
+ // Content is always re-rendered since its output depends on the current URL
168
+ if (element !== 'content' && !this.#refresh.has(element) && clientParts[element] && clientParts[element] === this.#part[element])
169
+ continue
170
+
155
171
  let viewPath = this.#part[element]
156
172
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
157
173
  if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
@@ -161,7 +177,7 @@ class View {
161
177
  // Extract title if present inside the part
162
178
  const titleMatch = html.match(TITLE_REGEX)
163
179
  if (titleMatch && titleMatch[1]) {
164
- title = titleMatch[1]
180
+ title = titleMatch[1].trim()
165
181
  }
166
182
  }
167
183
  }
@@ -171,7 +187,8 @@ class View {
171
187
  if (!title) {
172
188
  const priorityParts = ['head', 'header', 'meta']
173
189
  for (const key of priorityParts) {
174
- if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
190
+ // Only extract if it wasn't already processed in the main loop above
191
+ if (this.#part[key] && output[key] === undefined) {
175
192
  let viewPath = this.#part[key]
176
193
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
177
194
  if (await this.#exists(`./view/${key}/${viewPath}.html`)) {
@@ -179,7 +196,7 @@ class View {
179
196
  const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
180
197
  const titleMatch = partHtml.match(TITLE_REGEX)
181
198
  if (titleMatch && titleMatch[1]) {
182
- title = titleMatch[1]
199
+ title = titleMatch[1].trim()
183
200
  break
184
201
  }
185
202
  } catch (e) {
@@ -202,6 +219,7 @@ class View {
202
219
 
203
220
  this.#odac.Request.end({
204
221
  output: output,
222
+ parts: currentParts,
205
223
  variables: variables,
206
224
  data: this.#odac.Request.sharedData,
207
225
  title: title,
@@ -239,6 +257,9 @@ class View {
239
257
  }
240
258
  }
241
259
  }
260
+
261
+ // Clean up unresolved skeleton placeholders
262
+ result = result.replace(/\{\{\s*[A-Z_]+\s*\}\}/g, '')
242
263
  }
243
264
 
244
265
  if (result) {
@@ -294,7 +315,8 @@ class View {
294
315
  }
295
316
 
296
317
  if (attrs.get) {
297
- return `{{ get('${attrs.get}') || '' }}`
318
+ const escaped = attrs.get.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
319
+ return attrs.raw ? `{!! get('${escaped}') || '' !!}` : `{{ get('${escaped}') || '' }}`
298
320
  } else if (attrs.var) {
299
321
  if (attrs.raw) {
300
322
  return `{!! ${attrs.var} !!}`
@@ -323,7 +345,8 @@ class View {
323
345
  }
324
346
 
325
347
  if (attrs.get) {
326
- return `{{ get('${attrs.get}') || '' }}`
348
+ const escaped = attrs.get.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
349
+ return attrs.raw ? `{!! get('${escaped}') || '' !!}` : `{{ get('${escaped}') || '' }}`
327
350
  } else if (attrs.var) {
328
351
  if (attrs.raw) {
329
352
  return `{!! ${attrs.var} !!}`
@@ -350,8 +373,9 @@ class View {
350
373
  return `%s${placeholderIndex++}`
351
374
  })
352
375
 
376
+ const escapedContent = processedContent.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
353
377
  const translationCall =
354
- placeholders.length > 0 ? `__('${processedContent}', ${placeholders.join(', ')})` : `__('${processedContent}')`
378
+ placeholders.length > 0 ? `__('${escapedContent}', ${placeholders.join(', ')})` : `__('${escapedContent}')`
355
379
 
356
380
  if (attrs.raw) {
357
381
  return `{!! ${translationCall} !!}`
@@ -359,7 +383,7 @@ class View {
359
383
  return `{{ ${translationCall} }}`
360
384
  }
361
385
  } else {
362
- return `{{ '${innerContent}' }}`
386
+ return `{{ '${innerContent.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' }}`
363
387
  }
364
388
  })
365
389
  if (before === content) break
@@ -523,8 +547,12 @@ class View {
523
547
 
524
548
  // - SET PARTS
525
549
  set(...args) {
526
- if (args.length === 1 && typeof args[0] === 'object') for (let key in args[0]) this.#part[key] = args[0][key]
527
- else if (args.length === 2) this.#part[args[0]] = args[1]
550
+ if (args.length === 1 && typeof args[0] === 'object') {
551
+ for (let key in args[0]) this.#part[key] = args[0][key]
552
+ } else if (args.length >= 2) {
553
+ this.#part[args[0]] = args[1]
554
+ if (args[2]?.refresh) this.#refresh.add(args[0])
555
+ }
528
556
 
529
557
  if (!this.#odac.Request.page) {
530
558
  this.#odac.Request.page = this.#part.content || this.#part.all || ''
@@ -541,11 +569,32 @@ class View {
541
569
  }
542
570
 
543
571
  #addNavigateAttribute(skeleton) {
544
- skeleton = skeleton.replace(/(<[^>]+>)(\s*\{\{\s*CONTENT\s*\}\})/, (match, openTag, content) => {
545
- if (openTag.includes('data-odac-navigate')) return match
546
- const tagWithAttr = openTag.slice(0, -1) + ' data-odac-navigate="content">'
547
- return tagWithAttr + content
548
- })
572
+ // Single-pass regex to inject data-odac-navigate into parent tags or auto-wrap unwrapped placeholders.
573
+ // Group 1: Opening tag (<tag>)
574
+ // Group 2: Whitespace between tag and placeholder
575
+ // Group 3: Placeholder ({{ PART_NAME }})
576
+ // Group 4: Part name inside wrapped placeholder
577
+ // Group 5: Standalone/Unwrapped placeholder
578
+ // Group 6: Part name inside unwrapped placeholder
579
+ skeleton = skeleton.replace(
580
+ /(<(?!\/)[^>]+>)(\s*)(\{\{\s*([A-Z_]+)\s*\}\})|(\{\{\s*([A-Z_]+)\s*\}\})/g,
581
+ (match, openTag, spaces, wrappedPlaceholder, wrappedPartName, unwrappedPlaceholder, unwrappedPartName) => {
582
+ if (openTag) {
583
+ // Case 1: Placeholder immediately follows an opening tag. Inject attribute into the tag.
584
+ const attrName = wrappedPartName.toLowerCase()
585
+ const tagWithAttr = openTag.includes('data-odac-navigate') ? openTag : openTag.slice(0, -1) + ` data-odac-navigate="${attrName}">`
586
+ return `${tagWithAttr}${spaces}${wrappedPlaceholder}`
587
+ }
588
+
589
+ if (unwrappedPlaceholder) {
590
+ // Case 2: Placeholder is unwrapped (e.g. sibling to a closing tag). Wrap in display:contents.
591
+ const attrName = unwrappedPartName.toLowerCase()
592
+ return `<div style="display:contents" data-odac-navigate="${attrName}">${unwrappedPlaceholder}</div>`
593
+ }
594
+
595
+ return match
596
+ }
597
+ )
549
598
 
550
599
  const skeletonName = this.#part.skeleton || 'main'
551
600
  const pageName = this.#odac.Request.page || ''
@@ -558,6 +607,17 @@ class View {
558
607
  if (!attrs.includes('data-odac-page')) {
559
608
  updates.push(`data-odac-page="${pageName}"`)
560
609
  }
610
+
611
+ // Embed current parts manifest for client-side diffing on first load
612
+ const partsMap = {}
613
+ for (let key in this.#part) {
614
+ if (['all', 'skeleton'].includes(key)) continue
615
+ if (this.#part[key]) partsMap[key] = this.#part[key]
616
+ }
617
+ if (!attrs.includes('data-odac-parts') && Object.keys(partsMap).length > 0) {
618
+ updates.push(`data-odac-parts='${JSON.stringify(partsMap)}'`)
619
+ }
620
+
561
621
  if (updates.length === 0) return match
562
622
  return `<html${attrs} ${updates.join(' ')}>`
563
623
  })
@@ -6,6 +6,10 @@ jest.mock(
6
6
  mockKnex(...args)
7
7
  )
8
8
 
9
+ jest.mock('mysql2', () => ({}), {virtual: true})
10
+ jest.mock('pg', () => ({}), {virtual: true})
11
+ jest.mock('sqlite3', () => ({}), {virtual: true})
12
+
9
13
  const {buildConnections} = require('../../../src/Database/ConnectionFactory')
10
14
 
11
15
  describe('ConnectionFactory.buildConnections()', () => {
@@ -0,0 +1,53 @@
1
+ const fs = require('fs').promises
2
+ const path = require('path')
3
+ const View = require('../../src/View')
4
+
5
+ describe('View.#addNavigateAttribute()', () => {
6
+ let view
7
+ let mockOdac
8
+
9
+ beforeEach(() => {
10
+ global.__dir = path.resolve(__dirname, '../../')
11
+ mockOdac = {
12
+ Config: {view: {earlyHints: {enabled: false}}},
13
+ View: {},
14
+ Request: {
15
+ req: {url: '/test'},
16
+ res: {finished: false},
17
+ isAjaxLoad: false,
18
+ header: jest.fn(),
19
+ end: jest.fn(),
20
+ get: jest.fn(),
21
+ hasEarlyHints: jest.fn().mockReturnValue(false)
22
+ },
23
+ Lang: {get: jest.fn()}
24
+ }
25
+ view = new View(mockOdac)
26
+ })
27
+
28
+ afterEach(() => {
29
+ jest.restoreAllMocks()
30
+ delete global.__dir
31
+ })
32
+
33
+ it('should not wrap placeholder in display:contents if it is the first child of an element', async () => {
34
+ const skeletonDir = path.join(global.__dir, 'skeleton')
35
+ const contentDir = path.join(global.__dir, 'view/content/pages')
36
+ await fs.mkdir(skeletonDir, {recursive: true})
37
+ await fs.mkdir(contentDir, {recursive: true})
38
+
39
+ await fs.writeFile(path.join(skeletonDir, 'main.html'), '<main id="app-main">\n {{ CONTENT }}\n</main>')
40
+ await fs.writeFile(path.join(contentDir, 'test.html'), '<h1>Content</h1>')
41
+
42
+ view.skeleton('main').set({content: 'pages/test'})
43
+
44
+ await view.print()
45
+
46
+ expect(mockOdac.Request.end).toHaveBeenCalled()
47
+ const outputHtml = mockOdac.Request.end.mock.calls[0][0]
48
+
49
+ expect(outputHtml).toContain('<main id="app-main" data-odac-navigate="content">')
50
+ expect(outputHtml).toContain('<h1>Content</h1>')
51
+ expect(outputHtml).not.toContain('<div style="display:contents"')
52
+ })
53
+ })
@@ -0,0 +1,180 @@
1
+ const fs = require('fs')
2
+ const fsPromises = fs.promises
3
+ const path = require('path')
4
+ const View = require('../../src/View')
5
+
6
+ const FIXTURE_DIR = path.resolve(__dirname, '_fixtures')
7
+
8
+ /**
9
+ * Integration tests for the #parseOdacTag private method.
10
+ * Since #parseOdacTag is private, we test it indirectly through the
11
+ * full render pipeline by creating temporary .html view files,
12
+ * invoking View.print(), and asserting the rendered output.
13
+ */
14
+ describe('View.#parseOdacTag()', () => {
15
+ let originalDir
16
+ let originalCwd
17
+
18
+ beforeAll(async () => {
19
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'skeleton'), {recursive: true})
20
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'view', 'content'), {recursive: true})
21
+ })
22
+
23
+ afterAll(async () => {
24
+ await fsPromises.rm(FIXTURE_DIR, {recursive: true, force: true})
25
+ })
26
+
27
+ beforeEach(() => {
28
+ originalDir = global.__dir
29
+ originalCwd = process.cwd()
30
+ global.__dir = FIXTURE_DIR
31
+ process.chdir(FIXTURE_DIR)
32
+
33
+ if (global.Odac?.View?.cache) {
34
+ global.Odac.View.cache = {}
35
+ }
36
+ if (global.Odac?.View?.skeletons) {
37
+ global.Odac.View.skeletons = {}
38
+ }
39
+
40
+ // Clear require cache for compiled templates
41
+ for (const key of Object.keys(require.cache)) {
42
+ if (key.includes('.cache')) {
43
+ delete require.cache[key]
44
+ }
45
+ }
46
+ })
47
+
48
+ afterEach(() => {
49
+ global.__dir = originalDir
50
+ process.chdir(originalCwd)
51
+ })
52
+
53
+ let testCounter = 0
54
+
55
+ /**
56
+ * Writes a view file, triggers render via print(), and captures output.
57
+ * Uses a unique filename per call to avoid cache collisions between tests.
58
+ * Returns the rendered HTML string.
59
+ */
60
+ async function renderTemplate(templateContent) {
61
+ const uniqueId = `test_${++testCounter}`
62
+ const viewFile = path.join(FIXTURE_DIR, 'view', 'content', `${uniqueId}.html`)
63
+ await fsPromises.writeFile(viewFile, templateContent, 'utf8')
64
+
65
+ const skeletonFile = path.join(FIXTURE_DIR, 'skeleton', 'main.html')
66
+ await fsPromises.writeFile(skeletonFile, '{{ CONTENT }}', 'utf8')
67
+
68
+ let capturedOutput = ''
69
+ const errors = []
70
+ const originalError = console.error
71
+ console.error = (...args) => errors.push(args.join(' '))
72
+
73
+ const mockOdac = {
74
+ Config: {debug: true},
75
+ Var: value => {
76
+ const str = value === null || value === undefined ? '' : String(value)
77
+ return {
78
+ html: () => str.replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
79
+ }
80
+ },
81
+ Request: {
82
+ req: {url: '/test'},
83
+ res: {finished: false, headersSent: false},
84
+ isAjaxLoad: false,
85
+ ajaxLoad: [],
86
+ variables: {},
87
+ sharedData: {},
88
+ page: '',
89
+ get: () => '',
90
+ header: () => {},
91
+ end: output => {
92
+ capturedOutput = output
93
+ },
94
+ hasEarlyHints: () => false,
95
+ setEarlyHints: () => {}
96
+ },
97
+ Lang: {
98
+ get: (...args) => args[0]
99
+ },
100
+ View: {}
101
+ }
102
+
103
+ try {
104
+ const view = new View(mockOdac)
105
+ view.skeleton('main')
106
+ view.set('content', uniqueId)
107
+ await view.print()
108
+ } finally {
109
+ console.error = originalError
110
+ }
111
+
112
+ if (errors.length > 0) {
113
+ throw new Error(`Template render error: ${errors.join('\n')}`)
114
+ }
115
+
116
+ return capturedOutput
117
+ }
118
+
119
+ describe('single quote escaping in <odac> tags', () => {
120
+ it('should render a single quote inside <odac> without syntax error', async () => {
121
+ const result = await renderTemplate("<odac>'</odac>")
122
+ // Single quotes are HTML-escaped by Odac.Var().html()
123
+ expect(result).toContain('&#39;')
124
+ })
125
+
126
+ it('should render text with embedded single quotes', async () => {
127
+ const result = await renderTemplate("<odac>it's working</odac>")
128
+ expect(result).toContain('it&#39;s working')
129
+ })
130
+
131
+ it('should render multiple single quotes', async () => {
132
+ const result = await renderTemplate("<odac>it's a developer's life</odac>")
133
+ expect(result).toContain('it&#39;s a developer&#39;s life')
134
+ })
135
+
136
+ it('should render escaped apostrophe in translation tags', async () => {
137
+ const result = await renderTemplate("<odac t>it's translated</odac>")
138
+ expect(result).toContain('it&#39;s translated')
139
+ })
140
+ })
141
+
142
+ describe('basic <odac> tag rendering', () => {
143
+ it('should render plain text inside <odac> tags', async () => {
144
+ const result = await renderTemplate('<odac>hello world</odac>')
145
+ expect(result).toContain('hello world')
146
+ })
147
+
148
+ it('should strip backend comments (multi-line)', async () => {
149
+ const result = await renderTemplate('visible<!--odac hidden odac-->visible2')
150
+ expect(result).toContain('visible')
151
+ expect(result).toContain('visible2')
152
+ expect(result).not.toContain('hidden')
153
+ })
154
+
155
+ it('should strip backend comments (single-line)', async () => {
156
+ const result = await renderTemplate('visible<!--odac hidden -->visible2')
157
+ expect(result).toContain('visible')
158
+ expect(result).toContain('visible2')
159
+ expect(result).not.toContain('hidden')
160
+ })
161
+
162
+ it('should handle empty <odac> tags gracefully', async () => {
163
+ const result = await renderTemplate('<odac></odac>')
164
+ expect(typeof result).toBe('string')
165
+ })
166
+ })
167
+
168
+ describe('special characters in <odac> tags', () => {
169
+ it('should handle double quotes inside <odac> tags', async () => {
170
+ const result = await renderTemplate('<odac>say "hello"</odac>')
171
+ expect(result).toContain('say')
172
+ })
173
+
174
+ it('should handle angle brackets in text (HTML entities)', async () => {
175
+ const result = await renderTemplate('<odac>&lt;div&gt;</odac>')
176
+ // Already-escaped entities get double-escaped by Odac.Var().html()
177
+ expect(result).toContain('&amp;lt;div&amp;gt;')
178
+ })
179
+ })
180
+ })
@@ -1,3 +1,5 @@
1
+ const fs = require('fs').promises
2
+ const path = require('path')
1
3
  const View = require('../../src/View')
2
4
 
3
5
  describe('View.print()', () => {
@@ -5,15 +7,57 @@ describe('View.print()', () => {
5
7
  let mockOdac
6
8
 
7
9
  beforeEach(() => {
10
+ global.__dir = path.resolve(__dirname, '../../')
8
11
  mockOdac = {
9
12
  Config: {view: {earlyHints: {enabled: false}}},
10
13
  View: {},
11
- Request: {data: {all: {}}}
14
+ Request: {
15
+ req: {url: '/test'},
16
+ res: {finished: false},
17
+ isAjaxLoad: false,
18
+ header: jest.fn(),
19
+ end: jest.fn(),
20
+ get: jest.fn(),
21
+ hasEarlyHints: jest.fn().mockReturnValue(false)
22
+ },
23
+ Lang: {get: jest.fn()}
12
24
  }
13
25
  view = new View(mockOdac)
14
26
  })
15
27
 
28
+ afterEach(() => {
29
+ jest.restoreAllMocks()
30
+ delete global.__dir
31
+ })
32
+
16
33
  it('should be a function', () => {
17
34
  expect(typeof view.print).toBe('function')
18
35
  })
36
+
37
+ describe('AJAX Rendering Edge Cases', () => {
38
+ it("should extract title from priority parts even if their view path hasn't changed", async () => {
39
+ const headDir = path.join(global.__dir, 'view/head/inc')
40
+ const contentDir = path.join(global.__dir, 'view/content/pages')
41
+ await fs.mkdir(headDir, {recursive: true})
42
+ await fs.mkdir(contentDir, {recursive: true})
43
+
44
+ await fs.writeFile(path.join(headDir, 'head.html'), '<title>Dynamic Title</title>')
45
+ await fs.writeFile(path.join(contentDir, 'test.html'), '<h1>Test</h1>')
46
+
47
+ mockOdac.Request.isAjaxLoad = true
48
+ mockOdac.Request.ajaxLoad = ['head', 'content']
49
+ mockOdac.Request.clientParts = {head: 'inc/head'}
50
+ mockOdac.Request.page = 'test_page'
51
+
52
+ view.set({head: 'inc/head', content: 'pages/test'})
53
+
54
+ await view.print()
55
+
56
+ expect(mockOdac.Request.end).toHaveBeenCalled()
57
+
58
+ const payload = mockOdac.Request.end.mock.calls[0][0]
59
+ expect(payload.output.head).toBeUndefined()
60
+ expect(payload.title).toBe('Dynamic Title')
61
+ })
62
+ })
19
63
  })
@@ -0,0 +1,132 @@
1
+ const fs = require('fs')
2
+ const fsPromises = fs.promises
3
+ const path = require('path')
4
+ const View = require('../../src/View')
5
+
6
+ const FIXTURE_DIR = path.resolve(__dirname, '_fixtures_get')
7
+
8
+ describe('View odac tags (var, get, translate) raw attribute', () => {
9
+ let originalDir
10
+ let originalCwd
11
+
12
+ beforeAll(async () => {
13
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'skeleton'), {recursive: true})
14
+ await fsPromises.mkdir(path.join(FIXTURE_DIR, 'view', 'content'), {recursive: true})
15
+ })
16
+
17
+ afterAll(async () => {
18
+ await fsPromises.rm(FIXTURE_DIR, {recursive: true, force: true})
19
+ })
20
+
21
+ beforeEach(() => {
22
+ originalDir = global.__dir
23
+ originalCwd = process.cwd()
24
+ global.__dir = FIXTURE_DIR
25
+ process.chdir(FIXTURE_DIR)
26
+
27
+ if (global.Odac?.View?.cache) {
28
+ global.Odac.View.cache = {}
29
+ }
30
+ if (global.Odac?.View?.skeletons) {
31
+ global.Odac.View.skeletons = {}
32
+ }
33
+
34
+ for (const key of Object.keys(require.cache)) {
35
+ if (key.includes('.cache')) {
36
+ delete require.cache[key]
37
+ }
38
+ }
39
+ })
40
+
41
+ afterEach(() => {
42
+ global.__dir = originalDir
43
+ process.chdir(originalCwd)
44
+ })
45
+
46
+ let testCounter = 0
47
+
48
+ async function renderTemplate(templateContent, getValues = {}) {
49
+ const uniqueId = `test_get_${++testCounter}`
50
+ const viewFile = path.join(FIXTURE_DIR, 'view', 'content', `${uniqueId}.html`)
51
+ await fsPromises.writeFile(viewFile, templateContent, 'utf8')
52
+
53
+ const skeletonFile = path.join(FIXTURE_DIR, 'skeleton', 'main.html')
54
+ await fsPromises.writeFile(skeletonFile, '{{ CONTENT }}', 'utf8')
55
+
56
+ let capturedOutput = ''
57
+ const mockOdac = {
58
+ Config: {debug: true},
59
+ Var: value => {
60
+ const str = value === null || value === undefined ? '' : String(value)
61
+ return {
62
+ html: () => str.replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
63
+ }
64
+ },
65
+ Request: {
66
+ get: key => getValues[key] || '',
67
+ req: {url: '/test'},
68
+ res: {finished: false, headersSent: false},
69
+ isAjaxLoad: false,
70
+ ajaxLoad: [],
71
+ variables: {},
72
+ sharedData: {},
73
+ page: '',
74
+ header: () => {},
75
+ end: output => {
76
+ capturedOutput = output
77
+ },
78
+ hasEarlyHints: () => false,
79
+ setEarlyHints: () => {}
80
+ },
81
+ Lang: {
82
+ get: (...args) => args[0]
83
+ },
84
+ View: {}
85
+ }
86
+
87
+ const view = new View(mockOdac)
88
+ view.skeleton('main')
89
+ view.set('content', uniqueId)
90
+ await view.print()
91
+
92
+ return capturedOutput
93
+ }
94
+
95
+ it('should escape <odac get> by default', async () => {
96
+ const result = await renderTemplate('<odac get="html" />', {html: '<b>bold</b>'})
97
+ expect(result).toContain('&lt;b&gt;bold&lt;/b&gt;')
98
+ expect(result).not.toContain('<b>')
99
+ })
100
+
101
+ it('should not escape <odac get> when raw attribute is present (self-closing)', async () => {
102
+ const result = await renderTemplate('<odac get="html" raw />', {html: '<b>bold</b>'})
103
+ expect(result).toContain('<b>bold</b>')
104
+ })
105
+
106
+ it('should not escape <odac get> when raw attribute is present (block-level)', async () => {
107
+ const result = await renderTemplate('<odac get="html" raw></odac>', {html: '<b>bold</b>'})
108
+ expect(result).toContain('<b>bold</b>')
109
+ })
110
+
111
+ it('should handle missing parameters gracefully with raw', async () => {
112
+ const result = await renderTemplate('<odac get="missing" raw />', {})
113
+ expect(result).toContain('data-odac-navigate="content"')
114
+ expect(result).toContain('id="odac-data"')
115
+ })
116
+
117
+ it('should not escape <odac translate> when raw attribute is present', async () => {
118
+ const result = await renderTemplate('<odac translate raw><b>bold</b></odac>')
119
+ expect(result).toContain('<b>bold</b>')
120
+ expect(result).not.toContain('&lt;b&gt;')
121
+ })
122
+
123
+ it('should escape <odac var> by default', async () => {
124
+ const result = await renderTemplate('<script:odac>var htmlVar = "<em>test</em>";</script:odac><odac var="htmlVar" />')
125
+ expect(result).toContain('&lt;em&gt;test&lt;/em&gt;')
126
+ })
127
+
128
+ it('should not escape <odac var> when raw attribute is present', async () => {
129
+ const result = await renderTemplate('<script:odac>var htmlVar = "<em>test</em>";</script:odac><odac var="htmlVar" raw />')
130
+ expect(result).toContain('<em>test</em>')
131
+ })
132
+ })