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.
- package/.agent/rules/memory.md +10 -1
- package/.github/workflows/release.yml +1 -5
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +58 -0
- package/README.md +11 -1
- package/bin/odac.js +359 -6
- package/client/odac.js +15 -11
- package/docs/ai/README.md +49 -0
- package/docs/ai/skills/SKILL.md +40 -0
- package/docs/ai/skills/backend/authentication.md +74 -0
- package/docs/ai/skills/backend/config.md +39 -0
- package/docs/ai/skills/backend/controllers.md +69 -0
- package/docs/ai/skills/backend/cron.md +57 -0
- package/docs/ai/skills/backend/database.md +37 -0
- package/docs/ai/skills/backend/forms.md +26 -0
- package/docs/ai/skills/backend/ipc.md +62 -0
- package/docs/ai/skills/backend/mail.md +41 -0
- package/docs/ai/skills/backend/migrations.md +80 -0
- package/docs/ai/skills/backend/request_response.md +42 -0
- package/docs/ai/skills/backend/routing.md +58 -0
- package/docs/ai/skills/backend/storage.md +50 -0
- package/docs/ai/skills/backend/streaming.md +41 -0
- package/docs/ai/skills/backend/structure.md +64 -0
- package/docs/ai/skills/backend/translations.md +49 -0
- package/docs/ai/skills/backend/utilities.md +31 -0
- package/docs/ai/skills/backend/validation.md +60 -0
- package/docs/ai/skills/backend/views.md +68 -0
- package/docs/ai/skills/frontend/core.md +73 -0
- package/docs/ai/skills/frontend/forms.md +28 -0
- package/docs/ai/skills/frontend/navigation.md +27 -0
- package/docs/ai/skills/frontend/realtime.md +54 -0
- package/docs/backend/08-database/04-migrations.md +258 -37
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
- package/docs/backend/10-authentication/05-session-management.md +25 -3
- package/package.json +1 -1
- package/src/Auth.js +128 -17
- package/src/Config.js +1 -1
- package/src/Database/ConnectionFactory.js +69 -0
- package/src/Database/Migration.js +1203 -0
- package/src/Database.js +35 -35
- package/src/Route/Internal.js +21 -18
- package/src/Route/MimeTypes.js +56 -0
- package/src/Route.js +40 -63
- package/src/View/Form.js +91 -51
- package/src/View.js +8 -3
- package/template/schema/users.js +23 -0
- package/test/Auth.test.js +310 -0
- package/test/Client.test.js +29 -0
- package/test/Config.test.js +7 -0
- package/test/Database/ConnectionFactory.test.js +80 -0
- package/test/Migration.test.js +943 -0
- package/test/View/Form.test.js +37 -0
package/src/Database.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
const
|
|
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
|
-
|
|
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) {
|
package/src/Route/Internal.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
598
|
+
let method = controllerModule
|
|
599
|
+
let context = null
|
|
600
|
+
let isClass = false
|
|
601
|
+
|
|
609
602
|
if (typeof controllerModule === 'function' && controllerModule.prototype) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
70
|
+
const ControllerModule = controller.cache
|
|
71
|
+
const actionParts = controller.action.split('.')
|
|
72
|
+
|
|
126
73
|
try {
|
|
127
|
-
const instance = new
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
'&': '&',
|
|
10
|
+
'<': '<',
|
|
11
|
+
'>': '>',
|
|
12
|
+
'"': '"',
|
|
13
|
+
"'": '''
|
|
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
|
-
|
|
18
|
-
|
|
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([
|
|
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[
|
|
160
|
-
if (placeholderMatch) field.placeholder = placeholderMatch[
|
|
161
|
-
if (labelMatch) field.label = labelMatch[
|
|
162
|
-
if (classMatch) field.class = classMatch[
|
|
163
|
-
if (idMatch) field.id = idMatch[
|
|
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[
|
|
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=
|
|
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[
|
|
224
|
-
if (callbackMatch) set.callback = callbackMatch[
|
|
225
|
-
if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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}="${
|
|
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
|
|
410
|
-
if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength
|
|
411
|
-
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength
|
|
412
|
-
if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern
|
|
413
|
-
if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email
|
|
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([
|
|
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([
|
|
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 => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[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([
|
|
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
|
}
|