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/.agent/rules/memory.md +6 -1
- package/.releaserc.js +1 -1
- package/CHANGELOG.md +42 -0
- package/client/odac.js +128 -81
- package/docs/ai/skills/backend/views.md +95 -43
- package/docs/ai/skills/frontend/core.md +17 -3
- package/docs/ai/skills/frontend/navigation.md +65 -12
- package/docs/backend/00-getting-started/01-quick-start.md +77 -0
- package/docs/backend/07-views/01-the-view-directory.md +28 -6
- package/docs/backend/07-views/02-rendering-a-view.md +16 -23
- package/docs/backend/07-views/03-template-syntax.md +5 -0
- package/docs/backend/07-views/04-request-data.md +13 -0
- package/docs/index.json +10 -0
- package/package.json +30 -6
- package/src/Request.js +5 -4
- package/src/Route.js +11 -0
- package/src/View.js +75 -15
- package/test/Database/ConnectionFactory/buildConnections.test.js +4 -0
- package/test/View/addNavigateAttribute.test.js +53 -0
- package/test/View/parseOdacTag.test.js +180 -0
- package/test/View/print.test.js +45 -1
- package/test/View/tags.test.js +132 -0
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ? `__('${
|
|
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')
|
|
527
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[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(''')
|
|
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'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's a developer'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'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><div></odac>')
|
|
176
|
+
// Already-escaped entities get double-escaped by Odac.Var().html()
|
|
177
|
+
expect(result).toContain('&lt;div&gt;')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|
package/test/View/print.test.js
CHANGED
|
@@ -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: {
|
|
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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[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('<b>bold</b>')
|
|
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('<b>')
|
|
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('<em>test</em>')
|
|
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
|
+
})
|