odac 1.4.12 → 1.4.14

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,3 +1,28 @@
1
+ ### 🛠️ Fixes & Improvements
2
+
3
+ - **docs:** update frontend scripts documentation to include code obfuscation details and enhance clarity
4
+ - **form:** add escapeHtmlPreservingTemplates method to handle template tokens
5
+ - **route:** enhance error handling to support object responses in error method
6
+ - **view:** use escapeHtmlPreservingTemplates consistently for form attributes
7
+
8
+
9
+
10
+ ---
11
+
12
+ Powered by [⚡ ODAC](https://odac.run)
13
+
14
+ ### 🛠️ Fixes & Improvements
15
+
16
+ - **form:** enhance form handling with metadata and improved parsing logic
17
+ - **form:** implement token rotation on successful form submissions
18
+ - **view:** prevent script injection and handle escaped quotes in form configs
19
+
20
+
21
+
22
+ ---
23
+
24
+ Powered by [⚡ ODAC](https://odac.run)
25
+
1
26
  ### 📚 Documentation
2
27
 
3
28
  - refactor database configuration to a unified structure and document multi-connection support
package/client/odac.js CHANGED
@@ -541,6 +541,28 @@ if (typeof window !== 'undefined') {
541
541
  cache: cache,
542
542
  success: data => {
543
543
  if (!data.result) return false
544
+
545
+ // Token rotation must always apply on success, independent of messages config.
546
+ // Server has already cleared the old session entry; if we skip this update
547
+ // the next submit sends a stale token and gets "Form session expired".
548
+ if (data.result.success && data.result._token) {
549
+ const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
550
+ if (tokenInput) tokenInput.value = data.result._token
551
+
552
+ const formTokenAttr = formElement.getAttribute('data-odac-form')
553
+ if (formTokenAttr) {
554
+ formElement.setAttribute('data-odac-form', data.result._token)
555
+ if (!formElement.matches(formSelector)) {
556
+ if (this.#formSubmitHandlers.has(formSelector)) {
557
+ document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
558
+ this.#formSubmitHandlers.delete(formSelector)
559
+ }
560
+ const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
561
+ this.form(newObj, callback)
562
+ }
563
+ }
564
+ }
565
+
544
566
  if (obj.messages == undefined || obj.messages) {
545
567
  if (data.result.success && (obj.messages == undefined || obj.messages.includes('success') || obj.messages == true)) {
546
568
  const successEl = formElement.querySelector('*[odac-form-success]')
@@ -554,24 +576,6 @@ if (typeof window !== 'undefined') {
554
576
  formElement.appendChild(span)
555
577
  }
556
578
 
557
- if (data.result._token) {
558
- const tokenInput = formElement.querySelector('input[name="_odac_form_token"]')
559
- if (tokenInput) tokenInput.value = data.result._token
560
-
561
- const formTokenAttr = formElement.getAttribute('data-odac-form')
562
- if (formTokenAttr) {
563
- formElement.setAttribute('data-odac-form', data.result._token)
564
- if (!formElement.matches(formSelector)) {
565
- if (this.#formSubmitHandlers.has(formSelector)) {
566
- document.removeEventListener('submit', this.#formSubmitHandlers.get(formSelector))
567
- this.#formSubmitHandlers.delete(formSelector)
568
- }
569
- const newObj = {...obj, form: `form[data-odac-form="${data.result._token}"]`}
570
- this.form(newObj, callback)
571
- }
572
- }
573
- }
574
-
575
579
  if (obj.clear !== false && formElement.getAttribute('clear') !== 'false' && !data.result.redirect) {
576
580
  formElement
577
581
  .querySelectorAll(
@@ -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
  ```
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.12",
10
+ "version": "1.4.14",
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
@@ -3,6 +3,49 @@ const nodeCrypto = require('crypto')
3
3
  class Form {
4
4
  static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
5
5
 
6
+ static FORM_META = {
7
+ form: {
8
+ cssClass: 'odac-custom-form',
9
+ dataAttr: 'data-odac-form',
10
+ tokenInputName: '_odac_form_token',
11
+ action: '/_odac/form',
12
+ defaultSubmitText: 'Submit',
13
+ defaultSubmitLoading: 'Processing...',
14
+ storageKey: 'customForms',
15
+ sessionKeyPrefix: '_custom_form_'
16
+ },
17
+ register: {
18
+ cssClass: 'odac-register-form',
19
+ dataAttr: 'data-odac-register',
20
+ tokenInputName: '_odac_register_token',
21
+ action: '/_odac/register',
22
+ defaultSubmitText: 'Register',
23
+ defaultSubmitLoading: 'Processing...',
24
+ storageKey: 'registerForms',
25
+ sessionKeyPrefix: '_register_form_'
26
+ },
27
+ login: {
28
+ cssClass: 'odac-login-form',
29
+ dataAttr: 'data-odac-login',
30
+ tokenInputName: '_odac_login_token',
31
+ action: '/_odac/login',
32
+ defaultSubmitText: 'Login',
33
+ defaultSubmitLoading: 'Logging in...',
34
+ storageKey: 'loginForms',
35
+ sessionKeyPrefix: '_login_form_'
36
+ },
37
+ 'magic-login': {
38
+ cssClass: 'odac-magic-login-form',
39
+ dataAttr: 'data-odac-magic-login',
40
+ tokenInputName: '_odac_magic_login_token',
41
+ action: '/_odac/magic-login',
42
+ defaultSubmitText: 'Send Magic Link',
43
+ defaultSubmitLoading: 'Sending...',
44
+ storageKey: 'magicLoginForms',
45
+ sessionKeyPrefix: '_magic_login_form_'
46
+ }
47
+ }
48
+
6
49
  static escapeHtml(value) {
7
50
  if (value === null || value === undefined) return ''
8
51
  const map = {
@@ -15,6 +58,30 @@ class Form {
15
58
  return String(value).replace(/[&<>"']/g, ch => map[ch])
16
59
  }
17
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
+
18
85
  static parse(content, Odac) {
19
86
  for (const type of this.FORM_TYPES) {
20
87
  content = this.parseFormType(content, Odac, type)
@@ -22,70 +89,137 @@ class Form {
22
89
  return content
23
90
  }
24
91
 
92
+ /**
93
+ * Compile-time transform for <odac:{type}>...</odac:{type}> blocks.
94
+ *
95
+ * Emits two <script:odac> runtime hooks (openForm / closeForm) with the
96
+ * form body kept inline between them. This preserves the view engine
97
+ * pipeline for inner content — <odac:if>, <odac:for>, {{ }} interpolation,
98
+ * etc. all keep working inside forms instead of being frozen into a JSON
99
+ * string blob and then mangled by later passes.
100
+ */
25
101
  static parseFormType(content, Odac, type) {
102
+ const meta = this.FORM_META[type]
26
103
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
104
+
27
105
  return content.replace(regex, match => {
28
106
  const formConfig = this.extractConfig(match, null, type)
29
- let configStr = JSON.stringify(formConfig)
30
- let matchStr = JSON.stringify(match)
31
107
 
32
- // Unquote dynamic variables to make them live JS expressions in the compiled view
33
- // We avoid {{ }} here because View engine would turn them into ${ } which is invalid in naked JS
34
- configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await $1)')
35
- matchStr = matchStr.replace(/\{\{([\s\S]*?)\}\}/g, '" + (await Odac.Var(await $1).html()) + "')
108
+ const openTagRegex = new RegExp(`^<odac:${type}[^>]*>`)
109
+ const closeTagRegex = new RegExp(`</odac:${type}>$`)
110
+ let innerContent = match.replace(openTagRegex, '').replace(closeTagRegex, '')
111
+
112
+ // Pre-render <odac:input> tags into <input>/<textarea>/<label> markup.
113
+ // Field values may contain {{ }} — those stay as-is here and get
114
+ // resolved by the view engine's {{ }} pass on the surrounding HTML.
115
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
116
+ const field = this.parseInput(fieldMatch)
117
+ if (!field) return fieldMatch
118
+ return this.generateFieldHtml(field)
119
+ })
120
+
121
+ // Pre-render <odac:submit>...</odac:submit> (or self-closing) into <button>
122
+ let submitRendered = false
123
+ innerContent = innerContent.replace(/<odac:submit([^>]*?)(?:\/>|>(.*?)<\/odac:submit>)/g, () => {
124
+ submitRendered = true
125
+ return this.generateSubmitButton(formConfig, meta)
126
+ })
127
+
128
+ // magic-login convenience: if the author didn't write any <odac:input>,
129
+ // append the default email field that extractConfig pushed into config.
130
+ if (type === 'magic-login' && !match.includes('<odac:input')) {
131
+ const emailField = formConfig.fields.find(f => f.name === 'email')
132
+ if (emailField) innerContent += '\n' + this.generateFieldHtml(emailField)
133
+ }
134
+
135
+ // magic-login convenience: if no <odac:submit> was rendered, add one.
136
+ if (type === 'magic-login' && !submitRendered) {
137
+ innerContent += '\n' + this.generateSubmitButton(formConfig, meta)
138
+ }
139
+
140
+ // <odac:set> is server-side only (sent into stored config) — strip from DOM.
141
+ innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
142
+
143
+ // Serialize config; turn "{{ expr }}" string values into live (await expr)
144
+ // so dynamic config values are evaluated at request time, not compile time.
145
+ let configStr = JSON.stringify(formConfig).replace(/<\/script:odac/gi, '<\\/script:odac')
146
+ configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, (_, expr) => `(await ${expr.replace(/\\"/g, '"')})`)
36
147
 
37
- return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
148
+ return (
149
+ `<script:odac>html += await Odac.View.Form.openForm(Odac, '${type}', ${configStr});</script:odac>` +
150
+ innerContent +
151
+ `<script:odac>html += await Odac.View.Form.closeForm();</script:odac>`
152
+ )
38
153
  })
39
154
  }
40
155
 
41
- /**
42
- * Generates the form at runtime to ensure a fresh token is created and stored
43
- * in the current session for every request. This prevents "session expired"
44
- * errors caused by caching the form token in the compiled view.
45
- */
46
- static async runtime(Odac, type, config, originalHtml) {
156
+ // - RUNTIME: emits the opening <form ...> + hidden token input.
157
+ // Generates a fresh CSRF token per render and persists the resolved
158
+ // config in the session under the type-specific key.
159
+ static async openForm(Odac, type, config) {
160
+ const meta = this.FORM_META[type]
47
161
  const token = nodeCrypto.randomBytes(32).toString('hex')
48
162
  config.token = token
49
-
50
163
  this.storeConfig(token, config, Odac, type)
51
164
 
52
- return this.generateForm(originalHtml, config, token, type)
165
+ const method = (config.method || 'POST').toUpperCase()
166
+ let classes = meta.cssClass
167
+ if (config.class) classes += ' ' + config.class
168
+
169
+ let attrs = `class="${this.escapeHtml(classes)}"`
170
+ attrs += ` ${meta.dataAttr}="${this.escapeHtml(token)}"`
171
+ attrs += ` method="${this.escapeHtml(method)}"`
172
+ attrs += ` action="${this.escapeHtml(meta.action)}"`
173
+ attrs += ` novalidate`
174
+ if (config.id) attrs += ` id="${this.escapeHtml(config.id)}"`
175
+ if (type === 'form' && config.clear !== undefined) attrs += ` clear="${config.clear}"`
176
+
177
+ let html = `<form ${attrs}>\n`
178
+ html += ` <input type="hidden" name="${meta.tokenInputName}" value="${this.escapeHtml(token)}">\n`
179
+ return html
53
180
  }
54
181
 
55
- static extractConfig(html, formToken, type) {
56
- if (type === 'register') {
57
- return this.extractRegisterConfig(html, formToken)
58
- } else if (type === 'login') {
59
- return this.extractLoginConfig(html, formToken)
60
- } else if (type === 'magic-login') {
61
- return this.extractMagicLoginConfig(html, formToken)
62
- } else if (type === 'form') {
63
- return this.extractFormConfig(html, formToken)
64
- }
182
+ // - RUNTIME: emits the trailing success span + </form>.
183
+ static async closeForm() {
184
+ return `\n <span class="odac-form-success" style="display:none;"></span>\n</form>`
65
185
  }
66
186
 
67
187
  static storeConfig(token, config, Odac, type) {
68
- if (type === 'register') {
69
- this.storeRegisterConfig(token, config, Odac)
70
- } else if (type === 'login') {
71
- this.storeLoginConfig(token, config, Odac)
72
- } else if (type === 'magic-login') {
73
- this.storeMagicLoginConfig(token, config, Odac)
74
- } else if (type === 'form') {
75
- this.storeFormConfig(token, config, Odac)
188
+ const meta = this.FORM_META[type]
189
+ if (!Odac.View) Odac.View = {}
190
+ if (!Odac.View[meta.storageKey]) Odac.View[meta.storageKey] = {}
191
+
192
+ const formData = {
193
+ config: config,
194
+ created: Date.now(),
195
+ expires: Date.now() + 30 * 60 * 1000,
196
+ sessionId: Odac.Request.session('_client'),
197
+ userAgent: Odac.Request.header('user-agent'),
198
+ ip: Odac.Request.ip
76
199
  }
200
+
201
+ Odac.View[meta.storageKey][token] = formData
202
+ Odac.Request.session(meta.sessionKeyPrefix + token, formData)
77
203
  }
78
204
 
79
- static generateForm(originalHtml, config, formToken, type) {
80
- if (type === 'register') {
81
- return this.generateRegisterForm(originalHtml, config, formToken)
82
- } else if (type === 'login') {
83
- return this.generateLoginForm(originalHtml, config, formToken)
84
- } else if (type === 'magic-login') {
85
- return this.generateMagicLoginForm(originalHtml, config, formToken)
86
- } else if (type === 'form') {
87
- return this.generateCustomForm(originalHtml, config, formToken)
88
- }
205
+ static extractConfig(html, formToken, type) {
206
+ if (type === 'register') return this.extractRegisterConfig(html, formToken)
207
+ if (type === 'login') return this.extractLoginConfig(html, formToken)
208
+ if (type === 'magic-login') return this.extractMagicLoginConfig(html, formToken)
209
+ if (type === 'form') return this.extractFormConfig(html, formToken)
210
+ }
211
+
212
+ static generateSubmitButton(config, meta) {
213
+ const submitText = config.submitText || meta.defaultSubmitText
214
+ const submitLoading = config.submitLoading || meta.defaultSubmitLoading
215
+
216
+ let attrs = `type="submit"`
217
+ attrs += ` data-submit-text="${this.escapeHtml(submitText)}"`
218
+ attrs += ` data-loading-text="${this.escapeHtml(submitLoading)}"`
219
+ if (config.submitClass) attrs += ` class="${this.escapeHtml(config.submitClass)}"`
220
+ if (config.submitStyle) attrs += ` style="${this.escapeHtml(config.submitStyle)}"`
221
+ if (config.submitId) attrs += ` id="${this.escapeHtml(config.submitId)}"`
222
+ return `<button ${attrs}>${this.escapeHtml(submitText)}</button>`
89
223
  }
90
224
 
91
225
  static extractRegisterConfig(html, formToken) {
@@ -109,43 +243,174 @@ class Form {
109
243
  if (redirectMatch) config.redirect = redirectMatch[1]
110
244
  if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
111
245
 
112
- const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
113
- if (submitMatch) {
114
- const submitTag = submitMatch[1]
115
- const textMatch = submitTag.match(/text=["']([^"']+)["']/)
116
- const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
117
- const classMatch = submitTag.match(/class=["']([^"']+)["']/)
118
- const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
119
- const idMatch = submitTag.match(/id=["']([^"']+)["']/)
120
-
121
- if (textMatch) config.submitText = textMatch[1]
122
- else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
123
-
124
- if (loadingMatch) config.submitLoading = loadingMatch[1]
125
- if (classMatch) config.submitClass = classMatch[1]
126
- if (styleMatch) config.submitStyle = styleMatch[1]
127
- if (idMatch) config.submitId = idMatch[1]
246
+ this.applySubmitConfig(html, config)
247
+ this.collectFields(html, config)
248
+ this.collectSets(html, config)
249
+
250
+ return config
251
+ }
252
+
253
+ static extractLoginConfig(html, formToken) {
254
+ const config = {
255
+ token: formToken,
256
+ redirect: null,
257
+ submitText: 'Login',
258
+ submitLoading: 'Logging in...',
259
+ fields: []
260
+ }
261
+
262
+ const loginMatch = html.match(/<odac:login([^>]*)>/)
263
+ if (!loginMatch) return config
264
+
265
+ const redirectMatch = loginMatch[0].match(/redirect=["']([^"']+)["']/)
266
+ if (redirectMatch) config.redirect = redirectMatch[1]
267
+
268
+ this.applySubmitConfig(html, config)
269
+ this.collectFields(html, config)
270
+
271
+ return config
272
+ }
273
+
274
+ static extractMagicLoginConfig(html, formToken) {
275
+ const config = {
276
+ token: formToken,
277
+ redirect: null,
278
+ submitText: 'Send Magic Link',
279
+ submitLoading: 'Sending...',
280
+ fields: []
128
281
  }
129
282
 
283
+ const tagMatch = html.match(/<odac:magic-login([^>]*)>/)
284
+ if (!tagMatch) return config
285
+
286
+ const tag = tagMatch[0]
287
+ const redirectMatch = tag.match(/redirect=["']([^"']+)["']/)
288
+ const emailLabelMatch = tag.match(/email-label=["']([^"']+)["']/)
289
+ if (redirectMatch) config.redirect = redirectMatch[1]
290
+
130
291
  const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
131
292
  if (fieldMatches) {
132
293
  for (const fieldHtml of fieldMatches) {
133
294
  const field = this.parseInput(fieldHtml)
134
295
  if (field) config.fields.push(field)
135
296
  }
297
+ } else {
298
+ config.fields.push({
299
+ name: 'email',
300
+ type: 'email',
301
+ placeholder: 'e.g. user@example.com',
302
+ label: emailLabelMatch ? emailLabelMatch[1] : 'Email Address',
303
+ class: '',
304
+ id: null,
305
+ unique: false,
306
+ skip: false,
307
+ value: null,
308
+ validations: [
309
+ {rule: 'required', message: 'Email is required'},
310
+ {rule: 'email', message: 'Invalid email format'}
311
+ ]
312
+ })
136
313
  }
137
314
 
138
- const setMatches = html.match(/<odac:set[^>]*\/?>/g)
139
- if (setMatches) {
140
- for (const setTag of setMatches) {
141
- const set = this.parseSet(setTag)
142
- if (set) config.sets.push(set)
143
- }
315
+ const applied = this.applySubmitConfig(html, config)
316
+ if (!applied) {
317
+ const submitTextAttr = tag.match(/submit-text=["']([^"']+)["']/)
318
+ if (submitTextAttr) config.submitText = submitTextAttr[1]
319
+ }
320
+
321
+ return config
322
+ }
323
+
324
+ static extractFormConfig(html, formToken) {
325
+ const config = {
326
+ token: formToken,
327
+ action: null,
328
+ method: 'POST',
329
+ submitText: 'Submit',
330
+ submitLoading: 'Processing...',
331
+ fields: [],
332
+ sets: [],
333
+ class: '',
334
+ id: null,
335
+ table: null,
336
+ redirect: null,
337
+ successMessage: null
338
+ }
339
+
340
+ const formMatch = html.match(/<odac:form([^>]*)>/)
341
+ if (!formMatch) return config
342
+
343
+ const formTag = formMatch[0]
344
+ const extractAttr = name => {
345
+ const m = formTag.match(new RegExp(`${name}=(['"])((?:(?!\\1).)*)\\1`))
346
+ return m ? m[2] : null
144
347
  }
145
348
 
349
+ const actionMatch = extractAttr('action')
350
+ const methodMatch = extractAttr('method')
351
+ const classMatch = extractAttr('class')
352
+ const idMatch = extractAttr('id')
353
+ const tableMatch = extractAttr('table')
354
+ const redirectMatch = extractAttr('redirect')
355
+ const successMatch = extractAttr('success')
356
+ const clearMatch = extractAttr('clear')
357
+
358
+ if (actionMatch) config.action = actionMatch
359
+ if (methodMatch) config.method = methodMatch.toUpperCase()
360
+ if (classMatch) config.class = classMatch
361
+ if (idMatch) config.id = idMatch
362
+ if (tableMatch) config.table = tableMatch
363
+ if (redirectMatch) config.redirect = redirectMatch
364
+ if (successMatch) config.successMessage = successMatch
365
+ if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
366
+
367
+ this.applySubmitConfig(html, config)
368
+ this.collectFields(html, config)
369
+ this.collectSets(html, config)
370
+
146
371
  return config
147
372
  }
148
373
 
374
+ static applySubmitConfig(html, config) {
375
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
376
+ if (!submitMatch) return false
377
+
378
+ const submitTag = submitMatch[1]
379
+ const textMatch = submitTag.match(/text=["']([^"']+)["']/)
380
+ const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
381
+ const classMatch = submitTag.match(/class=["']([^"']+)["']/)
382
+ const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
383
+ const idMatch = submitTag.match(/id=["']([^"']+)["']/)
384
+
385
+ if (textMatch) config.submitText = textMatch[1]
386
+ else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
387
+
388
+ if (loadingMatch) config.submitLoading = loadingMatch[1]
389
+ if (classMatch) config.submitClass = classMatch[1]
390
+ if (styleMatch) config.submitStyle = styleMatch[1]
391
+ if (idMatch) config.submitId = idMatch[1]
392
+
393
+ return true
394
+ }
395
+
396
+ static collectFields(html, config) {
397
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
398
+ if (!fieldMatches) return
399
+ for (const fieldHtml of fieldMatches) {
400
+ const field = this.parseInput(fieldHtml)
401
+ if (field) config.fields.push(field)
402
+ }
403
+ }
404
+
405
+ static collectSets(html, config) {
406
+ const setMatches = html.match(/<odac:set[^>]*\/?>/g)
407
+ if (!setMatches) return
408
+ for (const setTag of setMatches) {
409
+ const set = this.parseSet(setTag)
410
+ if (set) config.sets.push(set)
411
+ }
412
+ }
413
+
149
414
  static parseInput(html) {
150
415
  const fieldTagMatch = html.match(/<odac:input([^>]*?)(?:\/>|>)/)
151
416
  if (!fieldTagMatch) return null
@@ -190,7 +455,6 @@ class Form {
190
455
  for (const validateTag of validateMatches) {
191
456
  const ruleMatch = validateTag.match(/rule=["']([^"']+)["']/)
192
457
  const messageMatch = validateTag.match(/message=(["'])(.*?)\1/)
193
-
194
458
  if (ruleMatch) {
195
459
  field.validations.push({
196
460
  rule: ruleMatch[1],
@@ -200,23 +464,15 @@ class Form {
200
464
  }
201
465
  }
202
466
 
203
- // Capture generic attributes
204
467
  const extraAttrs = {}
205
468
  const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
206
469
  const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
207
- let attrMatch
208
- // Clean tag to just attributes part for safer regex matching if needed,
209
- // or just run on fieldTag from start
210
470
  const attributesString = fieldTag.replace(/^<odac:input/, '').replace(/\/?>$/, '')
211
-
471
+ let attrMatch
212
472
  while ((attrMatch = attrRegex.exec(attributesString))) {
213
473
  const key = attrMatch[1]
214
- // If value is undefined, it's a boolean attribute (e.g. required, autofocus) -> set as true (or empty string)
215
474
  const value = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4] !== undefined ? attrMatch[4] : ''
216
-
217
- if (!knownAttrs.includes(key)) {
218
- extraAttrs[key] = value
219
- }
475
+ if (!knownAttrs.includes(key)) extraAttrs[key] = value
220
476
  }
221
477
  field.extraAttributes = extraAttrs
222
478
 
@@ -248,73 +504,22 @@ class Form {
248
504
  return set
249
505
  }
250
506
 
251
- static storeRegisterConfig(token, config, Odac) {
252
- if (!Odac.View) Odac.View = {}
253
- if (!Odac.View.registerForms) Odac.View.registerForms = {}
254
-
255
- const formData = {
256
- config: config,
257
- created: Date.now(),
258
- expires: Date.now() + 30 * 60 * 1000,
259
- sessionId: Odac.Request.session('_client'),
260
- userAgent: Odac.Request.header('user-agent'),
261
- ip: Odac.Request.ip
262
- }
263
-
264
- Odac.View.registerForms[token] = formData
265
- Odac.Request.session(`_register_form_${token}`, formData)
266
- }
267
-
268
- static generateRegisterForm(originalHtml, config, formToken) {
269
- const submitText = config.submitText || 'Register'
270
- const submitLoading = config.submitLoading || 'Processing...'
271
-
272
- let innerContent = originalHtml.replace(/<odac:register[^>]*>/, '').replace(/<\/odac:register>/, '')
273
-
274
- innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
275
- const field = this.parseInput(fieldMatch)
276
- if (!field) return fieldMatch
277
- // Sync with resolved config value if available
278
- const configField = config.fields.find(f => f.name === field.name)
279
- if (configField) field.value = configField.value
280
- return this.generateFieldHtml(field)
281
- })
282
-
283
- const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
284
- if (submitMatch) {
285
- let submitAttrs = `type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}"`
286
- if (config.submitClass) submitAttrs += ` class="${config.submitClass}"`
287
- if (config.submitStyle) submitAttrs += ` style="${config.submitStyle}"`
288
- if (config.submitId) submitAttrs += ` id="${config.submitId}"`
289
- const submitButton = `<button ${submitAttrs}>${submitText}</button>`
290
- innerContent = innerContent.replace(submitMatch[0], submitButton)
291
- }
292
-
293
- innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
294
-
295
- let html = `<form class="odac-register-form" data-odac-register="${formToken}" method="POST" action="/_odac/register" novalidate>\n`
296
- html += ` <input type="hidden" name="_odac_register_token" value="${formToken}">\n`
297
- html += innerContent
298
- html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
299
- html += `</form>`
300
-
301
- return html
302
- }
303
-
304
507
  static generateFieldHtml(field) {
305
508
  let html = ''
306
509
  const escapedName = this.escapeHtml(field.name)
307
510
  const escapedType = this.escapeHtml(field.type)
308
- const escapedPlaceholder = this.escapeHtml(field.placeholder)
511
+ const escapedPlaceholder = this.escapeHtmlPreservingTemplates(field.placeholder)
309
512
 
310
513
  if (field.label && field.type !== 'checkbox') {
311
- const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
312
- 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`
313
516
  }
314
517
 
315
- const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
316
- const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
317
- 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)}"` : ''
318
523
 
319
524
  if (field.type === 'checkbox') {
320
525
  const attrs = this.buildHtml5Attributes(field)
@@ -322,14 +527,14 @@ class Form {
322
527
  if (field.label) {
323
528
  html += `<label>\n`
324
529
  html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
325
- html += ` ${this.escapeHtml(field.label)}\n`
530
+ html += ` ${this.escapeHtmlPreservingTemplates(field.label)}\n`
326
531
  html += `</label>\n`
327
532
  } else {
328
533
  html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
329
534
  }
330
535
  } else if (field.type === 'textarea') {
331
536
  const attrs = this.buildHtml5Attributes(field)
332
- html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
537
+ html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtmlPreservingTemplates(
333
538
  field.value || ''
334
539
  )}</textarea>\n`
335
540
  } else {
@@ -341,18 +546,11 @@ class Form {
341
546
  }
342
547
 
343
548
  static appendExtraAttributes(attrs, field) {
344
- if (field.extraAttributes) {
345
- for (const key in field.extraAttributes) {
346
- const val = field.extraAttributes[key]
347
- // If val is empty string, render as boolean attribute if typical, or key=""
348
- // For HTML5 boolean attrs like autofocus, required, checked, readonly, disabled, multiple, selected
349
- // presence is enough.
350
- if (val === '') {
351
- attrs += ` ${key}`
352
- } else {
353
- attrs += ` ${key}="${this.escapeHtml(val)}"`
354
- }
355
- }
549
+ if (!field.extraAttributes) return attrs
550
+ for (const key in field.extraAttributes) {
551
+ const val = field.extraAttributes[key]
552
+ if (val === '') attrs += ` ${key}`
553
+ else attrs += ` ${key}="${this.escapeHtmlPreservingTemplates(val)}"`
356
554
  }
357
555
  return attrs
358
556
  }
@@ -443,379 +641,7 @@ class Form {
443
641
  if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
444
642
  if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
445
643
 
446
- attrs = this.appendExtraAttributes(attrs, field)
447
-
448
- return attrs
449
- }
450
-
451
- static extractLoginConfig(html, formToken) {
452
- const config = {
453
- token: formToken,
454
- redirect: null,
455
- submitText: 'Login',
456
- submitLoading: 'Logging in...',
457
- fields: []
458
- }
459
-
460
- const loginMatch = html.match(/<odac:login([^>]*)>/)
461
- if (!loginMatch) return config
462
-
463
- const loginTag = loginMatch[0]
464
- const redirectMatch = loginTag.match(/redirect=["']([^"']+)["']/)
465
-
466
- if (redirectMatch) config.redirect = redirectMatch[1]
467
-
468
- const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
469
- if (submitMatch) {
470
- const submitTag = submitMatch[1]
471
- const textMatch = submitTag.match(/text=["']([^"']+)["']/)
472
- const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
473
- const classMatch = submitTag.match(/class=["']([^"']+)["']/)
474
- const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
475
- const idMatch = submitTag.match(/id=["']([^"']+)["']/)
476
-
477
- if (textMatch) config.submitText = textMatch[1]
478
- else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
479
-
480
- if (loadingMatch) config.submitLoading = loadingMatch[1]
481
- if (classMatch) config.submitClass = classMatch[1]
482
- if (styleMatch) config.submitStyle = styleMatch[1]
483
- if (idMatch) config.submitId = idMatch[1]
484
- }
485
-
486
- const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
487
- if (fieldMatches) {
488
- for (const fieldHtml of fieldMatches) {
489
- const field = this.parseInput(fieldHtml)
490
- if (field) config.fields.push(field)
491
- }
492
- }
493
-
494
- return config
495
- }
496
-
497
- static storeLoginConfig(token, config, Odac) {
498
- if (!Odac.View) Odac.View = {}
499
- if (!Odac.View.loginForms) Odac.View.loginForms = {}
500
-
501
- const formData = {
502
- config: config,
503
- created: Date.now(),
504
- expires: Date.now() + 30 * 60 * 1000,
505
- sessionId: Odac.Request.session('_client'),
506
- userAgent: Odac.Request.header('user-agent'),
507
- ip: Odac.Request.ip
508
- }
509
-
510
- Odac.View.loginForms[token] = formData
511
- Odac.Request.session(`_login_form_${token}`, formData)
512
- }
513
-
514
- static generateLoginForm(originalHtml, config, formToken) {
515
- const submitText = config.submitText || 'Login'
516
- const submitLoading = config.submitLoading || 'Logging in...'
517
-
518
- let innerContent = originalHtml.replace(/<odac:login[^>]*>/, '').replace(/<\/odac:login>/, '')
519
-
520
- innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
521
- const field = this.parseInput(fieldMatch)
522
- if (!field) return fieldMatch
523
- // Sync with resolved config value if available
524
- const configField = config.fields.find(f => f.name === field.name)
525
- if (configField) field.value = configField.value
526
- return this.generateFieldHtml(field)
527
- })
528
-
529
- const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
530
- if (submitMatch) {
531
- let submitAttrs = `type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}"`
532
- if (config.submitClass) submitAttrs += ` class="${config.submitClass}"`
533
- if (config.submitStyle) submitAttrs += ` style="${config.submitStyle}"`
534
- if (config.submitId) submitAttrs += ` id="${config.submitId}"`
535
- const submitButton = `<button ${submitAttrs}>${submitText}</button>`
536
- innerContent = innerContent.replace(submitMatch[0], submitButton)
537
- }
538
-
539
- let html = `<form class="odac-login-form" data-odac-login="${formToken}" method="POST" action="/_odac/login" novalidate>\n`
540
- html += ` <input type="hidden" name="_odac_login_token" value="${formToken}">\n`
541
- html += innerContent
542
- html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
543
- html += `</form>`
544
-
545
- return html
546
- }
547
-
548
- static extractFormConfig(html, formToken) {
549
- const config = {
550
- token: formToken,
551
- action: null,
552
- method: 'POST',
553
- submitText: 'Submit',
554
- submitLoading: 'Processing...',
555
- fields: [],
556
- sets: [],
557
- class: '',
558
- id: null,
559
- table: null,
560
- redirect: null,
561
- successMessage: null
562
- }
563
-
564
- const formMatch = html.match(/<odac:form([^>]*)>/)
565
- if (!formMatch) return config
566
-
567
- const formTag = formMatch[0]
568
- const extractAttr = name => {
569
- const match = formTag.match(new RegExp(`${name}=(['"])((?:(?!\\1).)*)\\1`))
570
- return match ? match[2] : null
571
- }
572
-
573
- const actionMatch = extractAttr('action')
574
- const methodMatch = extractAttr('method')
575
- const classMatch = extractAttr('class')
576
- const idMatch = extractAttr('id')
577
- const tableMatch = extractAttr('table')
578
- const redirectMatch = extractAttr('redirect')
579
- const successMatch = extractAttr('success')
580
-
581
- if (actionMatch) config.action = actionMatch
582
- if (methodMatch) config.method = methodMatch.toUpperCase()
583
- if (classMatch) config.class = classMatch
584
- if (idMatch) config.id = idMatch
585
- if (tableMatch) config.table = tableMatch
586
- if (redirectMatch) config.redirect = redirectMatch
587
- if (successMatch) config.successMessage = successMatch
588
- const clearMatch = extractAttr('clear')
589
- if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
590
-
591
- const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
592
- if (submitMatch) {
593
- const submitTag = submitMatch[1]
594
- const textMatch = submitTag.match(/text=["']([^"']+)["']/)
595
- const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
596
- const classMatch = submitTag.match(/class=["']([^"']+)["']/)
597
- const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
598
- const idMatch = submitTag.match(/id=["']([^"']+)["']/)
599
-
600
- if (textMatch) config.submitText = textMatch[1]
601
- else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
602
-
603
- if (loadingMatch) config.submitLoading = loadingMatch[1]
604
- if (classMatch) config.submitClass = classMatch[1]
605
- if (styleMatch) config.submitStyle = styleMatch[1]
606
- if (idMatch) config.submitId = idMatch[1]
607
- }
608
-
609
- const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
610
- if (fieldMatches) {
611
- for (const fieldHtml of fieldMatches) {
612
- const field = this.parseInput(fieldHtml)
613
- if (field) config.fields.push(field)
614
- }
615
- }
616
-
617
- const setMatches = html.match(/<odac:set[^>]*\/?>/g)
618
- if (setMatches) {
619
- for (const setTag of setMatches) {
620
- const set = this.parseSet(setTag)
621
- if (set) config.sets.push(set)
622
- }
623
- }
624
-
625
- return config
626
- }
627
-
628
- static storeFormConfig(token, config, Odac) {
629
- if (!Odac.View) Odac.View = {}
630
- if (!Odac.View.customForms) Odac.View.customForms = {}
631
-
632
- const formData = {
633
- config: config,
634
- created: Date.now(),
635
- expires: Date.now() + 30 * 60 * 1000,
636
- sessionId: Odac.Request.session('_client'),
637
- userAgent: Odac.Request.header('user-agent'),
638
- ip: Odac.Request.ip
639
- }
640
-
641
- Odac.View.customForms[token] = formData
642
- Odac.Request.session(`_custom_form_${token}`, formData)
643
- }
644
-
645
- static generateCustomForm(originalHtml, config, formToken) {
646
- const submitText = config.submitText || 'Submit'
647
- const submitLoading = config.submitLoading || 'Processing...'
648
- // Always post to internal handler, real action is in session config
649
- const formAction = '/_odac/form'
650
- const method = config.method || 'POST'
651
-
652
- let innerContent = originalHtml.replace(/<odac:form[^>]*>/, '').replace(/<\/odac:form>/, '')
653
-
654
- innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
655
- const field = this.parseInput(fieldMatch)
656
- if (!field) return fieldMatch
657
- // Sync with resolved config value if available
658
- const configField = config.fields.find(f => f.name === field.name)
659
- if (configField) field.value = configField.value
660
- return this.generateFieldHtml(field)
661
- })
662
-
663
- const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
664
- if (submitMatch) {
665
- let submitAttrs = `type="submit" data-submit-text="${this.escapeHtml(submitText)}" data-loading-text="${this.escapeHtml(submitLoading)}"`
666
- if (config.submitClass) submitAttrs += ` class="${this.escapeHtml(config.submitClass)}"`
667
- if (config.submitStyle) submitAttrs += ` style="${this.escapeHtml(config.submitStyle)}"`
668
- if (config.submitId) submitAttrs += ` id="${this.escapeHtml(config.submitId)}"`
669
- const submitButton = `<button ${submitAttrs}>${this.escapeHtml(submitText)}</button>`
670
- innerContent = innerContent.replace(submitMatch[0], submitButton)
671
- }
672
-
673
- innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
674
-
675
- let formAttrs = `class="odac-custom-form${config.class ? ' ' + this.escapeHtml(config.class) : ''}" data-odac-form="${this.escapeHtml(formToken)}" method="${this.escapeHtml(method)}" action="${this.escapeHtml(formAction)}" novalidate`
676
- if (config.id) formAttrs += ` id="${this.escapeHtml(config.id)}"`
677
- if (config.clear !== undefined) formAttrs += ` clear="${config.clear}"`
678
-
679
- let html = `<form ${formAttrs}>\n`
680
- html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
681
- html += innerContent
682
- html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
683
- html += `</form>`
684
-
685
- return html
686
- }
687
-
688
- static extractMagicLoginConfig(html, formToken) {
689
- const config = {
690
- token: formToken,
691
- redirect: null,
692
- submitText: 'Send Magic Link',
693
- submitLoading: 'Sending...',
694
- fields: []
695
- }
696
-
697
- const tagMatch = html.match(/<odac:magic-login([^>]*)>/)
698
- if (!tagMatch) return config
699
-
700
- const tag = tagMatch[0]
701
- const redirectMatch = tag.match(/redirect=["']([^"']+)["']/)
702
- const emailLabelMatch = tag.match(/email-label=["']([^"']+)["']/)
703
-
704
- if (redirectMatch) config.redirect = redirectMatch[1]
705
-
706
- // Auto-add email field if not manually specified (simplified usage)
707
- const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
708
-
709
- if (fieldMatches) {
710
- // Custom fields included
711
- for (const fieldHtml of fieldMatches) {
712
- const field = this.parseInput(fieldHtml)
713
- if (field) config.fields.push(field)
714
- }
715
- } else {
716
- // Default Email Field
717
- config.fields.push({
718
- name: 'email',
719
- type: 'email',
720
- placeholder: 'e.g. user@example.com',
721
- label: emailLabelMatch ? emailLabelMatch[1] : 'Email Address',
722
- class: '',
723
- id: null,
724
- unique: false,
725
- skip: false,
726
- validations: [
727
- {rule: 'required', message: 'Email is required'},
728
- {rule: 'email', message: 'Invalid email format'}
729
- ]
730
- })
731
- }
732
-
733
- const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
734
- if (submitMatch) {
735
- const submitTag = submitMatch[1]
736
- const textMatch = submitTag.match(/text=["']([^"']+)["']/)
737
- const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
738
- const classMatch = submitTag.match(/class=["']([^"']+)["']/)
739
- const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
740
- const idMatch = submitTag.match(/id=["']([^"']+)["']/)
741
-
742
- if (textMatch) config.submitText = textMatch[1]
743
- else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
744
-
745
- if (loadingMatch) config.submitLoading = loadingMatch[1]
746
- if (classMatch) config.submitClass = classMatch[1]
747
- if (styleMatch) config.submitStyle = styleMatch[1]
748
- if (idMatch) config.submitId = idMatch[1]
749
- } else {
750
- // Check for submit-text attribute on main tag if no submit tag
751
- const submitTextAttr = tag.match(/submit-text=["']([^"']+)["']/)
752
- if (submitTextAttr) config.submitText = submitTextAttr[1]
753
- }
754
-
755
- return config
756
- }
757
-
758
- static storeMagicLoginConfig(token, config, Odac) {
759
- if (!Odac.View) Odac.View = {}
760
- if (!Odac.View.magicLoginForms) Odac.View.magicLoginForms = {}
761
-
762
- const formData = {
763
- config: config,
764
- created: Date.now(),
765
- expires: Date.now() + 30 * 60 * 1000,
766
- sessionId: Odac.Request.session('_client'),
767
- userAgent: Odac.Request.header('user-agent'),
768
- ip: Odac.Request.ip
769
- }
770
-
771
- Odac.View.magicLoginForms[token] = formData
772
- Odac.Request.session(`_magic_login_form_${token}`, formData)
773
- }
774
-
775
- static generateMagicLoginForm(originalHtml, config, formToken) {
776
- const submitText = config.submitText || 'Send Magic Link'
777
- const submitLoading = config.submitLoading || 'Sending...'
778
-
779
- let innerContent = originalHtml.replace(/<odac:magic-login[^>]*>/, '').replace(/<\/odac:magic-login>/, '')
780
-
781
- // If no custom fields were present in HTML but we added default email in config
782
- if (!originalHtml.includes('<odac:input')) {
783
- const emailField = config.fields.find(f => f.name === 'email')
784
- if (emailField) {
785
- innerContent += this.generateFieldHtml(emailField)
786
- }
787
- } else {
788
- innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
789
- const field = this.parseInput(fieldMatch)
790
- if (!field) return fieldMatch
791
- // Sync with resolved config value if available
792
- const configField = config.fields.find(f => f.name === field.name)
793
- if (configField) field.value = configField.value
794
- return this.generateFieldHtml(field)
795
- })
796
- }
797
-
798
- const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
799
- if (submitMatch) {
800
- let submitAttrs = `type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}"`
801
- if (config.submitClass) submitAttrs += ` class="${config.submitClass}"`
802
- if (config.submitStyle) submitAttrs += ` style="${config.submitStyle}"`
803
- if (config.submitId) submitAttrs += ` id="${config.submitId}"`
804
- const submitButton = `<button ${submitAttrs}>${submitText}</button>`
805
- innerContent = innerContent.replace(submitMatch[0], submitButton)
806
- } else if (!innerContent.includes('type="submit"')) {
807
- // Auto add submit button if missing
808
- const submitButton = `<button type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}">${submitText}</button>`
809
- innerContent += `\n${submitButton}`
810
- }
811
-
812
- let html = `<form class="odac-magic-login-form" data-odac-magic-login="${formToken}" method="POST" action="/_odac/magic-login" novalidate>\n`
813
- html += ` <input type="hidden" name="_odac_magic_login_token" value="${formToken}">\n`
814
- html += innerContent
815
- html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
816
- html += `</form>`
817
-
818
- return html
644
+ return this.appendExtraAttributes(attrs, field)
819
645
  }
820
646
  }
821
647
 
package/src/View.js CHANGED
@@ -443,12 +443,7 @@ class View {
443
443
  content = this.#parseOdacTag(content)
444
444
  content = content.replace(/`/g, '\\\\`').replace(/\$\{/g, '\\\\${')
445
445
 
446
- jsBlocks.forEach((jsContent, index) => {
447
- content = content.replace(`___ODAC_JS_BLOCK_${index}___`, jsContent)
448
- })
449
-
450
446
  let result = 'html += `\n' + content + '\n`'
451
- content = content.split('\n')
452
447
  for (let key in this.#functions) {
453
448
  let att = ''
454
449
  let func = this.#functions[key]
@@ -517,6 +512,14 @@ class View {
517
512
  }
518
513
  }
519
514
  }
515
+
516
+ // Restore <script:odac> JS bodies after function processing so the regex
517
+ // pipeline above never sees user JS as template syntax. Use a function
518
+ // replacer to keep $-sequences in the JS literal (e.g. $$, $&) intact.
519
+ jsBlocks.forEach((jsContent, index) => {
520
+ result = result.replace(`___ODAC_JS_BLOCK_${index}___`, () => jsContent)
521
+ })
522
+
520
523
  let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
521
524
  await fsPromises.mkdir(CACHE_DIR, {recursive: true})
522
525
  await fsPromises.writeFile(