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