odac 1.4.13 → 1.4.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ### 🛠️ Fixes & Improvements
2
2
 
3
+ - **docs:** update references from 'odac' to 'Odac' for consistency
4
+ - **form:** improve form submission handler ownership to prevent duplicate submissions
5
+ - **form:** prevent multiple submit handlers on re-bound forms
6
+ - **startup:** surface init/migration errors instead of swallowing them
7
+
8
+
9
+
10
+ ---
11
+
12
+ Powered by [⚡ ODAC](https://odac.run)
13
+
14
+ ### 🛠️ Fixes & Improvements
15
+
16
+ - **docs:** update frontend scripts documentation to include code obfuscation details and enhance clarity
17
+ - **form:** add escapeHtmlPreservingTemplates method to handle template tokens
18
+ - **route:** enhance error handling to support object responses in error method
19
+ - **view:** use escapeHtmlPreservingTemplates consistently for form attributes
20
+
21
+
22
+
23
+ ---
24
+
25
+ Powered by [⚡ ODAC](https://odac.run)
26
+
27
+ ### 🛠️ Fixes & Improvements
28
+
3
29
  - **form:** enhance form handling with metadata and improved parsing logic
4
30
  - **form:** implement token rotation on successful form submissions
5
31
  - **view:** prevent script injection and handle escaped quotes in form configs
package/bin/odac.js CHANGED
@@ -532,6 +532,7 @@ async function runMigration(cmd, cliArgs) {
532
532
  }
533
533
  } catch (err) {
534
534
  console.error('❌ Migration error:', err.message)
535
+ if (err.cause) console.error(' Cause:', err.cause.message || err.cause)
535
536
  process.exit(1)
536
537
  } finally {
537
538
  for (const conn of Object.values(connections)) {
package/client/odac.js CHANGED
@@ -119,6 +119,7 @@ if (typeof window !== 'undefined') {
119
119
  #page = null
120
120
  #token = {hash: [], data: false}
121
121
  #formSubmitHandlers = new Map()
122
+ #formElementSelectors = new WeakMap()
122
123
  #loader = {elements: {}, callback: null, parts: {}}
123
124
  #isNavigating = false
124
125
 
@@ -412,6 +413,20 @@ if (typeof window !== 'undefined') {
412
413
  const formElement = e.target.closest(formSelector)
413
414
  if (!formElement) return
414
415
 
416
+ // Ownership guard: a single form can be matched by more than one
417
+ // registered selector — e.g. an <odac:form> auto-bound by #initForms
418
+ // under its data-odac-form token, then re-bound by the app via
419
+ // Odac.form('#id', cb) to attach a callback. The form is tagged with the
420
+ // selector it was last registered under; only that handler may process
421
+ // the submit, so the form fires exactly once instead of 2x. We don't
422
+ // tear down the other selector's listener (it may still serve *other*
423
+ // forms — e.g. a shared '.ajax-form' selector), we just let those
424
+ // handlers ignore this form. Forms never explicitly (re-)bound aren't
425
+ // tracked and fall through normally, preserving delegation for forms
426
+ // added to the DOM after registration.
427
+ const owner = this.#formElementSelectors.get(formElement)
428
+ if (owner && owner !== formSelector) return
429
+
415
430
  e.preventDefault()
416
431
 
417
432
  if (obj.messages !== false) {
@@ -680,6 +695,7 @@ if (typeof window !== 'undefined') {
680
695
 
681
696
  document.addEventListener('submit', handler)
682
697
  this.#formSubmitHandlers.set(formSelector, handler)
698
+ document.querySelectorAll(formSelector).forEach(formEl => this.#formElementSelectors.set(formEl, formSelector))
683
699
  }
684
700
 
685
701
  get(url, callback) {
@@ -997,6 +1013,9 @@ if (typeof window !== 'undefined') {
997
1013
 
998
1014
  for (const {cls, attr} of formTypes) {
999
1015
  document.querySelectorAll(`form.${cls}[${attr}]`).forEach(form => {
1016
+ // Skip forms the app already bound via Odac.form(...) — re-registering
1017
+ // here would attach a second handler and cause 2x submit.
1018
+ if (this.#formElementSelectors.has(form)) return
1000
1019
  const token = form.getAttribute(attr)
1001
1020
  const selector = `form[${attr}="${token}"]`
1002
1021
  if (!this.#formSubmitHandlers.has(selector)) {
@@ -18,7 +18,7 @@ Frontend logic is organized into **Actions** using `Odac.action()`. This method
18
18
  - `start`: Fires once when the script initializes.
19
19
  - `load`: Fires after every page load (including AJAX navigations).
20
20
  3. **Page Scoping**: Use the `page: { name: fn }` object to isolate code to specific routes.
21
- 4. **Persistent Storage**: Use `odac.storage(key, value)` for a secure LocalStorage wrapper.
21
+ 4. **Persistent Storage**: Use `Odac.storage(key, value)` for a secure LocalStorage wrapper.
22
22
 
23
23
  ## Reference Patterns
24
24
 
@@ -60,11 +60,11 @@ 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
68
  ```
69
69
 
70
70
  ### 4. Programmatic Navigation
@@ -84,4 +84,4 @@ Odac.load('/dashboard', function(page, vars) {
84
84
  ## Best Practices
85
85
  - **Clean Selectors**: Use ID or specific data-attributes for event listeners to avoid conflicts.
86
86
  - **No Inline JS**: Move all logic from HTML attributes (onclick, etc.) into `Odac.action()`.
87
- - **Shared State**: Use `odac.data()` to pass complex objects from the backend once per request.
87
+ - **Shared State**: Use `Odac.data()` to pass complex objects from the backend once per request.
@@ -1,36 +1,92 @@
1
1
  ---
2
2
  name: frontend-scripts-typescript-skill
3
- description: ODAC frontend JS/TS pipeline guidelines for writing, bundling, and optimizing client-side scripts using esbuild.
3
+ description: ODAC frontend JS/TS pipeline guidelines for writing, bundling, and optimizing client-side scripts using esbuild and code obfuscation.
4
4
  metadata:
5
- tags: frontend, javascript, typescript, esbuild, bundling, minification, tree-shaking, scripts, assets
5
+ tags: frontend, javascript, typescript, esbuild, bundling, minification, tree-shaking, scripts, assets, obfuscation
6
6
  ---
7
7
 
8
8
  # Frontend Scripts & TypeScript Skill
9
9
 
10
- Zero-config frontend asset pipeline powered by esbuild for TypeScript transpilation, bundling, minification, and tree-shaking.
10
+ ODAC provides a built-in, Zero-Config frontend asset pipeline powered by **esbuild** for TypeScript transpilation, bundling, minification, tree-shaking, and multi-level code obfuscation.
11
11
 
12
- ## Core Rules
13
- 1. **Entry Points**: Place `.ts`, `.js`, `.mts`, or `.mjs` files in `view/js/`. Each becomes a separate bundle.
14
- 2. **Partials Convention**: Files starting with `_` (e.g., `_utils.ts`) are ignored as entry points — use them as shared imports only.
15
- 3. **Output Path**: Compiled files go to `public/assets/js/{name}.js`.
16
- 4. **No TypeScript Enforcement**: Both TypeScript and plain JavaScript are supported equally.
17
- 5. **Import Resolution**: Use standard ES module `import`/`export` between files. esbuild bundles everything into a single output per entry point.
18
- 6. **Configuration**: Optional `js` key in `odac.json` for `target`, `minify`, `sourcemap`, and `bundle` settings.
12
+ ## Core Rules & Conventions
19
13
 
20
- ## Development vs Production
21
- - **`odac dev`**: Watch mode with source maps, no minification, instant rebuilds.
22
- - **`odac build`**: Full minification, tree-shaking, and dead code elimination.
14
+ 1. **Entry Points**: Every `.ts`, `.js`, `.mts`, or `.mjs` file placed directly in `view/js/` represents a unique entry point and will compile into a separate bundle in `public/assets/js/{name}.js`.
15
+ 2. **Partials Convention**: Files starting with an underscore (e.g., `_utils.ts`, `_api.ts`) are treated as private modules/partials. They are **ignored** as entry points and should only be used as shared imports.
16
+ 3. **No TypeScript Enforcement**: TypeScript and plain JavaScript are supported equally out of the box.
17
+ 4. **Import Resolution**: Use standard ES module `import`/`export` syntax. esbuild bundles all imported modules into a single optimized bundle, eliminating extra runtime network requests.
18
+ 5. **Output Path**: Compiled output is saved statically under `public/assets/js/`.
19
+
20
+ ## Pipeline Modes
21
+
22
+ * **Development (`npm run dev` / `odac dev`)**:
23
+ * Watches all scripts in `view/js/` for instant sub-millisecond rebuilds.
24
+ * Source maps are always enabled to facilitate easy debugging.
25
+ * No minification or obfuscation is applied.
26
+ * **Production (`npm run build` / `odac build`)**:
27
+ * Enables full bundling, minification, and tree-shaking.
28
+ * Applies configured obfuscation levels.
29
+ * Exports clean production-ready assets to `public/assets/js/`.
30
+
31
+ ## Configuration (`odac.json`)
32
+
33
+ You can customize the pipeline behavior via the optional `js` key in the `odac.json` configuration file:
34
+
35
+ ```json
36
+ {
37
+ "js": {
38
+ "target": "es2020",
39
+ "minify": true,
40
+ "sourcemap": false,
41
+ "bundle": true,
42
+ "obfuscate": false
43
+ }
44
+ }
45
+ ```
46
+
47
+ ### Configuration Options
48
+
49
+ | Option | Default | Description |
50
+ |-------------|------------|-------------|
51
+ | `target` | `"es2020"` | JavaScript target version (`es2015`, `es2020`, `esnext`, etc.). |
52
+ | `minify` | `true` | Enables minification (whitespace removal, variable shortening, dead code elimination) in production. |
53
+ | `sourcemap` | `false` | Generates source maps in production builds (always enabled in dev mode). |
54
+ | `bundle` | `true` | Bundles all imported dependency modules into the output entry-point file. |
55
+ | `obfuscate` | `false` | Configures the level of production code obfuscation (`false`, `true`/`"low"`, `"medium"`, `"high"`). |
56
+
57
+ ---
58
+
59
+ ## Obfuscation Levels
60
+
61
+ ODAC supports three distinct levels of code obfuscation in production mode (`odac build`). Obfuscation is disabled by default and is never applied during development.
62
+
63
+ | Level | Behavior |
64
+ |-------|----------|
65
+ | `false` | **No Obfuscation**: Standard minification and tree-shaking only. |
66
+ | `true` / `"low"` | **Low Mangling**: Mangles properties starting with `_` (private-by-convention). |
67
+ | `"medium"` | **Medium Security**: Low level mangling + drops `debugger` statements + removes `console.debug` and `console.trace` calls. |
68
+ | `"high"` | **Maximum Hardening**: Mangles all `_` and `$` prefixed properties + drops all `console.*` calls and `debugger` statements. |
69
+
70
+ > [!WARNING]
71
+ > **High Obfuscation Compatibility Warning:**
72
+ > The `"high"` obfuscation level mangles `$`-prefixed properties. This can break frontend code interacting with external libraries or frameworks that rely heavily on the `$` naming convention (e.g., jQuery). Start with `"low"` or `"medium"` and verify compatibility thoroughly before deploying with `"high"`.
73
+
74
+ ---
75
+
76
+ ## Example Directory Structure
23
77
 
24
- ## Example Structure
25
78
  ```
26
79
  view/js/
27
- ├── app.ts → public/assets/js/app.js
28
- ├── admin.ts → public/assets/js/admin.js
29
- ├── _api.ts (shared module, not compiled)
30
- └── _utils.ts (shared module, not compiled)
80
+ ├── app.ts → compiled to public/assets/js/app.js (Entry Point)
81
+ ├── admin.ts → compiled to public/assets/js/admin.js (Entry Point)
82
+ ├── _api.ts (Shared API Module — Import only, not compiled on its own)
83
+ └── _utils.ts (Shared Utility Module — Import only, not compiled on its own)
31
84
  ```
32
85
 
33
86
  ## HTML Integration
87
+
88
+ Inject compiled scripts into your skeleton or layout templates using regular script tags:
89
+
34
90
  ```html
35
91
  <script src="/assets/js/app.js"></script>
36
92
  ```
@@ -15,7 +15,7 @@ Remember the `Odac` object? It's your best friend inside a controller. It's pass
15
15
  * `Odac.return(data)`: Send back a response.
16
16
  * `Odac.direct(url)`: Redirect the user to a new page.
17
17
  * `Odac.set(key, value)`: Pass variables to your View template.
18
- * `Odac.share(key, value)`: Share data directly with frontend JavaScript (`odac.data()`).
18
+ * `Odac.share(key, value)`: Share data directly with frontend JavaScript (`Odac.data()`).
19
19
  * `Odac.cookie(key, value)`: Set a browser cookie.
20
20
  * `Odac.validator()`: Check user input easily.
21
21
  * `Odac.setInterval(callback, delay)`: Schedule repeating tasks (auto-cleanup).
@@ -204,6 +204,50 @@ Automatically set field values without user input:
204
204
  <odac:submit text="Save" loading="Saving..." class="btn btn-primary" id="save-btn"/>
205
205
  ```
206
206
 
207
+ ## Client-Side Callbacks (Optional)
208
+
209
+ `<odac:form>` works fully on its own — submission, CSRF, validation, loading
210
+ states, success/error messages and redirects are all wired up automatically,
211
+ **no JavaScript required.**
212
+
213
+ If you also need to run your own code after a submit (analytics, closing a
214
+ modal, updating the UI, etc.), give the form an `id` and attach a callback with
215
+ `Odac.form()`:
216
+
217
+ ```html
218
+ <odac:form action="Account.unlink" id="server-unlink">
219
+ <!-- fields -->
220
+ <odac:submit text="Unlink"/>
221
+ </odac:form>
222
+ ```
223
+
224
+ ```javascript
225
+ Odac.form('#server-unlink', function(data) {
226
+ if (data.result.success) {
227
+ closeModal()
228
+ }
229
+ })
230
+ ```
231
+
232
+ This is **safe to combine** with the automatic registration — calling
233
+ `Odac.form()` on an `<odac:form>` re-binds the same form rather than adding a
234
+ second handler, so the form still submits only once. (Internally the dedup is
235
+ keyed by the form element, not the selector string.)
236
+
237
+ > **Note:** Call `Odac.form()` after the form exists in the DOM (e.g. on
238
+ > `DOMContentLoaded` or after AJAX navigation). Binding before the element is
239
+ > rendered can leave the automatic registration in place as a second handler.
240
+
241
+ You can trigger submission programmatically with the native API:
242
+
243
+ ```javascript
244
+ document.getElementById('server-unlink').requestSubmit()
245
+ ```
246
+
247
+ For the full callback/options API (`messages`, `loading`, redirect strings,
248
+ file-upload progress) see
249
+ [Form Handling with odac.js](../../frontend/03-forms/01-form-handling.md).
250
+
207
251
  ## Controller Handler (Server Actions)
208
252
 
209
253
  Handle form submission directly in your controller. The action is defined as `ControllerName.methodName`.
@@ -88,13 +88,13 @@ Odac.action({
88
88
 
89
89
  ```javascript
90
90
  // Basic usage
91
- odac.form('#my-form', function(data) {
91
+ Odac.form('#my-form', function(data) {
92
92
  // This callback is executed on success
93
93
  console.log('Form submitted successfully!', data);
94
94
  });
95
95
 
96
96
  // With options
97
- odac.form({
97
+ Odac.form({
98
98
  form: '#my-form',
99
99
  messages: ['success', 'error'], // Show both success and error messages
100
100
  loading: function(percent) {
@@ -119,7 +119,7 @@ To display validation errors, you can add elements with the `odac-form-error` at
119
119
  For simple GET requests, you can use the `Odac.get()` method.
120
120
 
121
121
  ```javascript
122
- odac.get('/api/users', function(data) {
122
+ Odac.get('/api/users', function(data) {
123
123
  console.log('Users:', data);
124
124
  });
125
125
  ```
@@ -133,18 +133,18 @@ odac.get('/api/users', function(data) {
133
133
  - **`Odac.client()`**: Returns a unique client identifier from a cookie.
134
134
  - **`Odac.data(key)`**: Returns shared data passed from the backend via `Odac.share`. You can get the full data object or a specific key:
135
135
  ```javascript
136
- let allData = odac.data();
137
- let user = odac.data('user'); // Returns null if not exists
136
+ let allData = Odac.data();
137
+ let user = Odac.data('user'); // Returns null if not exists
138
138
  ```
139
139
  - **`Odac.page()`**: Returns the identifier of the current page. This is the controller name (e.g., `'user'`) or view name (e.g., `'dashboard'`) set by the backend. Use this to conditionally run code for specific pages.
140
140
  - **`Odac.storage()`**: A wrapper for `localStorage`.
141
141
  ```javascript
142
142
  // Set a value
143
- odac.storage('my-key', 'my-value');
143
+ Odac.storage('my-key', 'my-value');
144
144
 
145
145
  // Get a value
146
- let value = odac.storage('my-key');
146
+ let value = Odac.storage('my-key');
147
147
 
148
148
  // Remove a value
149
- odac.storage('my-key', null);
149
+ Odac.storage('my-key', null);
150
150
  ```
@@ -126,17 +126,17 @@ Controllers automatically support AJAX loading. Use `Odac.View.skeleton()` to sp
126
126
  ```javascript
127
127
  module.exports = function (Odac) {
128
128
  // Define the skeleton template
129
- odac.View.skeleton('main')
129
+ Odac.View.skeleton('main')
130
130
 
131
131
  // Set view parts - lowercase keys map to UPPERCASE placeholders
132
- odac.View.set({
132
+ Odac.View.set({
133
133
  header: 'main', // Loads view/header/main.html into {{ HEADER }}
134
134
  content: 'about', // Loads view/content/about.html into {{ CONTENT }}
135
135
  footer: 'main' // Loads view/footer/main.html into {{ FOOTER }}
136
136
  })
137
137
 
138
138
  // Optional: Send variables to frontend (AJAX only)
139
- odac.set({
139
+ Odac.set({
140
140
  pageTitle: 'About',
141
141
  data: {foo: 'bar'}
142
142
  }, true) // true = include in AJAX responses
@@ -318,13 +318,13 @@ Both methods work automatically - no additional configuration needed!
318
318
  }
319
319
  ```
320
320
 
321
- ### odac.loader(selector, elements, callback)
321
+ ### Odac.loader(selector, elements, callback)
322
322
 
323
323
  Low-level method for direct initialization (not recommended for new code).
324
324
 
325
325
  **Parameters:** Same as navigate configuration, but as separate arguments.
326
326
 
327
- ### odac.load(url, callback, push)
327
+ ### Odac.load(url, callback, push)
328
328
 
329
329
  Programmatically load a page via AJAX.
330
330
 
@@ -335,7 +335,7 @@ Programmatically load a page via AJAX.
335
335
 
336
336
  **Example:**
337
337
  ```javascript
338
- odac.load('/about', function(page, variables) {
338
+ Odac.load('/about', function(page, variables) {
339
339
  console.log('Loaded:', page)
340
340
  })
341
341
  ```
@@ -377,7 +377,7 @@ Send data from server to client in AJAX responses:
377
377
 
378
378
  ```javascript
379
379
  // In controller
380
- odac.set({
380
+ Odac.set({
381
381
  user: {name: 'John', role: 'admin'},
382
382
  stats: {views: 1234}
383
383
  }, true) // true = include in AJAX
@@ -423,12 +423,12 @@ Odac.action({
423
423
  navigate: {
424
424
  update: 'main',
425
425
  on: function(page, variables) {
426
- odac.fn.updateActiveNav(window.location.pathname)
426
+ Odac.fn.updateActiveNav(window.location.pathname)
427
427
  console.log('Navigated to:', page)
428
428
  }
429
429
  },
430
430
 
431
- // Custom functions (accessible as odac.fn.functionName)
431
+ // Custom functions (accessible as Odac.fn.functionName)
432
432
  function: {
433
433
  updateActiveNav: function(url) {
434
434
  document.querySelectorAll('nav a').forEach(link => {
@@ -440,14 +440,14 @@ Odac.action({
440
440
  // App initialization
441
441
  load: function() {
442
442
  console.log('App initialized')
443
- odac.fn.updateActiveNav(window.location.pathname)
443
+ Odac.fn.updateActiveNav(window.location.pathname)
444
444
  },
445
445
 
446
446
  // Page-specific code
447
447
  page: {
448
448
  index: function(variables) {
449
449
  // Home page specific code
450
- odac.form('#contact-form', function(data) {
450
+ Odac.form('#contact-form', function(data) {
451
451
  if (data.result.success) {
452
452
  alert('Message sent!')
453
453
  }
@@ -463,7 +463,7 @@ Odac.action({
463
463
  // Event handlers
464
464
  click: {
465
465
  '#refresh-btn': function() {
466
- odac.load(window.location.pathname)
466
+ Odac.load(window.location.pathname)
467
467
  }
468
468
  }
469
469
  })
@@ -595,8 +595,8 @@ This is usually caused by mismatched keys between your skeleton, controller, and
595
595
 
596
596
  2. **Controller** (`controller/page/about.js`):
597
597
  ```javascript
598
- odac.View.skeleton('main')
599
- odac.View.set({
598
+ Odac.View.skeleton('main')
599
+ Odac.View.set({
600
600
  header: 'main', // Lowercase → {{ HEADER }}
601
601
  content: 'about' // Lowercase → {{ CONTENT }}
602
602
  })
@@ -129,7 +129,7 @@ The keys (content, header, etc.) must match the view parts defined in your contr
129
129
 
130
130
  ```javascript
131
131
  // In controller
132
- odac.View.set({
132
+ Odac.View.set({
133
133
  header: 'main',
134
134
  content: 'about',
135
135
  sidebar: 'main'
@@ -219,14 +219,14 @@ In your controller, send data to the client:
219
219
  ```javascript
220
220
  module.exports = function(Odac) {
221
221
  // Set variables for AJAX responses
222
- odac.set({
222
+ Odac.set({
223
223
  title: 'About Page',
224
224
  user: {name: 'John', role: 'admin'},
225
225
  stats: {views: 1234}
226
226
  }, true) // true = include in AJAX
227
227
 
228
- odac.View.skeleton('main')
229
- odac.View.set({
228
+ Odac.View.skeleton('main')
229
+ Odac.View.set({
230
230
  header: 'main',
231
231
  content: 'about',
232
232
  footer: 'main'
@@ -6,31 +6,31 @@ Advanced techniques and patterns for AJAX navigation in Odac.
6
6
 
7
7
  ## Programmatic Navigation
8
8
 
9
- ### Using odac.load()
9
+ ### Using Odac.load()
10
10
 
11
11
  Navigate programmatically from your code:
12
12
 
13
13
  ```javascript
14
14
  // Basic usage
15
- odac.load('/about')
15
+ Odac.load('/about')
16
16
 
17
17
  // With callback
18
- odac.load('/about', function(page, variables) {
18
+ Odac.load('/about', function(page, variables) {
19
19
  console.log('Loaded:', page)
20
20
  console.log('Data:', variables)
21
21
  })
22
22
 
23
23
  // Without updating history
24
- odac.load('/about', callback, false)
24
+ Odac.load('/about', callback, false)
25
25
  ```
26
26
 
27
27
  ### Use Cases
28
28
 
29
29
  **Redirect after form submission:**
30
30
  ```javascript
31
- odac.form('#my-form', function(data) {
31
+ Odac.form('#my-form', function(data) {
32
32
  if (data.result.success) {
33
- odac.load('/success')
33
+ Odac.load('/success')
34
34
  }
35
35
  })
36
36
  ```
@@ -38,16 +38,16 @@ odac.form('#my-form', function(data) {
38
38
  **Conditional navigation:**
39
39
  ```javascript
40
40
  if (user.isLoggedIn) {
41
- odac.load('/dashboard')
41
+ Odac.load('/dashboard')
42
42
  } else {
43
- odac.load('/login')
43
+ Odac.load('/login')
44
44
  }
45
45
  ```
46
46
 
47
47
  **Timed navigation:**
48
48
  ```javascript
49
49
  setTimeout(() => {
50
- odac.load('/next-page')
50
+ Odac.load('/next-page')
51
51
  }, 3000)
52
52
  ```
53
53
 
@@ -76,8 +76,8 @@ Define all parts in your controller:
76
76
 
77
77
  ```javascript
78
78
  module.exports = function(Odac) {
79
- odac.View.skeleton('main')
80
- odac.View.set({
79
+ Odac.View.skeleton('main')
80
+ Odac.View.set({
81
81
  header: 'main',
82
82
  content: 'dashboard',
83
83
  sidebar: 'dashboard',
@@ -333,10 +333,10 @@ Odac.action({
333
333
  Gracefully handle navigation errors:
334
334
 
335
335
  ```javascript
336
- // Override odac.load to add error handling
337
- const originalLoad = odac.load.bind(Odac)
336
+ // Override Odac.load to add error handling
337
+ const originalLoad = Odac.load.bind(Odac)
338
338
 
339
- odac.load = function(url, callback, push) {
339
+ Odac.load = function(url, callback, push) {
340
340
  try {
341
341
  originalLoad(url, function(page, variables) {
342
342
  if (callback) callback(page, variables)
@@ -359,7 +359,7 @@ function loadWithRetry(url, maxRetries = 3) {
359
359
 
360
360
  function attempt() {
361
361
  attempts++
362
- odac.load(url,
362
+ Odac.load(url,
363
363
  (page, vars) => {
364
364
  console.log('Success after', attempts, 'attempts')
365
365
  },
@@ -491,7 +491,7 @@ describe('Navigation', () => {
491
491
  })
492
492
 
493
493
  // Simulate navigation
494
- odac.load('/about')
494
+ Odac.load('/about')
495
495
 
496
496
  // Assert
497
497
  expect(document.querySelector('.nav-link.active').href)
@@ -14,7 +14,7 @@ Learn how to handle forms with automatic AJAX submission, CSRF protection, and v
14
14
  ```
15
15
 
16
16
  ```javascript
17
- odac.form('#contact-form', function(data) {
17
+ Odac.form('#contact-form', function(data) {
18
18
  if (data.result.success) {
19
19
  alert('Form submitted successfully!')
20
20
  }
@@ -35,7 +35,7 @@ That's it! odac.js handles:
35
35
  ### Basic Usage
36
36
 
37
37
  ```javascript
38
- odac.form('#my-form', function(data) {
38
+ Odac.form('#my-form', function(data) {
39
39
  console.log('Response:', data)
40
40
  })
41
41
  ```
@@ -43,7 +43,7 @@ odac.form('#my-form', function(data) {
43
43
  ### With Options
44
44
 
45
45
  ```javascript
46
- odac.form({
46
+ Odac.form({
47
47
  form: '#my-form',
48
48
  messages: true, // Show error/success messages
49
49
  loading: function(percent) {
@@ -59,10 +59,37 @@ odac.form({
59
59
  ### Redirect After Submit
60
60
 
61
61
  ```javascript
62
- odac.form('#my-form', '/success-page')
62
+ Odac.form('#my-form', '/success-page')
63
63
  // Redirects to /success-page on success
64
64
  ```
65
65
 
66
+ ## Using with `<odac:form>` Components
67
+
68
+ `Odac.form()` works with both plain `<form>` elements and server-rendered
69
+ [`<odac:form>` components](../../backend/05-forms/01-custom-forms.md).
70
+
71
+ An `<odac:form>` already registers itself automatically, so you only call
72
+ `Odac.form()` when you want to add your own callback. Give the component an `id`
73
+ and bind to it:
74
+
75
+ ```html
76
+ <odac:form action="Contact.submit" id="contact-form">
77
+ <!-- fields -->
78
+ <odac:submit text="Send"/>
79
+ </odac:form>
80
+ ```
81
+
82
+ ```javascript
83
+ Odac.form('#contact-form', function(data) {
84
+ if (data.result.success) {
85
+ closeModal()
86
+ }
87
+ })
88
+ ```
89
+
90
+ Calling `Odac.form()` on an already-registered `<odac:form>` **re-binds** the
91
+ same form instead of adding a second handler, so it still submits only once.
92
+
66
93
  ## Error Handling
67
94
 
68
95
  ### Automatic Error Display
@@ -85,7 +112,7 @@ If you want to control where errors appear, add error elements manually:
85
112
  ### Custom Error Display
86
113
 
87
114
  ```javascript
88
- odac.form('#my-form', function(data) {
115
+ Odac.form('#my-form', function(data) {
89
116
  if (!data.result.success) {
90
117
  // Custom error handling
91
118
  Object.entries(data.errors).forEach(([field, message]) => {
@@ -137,7 +164,7 @@ If you want to control where success messages appear, add a success element manu
137
164
  ### Custom Success Message
138
165
 
139
166
  ```javascript
140
- odac.form('#my-form', function(data) {
167
+ Odac.form('#my-form', function(data) {
141
168
  if (data.result.success) {
142
169
  document.querySelector('#custom-message').innerHTML =
143
170
  'Thank you! Your form has been submitted.'
@@ -157,7 +184,7 @@ odac.form('#my-form', function(data) {
157
184
  ```
158
185
 
159
186
  ```javascript
160
- odac.form('#upload-form', function(data) {
187
+ Odac.form('#upload-form', function(data) {
161
188
  if (data.result.success) {
162
189
  console.log('File uploaded:', data.result.filename)
163
190
  }
@@ -167,7 +194,7 @@ odac.form('#upload-form', function(data) {
167
194
  ### Upload Progress
168
195
 
169
196
  ```javascript
170
- odac.form({
197
+ Odac.form({
171
198
  form: '#upload-form',
172
199
  loading: function(percent) {
173
200
  document.querySelector('#progress').style.width = percent + '%'
@@ -189,7 +216,7 @@ odac.form({
189
216
  ### Disable Messages
190
217
 
191
218
  ```javascript
192
- odac.form({
219
+ Odac.form({
193
220
  form: '#my-form',
194
221
  messages: false // Don't show automatic messages
195
222
  }, function(data) {
@@ -201,7 +228,7 @@ odac.form({
201
228
 
202
229
  ```javascript
203
230
  // Only show error messages, suppress success messages
204
- odac.form({
231
+ Odac.form({
205
232
  form: '#my-form',
206
233
  messages: ['error']
207
234
  }, function(data) {
@@ -211,7 +238,7 @@ odac.form({
211
238
  })
212
239
 
213
240
  // Only show success messages, suppress error messages
214
- odac.form({
241
+ Odac.form({
215
242
  form: '#my-form',
216
243
  messages: ['success']
217
244
  }, function(data) {
@@ -224,7 +251,7 @@ odac.form({
224
251
  ### Form Reset
225
252
 
226
253
  ```javascript
227
- odac.form('#my-form', function(data) {
254
+ Odac.form('#my-form', function(data) {
228
255
  if (data.result.success) {
229
256
  // Reset the form
230
257
  document.querySelector('#my-form').reset()
@@ -242,7 +269,7 @@ document.querySelector('#my-form').addEventListener('submit', function(e) {
242
269
  }
243
270
  })
244
271
 
245
- odac.form('#my-form', function(data) {
272
+ Odac.form('#my-form', function(data) {
246
273
  console.log('Submitted!')
247
274
  })
248
275
  ```
@@ -254,8 +281,8 @@ odac.form('#my-form', function(data) {
254
281
  ```javascript
255
282
  // controller/post/contact.js
256
283
  module.exports = async function(Odac) {
257
- const email = await odac.Request.request('email')
258
- const message = await odac.Request.request('message')
284
+ const email = await Odac.Request.request('email')
285
+ const message = await Odac.Request.request('message')
259
286
 
260
287
  // Validation
261
288
  const errors = {}
@@ -285,7 +312,7 @@ module.exports = async function(Odac) {
285
312
 
286
313
  ```javascript
287
314
  // route/www.js
288
- odac.Route.post('/api/contact', 'contact')
315
+ Odac.Route.post('/api/contact', 'contact')
289
316
  ```
290
317
 
291
318
  ## Validation
@@ -306,7 +333,7 @@ Always validate on the server:
306
333
 
307
334
  ```javascript
308
335
  module.exports = async function(Odac) {
309
- const email = await odac.Request.request('email')
336
+ const email = await Odac.Request.request('email')
310
337
 
311
338
  const errors = {}
312
339
 
@@ -349,7 +376,7 @@ module.exports = async function(Odac) {
349
376
  **Note:** Error and success elements are auto-generated. Add them manually only if you need custom positioning or styling.
350
377
 
351
378
  ```javascript
352
- odac.form('#contact-form', function(data) {
379
+ Odac.form('#contact-form', function(data) {
353
380
  if (data.result.success) {
354
381
  document.querySelector('#contact-form').reset()
355
382
  }
@@ -367,7 +394,7 @@ odac.form('#contact-form', function(data) {
367
394
  ```
368
395
 
369
396
  ```javascript
370
- odac.form('#login-form', function(data) {
397
+ Odac.form('#login-form', function(data) {
371
398
  if (data.result.success) {
372
399
  // Redirect to dashboard
373
400
  window.location.href = '/dashboard'
@@ -420,6 +447,15 @@ odac.form('#login-form', function(data) {
420
447
  - Verify `messages` option is not set to `false`
421
448
  - If using custom error elements, ensure `odac-form-error` attributes match field names exactly
422
449
 
450
+ ### Form Submits Twice
451
+
452
+ - Make sure you don't register the **same form under two different selectors**
453
+ (e.g. `Odac.form('#my-form')` and `Odac.form('form#my-form')`) — use one
454
+ consistent selector.
455
+ - Binding `Odac.form()` to an `<odac:form>` is safe and will **not** double up;
456
+ it re-binds the existing handler. If you still see two submits, check that you
457
+ bind **after** the form is in the DOM, not before it renders.
458
+
423
459
  ### CSRF Token Errors
424
460
 
425
461
  - CSRF tokens are handled automatically
@@ -7,7 +7,7 @@ Learn how to make GET and POST requests to your API endpoints using odac.js.
7
7
  ### Basic GET Request
8
8
 
9
9
  ```javascript
10
- odac.get('/api/data', function(response) {
10
+ Odac.get('/api/data', function(response) {
11
11
  console.log('Data:', response)
12
12
  })
13
13
  ```
@@ -16,7 +16,7 @@ odac.get('/api/data', function(response) {
16
16
 
17
17
  ```javascript
18
18
  const userId = 123
19
- odac.get(`/api/users/${userId}`, function(user) {
19
+ Odac.get(`/api/users/${userId}`, function(user) {
20
20
  console.log('User:', user.name)
21
21
  console.log('Email:', user.email)
22
22
  })
@@ -25,7 +25,7 @@ odac.get(`/api/users/${userId}`, function(user) {
25
25
  ### Error Handling
26
26
 
27
27
  ```javascript
28
- odac.get('/api/data', function(response) {
28
+ Odac.get('/api/data', function(response) {
29
29
  if (response.error) {
30
30
  console.error('Error:', response.error)
31
31
  return
@@ -43,7 +43,7 @@ odac.get('/api/data', function(response) {
43
43
  The recommended way to make POST requests is using `Odac.form()`:
44
44
 
45
45
  ```javascript
46
- odac.form('#my-form', function(data) {
46
+ Odac.form('#my-form', function(data) {
47
47
  if (data.result.success) {
48
48
  console.log('Success!')
49
49
  }
@@ -58,12 +58,12 @@ For custom POST requests without forms, use the internal AJAX method:
58
58
 
59
59
  ```javascript
60
60
  // Note: This is an advanced pattern
61
- // For most cases, use odac.form() instead
61
+ // For most cases, use Odac.form() instead
62
62
 
63
63
  const formData = new FormData()
64
64
  formData.append('name', 'John')
65
65
  formData.append('email', 'john@example.com')
66
- formData.append('_token', odac.token())
66
+ formData.append('_token', Odac.token())
67
67
 
68
68
  fetch('/api/submit', {
69
69
  method: 'POST',
@@ -83,12 +83,12 @@ odac.js automatically handles CSRF tokens:
83
83
 
84
84
  ```javascript
85
85
  // Token is automatically included
86
- odac.get('/api/data', function(response) {
86
+ Odac.get('/api/data', function(response) {
87
87
  // ...
88
88
  })
89
89
 
90
90
  // Token is automatically included in forms
91
- odac.form('#my-form', function(data) {
91
+ Odac.form('#my-form', function(data) {
92
92
  // ...
93
93
  })
94
94
  ```
@@ -98,7 +98,7 @@ odac.form('#my-form', function(data) {
98
98
  If you need the token manually:
99
99
 
100
100
  ```javascript
101
- const token = odac.token()
101
+ const token = Odac.token()
102
102
  console.log('Current token:', token)
103
103
  ```
104
104
 
@@ -108,7 +108,7 @@ console.log('Current token:', token)
108
108
 
109
109
  ```javascript
110
110
  // Get user profile
111
- odac.get('/api/profile', function(profile) {
111
+ Odac.get('/api/profile', function(profile) {
112
112
  document.querySelector('#username').textContent = profile.name
113
113
  document.querySelector('#email').textContent = profile.email
114
114
  })
@@ -118,7 +118,7 @@ odac.get('/api/profile', function(profile) {
118
118
 
119
119
  ```javascript
120
120
  // Get products
121
- odac.get('/api/products', function(products) {
121
+ Odac.get('/api/products', function(products) {
122
122
  const container = document.querySelector('#products')
123
123
 
124
124
  products.forEach(product => {
@@ -143,7 +143,7 @@ searchInput.addEventListener('input', function() {
143
143
 
144
144
  if (query.length < 3) return
145
145
 
146
- odac.get(`/api/search?q=${encodeURIComponent(query)}`, function(results) {
146
+ Odac.get(`/api/search?q=${encodeURIComponent(query)}`, function(results) {
147
147
  displayResults(results)
148
148
  })
149
149
  })
@@ -172,7 +172,7 @@ document.querySelector('#autocomplete').addEventListener('input', function() {
172
172
  debounceTimer = setTimeout(() => {
173
173
  if (value.length < 2) return
174
174
 
175
- odac.get(`/api/autocomplete?q=${value}`, function(suggestions) {
175
+ Odac.get(`/api/autocomplete?q=${value}`, function(suggestions) {
176
176
  showSuggestions(suggestions)
177
177
  })
178
178
  }, 300)
@@ -200,7 +200,7 @@ function loadMore() {
200
200
  loading = true
201
201
  page++
202
202
 
203
- odac.get(`/api/posts?page=${page}`, function(posts) {
203
+ Odac.get(`/api/posts?page=${page}`, function(posts) {
204
204
  posts.forEach(post => {
205
205
  appendPost(post)
206
206
  })
@@ -214,7 +214,7 @@ function loadMore() {
214
214
  ```javascript
215
215
  // Poll for updates every 30 seconds
216
216
  setInterval(function() {
217
- odac.get('/api/notifications', function(notifications) {
217
+ Odac.get('/api/notifications', function(notifications) {
218
218
  updateNotificationBadge(notifications.count)
219
219
  })
220
220
  }, 30000)
@@ -239,7 +239,7 @@ module.exports = function(Odac) {
239
239
 
240
240
  ```javascript
241
241
  // route/www.js
242
- odac.Route.get('/api/users', 'users')
242
+ Odac.Route.get('/api/users', 'users')
243
243
  ```
244
244
 
245
245
  ### GET with Parameters
@@ -247,13 +247,13 @@ odac.Route.get('/api/users', 'users')
247
247
  ```javascript
248
248
  // controller/get/user.js
249
249
  module.exports = async function(Odac) {
250
- const userId = odac.Request.data.url.id
250
+ const userId = Odac.Request.data.url.id
251
251
 
252
252
  // Fetch user from database
253
253
  const user = await getUserById(userId)
254
254
 
255
255
  if (!user) {
256
- odac.Request.status(404)
256
+ Odac.Request.status(404)
257
257
  return {error: 'User not found'}
258
258
  }
259
259
 
@@ -263,7 +263,7 @@ module.exports = async function(Odac) {
263
263
 
264
264
  ```javascript
265
265
  // route/www.js
266
- odac.Route.get('/api/users/{id}', 'user')
266
+ Odac.Route.get('/api/users/{id}', 'user')
267
267
  ```
268
268
 
269
269
  ### POST Endpoint
@@ -271,8 +271,8 @@ odac.Route.get('/api/users/{id}', 'user')
271
271
  ```javascript
272
272
  // controller/post/create.js
273
273
  module.exports = async function(Odac) {
274
- const name = await odac.Request.request('name')
275
- const email = await odac.Request.request('email')
274
+ const name = await Odac.Request.request('name')
275
+ const email = await Odac.Request.request('email')
276
276
 
277
277
  // Validation
278
278
  if (!name || !email) {
@@ -300,7 +300,7 @@ module.exports = async function(Odac) {
300
300
 
301
301
  ```javascript
302
302
  // route/www.js
303
- odac.Route.post('/api/users/create', 'create')
303
+ Odac.Route.post('/api/users/create', 'create')
304
304
  ```
305
305
 
306
306
  ## Response Format
@@ -357,7 +357,7 @@ function getCached(url, callback) {
357
357
  return
358
358
  }
359
359
 
360
- odac.get(url, function(data) {
360
+ Odac.get(url, function(data) {
361
361
  cache[url] = data
362
362
  callback(data)
363
363
  })
@@ -386,7 +386,7 @@ function processQueue() {
386
386
  processing = true
387
387
  const {url, callback} = requestQueue.shift()
388
388
 
389
- odac.get(url, function(data) {
389
+ Odac.get(url, function(data) {
390
390
  callback(data)
391
391
  processing = false
392
392
  processQueue()
@@ -403,7 +403,7 @@ function getWithRetry(url, callback, maxRetries = 3) {
403
403
  function attempt() {
404
404
  attempts++
405
405
 
406
- odac.get(url, function(data) {
406
+ Odac.get(url, function(data) {
407
407
  if (data.error && attempts < maxRetries) {
408
408
  setTimeout(attempt, 1000 * attempts)
409
409
  } else {
@@ -6,7 +6,7 @@ Odac provides a simple client-side API for consuming Server-Sent Events (SSE) st
6
6
 
7
7
  ```javascript
8
8
  // Simple callback
9
- odac.listen('/events', (data) => {
9
+ Odac.listen('/events', (data) => {
10
10
  console.log('Received:', data)
11
11
  })
12
12
  ```
@@ -14,7 +14,7 @@ odac.listen('/events', (data) => {
14
14
  ### With Options
15
15
 
16
16
  ```javascript
17
- const stream = odac.listen('/events',
17
+ const stream = Odac.listen('/events',
18
18
  (data) => {
19
19
  console.log('Message:', data)
20
20
  },
@@ -34,7 +34,7 @@ stream.close()
34
34
 
35
35
  ```javascript
36
36
  // Server sends: data: {"message": "Hello"}\n\n
37
- odac.listen('/events', (data) => {
37
+ Odac.listen('/events', (data) => {
38
38
  console.log(data.message) // "Hello"
39
39
  })
40
40
  ```
@@ -47,8 +47,8 @@ odac.listen('/events', (data) => {
47
47
 
48
48
  ```javascript
49
49
  // Server
50
- odac.Route.get('/dashboard/stats', async (Odac) => {
51
- odac.stream((send) => {
50
+ Odac.Route.get('/dashboard/stats', async (Odac) => {
51
+ Odac.stream((send) => {
52
52
  const interval = setInterval(async () => {
53
53
  const stats = await getServerStats()
54
54
  send(stats)
@@ -62,7 +62,7 @@ odac.Route.get('/dashboard/stats', async (Odac) => {
62
62
  })
63
63
 
64
64
  // Client
65
- odac.listen('/dashboard/stats', (stats) => {
65
+ Odac.listen('/dashboard/stats', (stats) => {
66
66
  document.getElementById('cpu').textContent = stats.cpu + '%'
67
67
  document.getElementById('memory').textContent = stats.memory + 'MB'
68
68
  })
@@ -72,16 +72,16 @@ odac.listen('/dashboard/stats', (stats) => {
72
72
 
73
73
  ```javascript
74
74
  // Server
75
- odac.Route.get('/notifications', async (Odac) => {
76
- const userId = await odac.request('userId')
75
+ Odac.Route.get('/notifications', async (Odac) => {
76
+ const userId = await Odac.request('userId')
77
77
 
78
- odac.stream((send) => {
78
+ Odac.stream((send) => {
79
79
  global.notificationStreams[userId] = { send }
80
80
  })
81
81
  })
82
82
 
83
83
  // Client
84
- odac.listen('/notifications', (notification) => {
84
+ Odac.listen('/notifications', (notification) => {
85
85
  showToast(notification.message)
86
86
  })
87
87
  ```
@@ -90,8 +90,8 @@ odac.listen('/notifications', (notification) => {
90
90
 
91
91
  ```javascript
92
92
  // Server
93
- odac.Route.get('/build/logs', async (Odac) => {
94
- odac.stream(async function* () {
93
+ Odac.Route.get('/build/logs', async (Odac) => {
94
+ Odac.stream(async function* () {
95
95
  for await (const log of getBuildLogs()) {
96
96
  yield { timestamp: Date.now(), message: log }
97
97
  }
@@ -100,7 +100,7 @@ odac.Route.get('/build/logs', async (Odac) => {
100
100
 
101
101
  // Client
102
102
  const logs = []
103
- odac.listen('/build/logs', (log) => {
103
+ Odac.listen('/build/logs', (log) => {
104
104
  logs.push(log)
105
105
  updateLogsUI(logs)
106
106
  })
@@ -108,7 +108,7 @@ odac.listen('/build/logs', (log) => {
108
108
 
109
109
  ## API Reference
110
110
 
111
- ### odac.listen(url, onMessage, options)
111
+ ### Odac.listen(url, onMessage, options)
112
112
 
113
113
  **Parameters:**
114
114
  - `url` (string): Stream endpoint URL
@@ -6,7 +6,7 @@ Odac provides built-in WebSocket support with automatic reconnection and cross-t
6
6
 
7
7
  **Backend (route/main.js):**
8
8
  ```javascript
9
- odac.Route.ws('/chat', (ws, Odac) => {
9
+ Odac.Route.ws('/chat', (ws, Odac) => {
10
10
  ws.on('message', data => {
11
11
  ws.broadcast(data)
12
12
  })
package/index.js CHANGED
@@ -1,4 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  global.__dir = process.cwd()
4
- require('./src/Odac.js').init()
4
+
5
+ // Surface startup failures loudly. init() is async; without a .catch() any
6
+ // rejection (migration, DB connection, config parse) becomes an UnhandledPromiseRejection
7
+ // that the structured logger never sees — making a broken boot look like a clean start.
8
+ require('./src/Odac.js')
9
+ .init()
10
+ .catch(err => {
11
+ console.error('\x1b[31m[ODAC Fatal]\x1b[0m Startup failed:', err.message)
12
+ if (err.cause) console.error('\x1b[31m[ODAC Fatal]\x1b[0m Cause:', err.cause.message || err.cause)
13
+ process.exit(1)
14
+ })
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "mail@emre.red",
8
8
  "url": "https://emre.red"
9
9
  },
10
- "version": "1.4.13",
10
+ "version": "1.4.15",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
package/src/Request.js CHANGED
@@ -50,12 +50,18 @@ class OdacRequest {
50
50
  async abort(code) {
51
51
  this.status(code)
52
52
  let result = {401: 'Unauthorized', 404: 'Not Found', 408: 'Request Timeout'}[code] ?? null
53
- if (
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'
57
- )
58
- result = await this.#odac.Route.routes[this.route].error[code].cache(this.#odac)
53
+ const errorRoute = this.#odac.Route?.routes?.[this.route]?.error?.[code]
54
+ if (errorRoute && typeof errorRoute.cache === 'function') {
55
+ try {
56
+ const handlerResult = await errorRoute.cache(this.#odac)
57
+ // If the handler returned nothing, assume it configured the view via Odac.View.set()
58
+ // and let Route.request() continue to View.print() like normal pages.
59
+ if (handlerResult === undefined) return
60
+ result = handlerResult
61
+ } catch (e) {
62
+ console.error(JSON.stringify({level: 'ERROR', message: `Error in custom error handler for ${code}`, error: e.message}))
63
+ }
64
+ }
59
65
  this.end(result)
60
66
  }
61
67
 
package/src/Route.js CHANGED
@@ -858,6 +858,13 @@ class Route {
858
858
  }
859
859
 
860
860
  error(code, file) {
861
+ if (typeof file === 'object' && file !== null && !Array.isArray(file)) {
862
+ this.set('error', code, _odac => {
863
+ _odac.set(file)
864
+ _odac.View.set(file)
865
+ })
866
+ return
867
+ }
861
868
  this.set('error', code, file)
862
869
  }
863
870
 
package/src/View/Form.js CHANGED
@@ -58,6 +58,30 @@ class Form {
58
58
  return String(value).replace(/[&<>"']/g, ch => map[ch])
59
59
  }
60
60
 
61
+ // Like escapeHtml but leaves {{ ... }} / {!! ... !!} template tokens intact
62
+ // so they survive into the view engine's {{ }} pass. Escaping inside the
63
+ // tokens would corrupt the JS expression (e.g. ' -> &#39;) and produce
64
+ // "Unexpected token '&'" at render time.
65
+ static escapeHtmlPreservingTemplates(value) {
66
+ if (value === null || value === undefined) return ''
67
+ const str = String(value)
68
+ const regex = /\{\{[\s\S]*?\}\}|\{!![\s\S]*?!!\}/g
69
+ let result = ''
70
+ let lastIndex = 0
71
+ let match
72
+ while ((match = regex.exec(str)) !== null) {
73
+ if (match.index > lastIndex) {
74
+ result += this.escapeHtml(str.substring(lastIndex, match.index))
75
+ }
76
+ result += match[0]
77
+ lastIndex = regex.lastIndex
78
+ }
79
+ if (lastIndex < str.length) {
80
+ result += this.escapeHtml(str.substring(lastIndex))
81
+ }
82
+ return result
83
+ }
84
+
61
85
  static parse(content, Odac) {
62
86
  for (const type of this.FORM_TYPES) {
63
87
  content = this.parseFormType(content, Odac, type)
@@ -484,16 +508,18 @@ class Form {
484
508
  let html = ''
485
509
  const escapedName = this.escapeHtml(field.name)
486
510
  const escapedType = this.escapeHtml(field.type)
487
- const escapedPlaceholder = this.escapeHtml(field.placeholder)
511
+ const escapedPlaceholder = this.escapeHtmlPreservingTemplates(field.placeholder)
488
512
 
489
513
  if (field.label && field.type !== 'checkbox') {
490
- const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
491
- html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
514
+ const fieldId = this.escapeHtmlPreservingTemplates(field.id || `odac-${field.name}`)
515
+ html += `<label for="${fieldId}">${this.escapeHtmlPreservingTemplates(field.label)}</label>\n`
492
516
  }
493
517
 
494
- const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
495
- const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
496
- const valueAttr = field.value !== null ? ` value="${this.escapeHtml(field.value)}"` : ''
518
+ const classAttr = field.class ? ` class="${this.escapeHtmlPreservingTemplates(field.class)}"` : ''
519
+ const idAttr = field.id
520
+ ? ` id="${this.escapeHtmlPreservingTemplates(field.id)}"`
521
+ : ` id="${this.escapeHtmlPreservingTemplates(`odac-${field.name}`)}"`
522
+ const valueAttr = field.value !== null ? ` value="${this.escapeHtmlPreservingTemplates(field.value)}"` : ''
497
523
 
498
524
  if (field.type === 'checkbox') {
499
525
  const attrs = this.buildHtml5Attributes(field)
@@ -501,14 +527,14 @@ class Form {
501
527
  if (field.label) {
502
528
  html += `<label>\n`
503
529
  html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
504
- html += ` ${this.escapeHtml(field.label)}\n`
530
+ html += ` ${this.escapeHtmlPreservingTemplates(field.label)}\n`
505
531
  html += `</label>\n`
506
532
  } else {
507
533
  html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
508
534
  }
509
535
  } else if (field.type === 'textarea') {
510
536
  const attrs = this.buildHtml5Attributes(field)
511
- html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
537
+ html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtmlPreservingTemplates(
512
538
  field.value || ''
513
539
  )}</textarea>\n`
514
540
  } else {
@@ -524,7 +550,7 @@ class Form {
524
550
  for (const key in field.extraAttributes) {
525
551
  const val = field.extraAttributes[key]
526
552
  if (val === '') attrs += ` ${key}`
527
- else attrs += ` ${key}="${this.escapeHtml(val)}"`
553
+ else attrs += ` ${key}="${this.escapeHtmlPreservingTemplates(val)}"`
528
554
  }
529
555
  return attrs
530
556
  }