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/.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,45 @@
|
|
|
1
|
+
### ⚙️ Engine Tuning
|
|
2
|
+
|
|
3
|
+
- **test:** remove unused fsPromises and standardize async I/O in View tests
|
|
4
|
+
|
|
5
|
+
### 📚 Documentation
|
|
6
|
+
|
|
7
|
+
- add quick start guide and register in documentation index
|
|
8
|
+
|
|
9
|
+
### 🛠️ Fixes & Improvements
|
|
10
|
+
|
|
11
|
+
- add raw attribute support to `<odac get>` tag for unescaped HTML output
|
|
12
|
+
- improve title extraction logic and optimize data-odac-navigate attribute injection in View rendering
|
|
13
|
+
- prevent data-odac-navigate injection into closing HTML tags
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
20
|
+
|
|
21
|
+
### ⚙️ Engine Tuning
|
|
22
|
+
|
|
23
|
+
- replace global Odac reference with private instance and update dependency overrides for security hardening
|
|
24
|
+
- **test/view:** remove unused CACHE_DIR variable in parseOdacTag tests
|
|
25
|
+
|
|
26
|
+
### ✨ What's New
|
|
27
|
+
|
|
28
|
+
- **client:** log malformed data-odac-parts JSON to ease debugging
|
|
29
|
+
- **view:** smart AJAX part diffing with selective re-render
|
|
30
|
+
|
|
31
|
+
### 🛠️ Fixes & Improvements
|
|
32
|
+
|
|
33
|
+
- **route:** encode X-Odac-Parts header values to prevent splitting errors
|
|
34
|
+
- **View:** escape single quotes in template expressions to prevent syntax errors
|
|
35
|
+
- **view:** prevent template injection by escaping backslashes in dynamic parser
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
42
|
+
|
|
1
43
|
### security
|
|
2
44
|
|
|
3
45
|
- **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,107 @@
|
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
- `<odac get="key" />`: Query Parameter output (HTML-escaped, XSS-safe).
|
|
22
|
+
- `<odac var="key" raw />`, `<odac get="key" raw />` or `{!! key !!}`: Raw output (use with extreme caution).
|
|
23
|
+
4. **Choosing the Right Syntax**:
|
|
24
|
+
- **Inside HTML attributes** (`src`, `alt`, `href`, `class`, `value`, etc.) → Always prefer `{{ }}`.
|
|
23
25
|
- **Inline within text or mixed HTML** → Prefer `{{ }}` for short interpolations.
|
|
24
|
-
- **Standalone block output**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
- **Standalone block output** → Prefer `<odac var="" />` for structural clarity.
|
|
27
|
+
5. **Conditionals**: Use `<odac:if condition="VAR"> ... </odac:if>`.
|
|
28
|
+
6. **Looping**: Use `<odac:for in="ARRAY" value="ITEM"> ... </odac:for>`.
|
|
29
|
+
7. **Server-Side JS**: Use `<script:odac>` for complex calculations during rendering.
|
|
30
|
+
|
|
31
|
+
## Smart Part Diffing (AJAX Navigation)
|
|
32
|
+
|
|
33
|
+
ODAC uses a **server-driven part diffing** system during AJAX navigation. Understanding this is critical for building multi-section layouts correctly.
|
|
34
|
+
|
|
35
|
+
### How it works
|
|
36
|
+
- The client tracks which view file each part is currently showing.
|
|
37
|
+
- On every AJAX navigation, it sends this state to the server via `X-Odac-Parts`.
|
|
38
|
+
- The server compares the new page's parts against the client's current state and **only renders parts that changed**.
|
|
39
|
+
- The client only updates DOM elements that received new content.
|
|
40
|
+
|
|
41
|
+
### Rules
|
|
42
|
+
| Scenario | Behavior |
|
|
43
|
+
|----------|----------|
|
|
44
|
+
| Part view path unchanged | Skipped — not re-rendered, DOM untouched |
|
|
45
|
+
| Part view path changed | Re-rendered and DOM updated |
|
|
46
|
+
| Part removed on new page | DOM element content cleared |
|
|
47
|
+
| Skeleton changed | Full page reload |
|
|
48
|
+
| `content` part | **Always** re-rendered (URL-dependent by nature) |
|
|
49
|
+
|
|
50
|
+
### Force-refresh a part
|
|
51
|
+
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):
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
// Always re-renders on AJAX navigation even if view path is unchanged
|
|
55
|
+
Odac.View.set('sidebar', 'docs.nav', { refresh: true })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Skeleton placeholder rules
|
|
59
|
+
- Each `{{ PLACEHOLDER }}` must be wrapped in its own HTML element.
|
|
60
|
+
- The engine auto-injects `data-odac-navigate` on wrapper elements — do not add manually.
|
|
61
|
+
- Unset placeholders are silently removed from the final HTML output.
|
|
62
|
+
|
|
63
|
+
```html
|
|
64
|
+
<!-- skeleton/main.html -->
|
|
65
|
+
<aside>{{ SIDEBAR }}</aside> <!-- auto-gets data-odac-navigate="sidebar" -->
|
|
66
|
+
<main>{{ CONTENT }}</main> <!-- auto-gets data-odac-navigate="content" -->
|
|
67
|
+
```
|
|
29
68
|
|
|
30
69
|
## Reference Patterns
|
|
31
70
|
|
|
32
|
-
### 1.
|
|
71
|
+
### 1. Standard Page with Shared Layout
|
|
72
|
+
```javascript
|
|
73
|
+
// controller/docs/page.js
|
|
74
|
+
module.exports = function (Odac) {
|
|
75
|
+
Odac.View
|
|
76
|
+
.skeleton('main')
|
|
77
|
+
.set('sidebar', 'docs.nav') // shared — skipped if unchanged on next navigate
|
|
78
|
+
.set('content', 'docs.intro') // always re-rendered
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. Sidebar with Active State (force refresh)
|
|
83
|
+
```javascript
|
|
84
|
+
// controller/docs/page.js
|
|
85
|
+
module.exports = function (Odac) {
|
|
86
|
+
Odac.View
|
|
87
|
+
.skeleton('main')
|
|
88
|
+
.set('sidebar', 'docs.nav', { refresh: true }) // re-renders every navigate
|
|
89
|
+
.set('content', 'docs.intro')
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 3. Page Without Sidebar
|
|
33
94
|
```javascript
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
Odac.View
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
95
|
+
// controller/home.js — navigating here clears the sidebar DOM element
|
|
96
|
+
module.exports = function (Odac) {
|
|
97
|
+
Odac.View
|
|
98
|
+
.skeleton('main')
|
|
99
|
+
.set('content', 'home')
|
|
100
|
+
// sidebar not set → its DOM element is emptied on AJAX navigation
|
|
101
|
+
}
|
|
40
102
|
```
|
|
41
103
|
|
|
42
|
-
###
|
|
104
|
+
### 4. Template Syntax Reference
|
|
43
105
|
```html
|
|
44
106
|
<!-- Standalone block output — prefer <odac var> -->
|
|
45
107
|
<h1><odac var="title" /></h1>
|
|
@@ -47,47 +109,37 @@ Odac.View.set({
|
|
|
47
109
|
<!-- Inside attributes — prefer {{ }} -->
|
|
48
110
|
<img src="{{ product.image }}" alt="{{ product.name }}">
|
|
49
111
|
<a href="/user/{{ user.id }}" class="btn {{ isActive ? 'active' : '' }}">Profile</a>
|
|
50
|
-
<input type="text" value="{{ query }}">
|
|
51
112
|
|
|
52
|
-
<!-- Inline text interpolation
|
|
113
|
+
<!-- Inline text interpolation -->
|
|
53
114
|
<p>Welcome, {{ user.name }}. You have {{ notifications }} new messages.</p>
|
|
54
|
-
|
|
115
|
+
|
|
116
|
+
<!-- Query parameter from URL (/page?q=search) -->
|
|
117
|
+
<p>Search Results for: <odac get="q" /></p>
|
|
118
|
+
<div class="raw-content"><odac get="htmlContent" raw /></div>
|
|
55
119
|
|
|
56
120
|
<!-- Conditional -->
|
|
57
121
|
<odac:if condition="stats.users > 100">
|
|
58
122
|
<span class="badge">Popular!</span>
|
|
59
123
|
</odac:if>
|
|
60
124
|
|
|
61
|
-
<!--
|
|
62
|
-
|
|
125
|
+
<!-- Loop -->
|
|
126
|
+
<odac:for in="users" value="user">
|
|
63
127
|
<li>{{ user.name }}</li>
|
|
64
|
-
|
|
128
|
+
</odac:for>
|
|
65
129
|
```
|
|
66
130
|
|
|
67
|
-
###
|
|
68
|
-
Perfect for calculations that shouldn't clutter the controller but are too complex for simple tags.
|
|
131
|
+
### 5. Backend JavaScript (`<script:odac>`)
|
|
69
132
|
```html
|
|
70
133
|
<script:odac>
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const tax = total * 0.18;
|
|
134
|
+
const total = items.reduce((sum, i) => sum + i.price, 0)
|
|
135
|
+
const tax = total * 0.18
|
|
74
136
|
</script:odac>
|
|
75
137
|
|
|
76
138
|
<p>Subtotal: ${{ total }}</p>
|
|
77
139
|
<p>Tax: ${{ tax }}</p>
|
|
78
140
|
```
|
|
79
141
|
|
|
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
142
|
## Security Best Practices
|
|
90
|
-
- **Both `{{ }}` and `<odac var>` are XSS-safe**: Both apply HTML escaping by default.
|
|
143
|
+
- **Both `{{ }}` and `<odac var>` are XSS-safe**: Both apply HTML escaping by default.
|
|
91
144
|
- **Raw output requires trust**: Only use `raw` / `{!! !!}` with content you fully control. Never with user input.
|
|
92
145
|
- **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
|