odac 1.4.12 → 1.4.13

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,15 @@
1
+ ### 🛠️ Fixes & Improvements
2
+
3
+ - **form:** enhance form handling with metadata and improved parsing logic
4
+ - **form:** implement token rotation on successful form submissions
5
+ - **view:** prevent script injection and handle escaped quotes in form configs
6
+
7
+
8
+
9
+ ---
10
+
11
+ Powered by [⚡ ODAC](https://odac.run)
12
+
1
13
  ### 📚 Documentation
2
14
 
3
15
  - 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(
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.13",
11
11
  "license": "MIT",
12
12
  "engines": {
13
13
  "node": ">=18.0.0"
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 = {
@@ -22,70 +65,137 @@ class Form {
22
65
  return content
23
66
  }
24
67
 
68
+ /**
69
+ * Compile-time transform for <odac:{type}>...</odac:{type}> blocks.
70
+ *
71
+ * Emits two <script:odac> runtime hooks (openForm / closeForm) with the
72
+ * form body kept inline between them. This preserves the view engine
73
+ * pipeline for inner content — <odac:if>, <odac:for>, {{ }} interpolation,
74
+ * etc. all keep working inside forms instead of being frozen into a JSON
75
+ * string blob and then mangled by later passes.
76
+ */
25
77
  static parseFormType(content, Odac, type) {
78
+ const meta = this.FORM_META[type]
26
79
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
80
+
27
81
  return content.replace(regex, match => {
28
82
  const formConfig = this.extractConfig(match, null, type)
29
- let configStr = JSON.stringify(formConfig)
30
- let matchStr = JSON.stringify(match)
31
83
 
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()) + "')
84
+ const openTagRegex = new RegExp(`^<odac:${type}[^>]*>`)
85
+ const closeTagRegex = new RegExp(`</odac:${type}>$`)
86
+ let innerContent = match.replace(openTagRegex, '').replace(closeTagRegex, '')
87
+
88
+ // Pre-render <odac:input> tags into <input>/<textarea>/<label> markup.
89
+ // Field values may contain {{ }} — those stay as-is here and get
90
+ // resolved by the view engine's {{ }} pass on the surrounding HTML.
91
+ innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
92
+ const field = this.parseInput(fieldMatch)
93
+ if (!field) return fieldMatch
94
+ return this.generateFieldHtml(field)
95
+ })
96
+
97
+ // Pre-render <odac:submit>...</odac:submit> (or self-closing) into <button>
98
+ let submitRendered = false
99
+ innerContent = innerContent.replace(/<odac:submit([^>]*?)(?:\/>|>(.*?)<\/odac:submit>)/g, () => {
100
+ submitRendered = true
101
+ return this.generateSubmitButton(formConfig, meta)
102
+ })
103
+
104
+ // magic-login convenience: if the author didn't write any <odac:input>,
105
+ // append the default email field that extractConfig pushed into config.
106
+ if (type === 'magic-login' && !match.includes('<odac:input')) {
107
+ const emailField = formConfig.fields.find(f => f.name === 'email')
108
+ if (emailField) innerContent += '\n' + this.generateFieldHtml(emailField)
109
+ }
110
+
111
+ // magic-login convenience: if no <odac:submit> was rendered, add one.
112
+ if (type === 'magic-login' && !submitRendered) {
113
+ innerContent += '\n' + this.generateSubmitButton(formConfig, meta)
114
+ }
115
+
116
+ // <odac:set> is server-side only (sent into stored config) — strip from DOM.
117
+ innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
118
+
119
+ // Serialize config; turn "{{ expr }}" string values into live (await expr)
120
+ // so dynamic config values are evaluated at request time, not compile time.
121
+ let configStr = JSON.stringify(formConfig).replace(/<\/script:odac/gi, '<\\/script:odac')
122
+ configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, (_, expr) => `(await ${expr.replace(/\\"/g, '"')})`)
36
123
 
37
- return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
124
+ return (
125
+ `<script:odac>html += await Odac.View.Form.openForm(Odac, '${type}', ${configStr});</script:odac>` +
126
+ innerContent +
127
+ `<script:odac>html += await Odac.View.Form.closeForm();</script:odac>`
128
+ )
38
129
  })
39
130
  }
40
131
 
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) {
132
+ // - RUNTIME: emits the opening <form ...> + hidden token input.
133
+ // Generates a fresh CSRF token per render and persists the resolved
134
+ // config in the session under the type-specific key.
135
+ static async openForm(Odac, type, config) {
136
+ const meta = this.FORM_META[type]
47
137
  const token = nodeCrypto.randomBytes(32).toString('hex')
48
138
  config.token = token
49
-
50
139
  this.storeConfig(token, config, Odac, type)
51
140
 
52
- return this.generateForm(originalHtml, config, token, type)
141
+ const method = (config.method || 'POST').toUpperCase()
142
+ let classes = meta.cssClass
143
+ if (config.class) classes += ' ' + config.class
144
+
145
+ let attrs = `class="${this.escapeHtml(classes)}"`
146
+ attrs += ` ${meta.dataAttr}="${this.escapeHtml(token)}"`
147
+ attrs += ` method="${this.escapeHtml(method)}"`
148
+ attrs += ` action="${this.escapeHtml(meta.action)}"`
149
+ attrs += ` novalidate`
150
+ if (config.id) attrs += ` id="${this.escapeHtml(config.id)}"`
151
+ if (type === 'form' && config.clear !== undefined) attrs += ` clear="${config.clear}"`
152
+
153
+ let html = `<form ${attrs}>\n`
154
+ html += ` <input type="hidden" name="${meta.tokenInputName}" value="${this.escapeHtml(token)}">\n`
155
+ return html
53
156
  }
54
157
 
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
- }
158
+ // - RUNTIME: emits the trailing success span + </form>.
159
+ static async closeForm() {
160
+ return `\n <span class="odac-form-success" style="display:none;"></span>\n</form>`
65
161
  }
66
162
 
67
163
  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)
164
+ const meta = this.FORM_META[type]
165
+ if (!Odac.View) Odac.View = {}
166
+ if (!Odac.View[meta.storageKey]) Odac.View[meta.storageKey] = {}
167
+
168
+ const formData = {
169
+ config: config,
170
+ created: Date.now(),
171
+ expires: Date.now() + 30 * 60 * 1000,
172
+ sessionId: Odac.Request.session('_client'),
173
+ userAgent: Odac.Request.header('user-agent'),
174
+ ip: Odac.Request.ip
76
175
  }
176
+
177
+ Odac.View[meta.storageKey][token] = formData
178
+ Odac.Request.session(meta.sessionKeyPrefix + token, formData)
77
179
  }
78
180
 
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
- }
181
+ static extractConfig(html, formToken, type) {
182
+ if (type === 'register') return this.extractRegisterConfig(html, formToken)
183
+ if (type === 'login') return this.extractLoginConfig(html, formToken)
184
+ if (type === 'magic-login') return this.extractMagicLoginConfig(html, formToken)
185
+ if (type === 'form') return this.extractFormConfig(html, formToken)
186
+ }
187
+
188
+ static generateSubmitButton(config, meta) {
189
+ const submitText = config.submitText || meta.defaultSubmitText
190
+ const submitLoading = config.submitLoading || meta.defaultSubmitLoading
191
+
192
+ let attrs = `type="submit"`
193
+ attrs += ` data-submit-text="${this.escapeHtml(submitText)}"`
194
+ attrs += ` data-loading-text="${this.escapeHtml(submitLoading)}"`
195
+ if (config.submitClass) attrs += ` class="${this.escapeHtml(config.submitClass)}"`
196
+ if (config.submitStyle) attrs += ` style="${this.escapeHtml(config.submitStyle)}"`
197
+ if (config.submitId) attrs += ` id="${this.escapeHtml(config.submitId)}"`
198
+ return `<button ${attrs}>${this.escapeHtml(submitText)}</button>`
89
199
  }
90
200
 
91
201
  static extractRegisterConfig(html, formToken) {
@@ -109,43 +219,174 @@ class Form {
109
219
  if (redirectMatch) config.redirect = redirectMatch[1]
110
220
  if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
111
221
 
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]
222
+ this.applySubmitConfig(html, config)
223
+ this.collectFields(html, config)
224
+ this.collectSets(html, config)
225
+
226
+ return config
227
+ }
228
+
229
+ static extractLoginConfig(html, formToken) {
230
+ const config = {
231
+ token: formToken,
232
+ redirect: null,
233
+ submitText: 'Login',
234
+ submitLoading: 'Logging in...',
235
+ fields: []
236
+ }
237
+
238
+ const loginMatch = html.match(/<odac:login([^>]*)>/)
239
+ if (!loginMatch) return config
240
+
241
+ const redirectMatch = loginMatch[0].match(/redirect=["']([^"']+)["']/)
242
+ if (redirectMatch) config.redirect = redirectMatch[1]
243
+
244
+ this.applySubmitConfig(html, config)
245
+ this.collectFields(html, config)
246
+
247
+ return config
248
+ }
249
+
250
+ static extractMagicLoginConfig(html, formToken) {
251
+ const config = {
252
+ token: formToken,
253
+ redirect: null,
254
+ submitText: 'Send Magic Link',
255
+ submitLoading: 'Sending...',
256
+ fields: []
128
257
  }
129
258
 
259
+ const tagMatch = html.match(/<odac:magic-login([^>]*)>/)
260
+ if (!tagMatch) return config
261
+
262
+ const tag = tagMatch[0]
263
+ const redirectMatch = tag.match(/redirect=["']([^"']+)["']/)
264
+ const emailLabelMatch = tag.match(/email-label=["']([^"']+)["']/)
265
+ if (redirectMatch) config.redirect = redirectMatch[1]
266
+
130
267
  const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
131
268
  if (fieldMatches) {
132
269
  for (const fieldHtml of fieldMatches) {
133
270
  const field = this.parseInput(fieldHtml)
134
271
  if (field) config.fields.push(field)
135
272
  }
273
+ } else {
274
+ config.fields.push({
275
+ name: 'email',
276
+ type: 'email',
277
+ placeholder: 'e.g. user@example.com',
278
+ label: emailLabelMatch ? emailLabelMatch[1] : 'Email Address',
279
+ class: '',
280
+ id: null,
281
+ unique: false,
282
+ skip: false,
283
+ value: null,
284
+ validations: [
285
+ {rule: 'required', message: 'Email is required'},
286
+ {rule: 'email', message: 'Invalid email format'}
287
+ ]
288
+ })
136
289
  }
137
290
 
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
- }
291
+ const applied = this.applySubmitConfig(html, config)
292
+ if (!applied) {
293
+ const submitTextAttr = tag.match(/submit-text=["']([^"']+)["']/)
294
+ if (submitTextAttr) config.submitText = submitTextAttr[1]
295
+ }
296
+
297
+ return config
298
+ }
299
+
300
+ static extractFormConfig(html, formToken) {
301
+ const config = {
302
+ token: formToken,
303
+ action: null,
304
+ method: 'POST',
305
+ submitText: 'Submit',
306
+ submitLoading: 'Processing...',
307
+ fields: [],
308
+ sets: [],
309
+ class: '',
310
+ id: null,
311
+ table: null,
312
+ redirect: null,
313
+ successMessage: null
144
314
  }
145
315
 
316
+ const formMatch = html.match(/<odac:form([^>]*)>/)
317
+ if (!formMatch) return config
318
+
319
+ const formTag = formMatch[0]
320
+ const extractAttr = name => {
321
+ const m = formTag.match(new RegExp(`${name}=(['"])((?:(?!\\1).)*)\\1`))
322
+ return m ? m[2] : null
323
+ }
324
+
325
+ const actionMatch = extractAttr('action')
326
+ const methodMatch = extractAttr('method')
327
+ const classMatch = extractAttr('class')
328
+ const idMatch = extractAttr('id')
329
+ const tableMatch = extractAttr('table')
330
+ const redirectMatch = extractAttr('redirect')
331
+ const successMatch = extractAttr('success')
332
+ const clearMatch = extractAttr('clear')
333
+
334
+ if (actionMatch) config.action = actionMatch
335
+ if (methodMatch) config.method = methodMatch.toUpperCase()
336
+ if (classMatch) config.class = classMatch
337
+ if (idMatch) config.id = idMatch
338
+ if (tableMatch) config.table = tableMatch
339
+ if (redirectMatch) config.redirect = redirectMatch
340
+ if (successMatch) config.successMessage = successMatch
341
+ if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
342
+
343
+ this.applySubmitConfig(html, config)
344
+ this.collectFields(html, config)
345
+ this.collectSets(html, config)
346
+
146
347
  return config
147
348
  }
148
349
 
350
+ static applySubmitConfig(html, config) {
351
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
352
+ if (!submitMatch) return false
353
+
354
+ const submitTag = submitMatch[1]
355
+ const textMatch = submitTag.match(/text=["']([^"']+)["']/)
356
+ const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
357
+ const classMatch = submitTag.match(/class=["']([^"']+)["']/)
358
+ const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
359
+ const idMatch = submitTag.match(/id=["']([^"']+)["']/)
360
+
361
+ if (textMatch) config.submitText = textMatch[1]
362
+ else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
363
+
364
+ if (loadingMatch) config.submitLoading = loadingMatch[1]
365
+ if (classMatch) config.submitClass = classMatch[1]
366
+ if (styleMatch) config.submitStyle = styleMatch[1]
367
+ if (idMatch) config.submitId = idMatch[1]
368
+
369
+ return true
370
+ }
371
+
372
+ static collectFields(html, config) {
373
+ const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
374
+ if (!fieldMatches) return
375
+ for (const fieldHtml of fieldMatches) {
376
+ const field = this.parseInput(fieldHtml)
377
+ if (field) config.fields.push(field)
378
+ }
379
+ }
380
+
381
+ static collectSets(html, config) {
382
+ const setMatches = html.match(/<odac:set[^>]*\/?>/g)
383
+ if (!setMatches) return
384
+ for (const setTag of setMatches) {
385
+ const set = this.parseSet(setTag)
386
+ if (set) config.sets.push(set)
387
+ }
388
+ }
389
+
149
390
  static parseInput(html) {
150
391
  const fieldTagMatch = html.match(/<odac:input([^>]*?)(?:\/>|>)/)
151
392
  if (!fieldTagMatch) return null
@@ -190,7 +431,6 @@ class Form {
190
431
  for (const validateTag of validateMatches) {
191
432
  const ruleMatch = validateTag.match(/rule=["']([^"']+)["']/)
192
433
  const messageMatch = validateTag.match(/message=(["'])(.*?)\1/)
193
-
194
434
  if (ruleMatch) {
195
435
  field.validations.push({
196
436
  rule: ruleMatch[1],
@@ -200,23 +440,15 @@ class Form {
200
440
  }
201
441
  }
202
442
 
203
- // Capture generic attributes
204
443
  const extraAttrs = {}
205
444
  const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
206
445
  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
446
  const attributesString = fieldTag.replace(/^<odac:input/, '').replace(/\/?>$/, '')
211
-
447
+ let attrMatch
212
448
  while ((attrMatch = attrRegex.exec(attributesString))) {
213
449
  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
450
  const value = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4] !== undefined ? attrMatch[4] : ''
216
-
217
- if (!knownAttrs.includes(key)) {
218
- extraAttrs[key] = value
219
- }
451
+ if (!knownAttrs.includes(key)) extraAttrs[key] = value
220
452
  }
221
453
  field.extraAttributes = extraAttrs
222
454
 
@@ -248,59 +480,6 @@ class Form {
248
480
  return set
249
481
  }
250
482
 
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
483
  static generateFieldHtml(field) {
305
484
  let html = ''
306
485
  const escapedName = this.escapeHtml(field.name)
@@ -341,18 +520,11 @@ class Form {
341
520
  }
342
521
 
343
522
  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
- }
523
+ if (!field.extraAttributes) return attrs
524
+ for (const key in field.extraAttributes) {
525
+ const val = field.extraAttributes[key]
526
+ if (val === '') attrs += ` ${key}`
527
+ else attrs += ` ${key}="${this.escapeHtml(val)}"`
356
528
  }
357
529
  return attrs
358
530
  }
@@ -443,379 +615,7 @@ class Form {
443
615
  if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
444
616
  if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
445
617
 
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
618
+ return this.appendExtraAttributes(attrs, field)
819
619
  }
820
620
  }
821
621
 
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(