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.
@@ -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="content"` into the element immediately wrapping `{{ CONTENT }}`. Do NOT manually add this attribute to HTML templates or assume it is missing based on static analysis, as it is handled seamlessly at runtime via SSR.
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
- const elements = Object.entries(this.#loader.elements)
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
- elements.forEach(([key, selector]) => {
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
- * This is the fallback path when the View Transition API is unavailable
858
- * or no `odac-transition` elements exist in the DOM.
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
- let ajaxData = null,
862
- ajaxXhr = null,
863
- fadeOutComplete = false,
864
- ajaxComplete = false
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
- ajaxData = data
926
- ajaxXhr = xhr
927
- ajaxComplete = true
928
- applyUpdate()
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 contentEl = document.querySelector('[data-odac-navigate="content"]')
1156
- if (contentEl) {
1157
- window.Odac.loader('a[href^="/"]:not([data-navigate="false"]):not(.no-navigate)', {content: '[data-odac-navigate="content"]'}, null)
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 template performance, skeleton layouts, and safe output rendering.
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, and server-side JavaScript execution via `<script:odac>`.
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. **Data Binding Two Equivalent Syntaxes**:
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 (Use with extreme caution).
21
- 3. **Choosing the Right Syntax**:
22
- - **Inside HTML attributes** (`src`, `alt`, `href`, `class`, `value`, etc.) → Always prefer `{{ }}`. It reads naturally and keeps markup clean.
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** (the variable is the only content of an element) → Prefer `<odac var="" />` for structural clarity and IDE support.
25
- - Both syntaxes compile to the same engine output. The choice is about readability, not functionality.
26
- 4. **Conditionals**: Use `<odac:if condition="VAR"> ... </odac:if>`.
27
- 5. **Looping**: Use `<odac:for in="ARRAY" value="ITEM"> ... </odac:for>` or the performance-optimized `[[odac_for ...]]`.
28
- 6. **Server-Side JS**: Use `<script:odac>` for complex calculations during rendering.
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. The Controller to View Flow
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
- // Controller
35
- Odac.View.skeleton('main');
36
- Odac.View.set({
37
- title: 'Dashboard',
38
- stats: { users: 150, orders: 45 }
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
- ### 2. Template Syntax Reference
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 — prefer {{ }} -->
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
- <!-- Performance Loop -->
62
- [[odac_for user in users]]
120
+ <!-- Loop -->
121
+ <odac:for in="users" value="user">
63
122
  <li>{{ user.name }}</li>
64
- [[odac_endfor]]
123
+ </odac:for>
65
124
  ```
66
125
 
67
- ### 3. Backend JavaScript (`<script:odac>`)
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
- // Runs on the SERVER during rendering
72
- const total = items.reduce((sum, i) => sum + i.price, 0);
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. Use either with confidence.
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 for smooth transitions, route control, and lifecycle-safe execution.
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. **Selection**: Enable via `Odac.action({ navigate: 'main' })`.
14
- 2. **Exclusion**: Use `data-navigate="false"` or `.no-navigate` class for full reloads.
15
- 3. **Lifecycle**: Use `load` and `page` events to run code after navigation.
16
- 4. **View Transitions**: Add `odac-transition` attribute to elements for native browser View Transition API animations. Zero-config — no JS setup required.
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., shared header) for smooth morphing.
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 tags** - This allows AJAX to identify and update specific sections
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.
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 needs to identify which part of the page to update. HTML tags provide clear boundaries for each section.
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 currently support only view part placeholders (uppercase). For dynamic content like page titles, use a view part for the `<head>` section or set them in individual view 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', // Include dynamic head
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
- **Note:** This approach is less clean but works for simple cases.
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 skeleton/views are accessed via `Odac` object: `{{ Odac.variableName }}`
179
- - You don't need to use `return` from the controller, `Odac.View.set()` automatically initiates the rendering process
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": " Next-Gen Server & Framework: Web, DNS, Mail, SSL & Monitoring in one CLI.",
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.5",
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
- "sharp": "^0.33.0"
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
- "tar": "7.5.9",
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": "^5.1.7"
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
- global.Odac.Route.routes[this.route].error &&
54
- global.Odac.Route.routes[this.route].error[code] &&
55
- typeof global.Odac.Route.routes[this.route].error[code].cache === 'function'
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 global.Odac.Route.routes[this.route].error[code].cache(this.#odac)
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
- // Render requested elements
152
+ // Build current parts manifest (part name → view path)
153
+ const currentParts = {}
154
+ for (let key in this.#part) {
155
+ if (['all', 'skeleton'].includes(key)) continue
156
+ if (this.#part[key]) currentParts[key] = this.#part[key]
157
+ }
158
+
159
+ // Parse client's previous parts to detect unchanged regions
160
+ const clientParts = this.#odac.Request.clientParts || {}
161
+
162
+ // Render requested elements (skip unchanged parts)
152
163
  let title = null
153
164
  for (let element of this.#odac.Request.ajaxLoad) {
154
165
  if (this.#part[element]) {
166
+ // Skip rendering if the part view path has not changed and refresh is not forced
167
+ // Content is always re-rendered since its output depends on the current URL
168
+ if (element !== 'content' && !this.#refresh.has(element) && clientParts[element] && clientParts[element] === this.#part[element])
169
+ continue
170
+
155
171
  let viewPath = this.#part[element]
156
172
  if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
157
173
  if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
@@ -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 ? `__('${processedContent}', ${placeholders.join(', ')})` : `__('${processedContent}')`
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') for (let key in args[0]) this.#part[key] = args[0][key]
527
- else if (args.length === 2) this.#part[args[0]] = args[1]
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
- skeleton = skeleton.replace(/(<[^>]+>)(\s*\{\{\s*CONTENT\s*\}\})/, (match, openTag, content) => {
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) + ' data-odac-navigate="content">'
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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
79
+ }
80
+ },
81
+ Request: {
82
+ req: {url: '/test'},
83
+ res: {finished: false, headersSent: false},
84
+ isAjaxLoad: false,
85
+ ajaxLoad: [],
86
+ variables: {},
87
+ sharedData: {},
88
+ page: '',
89
+ get: () => '',
90
+ header: () => {},
91
+ end: output => {
92
+ capturedOutput = output
93
+ },
94
+ hasEarlyHints: () => false,
95
+ setEarlyHints: () => {}
96
+ },
97
+ Lang: {
98
+ get: (...args) => args[0]
99
+ },
100
+ View: {}
101
+ }
102
+
103
+ try {
104
+ const view = new View(mockOdac)
105
+ view.skeleton('main')
106
+ view.set('content', uniqueId)
107
+ await view.print()
108
+ } finally {
109
+ console.error = originalError
110
+ }
111
+
112
+ if (errors.length > 0) {
113
+ throw new Error(`Template render error: ${errors.join('\n')}`)
114
+ }
115
+
116
+ return capturedOutput
117
+ }
118
+
119
+ describe('single quote escaping in <odac> tags', () => {
120
+ it('should render a single quote inside <odac> without syntax error', async () => {
121
+ const result = await renderTemplate("<odac>'</odac>")
122
+ // Single quotes are HTML-escaped by Odac.Var().html()
123
+ expect(result).toContain('&#39;')
124
+ })
125
+
126
+ it('should render text with embedded single quotes', async () => {
127
+ const result = await renderTemplate("<odac>it's working</odac>")
128
+ expect(result).toContain('it&#39;s working')
129
+ })
130
+
131
+ it('should render multiple single quotes', async () => {
132
+ const result = await renderTemplate("<odac>it's a developer's life</odac>")
133
+ expect(result).toContain('it&#39;s a developer&#39;s life')
134
+ })
135
+
136
+ it('should render escaped apostrophe in translation tags', async () => {
137
+ const result = await renderTemplate("<odac t>it's translated</odac>")
138
+ expect(result).toContain('it&#39;s translated')
139
+ })
140
+ })
141
+
142
+ describe('basic <odac> tag rendering', () => {
143
+ it('should render plain text inside <odac> tags', async () => {
144
+ const result = await renderTemplate('<odac>hello world</odac>')
145
+ expect(result).toContain('hello world')
146
+ })
147
+
148
+ it('should strip backend comments (multi-line)', async () => {
149
+ const result = await renderTemplate('visible<!--odac hidden odac-->visible2')
150
+ expect(result).toContain('visible')
151
+ expect(result).toContain('visible2')
152
+ expect(result).not.toContain('hidden')
153
+ })
154
+
155
+ it('should strip backend comments (single-line)', async () => {
156
+ const result = await renderTemplate('visible<!--odac hidden -->visible2')
157
+ expect(result).toContain('visible')
158
+ expect(result).toContain('visible2')
159
+ expect(result).not.toContain('hidden')
160
+ })
161
+
162
+ it('should handle empty <odac> tags gracefully', async () => {
163
+ const result = await renderTemplate('<odac></odac>')
164
+ expect(typeof result).toBe('string')
165
+ })
166
+ })
167
+
168
+ describe('special characters in <odac> tags', () => {
169
+ it('should handle double quotes inside <odac> tags', async () => {
170
+ const result = await renderTemplate('<odac>say "hello"</odac>')
171
+ expect(result).toContain('say')
172
+ })
173
+
174
+ it('should handle angle brackets in text (HTML entities)', async () => {
175
+ const result = await renderTemplate('<odac>&lt;div&gt;</odac>')
176
+ // Already-escaped entities get double-escaped by Odac.Var().html()
177
+ expect(result).toContain('&amp;lt;div&amp;gt;')
178
+ })
179
+ })
180
+ })