odac 1.4.5 → 1.4.6
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 +22 -0
- package/client/odac.js +128 -81
- package/docs/ai/skills/backend/views.md +90 -43
- package/docs/ai/skills/frontend/core.md +17 -3
- package/docs/ai/skills/frontend/navigation.md +65 -12
- 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/package.json +30 -6
- package/src/Request.js +5 -4
- package/src/Route.js +11 -0
- package/src/View.js +54 -9
- package/test/Database/ConnectionFactory/buildConnections.test.js +4 -0
- package/test/View/parseOdacTag.test.js +180 -0
package/.agent/rules/memory.md
CHANGED
|
@@ -32,6 +32,7 @@ trigger: always_on
|
|
|
32
32
|
|
|
33
33
|
## Dependency Management
|
|
34
34
|
- **Prefer Native Fetch:** Use the native `fetch` API for network requests in both Node.js (18+) and browser environments to reduce dependencies and bundle size.
|
|
35
|
+
- **Hybrid Overrides:** When using `overrides` in `package.json` for security hardening, always verify tool compatibility (especially Jest/Coverage). If a global override (e.g., `minimatch: 10.x`) breaks a critical utility (e.g., `test-exclude`), use a nested override to provide a compatible version (e.g., `3.x`) for that specific tool while keeping the rest of the project secure.
|
|
35
36
|
|
|
36
37
|
## Naming & Text Conventions
|
|
37
38
|
- **ODAC Casing:** Always write "ODAC" in uppercase letters when referring to the framework name in strings, comments, log messages, or user-facing text. **EXCEPTION:** The class name itself (`class Odac`) and variable references to it should remain `Odac` (PascalCase) as per code conventions.
|
|
@@ -55,6 +56,10 @@ trigger: always_on
|
|
|
55
56
|
|
|
56
57
|
## Security Logic & Authentication
|
|
57
58
|
- **Enterprise Token Rotation:** The `Auth.js` system utilizes a non-blocking refresh token rotation mechanism for cookies (`odac_x`/`odac_y`). To prevent race conditions during concurrent requests in high-throughput SPAs, rotated tokens are **not** immediately deleted. Instead, their `active` timestamp is set to naturally expire in 60 seconds (Grace Period), and their `date` timestamp is set to the Unix Epoch (`new Date(0)`) as an identifier mark. Never delete rotated tokens immediately.
|
|
59
|
+
- **Template String Escaping:** When escaping template strings for inline JavaScript execution or translation payloads, always escape backslashes first before escaping single quotes (e.g., `.replace(/\\/g, '\\\\').replace(/'/g, "\\'")`) to prevent an injected backslash from disabling the quote escape and causing a Template Injection / XSS vulnerability.
|
|
58
60
|
|
|
59
61
|
## View Engine & SSR
|
|
60
|
-
- **Auto-Navigation Injection:** The ODAC View engine (`src/View.js`) automatically parses skeleton HTML files and dynamically injects `data-odac-navigate
|
|
62
|
+
- **Auto-Navigation Injection:** The ODAC View engine (`src/View.js`) automatically parses skeleton HTML files and dynamically injects `data-odac-navigate` attributes into ALL elements wrapping `{{ PART_NAME }}` placeholders (e.g. `{{ CONTENT }}`, `{{ SIDEBAR }}`, `{{ FOOTER }}`). Do NOT manually add these attributes to HTML templates.
|
|
63
|
+
- **Smart Part Diffing (AJAX Navigation):** The AJAX navigation system uses a server-driven part diffing mechanism. The client sends its current part values via `X-Odac-Parts` header (e.g. `content=docs/intro,sidebar=docs/nav`). The server compares these with the new page's parts and only renders/returns parts whose view path has changed. Parts that are identical between pages are skipped (no re-render, no DOM update). Parts that exist on the old page but not the new page are cleared. The `parts` manifest is included in every AJAX response for the client to track state. **Exception:** The `content` part is always re-rendered regardless of view path match, since its output is URL-dependent.
|
|
64
|
+
- **Part Refresh Override:** `View.set('partName', 'viewPath', { refresh: true })` forces a part to re-render on every AJAX navigation even if its view path hasn't changed. Useful for parts with request-dependent dynamic content (e.g. active menu state). The `#refresh` Set tracks which parts bypass the diffing comparison.
|
|
65
|
+
- **Initial Parts State:** On first page load, the `<html>` tag receives a `data-odac-parts` JSON attribute containing the current part-to-view mapping. The client reads this to initialize its parts tracking state for subsequent AJAX navigations.
|
package/.releaserc.js
CHANGED
|
@@ -135,7 +135,7 @@ Powered by [⚡ ODAC](https://odac.run)
|
|
|
135
135
|
[
|
|
136
136
|
'@semantic-release/git',
|
|
137
137
|
{
|
|
138
|
-
assets: ['package.json', 'CHANGELOG.md'],
|
|
138
|
+
assets: ['package.json', 'package-lock.json', 'CHANGELOG.md'],
|
|
139
139
|
message: '⚡ ODAC.JS v${nextRelease.version} Released'
|
|
140
140
|
}
|
|
141
141
|
],
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
### ⚙️ Engine Tuning
|
|
2
|
+
|
|
3
|
+
- replace global Odac reference with private instance and update dependency overrides for security hardening
|
|
4
|
+
- **test/view:** remove unused CACHE_DIR variable in parseOdacTag tests
|
|
5
|
+
|
|
6
|
+
### ✨ What's New
|
|
7
|
+
|
|
8
|
+
- **client:** log malformed data-odac-parts JSON to ease debugging
|
|
9
|
+
- **view:** smart AJAX part diffing with selective re-render
|
|
10
|
+
|
|
11
|
+
### 🛠️ Fixes & Improvements
|
|
12
|
+
|
|
13
|
+
- **route:** encode X-Odac-Parts header values to prevent splitting errors
|
|
14
|
+
- **View:** escape single quotes in template expressions to prevent syntax errors
|
|
15
|
+
- **view:** prevent template injection by escaping backslashes in dynamic parser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
22
|
+
|
|
1
23
|
### security
|
|
2
24
|
|
|
3
25
|
- **template:** add noopener to external footer links to mitigate reverse tabnabbing
|
package/client/odac.js
CHANGED
|
@@ -119,7 +119,7 @@ if (typeof window !== 'undefined') {
|
|
|
119
119
|
#page = null
|
|
120
120
|
#token = {hash: [], data: false}
|
|
121
121
|
#formSubmitHandlers = new Map()
|
|
122
|
-
#loader = {elements: {}, callback: null}
|
|
122
|
+
#loader = {elements: {}, callback: null, parts: {}}
|
|
123
123
|
#isNavigating = false
|
|
124
124
|
|
|
125
125
|
constructor() {
|
|
@@ -772,20 +772,34 @@ if (typeof window !== 'undefined') {
|
|
|
772
772
|
this.#isNavigating = true
|
|
773
773
|
|
|
774
774
|
const currentSkeleton = document.documentElement.dataset.odacSkeleton
|
|
775
|
-
|
|
775
|
+
|
|
776
|
+
// Merge loader-registered elements with auto-discovered navigate elements
|
|
777
|
+
const elements = {...this.#loader.elements}
|
|
778
|
+
document.querySelectorAll('[data-odac-navigate]').forEach(el => {
|
|
779
|
+
const partName = el.getAttribute('data-odac-navigate')
|
|
780
|
+
if (!elements[partName]) elements[partName] = `[data-odac-navigate="${partName}"]`
|
|
781
|
+
})
|
|
776
782
|
|
|
777
783
|
const elementsToUpdate = []
|
|
778
|
-
|
|
784
|
+
for (const [key, selector] of Object.entries(elements)) {
|
|
779
785
|
const element = document.querySelector(selector)
|
|
780
|
-
if (element) elementsToUpdate.push({key, element})
|
|
781
|
-
}
|
|
786
|
+
if (element) elementsToUpdate.push({key, element, selector})
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Build X-Odac-Parts header from current known parts
|
|
790
|
+
const partsHeader = Object.entries(this.#loader.parts)
|
|
791
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
792
|
+
.join(',')
|
|
793
|
+
|
|
794
|
+
// Collect part keys to request from server
|
|
795
|
+
const loadKeys = Object.keys(elements)
|
|
782
796
|
|
|
783
797
|
const useViewTransition = document.startViewTransition && document.querySelectorAll('[odac-transition]').length > 0
|
|
784
798
|
|
|
785
799
|
if (useViewTransition) {
|
|
786
|
-
this.#loadWithViewTransition(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate)
|
|
800
|
+
this.#loadWithViewTransition(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate, partsHeader, loadKeys)
|
|
787
801
|
} else {
|
|
788
|
-
this.#loadWithFade(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate)
|
|
802
|
+
this.#loadWithFade(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate, partsHeader, loadKeys)
|
|
789
803
|
}
|
|
790
804
|
}
|
|
791
805
|
|
|
@@ -795,17 +809,20 @@ if (typeof window !== 'undefined') {
|
|
|
795
809
|
* names, enabling per-element morphing animations orchestrated by the browser.
|
|
796
810
|
* Non-transition elements still update their content within the transition frame.
|
|
797
811
|
*/
|
|
798
|
-
#loadWithViewTransition(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate) {
|
|
812
|
+
#loadWithViewTransition(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate, partsHeader, loadKeys) {
|
|
799
813
|
const oldTransitionElements = this.#applyTransitionNames()
|
|
800
814
|
|
|
815
|
+
const headers = {
|
|
816
|
+
'X-Odac': 'ajaxload',
|
|
817
|
+
'X-Odac-Load': loadKeys.join(','),
|
|
818
|
+
'X-Odac-Skeleton': currentSkeleton || ''
|
|
819
|
+
}
|
|
820
|
+
if (partsHeader) headers['X-Odac-Parts'] = partsHeader
|
|
821
|
+
|
|
801
822
|
this.#ajax({
|
|
802
823
|
url,
|
|
803
824
|
type: 'GET',
|
|
804
|
-
headers
|
|
805
|
-
'X-Odac': 'ajaxload',
|
|
806
|
-
'X-Odac-Load': Object.keys(this.#loader.elements).join(','),
|
|
807
|
-
'X-Odac-Skeleton': currentSkeleton || ''
|
|
808
|
-
},
|
|
825
|
+
headers,
|
|
809
826
|
dataType: 'json',
|
|
810
827
|
success: (data, status, xhr) => {
|
|
811
828
|
const finalUrl = xhr.responseURL || url
|
|
@@ -815,6 +832,15 @@ if (typeof window !== 'undefined') {
|
|
|
815
832
|
return
|
|
816
833
|
}
|
|
817
834
|
|
|
835
|
+
// Update tracked parts from server manifest
|
|
836
|
+
if (data.parts) this.#loader.parts = data.parts
|
|
837
|
+
|
|
838
|
+
// Determine which parts were removed by the new page
|
|
839
|
+
const removedParts = []
|
|
840
|
+
for (const {key, element} of elementsToUpdate) {
|
|
841
|
+
if (data.parts && !(key in data.parts)) removedParts.push({key, element})
|
|
842
|
+
}
|
|
843
|
+
|
|
818
844
|
const transition = document.startViewTransition(() => {
|
|
819
845
|
if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
|
|
820
846
|
|
|
@@ -827,10 +853,16 @@ if (typeof window !== 'undefined') {
|
|
|
827
853
|
if (data.data) this.#data = data.data
|
|
828
854
|
if (data.title) document.title = this.#decodeHtmlEntities(data.title)
|
|
829
855
|
|
|
856
|
+
// Update only parts that have new content from server
|
|
830
857
|
elementsToUpdate.forEach(({key, element}) => {
|
|
831
858
|
if (data.output && data.output[key] !== undefined) element.innerHTML = data.output[key]
|
|
832
859
|
})
|
|
833
860
|
|
|
861
|
+
// Clear removed parts
|
|
862
|
+
for (const {element} of removedParts) {
|
|
863
|
+
element.innerHTML = ''
|
|
864
|
+
}
|
|
865
|
+
|
|
834
866
|
this.#applyTransitionNames()
|
|
835
867
|
})
|
|
836
868
|
|
|
@@ -854,78 +886,74 @@ if (typeof window !== 'undefined') {
|
|
|
854
886
|
|
|
855
887
|
/**
|
|
856
888
|
* Performs page navigation using the legacy fade-in/fade-out animation.
|
|
857
|
-
*
|
|
858
|
-
*
|
|
889
|
+
* Only fades elements whose content actually changed, leaving unchanged
|
|
890
|
+
* parts (like a shared sidebar) completely untouched and visible.
|
|
859
891
|
*/
|
|
860
|
-
#loadWithFade(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate) {
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const applyUpdate = () => {
|
|
867
|
-
if (!fadeOutComplete || !ajaxComplete || !ajaxData) return
|
|
868
|
-
|
|
869
|
-
const finalUrl = ajaxXhr.responseURL || url
|
|
870
|
-
if (ajaxData.skeletonChanged) {
|
|
871
|
-
window.location.href = finalUrl
|
|
872
|
-
return
|
|
873
|
-
}
|
|
874
|
-
if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
|
|
875
|
-
|
|
876
|
-
const newPage = ajaxXhr.getResponseHeader('X-Odac-Page')
|
|
877
|
-
if (newPage !== null) {
|
|
878
|
-
this.#page = newPage
|
|
879
|
-
document.documentElement.dataset.odacPage = newPage
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
if (ajaxData.data) this.#data = ajaxData.data
|
|
883
|
-
if (ajaxData.title) document.title = this.#decodeHtmlEntities(ajaxData.title)
|
|
884
|
-
|
|
885
|
-
if (elementsToUpdate.length === 0) {
|
|
886
|
-
this.#handleLoadComplete(ajaxData, callback)
|
|
887
|
-
return
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
let completed = 0
|
|
891
|
-
elementsToUpdate.forEach(({key, element}) => {
|
|
892
|
-
if (ajaxData.output && ajaxData.output[key] !== undefined) element.innerHTML = ajaxData.output[key]
|
|
893
|
-
this.#fadeIn(element, 200, () => {
|
|
894
|
-
completed++
|
|
895
|
-
if (completed === elementsToUpdate.length) this.#handleLoadComplete(ajaxData, callback)
|
|
896
|
-
})
|
|
897
|
-
})
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
if (elementsToUpdate.length > 0) {
|
|
901
|
-
let fadeOutCount = 0
|
|
902
|
-
elementsToUpdate.forEach(({element}) => {
|
|
903
|
-
this.#fadeOut(element, 200, () => {
|
|
904
|
-
fadeOutCount++
|
|
905
|
-
if (fadeOutCount === elementsToUpdate.length) {
|
|
906
|
-
fadeOutComplete = true
|
|
907
|
-
applyUpdate()
|
|
908
|
-
}
|
|
909
|
-
})
|
|
910
|
-
})
|
|
911
|
-
} else {
|
|
912
|
-
fadeOutComplete = true
|
|
892
|
+
#loadWithFade(url, callback, push, currentUrl, currentSkeleton, elementsToUpdate, partsHeader, loadKeys) {
|
|
893
|
+
const headers = {
|
|
894
|
+
'X-Odac': 'ajaxload',
|
|
895
|
+
'X-Odac-Load': loadKeys.join(','),
|
|
896
|
+
'X-Odac-Skeleton': currentSkeleton || ''
|
|
913
897
|
}
|
|
898
|
+
if (partsHeader) headers['X-Odac-Parts'] = partsHeader
|
|
914
899
|
|
|
915
900
|
this.#ajax({
|
|
916
901
|
url,
|
|
917
902
|
type: 'GET',
|
|
918
|
-
headers
|
|
919
|
-
'X-Odac': 'ajaxload',
|
|
920
|
-
'X-Odac-Load': Object.keys(this.#loader.elements).join(','),
|
|
921
|
-
'X-Odac-Skeleton': currentSkeleton || ''
|
|
922
|
-
},
|
|
903
|
+
headers,
|
|
923
904
|
dataType: 'json',
|
|
924
905
|
success: (data, status, xhr) => {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
906
|
+
const finalUrl = xhr.responseURL || url
|
|
907
|
+
if (data.skeletonChanged) {
|
|
908
|
+
window.location.href = finalUrl
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
if (finalUrl !== currentUrl && push) window.history.pushState(null, document.title, finalUrl)
|
|
912
|
+
|
|
913
|
+
const newPage = xhr.getResponseHeader('X-Odac-Page')
|
|
914
|
+
if (newPage !== null) {
|
|
915
|
+
this.#page = newPage
|
|
916
|
+
document.documentElement.dataset.odacPage = newPage
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (data.data) this.#data = data.data
|
|
920
|
+
if (data.title) document.title = this.#decodeHtmlEntities(data.title)
|
|
921
|
+
if (data.parts) this.#loader.parts = data.parts
|
|
922
|
+
|
|
923
|
+
// Classify elements: changed, removed, or unchanged
|
|
924
|
+
const changedParts = []
|
|
925
|
+
const removedParts = []
|
|
926
|
+
|
|
927
|
+
for (const {key, element} of elementsToUpdate) {
|
|
928
|
+
if (data.output && data.output[key] !== undefined) {
|
|
929
|
+
changedParts.push({key, element})
|
|
930
|
+
} else if (data.parts && !(key in data.parts)) {
|
|
931
|
+
removedParts.push({key, element})
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Clear removed parts immediately
|
|
936
|
+
for (const {element} of removedParts) {
|
|
937
|
+
element.innerHTML = ''
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// No changed parts — complete immediately
|
|
941
|
+
if (changedParts.length === 0) {
|
|
942
|
+
this.#handleLoadComplete(data, callback)
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Fade out only changed parts, then swap content and fade in
|
|
947
|
+
let fadeOutCount = 0
|
|
948
|
+
changedParts.forEach(({key, element}) => {
|
|
949
|
+
this.#fadeOut(element, 200, () => {
|
|
950
|
+
element.innerHTML = data.output[key]
|
|
951
|
+
this.#fadeIn(element, 200, () => {
|
|
952
|
+
fadeOutCount++
|
|
953
|
+
if (fadeOutCount === changedParts.length) this.#handleLoadComplete(data, callback)
|
|
954
|
+
})
|
|
955
|
+
})
|
|
956
|
+
})
|
|
929
957
|
},
|
|
930
958
|
error: () => {
|
|
931
959
|
this.#isNavigating = false
|
|
@@ -974,9 +1002,10 @@ if (typeof window !== 'undefined') {
|
|
|
974
1002
|
}
|
|
975
1003
|
}
|
|
976
1004
|
|
|
977
|
-
loader(selector, elements, callback) {
|
|
1005
|
+
loader(selector, elements, callback, initialParts) {
|
|
978
1006
|
this.#loader.elements = elements
|
|
979
1007
|
this.#loader.callback = callback
|
|
1008
|
+
if (initialParts) this.#loader.parts = initialParts
|
|
980
1009
|
const odacInstance = this
|
|
981
1010
|
|
|
982
1011
|
this.#on(document, 'click', selector, function (e) {
|
|
@@ -1152,10 +1181,28 @@ if (typeof window !== 'undefined') {
|
|
|
1152
1181
|
window.Odac = new _odac()
|
|
1153
1182
|
;(function initAutoNavigate() {
|
|
1154
1183
|
const init = () => {
|
|
1155
|
-
const
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1184
|
+
const navigateElements = document.querySelectorAll('[data-odac-navigate]')
|
|
1185
|
+
if (navigateElements.length === 0) return
|
|
1186
|
+
|
|
1187
|
+
const elements = {}
|
|
1188
|
+
navigateElements.forEach(el => {
|
|
1189
|
+
const partName = el.getAttribute('data-odac-navigate')
|
|
1190
|
+
elements[partName] = `[data-odac-navigate="${partName}"]`
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
// Initialize parts state from server-rendered data attribute
|
|
1194
|
+
const partsAttr = document.documentElement.dataset.odacParts
|
|
1195
|
+
let initialParts = {}
|
|
1196
|
+
if (partsAttr) {
|
|
1197
|
+
try {
|
|
1198
|
+
initialParts = JSON.parse(partsAttr)
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
console.error('Odac: Failed to parse data-odac-parts attribute.', e)
|
|
1201
|
+
// Graceful fallback if attribute is malformed
|
|
1202
|
+
}
|
|
1158
1203
|
}
|
|
1204
|
+
|
|
1205
|
+
window.Odac.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', elements, null, initialParts)
|
|
1159
1206
|
}
|
|
1160
1207
|
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init()
|
|
1161
1208
|
})()
|
|
@@ -1,45 +1,106 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: backend-views-templates-skill
|
|
3
|
-
description: ODAC server-side rendering guidelines for
|
|
3
|
+
description: ODAC server-side rendering guidelines for skeleton layouts, smart part diffing, template syntax, and safe output rendering.
|
|
4
4
|
metadata:
|
|
5
|
-
tags: backend, views, templates, ssr, xss-protection, skeleton, rendering
|
|
5
|
+
tags: backend, views, templates, ssr, xss-protection, skeleton, rendering, part-diffing, ajax-navigation
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Backend Views & Templates Skill
|
|
9
9
|
|
|
10
|
-
High-performance server-side rendering using ODAC's optimized template engine.
|
|
10
|
+
High-performance server-side rendering using ODAC's optimized template engine with smart AJAX part diffing.
|
|
11
11
|
|
|
12
12
|
## Architectural Approach
|
|
13
|
-
Views in ODAC are logic-light but powerful. They support automatic XSS protection, high-performance looping,
|
|
13
|
+
Views in ODAC are logic-light but powerful. They support automatic XSS protection, high-performance looping, server-side JavaScript execution via `<script:odac>`, and a smart AJAX navigation system that only updates parts of the page that actually changed.
|
|
14
14
|
|
|
15
15
|
## Core Rules
|
|
16
16
|
1. **Skeleton Architecture**: Use `Odac.View.skeleton('name')` to wrap content in a layout.
|
|
17
|
-
2. **
|
|
17
|
+
2. **Part Setting**: Use `Odac.View.set(partName, viewPath)` to fill skeleton placeholders.
|
|
18
|
+
3. **Data Binding — Two Equivalent Syntaxes**:
|
|
18
19
|
- `<odac var="key" />`: Tag-based output (HTML-escaped, XSS-safe).
|
|
19
20
|
- `{{ key }}`: Inline/interpolation output (HTML-escaped, XSS-safe). Identical behavior to `<odac var>`.
|
|
20
|
-
- `<odac var="key" raw />` or `{!! key !!}`: Raw output (
|
|
21
|
-
|
|
22
|
-
- **Inside HTML attributes** (`src`, `alt`, `href`, `class`, `value`, etc.) → Always prefer `{{ }}`.
|
|
21
|
+
- `<odac var="key" raw />` or `{!! key !!}`: Raw output (use with extreme caution).
|
|
22
|
+
4. **Choosing the Right Syntax**:
|
|
23
|
+
- **Inside HTML attributes** (`src`, `alt`, `href`, `class`, `value`, etc.) → Always prefer `{{ }}`.
|
|
23
24
|
- **Inline within text or mixed HTML** → Prefer `{{ }}` for short interpolations.
|
|
24
|
-
- **Standalone block output**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
- **Standalone block output** → Prefer `<odac var="" />` for structural clarity.
|
|
26
|
+
5. **Conditionals**: Use `<odac:if condition="VAR"> ... </odac:if>`.
|
|
27
|
+
6. **Looping**: Use `<odac:for in="ARRAY" value="ITEM"> ... </odac:for>`.
|
|
28
|
+
7. **Server-Side JS**: Use `<script:odac>` for complex calculations during rendering.
|
|
29
|
+
|
|
30
|
+
## Smart Part Diffing (AJAX Navigation)
|
|
31
|
+
|
|
32
|
+
ODAC uses a **server-driven part diffing** system during AJAX navigation. Understanding this is critical for building multi-section layouts correctly.
|
|
33
|
+
|
|
34
|
+
### How it works
|
|
35
|
+
- The client tracks which view file each part is currently showing.
|
|
36
|
+
- On every AJAX navigation, it sends this state to the server via `X-Odac-Parts`.
|
|
37
|
+
- The server compares the new page's parts against the client's current state and **only renders parts that changed**.
|
|
38
|
+
- The client only updates DOM elements that received new content.
|
|
39
|
+
|
|
40
|
+
### Rules
|
|
41
|
+
| Scenario | Behavior |
|
|
42
|
+
|----------|----------|
|
|
43
|
+
| Part view path unchanged | Skipped — not re-rendered, DOM untouched |
|
|
44
|
+
| Part view path changed | Re-rendered and DOM updated |
|
|
45
|
+
| Part removed on new page | DOM element content cleared |
|
|
46
|
+
| Skeleton changed | Full page reload |
|
|
47
|
+
| `content` part | **Always** re-rendered (URL-dependent by nature) |
|
|
48
|
+
|
|
49
|
+
### Force-refresh a part
|
|
50
|
+
If a part's view path stays the same but its rendered output changes per request (e.g. a sidebar with an active menu state):
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
// Always re-renders on AJAX navigation even if view path is unchanged
|
|
54
|
+
Odac.View.set('sidebar', 'docs.nav', { refresh: true })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Skeleton placeholder rules
|
|
58
|
+
- Each `{{ PLACEHOLDER }}` must be wrapped in its own HTML element.
|
|
59
|
+
- The engine auto-injects `data-odac-navigate` on wrapper elements — do not add manually.
|
|
60
|
+
- Unset placeholders are silently removed from the final HTML output.
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<!-- skeleton/main.html -->
|
|
64
|
+
<aside>{{ SIDEBAR }}</aside> <!-- auto-gets data-odac-navigate="sidebar" -->
|
|
65
|
+
<main>{{ CONTENT }}</main> <!-- auto-gets data-odac-navigate="content" -->
|
|
66
|
+
```
|
|
29
67
|
|
|
30
68
|
## Reference Patterns
|
|
31
69
|
|
|
32
|
-
### 1.
|
|
70
|
+
### 1. Standard Page with Shared Layout
|
|
71
|
+
```javascript
|
|
72
|
+
// controller/docs/page.js
|
|
73
|
+
module.exports = function (Odac) {
|
|
74
|
+
Odac.View
|
|
75
|
+
.skeleton('main')
|
|
76
|
+
.set('sidebar', 'docs.nav') // shared — skipped if unchanged on next navigate
|
|
77
|
+
.set('content', 'docs.intro') // always re-rendered
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2. Sidebar with Active State (force refresh)
|
|
33
82
|
```javascript
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
Odac.View
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
83
|
+
// controller/docs/page.js
|
|
84
|
+
module.exports = function (Odac) {
|
|
85
|
+
Odac.View
|
|
86
|
+
.skeleton('main')
|
|
87
|
+
.set('sidebar', 'docs.nav', { refresh: true }) // re-renders every navigate
|
|
88
|
+
.set('content', 'docs.intro')
|
|
89
|
+
}
|
|
40
90
|
```
|
|
41
91
|
|
|
42
|
-
###
|
|
92
|
+
### 3. Page Without Sidebar
|
|
93
|
+
```javascript
|
|
94
|
+
// controller/home.js — navigating here clears the sidebar DOM element
|
|
95
|
+
module.exports = function (Odac) {
|
|
96
|
+
Odac.View
|
|
97
|
+
.skeleton('main')
|
|
98
|
+
.set('content', 'home')
|
|
99
|
+
// sidebar not set → its DOM element is emptied on AJAX navigation
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 4. Template Syntax Reference
|
|
43
104
|
```html
|
|
44
105
|
<!-- Standalone block output — prefer <odac var> -->
|
|
45
106
|
<h1><odac var="title" /></h1>
|
|
@@ -47,47 +108,33 @@ Odac.View.set({
|
|
|
47
108
|
<!-- Inside attributes — prefer {{ }} -->
|
|
48
109
|
<img src="{{ product.image }}" alt="{{ product.name }}">
|
|
49
110
|
<a href="/user/{{ user.id }}" class="btn {{ isActive ? 'active' : '' }}">Profile</a>
|
|
50
|
-
<input type="text" value="{{ query }}">
|
|
51
111
|
|
|
52
|
-
<!-- Inline text interpolation
|
|
112
|
+
<!-- Inline text interpolation -->
|
|
53
113
|
<p>Welcome, {{ user.name }}. You have {{ notifications }} new messages.</p>
|
|
54
|
-
<span>${{ product.price }}</span>
|
|
55
114
|
|
|
56
115
|
<!-- Conditional -->
|
|
57
116
|
<odac:if condition="stats.users > 100">
|
|
58
117
|
<span class="badge">Popular!</span>
|
|
59
118
|
</odac:if>
|
|
60
119
|
|
|
61
|
-
<!--
|
|
62
|
-
|
|
120
|
+
<!-- Loop -->
|
|
121
|
+
<odac:for in="users" value="user">
|
|
63
122
|
<li>{{ user.name }}</li>
|
|
64
|
-
|
|
123
|
+
</odac:for>
|
|
65
124
|
```
|
|
66
125
|
|
|
67
|
-
###
|
|
68
|
-
Perfect for calculations that shouldn't clutter the controller but are too complex for simple tags.
|
|
126
|
+
### 5. Backend JavaScript (`<script:odac>`)
|
|
69
127
|
```html
|
|
70
128
|
<script:odac>
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const tax = total * 0.18;
|
|
129
|
+
const total = items.reduce((sum, i) => sum + i.price, 0)
|
|
130
|
+
const tax = total * 0.18
|
|
74
131
|
</script:odac>
|
|
75
132
|
|
|
76
133
|
<p>Subtotal: ${{ total }}</p>
|
|
77
134
|
<p>Tax: ${{ tax }}</p>
|
|
78
135
|
```
|
|
79
136
|
|
|
80
|
-
## Syntax Selection Guide
|
|
81
|
-
|
|
82
|
-
| Context | Preferred Syntax | Example |
|
|
83
|
-
|---------|-----------------|---------|
|
|
84
|
-
| HTML attributes (`src`, `href`, `alt`, `class`, `value`) | `{{ }}` | `<img src="{{ photo.url }}" alt="{{ photo.caption }}">` |
|
|
85
|
-
| Inline text within elements | `{{ }}` | `<p>Hello, {{ user.name }}</p>` |
|
|
86
|
-
| Standalone element content | `<odac var />` | `<h1><odac var="title" /></h1>` |
|
|
87
|
-
| Raw HTML output (trusted only) | `<odac var raw />` or `{!! !!}` | `<div><odac var="content" raw /></div>` |
|
|
88
|
-
|
|
89
137
|
## Security Best Practices
|
|
90
|
-
- **Both `{{ }}` and `<odac var>` are XSS-safe**: Both apply HTML escaping by default.
|
|
138
|
+
- **Both `{{ }}` and `<odac var>` are XSS-safe**: Both apply HTML escaping by default.
|
|
91
139
|
- **Raw output requires trust**: Only use `raw` / `{!! !!}` with content you fully control. Never with user input.
|
|
92
140
|
- **Limit `<script:odac>`**: Do not perform database queries or API calls inside views; keep them in the controller.
|
|
93
|
-
- **Partial Awareness**: Use `<odac:include view="path.to.view" />` for reusable components.
|
|
@@ -60,11 +60,25 @@ Odac.action({
|
|
|
60
60
|
### 3. Data Utilities
|
|
61
61
|
```javascript
|
|
62
62
|
// Accessing data shared from backend (Odac.share)
|
|
63
|
-
const user = odac.data('user')
|
|
63
|
+
const user = odac.data('user')
|
|
64
64
|
|
|
65
65
|
// Using Storage wrapper
|
|
66
|
-
odac.storage('theme', 'dark')
|
|
67
|
-
const theme = odac.storage('theme')
|
|
66
|
+
odac.storage('theme', 'dark')
|
|
67
|
+
const theme = odac.storage('theme')
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 4. Programmatic Navigation
|
|
71
|
+
```javascript
|
|
72
|
+
// Navigate to a URL (AJAX, pushes to history)
|
|
73
|
+
Odac.load('/dashboard')
|
|
74
|
+
|
|
75
|
+
// Navigate without history entry
|
|
76
|
+
Odac.load('/dashboard', null, false)
|
|
77
|
+
|
|
78
|
+
// Navigate with a post-load callback
|
|
79
|
+
Odac.load('/dashboard', function(page, vars) {
|
|
80
|
+
console.log('Loaded page:', page)
|
|
81
|
+
})
|
|
68
82
|
```
|
|
69
83
|
|
|
70
84
|
## Best Practices
|
|
@@ -1,32 +1,87 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: frontend-navigation-spa-skill
|
|
3
|
-
description: Single-page navigation patterns in odac.js
|
|
3
|
+
description: Single-page navigation patterns in odac.js including smart part diffing, smooth transitions, route control, and lifecycle-safe execution.
|
|
4
4
|
metadata:
|
|
5
|
-
tags: frontend, navigation, spa, ajax-navigation, page-lifecycle, transitions, view-transitions
|
|
5
|
+
tags: frontend, navigation, spa, ajax-navigation, page-lifecycle, transitions, view-transitions, part-diffing
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Frontend Navigation & SPA Skill
|
|
9
9
|
|
|
10
10
|
Smooth transitions and single-page application behavior using `odac.js`.
|
|
11
11
|
|
|
12
|
+
## How AJAX Navigation Works
|
|
13
|
+
|
|
14
|
+
ODAC's navigation system is **zero-config** and **server-driven**. On first page load, the framework automatically:
|
|
15
|
+
1. Detects all skeleton placeholder wrapper elements (those with `data-odac-navigate` attributes injected by the server).
|
|
16
|
+
2. Registers click handlers on all internal links.
|
|
17
|
+
3. Reads the initial parts state from `data-odac-parts` on the `<html>` element.
|
|
18
|
+
|
|
19
|
+
On every subsequent navigation:
|
|
20
|
+
1. The client sends the current parts state to the server via `X-Odac-Parts`.
|
|
21
|
+
2. The server returns only the parts that changed (`output`) plus the new parts manifest (`parts`).
|
|
22
|
+
3. The client fades out and updates only the changed elements. Unchanged parts (e.g. a shared sidebar) are never touched.
|
|
23
|
+
|
|
12
24
|
## Rules
|
|
13
|
-
1. **
|
|
14
|
-
2. **
|
|
15
|
-
3. **
|
|
16
|
-
4. **
|
|
25
|
+
1. **Zero-config auto-navigation**: Works automatically when the skeleton has `data-odac-navigate` elements. No `Odac.action()` call needed for basic navigation.
|
|
26
|
+
2. **Manual setup**: Use `Odac.action({ navigate: ... })` to customize selectors, update targets, or add callbacks.
|
|
27
|
+
3. **Exclusion**: Use `data-navigate="false"` attribute or `.no-navigate` class on links to force full page reloads.
|
|
28
|
+
4. **Lifecycle**: Use `load` and `page` events to run code after navigation.
|
|
29
|
+
5. **View Transitions**: Add `odac-transition` attribute to elements for native browser View Transition API animations.
|
|
17
30
|
|
|
18
31
|
## Patterns
|
|
32
|
+
|
|
33
|
+
### Auto-navigation (zero-config)
|
|
34
|
+
No setup required. As long as the skeleton has properly wrapped placeholders, navigation is automatic.
|
|
35
|
+
|
|
36
|
+
### Manual navigation setup
|
|
19
37
|
```javascript
|
|
20
38
|
Odac.action({
|
|
21
39
|
navigate: {
|
|
22
|
-
update: 'main',
|
|
40
|
+
update: 'main', // CSS selector of the element to update
|
|
23
41
|
on: function(page, vars) {
|
|
24
|
-
console.log('Navigated to:', page)
|
|
42
|
+
console.log('Navigated to:', page)
|
|
25
43
|
}
|
|
26
44
|
}
|
|
27
|
-
})
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Programmatic navigation
|
|
49
|
+
```javascript
|
|
50
|
+
// Navigate to a URL programmatically
|
|
51
|
+
Odac.load('/docs/getting-started')
|
|
52
|
+
|
|
53
|
+
// Navigate without pushing to history
|
|
54
|
+
Odac.load('/docs/getting-started', null, false)
|
|
55
|
+
|
|
56
|
+
// Navigate with a callback
|
|
57
|
+
Odac.load('/docs/getting-started', function(page, vars) {
|
|
58
|
+
console.log('Loaded:', page)
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Excluding links from AJAX navigation
|
|
63
|
+
```html
|
|
64
|
+
<!-- Full reload for this link -->
|
|
65
|
+
<a href="/logout" data-navigate="false">Logout</a>
|
|
66
|
+
|
|
67
|
+
<!-- Full reload via class -->
|
|
68
|
+
<a href="/external-page" class="no-navigate">External</a>
|
|
28
69
|
```
|
|
29
70
|
|
|
71
|
+
## Smart Part Diffing Behavior
|
|
72
|
+
|
|
73
|
+
The client automatically handles these scenarios without any configuration:
|
|
74
|
+
|
|
75
|
+
| Scenario | Client behavior |
|
|
76
|
+
|----------|----------------|
|
|
77
|
+
| Sidebar unchanged between pages | Sidebar DOM untouched, no flicker |
|
|
78
|
+
| Sidebar changes between pages | Sidebar fades out → new content → fades in |
|
|
79
|
+
| Sidebar removed on new page | Sidebar element content cleared |
|
|
80
|
+
| Skeleton changes | Full page reload |
|
|
81
|
+
| `content` part | Always updated |
|
|
82
|
+
|
|
83
|
+
The fade animation only runs on elements that actually receive new content. Unchanged parts stay fully visible throughout the navigation.
|
|
84
|
+
|
|
30
85
|
## View Transitions (Native Browser API)
|
|
31
86
|
|
|
32
87
|
Elements with the `odac-transition` attribute automatically use the browser's View Transition API instead of the legacy fade animation. The attribute value becomes the `view-transition-name`, enabling per-element morphing between pages.
|
|
@@ -47,7 +102,6 @@ Elements with the `odac-transition` attribute automatically use the browser's Vi
|
|
|
47
102
|
|
|
48
103
|
### CSS Customization
|
|
49
104
|
```css
|
|
50
|
-
/* Target a specific element's transition */
|
|
51
105
|
::view-transition-old(hero) {
|
|
52
106
|
animation: fade-out 0.3s ease;
|
|
53
107
|
}
|
|
@@ -55,7 +109,6 @@ Elements with the `odac-transition` attribute automatically use the browser's Vi
|
|
|
55
109
|
animation: fade-in 0.3s ease;
|
|
56
110
|
}
|
|
57
111
|
|
|
58
|
-
/* Slide sidebar from left */
|
|
59
112
|
::view-transition-old(sidebar) {
|
|
60
113
|
animation: slide-out-left 0.25s ease;
|
|
61
114
|
}
|
|
@@ -66,6 +119,6 @@ Elements with the `odac-transition` attribute automatically use the browser's Vi
|
|
|
66
119
|
|
|
67
120
|
### Rules
|
|
68
121
|
1. Each `odac-transition` value must be unique within the page.
|
|
69
|
-
2. Elements persist across navigations (e.g
|
|
122
|
+
2. Elements that persist across navigations (e.g. shared header) produce smooth morphing animations.
|
|
70
123
|
3. No JavaScript configuration needed — attribute-only setup.
|
|
71
124
|
4. Falls back to fade animation gracefully on unsupported browsers.
|
|
@@ -42,15 +42,16 @@ Example: `skeleton/main.html`
|
|
|
42
42
|
|
|
43
43
|
**Important Rules for Placeholders:**
|
|
44
44
|
|
|
45
|
-
1. **Each placeholder must be wrapped in HTML
|
|
46
|
-
2. **Never place placeholders directly next to each other**
|
|
47
|
-
3. **Placeholders are uppercase**
|
|
48
|
-
4. **Use semantic HTML tags**
|
|
45
|
+
1. **Each placeholder must be wrapped in its own HTML tag** — This allows the AJAX navigation system to identify and independently update each section.
|
|
46
|
+
2. **Never place placeholders directly next to each other** — Bad: `{{ HEADER }}{{ CONTENT }}`, Good: `<header>{{ HEADER }}</header><main>{{ CONTENT }}</main>`
|
|
47
|
+
3. **Placeholders are uppercase** — `{{ HEADER }}`, `{{ CONTENT }}`, `{{ FOOTER }}`
|
|
48
|
+
4. **Use semantic HTML tags** — `<header>`, `<main>`, `<footer>`, `<aside>`, `<nav>`, etc.
|
|
49
|
+
5. **Unset placeholders are automatically removed** — If a controller does not call `set('sidebar', ...)`, the `{{ SIDEBAR }}` placeholder is silently removed from the output. No stale text leaks into the HTML.
|
|
49
50
|
|
|
50
51
|
**Why wrap in tags?**
|
|
51
|
-
When using AJAX navigation, the system
|
|
52
|
+
When using AJAX navigation, the system automatically injects `data-odac-navigate` attributes onto the wrapper elements of each placeholder. This enables the smart part-diffing engine to update only the sections that actually changed between navigations.
|
|
52
53
|
|
|
53
|
-
**Note:** Skeleton files
|
|
54
|
+
**Note:** Skeleton files support only view part placeholders (uppercase). For dynamic content like page titles, use a view part for the `<head>` section or place a `<title>` tag inside the content view.
|
|
54
55
|
|
|
55
56
|
### View Files
|
|
56
57
|
|
|
@@ -66,8 +67,29 @@ view/
|
|
|
66
67
|
├── content/
|
|
67
68
|
│ ├── home.html
|
|
68
69
|
│ └── about.html
|
|
70
|
+
├── sidebar/
|
|
71
|
+
│ └── docs.html
|
|
69
72
|
└── footer/
|
|
70
73
|
└── main.html
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
### Smart AJAX Navigation & Part Diffing
|
|
73
77
|
|
|
78
|
+
When navigating between pages via AJAX, ODAC uses a **server-driven part diffing** system to minimize unnecessary work:
|
|
79
|
+
|
|
80
|
+
- **Unchanged parts are skipped** — If `sidebar` points to the same view file on both the current and next page, the server does not re-render it and the client does not update its DOM. The sidebar stays visible and untouched.
|
|
81
|
+
- **Changed parts are updated** — Only parts whose view path changed are rendered and sent to the client.
|
|
82
|
+
- **Removed parts are cleared** — If the next page does not set a part that the current page had (e.g. navigating away from a page with a sidebar), that element's content is emptied.
|
|
83
|
+
- **`content` is always refreshed** — Because content views are typically URL-dependent (e.g. `/{id}`), the `content` part is always re-rendered regardless of view path.
|
|
84
|
+
- **Skeleton change triggers full reload** — If the next page uses a different skeleton, a full page navigation is performed automatically.
|
|
85
|
+
|
|
86
|
+
This means a shared sidebar, header, or footer that does not change between pages will never flicker or reload during AJAX navigation.
|
|
87
|
+
|
|
88
|
+
#### Force-refresh a part
|
|
89
|
+
|
|
90
|
+
If a part's view path stays the same but its rendered output changes per request (e.g. a sidebar with an active menu state), mark it with `{ refresh: true }`:
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
// This sidebar will re-render on every navigation even if the view path is unchanged
|
|
94
|
+
Odac.View.set('sidebar', 'docs.nav', { refresh: true })
|
|
95
|
+
```
|
|
@@ -70,6 +70,17 @@ Odac.View
|
|
|
70
70
|
|
|
71
71
|
In this case, placeholders like `{{ HEADER }}`, `{{ CONTENT }}`, `{{ FOOTER }}` in the skeleton are automatically matched with `view/home/header.html`, `view/home/content.html`, `view/home/footer.html` files.
|
|
72
72
|
|
|
73
|
+
### 6. Force-Refreshing a Part
|
|
74
|
+
|
|
75
|
+
By default, ODAC's smart diffing skips re-rendering a part if its view path hasn't changed between navigations. If a part's output is request-dependent (e.g. a sidebar that highlights the active menu item), use `{ refresh: true }` to always re-render it:
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
// Sidebar re-renders on every AJAX navigation regardless of view path
|
|
79
|
+
Odac.View.set('sidebar', 'docs.nav', { refresh: true })
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This option is only relevant for AJAX navigations. Full page loads always render all parts.
|
|
83
|
+
|
|
73
84
|
### Setting Dynamic Page Titles and Meta Tags
|
|
74
85
|
|
|
75
86
|
Since skeleton files only support view part placeholders, you have two approaches for dynamic titles:
|
|
@@ -101,8 +112,6 @@ Create a separate view part for the `<head>` section:
|
|
|
101
112
|
</html>
|
|
102
113
|
```
|
|
103
114
|
|
|
104
|
-
**Note:** Each placeholder is wrapped in an HTML tag so AJAX can identify and update specific sections.
|
|
105
|
-
|
|
106
115
|
**Head View (view/head/main.html):**
|
|
107
116
|
```html
|
|
108
117
|
<head>
|
|
@@ -122,15 +131,13 @@ module.exports = async function (Odac) {
|
|
|
122
131
|
.where('id', productId)
|
|
123
132
|
.first()
|
|
124
133
|
|
|
125
|
-
// Set dynamic title and description
|
|
126
134
|
Odac.pageTitle = product ? `${product.name} - My Store` : 'Product Not Found'
|
|
127
135
|
Odac.pageDescription = product ? product.short_description : ''
|
|
128
|
-
|
|
129
136
|
Odac.product = product
|
|
130
137
|
|
|
131
138
|
Odac.View.set({
|
|
132
139
|
skeleton: 'main',
|
|
133
|
-
head: 'main',
|
|
140
|
+
head: 'main',
|
|
134
141
|
header: 'main',
|
|
135
142
|
content: 'product.detail',
|
|
136
143
|
footer: 'main'
|
|
@@ -142,21 +149,6 @@ module.exports = async function (Odac) {
|
|
|
142
149
|
|
|
143
150
|
Include the title tag in your content view:
|
|
144
151
|
|
|
145
|
-
**Skeleton (skeleton/simple.html):**
|
|
146
|
-
```html
|
|
147
|
-
<!DOCTYPE html>
|
|
148
|
-
<html lang="en">
|
|
149
|
-
<head>
|
|
150
|
-
<meta charset="UTF-8">
|
|
151
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
152
|
-
<link rel="stylesheet" href="/assets/css/style.css">
|
|
153
|
-
</head>
|
|
154
|
-
<body>
|
|
155
|
-
{{ CONTENT }}
|
|
156
|
-
</body>
|
|
157
|
-
</html>
|
|
158
|
-
```
|
|
159
|
-
|
|
160
152
|
**Content View (view/content/product.html):**
|
|
161
153
|
```html
|
|
162
154
|
<title>{{ Odac.product.name }} - My Store</title>
|
|
@@ -167,7 +159,7 @@ Include the title tag in your content view:
|
|
|
167
159
|
</div>
|
|
168
160
|
```
|
|
169
161
|
|
|
170
|
-
|
|
162
|
+
The AJAX navigation system automatically extracts the `<title>` tag from the rendered content and updates `document.title`.
|
|
171
163
|
|
|
172
164
|
### Important Notes
|
|
173
165
|
|
|
@@ -175,5 +167,6 @@ Include the title tag in your content view:
|
|
|
175
167
|
- Skeleton files should be in the `skeleton/` directory, view files in the `view/` directory
|
|
176
168
|
- Placeholders for view parts are written in uppercase: `{{ HEADER }}`, `{{ CONTENT }}`, etc.
|
|
177
169
|
- View part names are specified in lowercase: `header`, `content`, etc.
|
|
178
|
-
- Variables in
|
|
179
|
-
-
|
|
170
|
+
- Variables in views are accessed via the `Odac` object: `{{ Odac.variableName }}`
|
|
171
|
+
- Unset placeholders are silently removed from the final HTML output
|
|
172
|
+
- You don't need to call `return` from the controller — `Odac.View.set()` automatically initiates rendering
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odac",
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "Lightweight, high-performance Node.js framework for building modern web applications with built-in routing, auth, database, templating, WebSocket, i18n and zero-config Tailwind CSS.",
|
|
4
4
|
"homepage": "https://odac.run",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "emre.red",
|
|
7
7
|
"email": "mail@emre.red",
|
|
8
8
|
"url": "https://emre.red"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.4.
|
|
10
|
+
"version": "1.4.6",
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=18.0.0"
|
|
@@ -27,14 +27,38 @@
|
|
|
27
27
|
"tailwindcss": "^4.1.18"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
+
"sharp": "^0.33.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
30
33
|
"mysql2": "^3.16.0",
|
|
31
34
|
"pg": "^8.16.3",
|
|
32
35
|
"redis": "^5.10.0",
|
|
33
|
-
"
|
|
36
|
+
"sqlite3": "^6.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"mysql2": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"pg": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"redis": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"sqlite3": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
34
51
|
},
|
|
35
52
|
"overrides": {
|
|
36
|
-
"
|
|
37
|
-
"cross-spawn": "7.0.6"
|
|
53
|
+
"brace-expansion": "5.0.5",
|
|
54
|
+
"cross-spawn": "7.0.6",
|
|
55
|
+
"handlebars": "4.7.9",
|
|
56
|
+
"minimatch": "10.2.4",
|
|
57
|
+
"test-exclude": {
|
|
58
|
+
"minimatch": "3.1.5"
|
|
59
|
+
},
|
|
60
|
+
"picomatch": "4.0.4",
|
|
61
|
+
"tar": "7.5.13"
|
|
38
62
|
},
|
|
39
63
|
"devDependencies": {
|
|
40
64
|
"@eslint/js": "^9.39.2",
|
|
@@ -54,7 +78,7 @@
|
|
|
54
78
|
"lint-staged": "^16.2.7",
|
|
55
79
|
"prettier": "^3.8.1",
|
|
56
80
|
"semantic-release": "^25.0.3",
|
|
57
|
-
"sqlite3": "^
|
|
81
|
+
"sqlite3": "^6.0.1"
|
|
58
82
|
},
|
|
59
83
|
"scripts": {
|
|
60
84
|
"lint": "eslint .",
|
package/src/Request.js
CHANGED
|
@@ -16,6 +16,7 @@ class OdacRequest {
|
|
|
16
16
|
isAjaxLoad = false
|
|
17
17
|
ajaxLoad = null
|
|
18
18
|
clientSkeleton = null
|
|
19
|
+
clientParts = null
|
|
19
20
|
page = null
|
|
20
21
|
|
|
21
22
|
constructor(id, req, res, odac) {
|
|
@@ -50,11 +51,11 @@ class OdacRequest {
|
|
|
50
51
|
this.status(code)
|
|
51
52
|
let result = {401: 'Unauthorized', 404: 'Not Found', 408: 'Request Timeout'}[code] ?? null
|
|
52
53
|
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
typeof
|
|
54
|
+
this.#odac.Route?.routes?.[this.route]?.error &&
|
|
55
|
+
this.#odac.Route.routes[this.route].error[code] &&
|
|
56
|
+
typeof this.#odac.Route.routes[this.route].error[code].cache === 'function'
|
|
56
57
|
)
|
|
57
|
-
result = await
|
|
58
|
+
result = await this.#odac.Route.routes[this.route].error[code].cache(this.#odac)
|
|
58
59
|
this.end(result)
|
|
59
60
|
}
|
|
60
61
|
|
package/src/Route.js
CHANGED
|
@@ -150,6 +150,17 @@ class Route {
|
|
|
150
150
|
}
|
|
151
151
|
Odac.Request.isAjaxLoad = true
|
|
152
152
|
Odac.Request.clientSkeleton = Odac.Request.header('X-Odac-Skeleton')
|
|
153
|
+
|
|
154
|
+
// Parse client's current part values for smart diffing
|
|
155
|
+
const partsHeader = Odac.Request.header('X-Odac-Parts')
|
|
156
|
+
if (partsHeader) {
|
|
157
|
+
const parts = {}
|
|
158
|
+
for (const entry of partsHeader.split(',')) {
|
|
159
|
+
const idx = entry.indexOf('=')
|
|
160
|
+
if (idx > 0) parts[entry.substring(0, idx)] = decodeURIComponent(entry.substring(idx + 1))
|
|
161
|
+
}
|
|
162
|
+
Odac.Request.clientParts = parts
|
|
163
|
+
}
|
|
153
164
|
}
|
|
154
165
|
if (Odac.Config?.route?.[url]) {
|
|
155
166
|
// PROD CACHE HIT
|
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`)) {
|
|
@@ -202,6 +218,7 @@ class View {
|
|
|
202
218
|
|
|
203
219
|
this.#odac.Request.end({
|
|
204
220
|
output: output,
|
|
221
|
+
parts: currentParts,
|
|
205
222
|
variables: variables,
|
|
206
223
|
data: this.#odac.Request.sharedData,
|
|
207
224
|
title: title,
|
|
@@ -239,6 +256,9 @@ class View {
|
|
|
239
256
|
}
|
|
240
257
|
}
|
|
241
258
|
}
|
|
259
|
+
|
|
260
|
+
// Clean up unresolved skeleton placeholders
|
|
261
|
+
result = result.replace(/\{\{\s*[A-Z_]+\s*\}\}/g, '')
|
|
242
262
|
}
|
|
243
263
|
|
|
244
264
|
if (result) {
|
|
@@ -294,7 +314,7 @@ class View {
|
|
|
294
314
|
}
|
|
295
315
|
|
|
296
316
|
if (attrs.get) {
|
|
297
|
-
return `{{ get('${attrs.get}') || '' }}`
|
|
317
|
+
return `{{ get('${attrs.get.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}') || '' }}`
|
|
298
318
|
} else if (attrs.var) {
|
|
299
319
|
if (attrs.raw) {
|
|
300
320
|
return `{!! ${attrs.var} !!}`
|
|
@@ -323,7 +343,7 @@ class View {
|
|
|
323
343
|
}
|
|
324
344
|
|
|
325
345
|
if (attrs.get) {
|
|
326
|
-
return `{{ get('${attrs.get}') || '' }}`
|
|
346
|
+
return `{{ get('${attrs.get.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}') || '' }}`
|
|
327
347
|
} else if (attrs.var) {
|
|
328
348
|
if (attrs.raw) {
|
|
329
349
|
return `{!! ${attrs.var} !!}`
|
|
@@ -350,8 +370,9 @@ class View {
|
|
|
350
370
|
return `%s${placeholderIndex++}`
|
|
351
371
|
})
|
|
352
372
|
|
|
373
|
+
const escapedContent = processedContent.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
353
374
|
const translationCall =
|
|
354
|
-
placeholders.length > 0 ? `__('${
|
|
375
|
+
placeholders.length > 0 ? `__('${escapedContent}', ${placeholders.join(', ')})` : `__('${escapedContent}')`
|
|
355
376
|
|
|
356
377
|
if (attrs.raw) {
|
|
357
378
|
return `{!! ${translationCall} !!}`
|
|
@@ -359,7 +380,7 @@ class View {
|
|
|
359
380
|
return `{{ ${translationCall} }}`
|
|
360
381
|
}
|
|
361
382
|
} else {
|
|
362
|
-
return `{{ '${innerContent}' }}`
|
|
383
|
+
return `{{ '${innerContent.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' }}`
|
|
363
384
|
}
|
|
364
385
|
})
|
|
365
386
|
if (before === content) break
|
|
@@ -523,8 +544,12 @@ class View {
|
|
|
523
544
|
|
|
524
545
|
// - SET PARTS
|
|
525
546
|
set(...args) {
|
|
526
|
-
if (args.length === 1 && typeof args[0] === 'object')
|
|
527
|
-
|
|
547
|
+
if (args.length === 1 && typeof args[0] === 'object') {
|
|
548
|
+
for (let key in args[0]) this.#part[key] = args[0][key]
|
|
549
|
+
} else if (args.length >= 2) {
|
|
550
|
+
this.#part[args[0]] = args[1]
|
|
551
|
+
if (args[2]?.refresh) this.#refresh.add(args[0])
|
|
552
|
+
}
|
|
528
553
|
|
|
529
554
|
if (!this.#odac.Request.page) {
|
|
530
555
|
this.#odac.Request.page = this.#part.content || this.#part.all || ''
|
|
@@ -541,12 +566,21 @@ class View {
|
|
|
541
566
|
}
|
|
542
567
|
|
|
543
568
|
#addNavigateAttribute(skeleton) {
|
|
544
|
-
|
|
569
|
+
// Inject data-odac-navigate for placeholders already wrapped in an HTML tag
|
|
570
|
+
skeleton = skeleton.replace(/(<[^>]+>)(\s*\{\{\s*([A-Z_]+)\s*\}\})/g, (match, openTag, content, partName) => {
|
|
571
|
+
const attrName = partName.toLowerCase()
|
|
545
572
|
if (openTag.includes('data-odac-navigate')) return match
|
|
546
|
-
const tagWithAttr = openTag.slice(0, -1) +
|
|
573
|
+
const tagWithAttr = openTag.slice(0, -1) + ` data-odac-navigate="${attrName}">`
|
|
547
574
|
return tagWithAttr + content
|
|
548
575
|
})
|
|
549
576
|
|
|
577
|
+
// Auto-wrap unwrapped placeholders so AJAX navigation can target them
|
|
578
|
+
// Uses display:contents to avoid breaking flex/grid layouts
|
|
579
|
+
skeleton = skeleton.replace(/(?<!>)\s*(\{\{\s*([A-Z_]+)\s*\}\})/g, (match, placeholder, partName) => {
|
|
580
|
+
const attrName = partName.toLowerCase()
|
|
581
|
+
return `<div style="display:contents" data-odac-navigate="${attrName}">${placeholder}</div>`
|
|
582
|
+
})
|
|
583
|
+
|
|
550
584
|
const skeletonName = this.#part.skeleton || 'main'
|
|
551
585
|
const pageName = this.#odac.Request.page || ''
|
|
552
586
|
|
|
@@ -558,6 +592,17 @@ class View {
|
|
|
558
592
|
if (!attrs.includes('data-odac-page')) {
|
|
559
593
|
updates.push(`data-odac-page="${pageName}"`)
|
|
560
594
|
}
|
|
595
|
+
|
|
596
|
+
// Embed current parts manifest for client-side diffing on first load
|
|
597
|
+
const partsMap = {}
|
|
598
|
+
for (let key in this.#part) {
|
|
599
|
+
if (['all', 'skeleton'].includes(key)) continue
|
|
600
|
+
if (this.#part[key]) partsMap[key] = this.#part[key]
|
|
601
|
+
}
|
|
602
|
+
if (!attrs.includes('data-odac-parts') && Object.keys(partsMap).length > 0) {
|
|
603
|
+
updates.push(`data-odac-parts='${JSON.stringify(partsMap)}'`)
|
|
604
|
+
}
|
|
605
|
+
|
|
561
606
|
if (updates.length === 0) return match
|
|
562
607
|
return `<html${attrs} ${updates.join(' ')}>`
|
|
563
608
|
})
|
|
@@ -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,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
|
+
})
|