odac 1.3.0 → 1.4.1

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.
Files changed (52) hide show
  1. package/.agent/rules/memory.md +10 -1
  2. package/.github/workflows/release.yml +1 -5
  3. package/AGENTS.md +47 -0
  4. package/CHANGELOG.md +58 -0
  5. package/README.md +11 -1
  6. package/bin/odac.js +359 -6
  7. package/client/odac.js +15 -11
  8. package/docs/ai/README.md +49 -0
  9. package/docs/ai/skills/SKILL.md +40 -0
  10. package/docs/ai/skills/backend/authentication.md +74 -0
  11. package/docs/ai/skills/backend/config.md +39 -0
  12. package/docs/ai/skills/backend/controllers.md +69 -0
  13. package/docs/ai/skills/backend/cron.md +57 -0
  14. package/docs/ai/skills/backend/database.md +37 -0
  15. package/docs/ai/skills/backend/forms.md +26 -0
  16. package/docs/ai/skills/backend/ipc.md +62 -0
  17. package/docs/ai/skills/backend/mail.md +41 -0
  18. package/docs/ai/skills/backend/migrations.md +80 -0
  19. package/docs/ai/skills/backend/request_response.md +42 -0
  20. package/docs/ai/skills/backend/routing.md +58 -0
  21. package/docs/ai/skills/backend/storage.md +50 -0
  22. package/docs/ai/skills/backend/streaming.md +41 -0
  23. package/docs/ai/skills/backend/structure.md +64 -0
  24. package/docs/ai/skills/backend/translations.md +49 -0
  25. package/docs/ai/skills/backend/utilities.md +31 -0
  26. package/docs/ai/skills/backend/validation.md +60 -0
  27. package/docs/ai/skills/backend/views.md +68 -0
  28. package/docs/ai/skills/frontend/core.md +73 -0
  29. package/docs/ai/skills/frontend/forms.md +28 -0
  30. package/docs/ai/skills/frontend/navigation.md +27 -0
  31. package/docs/ai/skills/frontend/realtime.md +54 -0
  32. package/docs/backend/08-database/04-migrations.md +258 -37
  33. package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
  34. package/docs/backend/10-authentication/05-session-management.md +25 -3
  35. package/package.json +1 -1
  36. package/src/Auth.js +128 -17
  37. package/src/Config.js +1 -1
  38. package/src/Database/ConnectionFactory.js +69 -0
  39. package/src/Database/Migration.js +1203 -0
  40. package/src/Database.js +35 -35
  41. package/src/Route/Internal.js +21 -18
  42. package/src/Route/MimeTypes.js +56 -0
  43. package/src/Route.js +40 -63
  44. package/src/View/Form.js +91 -51
  45. package/src/View.js +8 -3
  46. package/template/schema/users.js +23 -0
  47. package/test/Auth.test.js +310 -0
  48. package/test/Client.test.js +29 -0
  49. package/test/Config.test.js +7 -0
  50. package/test/Database/ConnectionFactory.test.js +80 -0
  51. package/test/Migration.test.js +943 -0
  52. package/test/View/Form.test.js +37 -0
package/src/Database.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict'
2
- const knex = require('knex')
2
+ const {buildConnections} = require('./Database/ConnectionFactory')
3
3
 
4
4
  class DatabaseManager {
5
5
  constructor() {
@@ -9,41 +9,9 @@ class DatabaseManager {
9
9
  async init() {
10
10
  if (!Odac.Config.database) return
11
11
 
12
- let multiple = typeof Odac.Config.database[Object.keys(Odac.Config.database)[0]] === 'object'
13
- let dbs = multiple ? Odac.Config.database : {default: Odac.Config.database}
14
-
15
- for (let key of Object.keys(dbs)) {
16
- let db = dbs[key]
17
- let client = 'mysql2'
18
- if (db.type === 'postgres' || db.type === 'pg' || db.type === 'postgresql') client = 'pg'
19
- if (db.type === 'sqlite' || db.type === 'sqlite3') client = 'sqlite3'
20
-
21
- let connectionConfig
22
-
23
- if (client === 'sqlite3') {
24
- connectionConfig = {
25
- filename: db.filename || db.database || './dev.sqlite3'
26
- }
27
- } else {
28
- connectionConfig = {
29
- host: db.host || '127.0.0.1',
30
- user: db.user,
31
- password: db.password,
32
- database: db.database,
33
- port: db.port
34
- }
35
- }
36
-
37
- this.connections[key] = knex({
38
- client: client,
39
- connection: connectionConfig,
40
- pool: {
41
- min: 0,
42
- max: db.connectionLimit || 10
43
- },
44
- useNullAsDefault: true // For sqlite
45
- })
12
+ this.connections = buildConnections(Odac.Config.database)
46
13
 
14
+ for (const key of Object.keys(this.connections)) {
47
15
  // Test connection
48
16
  try {
49
17
  await this.connections[key].raw('SELECT 1')
@@ -52,6 +20,38 @@ class DatabaseManager {
52
20
  console.error(e.message)
53
21
  }
54
22
  }
23
+
24
+ // Auto-migrate: sync schema/ files with the database on every startup.
25
+ // Why: Zero-config philosophy — deploy and forget. The app always starts with the correct DB state.
26
+ await this._autoMigrate()
27
+ }
28
+
29
+ /**
30
+ * Runs the schema-first migration engine against all active connections.
31
+ * CLUSTER SAFETY: Only runs on the primary process to prevent race conditions.
32
+ * Workers are forked AFTER Server.init(), which happens after Database.init(),
33
+ * so migrations are guaranteed to complete before any worker touches the DB.
34
+ * Silently skips if no schema/ directory exists (no-op for projects without migrations).
35
+ */
36
+ async _autoMigrate() {
37
+ const cluster = require('node:cluster')
38
+ if (!cluster.isPrimary) return
39
+
40
+ const fs = require('node:fs')
41
+ const path = require('node:path')
42
+ const schemaDir = path.join(global.__dir, 'schema')
43
+
44
+ if (!fs.existsSync(schemaDir)) return
45
+ if (Object.keys(this.connections).length === 0) return
46
+
47
+ const Migration = require('./Database/Migration')
48
+ Migration.init(global.__dir, this.connections)
49
+
50
+ try {
51
+ await Migration.migrate()
52
+ } catch (e) {
53
+ throw new Error(`Odac Migration Error: ${e.message}`, {cause: e})
54
+ }
55
55
  }
56
56
 
57
57
  nanoid(size = 21) {
@@ -541,23 +541,13 @@ class Internal {
541
541
 
542
542
  if (Odac.formConfig.action) {
543
543
  const actionParts = Odac.formConfig.action.split('.')
544
- if (actionParts.length === 2) {
544
+ if (actionParts.length >= 2) {
545
545
  const controllerName = actionParts[0]
546
- const methodName = actionParts[1]
547
-
548
- // Dynamically load controller
549
- // We need to access Odac.Route.class to find the controller path/module
550
- // Or use require directly if we know the path structure.
551
- // Since we are in framework/src/Route/Internal.js, controllers are in framework/controller/ OR app/controller/
552
- // Ideally Odac.Route.class has the loaded controllers.
553
546
 
554
547
  let controllerModule = null
555
548
 
556
549
  if (Odac.Route && Odac.Route.class && Odac.Route.class[controllerName]) {
557
550
  controllerModule = Odac.Route.class[controllerName].module
558
- } else {
559
- // Try to require it if not loaded (though Route.js should have loaded it)
560
- // This fallback might be tricky with absolute paths, relying on Route.class is safer.
561
551
  }
562
552
 
563
553
  if (controllerModule) {
@@ -605,16 +595,29 @@ class Internal {
605
595
  }
606
596
  }
607
597
 
608
- // Handle Class-based Controller
598
+ let method = controllerModule
599
+ let context = null
600
+ let isClass = false
601
+
609
602
  if (typeof controllerModule === 'function' && controllerModule.prototype) {
610
- const instance = new controllerModule(Odac)
611
- if (typeof instance[methodName] === 'function') {
612
- return await instance[methodName](formHelper)
603
+ isClass = true
604
+ context = new controllerModule(Odac)
605
+ method = context
606
+ }
607
+
608
+ for (let i = 1; i < actionParts.length; i++) {
609
+ if (method) {
610
+ context = method
611
+ method = method[actionParts[i]]
613
612
  }
614
613
  }
615
- // Handle Object-based Controller (Backwards Compatibility)
616
- else if (typeof controllerModule[methodName] === 'function') {
617
- return await controllerModule[methodName](Odac, formHelper)
614
+
615
+ if (typeof method === 'function') {
616
+ if (isClass) {
617
+ return await method.call(context, formHelper)
618
+ } else {
619
+ return await method.call(context, Odac, formHelper)
620
+ }
618
621
  }
619
622
  } catch (e) {
620
623
  console.error(e)
@@ -0,0 +1,56 @@
1
+ module.exports = {
2
+ html: 'text/html',
3
+ css: 'text/css',
4
+ js: 'text/javascript',
5
+ json: 'application/json',
6
+ png: 'image/png',
7
+ jpg: 'image/jpg',
8
+ jpeg: 'image/jpeg',
9
+ svg: 'image/svg+xml',
10
+ ico: 'image/x-icon',
11
+ mp3: 'audio/mpeg',
12
+ mp4: 'video/mp4',
13
+ webm: 'video/webm',
14
+ woff: 'font/woff',
15
+ woff2: 'font/woff2',
16
+ ttf: 'font/ttf',
17
+ otf: 'font/otf',
18
+ eot: 'font/eot',
19
+ pdf: 'application/pdf',
20
+ zip: 'application/zip',
21
+ tar: 'application/x-tar',
22
+ gz: 'application/gzip',
23
+ rar: 'application/x-rar-compressed',
24
+ '7z': 'application/x-7z-compressed',
25
+ txt: 'text/plain',
26
+ log: 'text/plain',
27
+ csv: 'text/csv',
28
+ xml: 'text/xml',
29
+ rss: 'application/rss+xml',
30
+ atom: 'application/atom+xml',
31
+ yaml: 'application/x-yaml',
32
+ sh: 'application/x-sh',
33
+ bat: 'application/x-bat',
34
+ exe: 'application/x-exe',
35
+ bin: 'application/x-binary',
36
+ doc: 'application/msword',
37
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
38
+ xls: 'application/vnd.ms-excel',
39
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
40
+ ppt: 'application/vnd.ms-powerpoint',
41
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
42
+ avi: 'video/x-msvideo',
43
+ wmv: 'video/x-ms-wmv',
44
+ flv: 'video/x-flv',
45
+ webp: 'image/webp',
46
+ gif: 'image/gif',
47
+ bmp: 'image/bmp',
48
+ tiff: 'image/tiff',
49
+ tif: 'image/tiff',
50
+ weba: 'audio/webm',
51
+ wav: 'audio/wav',
52
+ ogg: 'audio/ogg',
53
+ flac: 'audio/flac',
54
+ aac: 'audio/aac',
55
+ midi: 'audio/midi'
56
+ }
package/src/Route.js CHANGED
@@ -7,62 +7,7 @@ const MiddlewareChain = require('./Route/Middleware.js')
7
7
  const {WebSocketServer} = require('./WebSocket.js')
8
8
 
9
9
  var routes2 = {}
10
- const mime = {
11
- html: 'text/html',
12
- css: 'text/css',
13
- js: 'text/javascript',
14
- json: 'application/json',
15
- png: 'image/png',
16
- jpg: 'image/jpg',
17
- jpeg: 'image/jpeg',
18
- svg: 'image/svg+xml',
19
- ico: 'image/x-icon',
20
- mp3: 'audio/mpeg',
21
- mp4: 'video/mp4',
22
- webm: 'video/webm',
23
- woff: 'font/woff',
24
- woff2: 'font/woff2',
25
- ttf: 'font/ttf',
26
- otf: 'font/otf',
27
- eot: 'font/eot',
28
- pdf: 'application/pdf',
29
- zip: 'application/zip',
30
- tar: 'application/x-tar',
31
- gz: 'application/gzip',
32
- rar: 'application/x-rar-compressed',
33
- '7z': 'application/x-7z-compressed',
34
- txt: 'text/plain',
35
- log: 'text/plain',
36
- csv: 'text/csv',
37
- xml: 'text/xml',
38
- rss: 'application/rss+xml',
39
- atom: 'application/atom+xml',
40
- yaml: 'application/x-yaml',
41
- sh: 'application/x-sh',
42
- bat: 'application/x-bat',
43
- exe: 'application/x-exe',
44
- bin: 'application/x-binary',
45
- doc: 'application/msword',
46
- docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
47
- xls: 'application/vnd.ms-excel',
48
- xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49
- ppt: 'application/vnd.ms-powerpoint',
50
- pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
51
- avi: 'video/x-msvideo',
52
- wmv: 'video/x-ms-wmv',
53
- flv: 'video/x-flv',
54
- webp: 'image/webp',
55
- gif: 'image/gif',
56
- bmp: 'image/bmp',
57
- tiff: 'image/tiff',
58
- tif: 'image/tiff',
59
- weba: 'audio/webm',
60
- wav: 'audio/wav',
61
- ogg: 'audio/ogg',
62
- flac: 'audio/flac',
63
- aac: 'audio/aac',
64
- midi: 'audio/midi'
65
- }
10
+ const mime = require('./Route/MimeTypes.js')
66
11
 
67
12
  class Route {
68
13
  loading = false
@@ -122,15 +67,37 @@ class Route {
122
67
  if (middlewareResult !== undefined) return middlewareResult
123
68
 
124
69
  if (controller.action) {
125
- const ControllerClass = controller.cache
70
+ const ControllerModule = controller.cache
71
+ const actionParts = controller.action.split('.')
72
+
126
73
  try {
127
- const instance = new ControllerClass(Odac)
128
- if (typeof instance[controller.action] === 'function') {
129
- return instance[controller.action](Odac)
74
+ const instance = new ControllerModule(Odac)
75
+ let method = instance
76
+ let context = instance
77
+
78
+ for (const segment of actionParts) {
79
+ if (method) {
80
+ context = method
81
+ method = method[segment]
82
+ }
83
+ }
84
+
85
+ if (typeof method === 'function') {
86
+ return method.call(context, Odac)
130
87
  }
131
88
  } catch {
132
- if (typeof ControllerClass[controller.action] === 'function') {
133
- return ControllerClass[controller.action](Odac)
89
+ let method = ControllerModule
90
+ let context = ControllerModule
91
+
92
+ for (const segment of actionParts) {
93
+ if (method) {
94
+ context = method
95
+ method = method[segment]
96
+ }
97
+ }
98
+
99
+ if (typeof method === 'function') {
100
+ return method.call(context, Odac)
134
101
  }
135
102
  }
136
103
  return Odac.Request.abort(500)
@@ -144,6 +111,9 @@ class Route {
144
111
  async check(Odac) {
145
112
  let url = Odac.Request.url.split('?')[0]
146
113
  if (url.endsWith('/')) url = url.slice(0, -1)
114
+ // Global Auth Check: Load user if valid tokens exist to simplify DX
115
+ // This allows calling Odac.Auth.user() anywhere without manual await Odac.Auth.check()
116
+ if (Odac.Auth) await Odac.Auth.check()
147
117
 
148
118
  if (url.startsWith('/_odac/')) {
149
119
  Odac.Request.route = '_odac_internal'
@@ -882,8 +852,15 @@ class Route {
882
852
  }
883
853
  })
884
854
 
855
+ // Global Auth Check: Load user if valid tokens exist to simplify DX
856
+ // This allows calling Odac.Auth.user() anywhere without manual await Odac.Auth.check()
857
+ if (Odac.Auth) await Odac.Auth.check()
858
+
885
859
  if (requireAuth) {
886
- const isAuthenticated = await Odac.Auth.check()
860
+ let isAuthenticated = false
861
+ if (Odac.Auth) {
862
+ isAuthenticated = await Odac.Auth.check()
863
+ }
887
864
  if (!isAuthenticated) {
888
865
  ws.close(4001, 'Unauthorized')
889
866
  return
package/src/View/Form.js CHANGED
@@ -3,6 +3,18 @@ const nodeCrypto = require('crypto')
3
3
  class Form {
4
4
  static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
5
5
 
6
+ static escapeHtml(value) {
7
+ if (value === null || value === undefined) return ''
8
+ const map = {
9
+ '&': '&amp;',
10
+ '<': '&lt;',
11
+ '>': '&gt;',
12
+ '"': '&quot;',
13
+ "'": '&#39;'
14
+ }
15
+ return String(value).replace(/[&<>"']/g, ch => map[ch])
16
+ }
17
+
6
18
  static parse(content, Odac) {
7
19
  for (const type of this.FORM_TYPES) {
8
20
  content = this.parseFormType(content, Odac, type)
@@ -14,8 +26,14 @@ class Form {
14
26
  const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
15
27
  return content.replace(regex, match => {
16
28
  const formConfig = this.extractConfig(match, null, type)
17
- const configStr = JSON.stringify(formConfig)
18
- const matchStr = JSON.stringify(match)
29
+ let configStr = JSON.stringify(formConfig)
30
+ let matchStr = JSON.stringify(match)
31
+
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()) + "')
36
+
19
37
  return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
20
38
  })
21
39
  }
@@ -91,7 +109,7 @@ class Form {
91
109
  if (redirectMatch) config.redirect = redirectMatch[1]
92
110
  if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
93
111
 
94
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
112
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
95
113
  if (submitMatch) {
96
114
  const submitTag = submitMatch[1]
97
115
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -145,22 +163,25 @@ class Form {
145
163
  id: null,
146
164
  unique: false,
147
165
  skip: false,
166
+ value: null,
148
167
  validations: []
149
168
  }
150
169
 
151
- const typeMatch = fieldTag.match(/type=["']([^"']+)["']/)
152
- const placeholderMatch = fieldTag.match(/placeholder=["']([^"']+)["']/)
153
- const labelMatch = fieldTag.match(/label=["']([^"']+)["']/)
154
- const classMatch = fieldTag.match(/class=["']([^"']+)["']/)
155
- const idMatch = fieldTag.match(/id=["']([^"']+)["']/)
170
+ const typeMatch = fieldTag.match(/type=(["'])(.*?)\1/)
171
+ const placeholderMatch = fieldTag.match(/placeholder=(["'])(.*?)\1/)
172
+ const labelMatch = fieldTag.match(/label=(["'])(.*?)\1/)
173
+ const classMatch = fieldTag.match(/class=(["'])(.*?)\1/)
174
+ const idMatch = fieldTag.match(/id=(["'])(.*?)\1/)
175
+ const valueMatch = fieldTag.match(/value=(["'])(.*?)\1/)
156
176
  const uniqueMatch = fieldTag.match(/unique=["']([^"']+)["']/) || fieldTag.match(/\sunique[\s/>]/)
157
177
  const skipMatch = fieldTag.match(/skip=["']([^"']+)["']/) || fieldTag.match(/\sskip[\s/>]/)
158
178
 
159
- if (typeMatch) field.type = typeMatch[1]
160
- if (placeholderMatch) field.placeholder = placeholderMatch[1]
161
- if (labelMatch) field.label = labelMatch[1]
162
- if (classMatch) field.class = classMatch[1]
163
- if (idMatch) field.id = idMatch[1]
179
+ if (typeMatch) field.type = typeMatch[2]
180
+ if (placeholderMatch) field.placeholder = placeholderMatch[2]
181
+ if (labelMatch) field.label = labelMatch[2]
182
+ if (classMatch) field.class = classMatch[2]
183
+ if (idMatch) field.id = idMatch[2]
184
+ if (valueMatch) field.value = valueMatch[2]
164
185
  if (uniqueMatch) field.unique = uniqueMatch[1] !== 'false'
165
186
  if (skipMatch) field.skip = skipMatch[1] !== 'false'
166
187
 
@@ -181,7 +202,7 @@ class Form {
181
202
 
182
203
  // Capture generic attributes
183
204
  const extraAttrs = {}
184
- const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
205
+ const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
185
206
  const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
186
207
  let attrMatch
187
208
  // Clean tag to just attributes part for safer regex matching if needed,
@@ -203,11 +224,11 @@ class Form {
203
224
  }
204
225
 
205
226
  static parseSet(html) {
206
- const nameMatch = html.match(/name=["']([^"']+)["']/)
227
+ const nameMatch = html.match(/name=(["'])(.*?)\1/)
207
228
  if (!nameMatch) return null
208
229
 
209
230
  const set = {
210
- name: nameMatch[1],
231
+ name: nameMatch[2],
211
232
  value: null,
212
233
  compute: null,
213
234
  callback: null,
@@ -215,14 +236,14 @@ class Form {
215
236
  }
216
237
 
217
238
  const valueMatch = html.match(/value=(["'])(.*?)\1/)
218
- const computeMatch = html.match(/compute=["']([^"']+)["']/)
219
- const callbackMatch = html.match(/callback=["']([^"']+)["']/)
220
- const ifEmptyMatch = html.match(/if-empty=["']([^"']+)["']/) || html.match(/\sif-empty[\s/>]/)
239
+ const computeMatch = html.match(/compute=(["'])(.*?)\1/)
240
+ const callbackMatch = html.match(/callback=(["'])(.*?)\1/)
241
+ const ifEmptyMatch = html.match(/if-empty=(["'])(.*?)\1/) || html.match(/\sif-empty[\s/>]/)
221
242
 
222
243
  if (valueMatch) set.value = valueMatch[2]
223
- if (computeMatch) set.compute = computeMatch[1]
224
- if (callbackMatch) set.callback = callbackMatch[1]
225
- if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[1] !== 'false'
244
+ if (computeMatch) set.compute = computeMatch[2]
245
+ if (callbackMatch) set.callback = callbackMatch[2]
246
+ if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[2] !== 'false'
226
247
 
227
248
  return set
228
249
  }
@@ -253,6 +274,9 @@ class Form {
253
274
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
254
275
  const field = this.parseInput(fieldMatch)
255
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
256
280
  return this.generateFieldHtml(field)
257
281
  })
258
282
 
@@ -279,31 +303,38 @@ class Form {
279
303
 
280
304
  static generateFieldHtml(field) {
281
305
  let html = ''
306
+ const escapedName = this.escapeHtml(field.name)
307
+ const escapedType = this.escapeHtml(field.type)
308
+ const escapedPlaceholder = this.escapeHtml(field.placeholder)
282
309
 
283
310
  if (field.label && field.type !== 'checkbox') {
284
- const fieldId = field.id || `odac-${field.name}`
285
- html += `<label for="${fieldId}">${field.label}</label>\n`
311
+ const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
312
+ html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
286
313
  }
287
314
 
288
- const classAttr = field.class ? ` class="${field.class}"` : ''
289
- const idAttr = field.id ? ` id="${field.id}"` : ` id="odac-${field.name}"`
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)}"` : ''
290
318
 
291
319
  if (field.type === 'checkbox') {
292
320
  const attrs = this.buildHtml5Attributes(field)
321
+ const checkedAttr = field.value === '1' || field.value === true || field.value === 'true' ? ' checked' : ''
293
322
  if (field.label) {
294
323
  html += `<label>\n`
295
- html += ` <input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
296
- html += ` ${field.label}\n`
324
+ html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
325
+ html += ` ${this.escapeHtml(field.label)}\n`
297
326
  html += `</label>\n`
298
327
  } else {
299
- html += `<input type="checkbox"${idAttr} name="${field.name}" value="1"${classAttr}${attrs}>\n`
328
+ html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
300
329
  }
301
330
  } else if (field.type === 'textarea') {
302
331
  const attrs = this.buildHtml5Attributes(field)
303
- html += `<textarea${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}></textarea>\n`
332
+ html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
333
+ field.value || ''
334
+ )}</textarea>\n`
304
335
  } else {
305
336
  const attrs = this.buildHtml5Attributes(field)
306
- html += `<input type="${field.type}"${idAttr} name="${field.name}" placeholder="${field.placeholder}"${classAttr}${attrs}>\n`
337
+ html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
307
338
  }
308
339
 
309
340
  return html
@@ -319,7 +350,7 @@ class Form {
319
350
  if (val === '') {
320
351
  attrs += ` ${key}`
321
352
  } else {
322
- attrs += ` ${key}="${val.replace(/"/g, '&quot;')}"`
353
+ attrs += ` ${key}="${this.escapeHtml(val)}"`
323
354
  }
324
355
  }
325
356
  }
@@ -406,11 +437,11 @@ class Form {
406
437
  if (html5Rules.max) attrs += ` max="${html5Rules.max}"`
407
438
  if (html5Rules.pattern) attrs += ` pattern="${html5Rules.pattern}"`
408
439
 
409
- if (errorMessages.required) attrs += ` data-error-required="${errorMessages.required.replace(/"/g, '&quot;')}"`
410
- if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength.replace(/"/g, '&quot;')}"`
411
- if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength.replace(/"/g, '&quot;')}"`
412
- if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern.replace(/"/g, '&quot;')}"`
413
- if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email.replace(/"/g, '&quot;')}"`
440
+ if (errorMessages.required) attrs += ` data-error-required="${this.escapeHtml(errorMessages.required)}"`
441
+ if (errorMessages.minlength) attrs += ` data-error-minlength="${this.escapeHtml(errorMessages.minlength)}"`
442
+ if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
443
+ if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
444
+ if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
414
445
 
415
446
  attrs = this.appendExtraAttributes(attrs, field)
416
447
 
@@ -434,7 +465,7 @@ class Form {
434
465
 
435
466
  if (redirectMatch) config.redirect = redirectMatch[1]
436
467
 
437
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
468
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
438
469
  if (submitMatch) {
439
470
  const submitTag = submitMatch[1]
440
471
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -489,6 +520,9 @@ class Form {
489
520
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
490
521
  const field = this.parseInput(fieldMatch)
491
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
492
526
  return this.generateFieldHtml(field)
493
527
  })
494
528
 
@@ -551,8 +585,10 @@ class Form {
551
585
  if (tableMatch) config.table = tableMatch
552
586
  if (redirectMatch) config.redirect = redirectMatch
553
587
  if (successMatch) config.successMessage = successMatch
588
+ const clearMatch = extractAttr('clear')
589
+ if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
554
590
 
555
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
591
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
556
592
  if (submitMatch) {
557
593
  const submitTag = submitMatch[1]
558
594
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -618,29 +654,30 @@ class Form {
618
654
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
619
655
  const field = this.parseInput(fieldMatch)
620
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
621
660
  return this.generateFieldHtml(field)
622
661
  })
623
662
 
624
- const escapeHtml = str =>
625
- String(str).replace(/[&<>"']/g, m => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[m])
626
-
627
663
  const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
628
664
  if (submitMatch) {
629
- let submitAttrs = `type="submit" data-submit-text="${escapeHtml(submitText)}" data-loading-text="${escapeHtml(submitLoading)}"`
630
- if (config.submitClass) submitAttrs += ` class="${escapeHtml(config.submitClass)}"`
631
- if (config.submitStyle) submitAttrs += ` style="${escapeHtml(config.submitStyle)}"`
632
- if (config.submitId) submitAttrs += ` id="${escapeHtml(config.submitId)}"`
633
- const submitButton = `<button ${submitAttrs}>${escapeHtml(submitText)}</button>`
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>`
634
670
  innerContent = innerContent.replace(submitMatch[0], submitButton)
635
671
  }
636
672
 
637
673
  innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
638
674
 
639
- let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
640
- if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
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}"`
641
678
 
642
679
  let html = `<form ${formAttrs}>\n`
643
- html += ` <input type="hidden" name="_odac_form_token" value="${escapeHtml(formToken)}">\n`
680
+ html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
644
681
  html += innerContent
645
682
  html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
646
683
  html += `</form>`
@@ -693,7 +730,7 @@ class Form {
693
730
  })
694
731
  }
695
732
 
696
- const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
733
+ const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
697
734
  if (submitMatch) {
698
735
  const submitTag = submitMatch[1]
699
736
  const textMatch = submitTag.match(/text=["']([^"']+)["']/)
@@ -751,6 +788,9 @@ class Form {
751
788
  innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
752
789
  const field = this.parseInput(fieldMatch)
753
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
754
794
  return this.generateFieldHtml(field)
755
795
  })
756
796
  }