structured-fw 0.7.2
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/Config.ts +47 -0
- package/LICENSE +21 -0
- package/README.md +332 -0
- package/app/Types.ts +1 -0
- package/app/models/README.md +9 -0
- package/app/routes/README.md +19 -0
- package/app/views/README.md +1 -0
- package/app/views/layout.html +1 -0
- package/bin/structured +114 -0
- package/build/Config.d.ts +2 -0
- package/build/Config.js +31 -0
- package/build/app/Types.d.ts +1 -0
- package/build/app/Types.js +1 -0
- package/build/app/models/Users.d.ts +0 -0
- package/build/app/models/Users.js +1 -0
- package/build/app/routes/Auth.d.ts +0 -0
- package/build/app/routes/Auth.js +1 -0
- package/build/app/routes/Test.d.ts +2 -0
- package/build/app/routes/Test.js +101 -0
- package/build/app/routes/Todo.d.ts +0 -0
- package/build/app/routes/Todo.js +1 -0
- package/build/app/routes/Upload.d.ts +0 -0
- package/build/app/routes/Upload.js +1 -0
- package/build/app/routes/Validation.d.ts +2 -0
- package/build/app/routes/Validation.js +34 -0
- package/build/app/views/components/ClientImport/ClientImport.client.d.ts +2 -0
- package/build/app/views/components/ClientImport/ClientImport.client.js +4 -0
- package/build/app/views/components/ClientImport/Export.d.ts +1 -0
- package/build/app/views/components/ClientImport/Export.js +1 -0
- package/build/app/views/components/Conditionals/Conditionals.client.d.ts +2 -0
- package/build/app/views/components/Conditionals/Conditionals.client.js +43 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.d.ts +8 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.js +7 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.d.ts +2 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.js +5 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.d.ts +0 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.js +1 -0
- package/build/app/views/components/PassObject/PassObject.d.ts +10 -0
- package/build/app/views/components/PassObject/PassObject.js +10 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.d.ts +6 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.d.ts +2 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.d.ts +8 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.js +8 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.d.ts +7 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.js +10 -0
- package/build/assets/ts/Export.d.ts +1 -0
- package/build/assets/ts/Export.js +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +3 -0
- package/build/system/Helpers.d.ts +3 -0
- package/build/system/Helpers.js +72 -0
- package/build/system/Symbols.d.ts +3 -0
- package/build/system/Symbols.js +3 -0
- package/build/system/Types.d.ts +171 -0
- package/build/system/Types.js +1 -0
- package/build/system/Util.d.ts +20 -0
- package/build/system/Util.js +336 -0
- package/build/system/client/App.d.ts +7 -0
- package/build/system/client/App.js +8 -0
- package/build/system/client/Client.d.ts +6 -0
- package/build/system/client/Client.js +9 -0
- package/build/system/client/ClientComponent.d.ts +68 -0
- package/build/system/client/ClientComponent.js +734 -0
- package/build/system/client/DataStore.d.ts +22 -0
- package/build/system/client/DataStore.js +64 -0
- package/build/system/client/DataStoreView.d.ts +19 -0
- package/build/system/client/DataStoreView.js +56 -0
- package/build/system/client/EventEmitter.d.ts +7 -0
- package/build/system/client/EventEmitter.js +31 -0
- package/build/system/client/Net.d.ts +13 -0
- package/build/system/client/Net.js +39 -0
- package/build/system/client/NetRequest.d.ts +13 -0
- package/build/system/client/NetRequest.js +45 -0
- package/build/system/server/Application.d.ts +31 -0
- package/build/system/server/Application.js +171 -0
- package/build/system/server/Component.d.ts +27 -0
- package/build/system/server/Component.js +249 -0
- package/build/system/server/Components.d.ts +12 -0
- package/build/system/server/Components.js +77 -0
- package/build/system/server/Cookies.d.ts +6 -0
- package/build/system/server/Cookies.js +19 -0
- package/build/system/server/Document.d.ts +24 -0
- package/build/system/server/Document.js +107 -0
- package/build/system/server/DocumentHead.d.ts +32 -0
- package/build/system/server/DocumentHead.js +118 -0
- package/build/system/server/FormValidation.d.ts +16 -0
- package/build/system/server/FormValidation.js +197 -0
- package/build/system/server/Handlebars.d.ts +11 -0
- package/build/system/server/Handlebars.js +34 -0
- package/build/system/server/Request.d.ts +21 -0
- package/build/system/server/Request.js +356 -0
- package/build/system/server/Session.d.ts +23 -0
- package/build/system/server/Session.js +114 -0
- package/build/system/server/dom/DOMFragment.d.ts +4 -0
- package/build/system/server/dom/DOMFragment.js +6 -0
- package/build/system/server/dom/DOMNode.d.ts +31 -0
- package/build/system/server/dom/DOMNode.js +110 -0
- package/build/system/server/dom/HTMLParser.d.ts +21 -0
- package/build/system/server/dom/HTMLParser.js +204 -0
- package/index.ts +4 -0
- package/package.json +31 -0
- package/system/Helpers.ts +97 -0
- package/system/Symbols.ts +6 -0
- package/system/Types.ts +234 -0
- package/system/Util.ts +488 -0
- package/system/client/App.ts +11 -0
- package/system/client/Client.ts +9 -0
- package/system/client/ClientComponent.ts +1117 -0
- package/system/client/DataStore.ts +101 -0
- package/system/client/DataStoreView.ts +82 -0
- package/system/client/EventEmitter.ts +38 -0
- package/system/client/Net.ts +58 -0
- package/system/client/NetRequest.ts +64 -0
- package/system/server/Application.ts +230 -0
- package/system/server/Component.ts +404 -0
- package/system/server/Components.ts +111 -0
- package/system/server/Cookies.ts +29 -0
- package/system/server/Document.ts +163 -0
- package/system/server/DocumentHead.ts +150 -0
- package/system/server/FormValidation.ts +231 -0
- package/system/server/Handlebars.ts +51 -0
- package/system/server/Request.ts +497 -0
- package/system/server/Session.ts +151 -0
- package/system/server/dom/DOMFragment.ts +7 -0
- package/system/server/dom/DOMNode.ts +140 -0
- package/system/server/dom/HTMLParser.ts +238 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ServerResponse } from 'node:http';
|
|
2
|
+
import { Md5 } from 'ts-md5';
|
|
3
|
+
|
|
4
|
+
import { Initializers, LooseObject, RequestContext, StructuredClientConfig } from '../../system/Types.js';
|
|
5
|
+
import { Application } from './Application.js';
|
|
6
|
+
import { DocumentHead } from './DocumentHead.js';
|
|
7
|
+
import { Component } from './Component.js';
|
|
8
|
+
import { attributeValueToString, randomString } from '../Util.js';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
export class Document extends Component {
|
|
13
|
+
|
|
14
|
+
head: DocumentHead;
|
|
15
|
+
language = 'en';
|
|
16
|
+
application: Application;
|
|
17
|
+
|
|
18
|
+
initializers: Initializers = {};
|
|
19
|
+
initializersInitialized: boolean = false;
|
|
20
|
+
|
|
21
|
+
componentIds: Array<string> = [];
|
|
22
|
+
|
|
23
|
+
ctx: undefined|RequestContext;
|
|
24
|
+
|
|
25
|
+
appendHTML: string = '';
|
|
26
|
+
|
|
27
|
+
constructor(app: Application, title: string, ctx?: RequestContext) {
|
|
28
|
+
super('root');
|
|
29
|
+
this.application = app;
|
|
30
|
+
this.ctx = ctx;
|
|
31
|
+
this.document = this;
|
|
32
|
+
this.head = new DocumentHead(title);
|
|
33
|
+
|
|
34
|
+
// include client side JS, not an actual URL, Application.ts adds a request handler
|
|
35
|
+
// for routes starting with /assets/client-js/
|
|
36
|
+
this.head.addJS('/assets/client-js/client/Client.js', 0, { type: 'module' });
|
|
37
|
+
|
|
38
|
+
this.application.emit('documentCreated', this);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// HTTP2 push, Link headers
|
|
43
|
+
push(response: ServerResponse): void {
|
|
44
|
+
const resourcesJS = this.head.js.map((resource) => {
|
|
45
|
+
return `<${resource.path}>; rel=${this.application.config.http.linkHeaderRel}; as=script; crossorigin=anonymous`;
|
|
46
|
+
});
|
|
47
|
+
const resourcesCSS = this.head.css.map((resource) => {
|
|
48
|
+
return `<${resource.path}>; rel=${this.application.config.http.linkHeaderRel}; as=style; crossorigin=anonymous`;
|
|
49
|
+
});
|
|
50
|
+
const value = resourcesCSS.concat(resourcesJS).join(', ');
|
|
51
|
+
response.setHeader('Link', value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
body(): string {
|
|
55
|
+
return this.dom.innerHTML + '\n' + this.appendHTML;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public initInitializers(): Record<string, string> {
|
|
59
|
+
const initializers: {
|
|
60
|
+
[key: string] : string
|
|
61
|
+
} = {};
|
|
62
|
+
|
|
63
|
+
for (const name in this.initializers) {
|
|
64
|
+
initializers[name] = this.initializers[name].toString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const initializersString = '<script type="application/javascript">window.initializers = ' + JSON.stringify(initializers) + '</script>';
|
|
68
|
+
|
|
69
|
+
this.head.add(initializersString);
|
|
70
|
+
this.initializersInitialized = true;
|
|
71
|
+
return initializers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private initClientConfig(): void {
|
|
75
|
+
const clientConf: StructuredClientConfig = {
|
|
76
|
+
componentRender: this.application.config.url.componentRender,
|
|
77
|
+
componentNameAttribute: this.application.config.components.componentNameAttribute
|
|
78
|
+
}
|
|
79
|
+
const clientConfString = `<script type="application/javascript">window.structuredClientConfig = ${JSON.stringify(clientConf)}</script>`;
|
|
80
|
+
this.head.add(clientConfString);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public toString(): string {
|
|
84
|
+
|
|
85
|
+
if (! this.initializersInitialized) {
|
|
86
|
+
this.initInitializers();
|
|
87
|
+
this.initClientConfig();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return `<!DOCTYPE html>
|
|
91
|
+
<html lang="${this.language}">
|
|
92
|
+
${this.head.toString()}
|
|
93
|
+
<body>
|
|
94
|
+
${this.body()}
|
|
95
|
+
</body>
|
|
96
|
+
</html>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// generate an unique component id and store it to componentIds
|
|
100
|
+
// so that each component within the document has an unique id
|
|
101
|
+
allocateId(component: Component): string {
|
|
102
|
+
if (! this.componentIds) {
|
|
103
|
+
// if auto initialized it may have not yet initialized it as an empty array
|
|
104
|
+
this.componentIds = [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// if component has data-id then md5(ComponentName:id), otherwise md5(ComponentName:DOM path:attributes JSON string)
|
|
108
|
+
let id = Md5.hashStr(`${component.name}:${'id' in component.attributes ? component.attributes.id : `${component.path.join('/')}:${JSON.stringify(component.attributesRaw)}`}`);
|
|
109
|
+
|
|
110
|
+
// but multiple components might render the exact same thing
|
|
111
|
+
// so in those cases travel up the tree and append the MD5 sum of the parent
|
|
112
|
+
if (this.componentIds.includes(id)) {
|
|
113
|
+
let current: Component|Document|null = component.parent;
|
|
114
|
+
|
|
115
|
+
do {
|
|
116
|
+
if (current === null || current.isRoot) {
|
|
117
|
+
// reached root without being able to uniquely identify
|
|
118
|
+
// resort to a random string
|
|
119
|
+
// these components won't work as expected
|
|
120
|
+
// they will lose access to their store (client side) whenever they or their parent is redrawn
|
|
121
|
+
console.error(`Could not define an unique ID for component ${component.name}, path: ${component.path}`);
|
|
122
|
+
id = randomString(16);
|
|
123
|
+
} else {
|
|
124
|
+
id += '-' + Md5.hashStr(current.dom.outerHTML);
|
|
125
|
+
}
|
|
126
|
+
current = current?.parent || null;
|
|
127
|
+
} while(this.componentIds.includes(id));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.componentIds.push(id);
|
|
131
|
+
|
|
132
|
+
return id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// load the view from file system
|
|
136
|
+
public async loadView(pathRelative: string, data?: LooseObject): Promise<boolean> {
|
|
137
|
+
const viewPath = path.resolve('../' + this.application.config.components.path + '/' + pathRelative + (pathRelative.endsWith('.html') ? '' : '.html'));
|
|
138
|
+
|
|
139
|
+
if (! existsSync(viewPath)) {
|
|
140
|
+
console.warn(`Couldn't load document ${this.document.head.title}: ${viewPath}`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const html = readFileSync(viewPath).toString();
|
|
145
|
+
|
|
146
|
+
await this.init(html, data);
|
|
147
|
+
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// load given component into this document
|
|
152
|
+
public async loadComponent(componentName: string, data?: LooseObject): Promise<void> {
|
|
153
|
+
const componentEntry = this.document.application.components.getByName(componentName);
|
|
154
|
+
if (componentEntry) {
|
|
155
|
+
const dataString = data === undefined ? '' : Object.keys(data).reduce((prev, key) => {
|
|
156
|
+
prev.push(`data-${key}="${attributeValueToString(key, data[key])}"`)
|
|
157
|
+
return prev;
|
|
158
|
+
}, [] as Array<string>).join(' ');
|
|
159
|
+
await this.init(`<${componentName} ${dataString}></${componentName}>`, data);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { DocumentResource } from '../Types';
|
|
2
|
+
|
|
3
|
+
export class DocumentHead {
|
|
4
|
+
|
|
5
|
+
title: string;
|
|
6
|
+
js: Array<DocumentResource> = [];
|
|
7
|
+
css: Array<DocumentResource> = [];
|
|
8
|
+
custom: Array<string> = [];
|
|
9
|
+
charset = 'UTF-8';
|
|
10
|
+
|
|
11
|
+
favicon: {
|
|
12
|
+
image: string|null,
|
|
13
|
+
type: string
|
|
14
|
+
} = {
|
|
15
|
+
image: null,
|
|
16
|
+
type: 'image/png'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
constructor(title: string) {
|
|
20
|
+
this.title = title;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public setTitle(title: string): void {
|
|
24
|
+
this.title = title;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public add(str: string): void {
|
|
28
|
+
this.custom.push(str);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public remove(str: string): void {
|
|
32
|
+
this.custom = this.custom.filter((strExisting) => {
|
|
33
|
+
return strExisting !== str;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public addJS(path: string, priority: number = 0, attributes: { [ attributeName: string ] : string|null } = {}): DocumentResource {
|
|
38
|
+
const resource = this.toResource(path, priority, attributes);
|
|
39
|
+
this.js.push(resource);
|
|
40
|
+
return resource;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public addCSS(path: string, priority: number = 0, attributes: { [ attributeName: string ] : string|null } = {}): DocumentResource {
|
|
44
|
+
const resource = this.toResource(path, priority, attributes);
|
|
45
|
+
this.css.push(resource);
|
|
46
|
+
return resource;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public removeJS(path: string): void {
|
|
50
|
+
const index = this.js.findIndex((resource) => {
|
|
51
|
+
return resource.path == path;
|
|
52
|
+
});
|
|
53
|
+
this.js.splice(index, 1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public removeCSS(path: string): void {
|
|
57
|
+
const index = this.css.findIndex((resource) => {
|
|
58
|
+
return resource.path == path;
|
|
59
|
+
});
|
|
60
|
+
this.css.splice(index, 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private toResource(path: string, priority: number = 0, attributes: { [ attributeName: string ] : string|null } = {}): DocumentResource {
|
|
64
|
+
return {
|
|
65
|
+
path,
|
|
66
|
+
priority,
|
|
67
|
+
attributes
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private attributesString(resource: DocumentResource): string {
|
|
72
|
+
let attributesString = '';
|
|
73
|
+
for (const attributeName in resource.attributes) {
|
|
74
|
+
const val = resource.attributes[attributeName]
|
|
75
|
+
if (val === null) {
|
|
76
|
+
attributesString += ` ${attributeName}`;
|
|
77
|
+
} else {
|
|
78
|
+
attributesString += ` ${attributeName}="${val}"`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return attributesString;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public toString(): string {
|
|
85
|
+
|
|
86
|
+
const css = this.css.reduce((prev, curr) => {
|
|
87
|
+
return prev + '\n' + `<link rel="stylesheet" href="${curr.path}"${this.attributesString(curr)}>`;
|
|
88
|
+
}, '');
|
|
89
|
+
|
|
90
|
+
const js = this.js.reduce((prev, curr) => {
|
|
91
|
+
return prev + '\n' + `<script src="${curr.path}"${this.attributesString(curr)}></script>`;
|
|
92
|
+
}, '');
|
|
93
|
+
|
|
94
|
+
const custom = this.custom.reduce((prev, curr) => {
|
|
95
|
+
return prev + '\n' + curr;
|
|
96
|
+
}, '');
|
|
97
|
+
|
|
98
|
+
return `<head>
|
|
99
|
+
<meta charset="${this.charset}">
|
|
100
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
101
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
102
|
+
<title>${this.title}</title>
|
|
103
|
+
<link rel="icon" type="${this.favicon.type}" href="${this.favicon.image}">
|
|
104
|
+
${css}
|
|
105
|
+
${js}
|
|
106
|
+
${custom}
|
|
107
|
+
</head>`;
|
|
108
|
+
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public setFavicon(faviconPath: string|{
|
|
112
|
+
image: string|null,
|
|
113
|
+
type: string
|
|
114
|
+
}): void {
|
|
115
|
+
if (typeof faviconPath === 'string') {
|
|
116
|
+
this.favicon = {
|
|
117
|
+
image: faviconPath,
|
|
118
|
+
type: this.faviconType(faviconPath)
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// favicon given as object
|
|
123
|
+
if (faviconPath.type === '') {
|
|
124
|
+
// detect type
|
|
125
|
+
faviconPath.type = faviconPath.image ? this.faviconType(faviconPath.image) : 'image/png';
|
|
126
|
+
}
|
|
127
|
+
this.favicon = faviconPath;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private faviconType(file: string): string {
|
|
131
|
+
let ext: RegExpExecArray|string|null = /\.([^.]+)$/.exec(file);
|
|
132
|
+
let type = 'image/png';
|
|
133
|
+
if (ext !== null) {
|
|
134
|
+
ext = ext[1].toLowerCase();
|
|
135
|
+
const types: {
|
|
136
|
+
[key: string] : string
|
|
137
|
+
} = {
|
|
138
|
+
'png' : 'image/png',
|
|
139
|
+
'jpg' : 'image/jpeg',
|
|
140
|
+
'jpeg' : 'image/jpeg',
|
|
141
|
+
'gif' : 'image/gif',
|
|
142
|
+
'ico' : 'image/x-icon'
|
|
143
|
+
}
|
|
144
|
+
if (types[ext]) {
|
|
145
|
+
type = types[ext];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return type;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { FormValidationEntry, PostedDataDecoded, ValidationErrors, ValidationErrorsSingle, ValidationResult, ValidationRuleWithArguments, ValidatorErrorDecorator, ValidatorFunction } from '../Types';
|
|
2
|
+
|
|
3
|
+
export class FormValidation {
|
|
4
|
+
|
|
5
|
+
fieldRules: Array<FormValidationEntry> = [];
|
|
6
|
+
|
|
7
|
+
// if true, only a single error is kept per field
|
|
8
|
+
singleError: boolean = false;
|
|
9
|
+
|
|
10
|
+
validators: {
|
|
11
|
+
[name: string] : ValidatorFunction
|
|
12
|
+
} = {
|
|
13
|
+
'required' : async (data, field) => {
|
|
14
|
+
if (! (field in data)) {
|
|
15
|
+
// field missing but required
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const value = data[field];
|
|
20
|
+
if (typeof value !== 'string') {return false;}
|
|
21
|
+
|
|
22
|
+
// field exists, but consider empty strings non valid
|
|
23
|
+
return value.trim().length > 0;
|
|
24
|
+
},
|
|
25
|
+
'number' : async (data, field) => {
|
|
26
|
+
// does not need to be a number, but rather contain only numbers
|
|
27
|
+
// eg. 14
|
|
28
|
+
const value = data[field];
|
|
29
|
+
if (typeof value !== 'string') {return false;}
|
|
30
|
+
return /^-?\d+$/.test(value);
|
|
31
|
+
},
|
|
32
|
+
'float' : async (data, field) => {
|
|
33
|
+
// 14.2
|
|
34
|
+
const value = data[field];
|
|
35
|
+
if (typeof value !== 'string') {return false;}
|
|
36
|
+
return /^-?\d+\.\d+$/.test(value);
|
|
37
|
+
},
|
|
38
|
+
'numeric' : async (data, field, arg, rules) => {
|
|
39
|
+
// 14 or 14.2
|
|
40
|
+
return await this.validators['number'](data, field, arg, rules) || await this.validators['float'](data, field, arg, rules);
|
|
41
|
+
},
|
|
42
|
+
'min' : async(data, field, arg, rules) => {
|
|
43
|
+
const value = data[field];
|
|
44
|
+
if (typeof value !== 'string') {return false;}
|
|
45
|
+
if (await this.validators['numeric'](data, field, arg, rules)) {
|
|
46
|
+
return parseFloat(value) >= arg;
|
|
47
|
+
}
|
|
48
|
+
// non numeric value, can't be determined so consider invalid
|
|
49
|
+
return false;
|
|
50
|
+
},
|
|
51
|
+
'max' : async(data, field, arg, rules) => {
|
|
52
|
+
const value = data[field];
|
|
53
|
+
if (typeof value !== 'string') {return false;}
|
|
54
|
+
if (await this.validators['numeric'](data, field, arg, rules)) {
|
|
55
|
+
return parseFloat(value) <= arg;
|
|
56
|
+
}
|
|
57
|
+
// non numeric value, can't be determined so consider invalid
|
|
58
|
+
return false;
|
|
59
|
+
},
|
|
60
|
+
'minLength' : async (data, field, arg) => {
|
|
61
|
+
const value = data[field];
|
|
62
|
+
if (typeof value !== 'string') {return false;}
|
|
63
|
+
return value.length >= arg;
|
|
64
|
+
},
|
|
65
|
+
'maxLength' : async (data, field, arg) => {
|
|
66
|
+
const value = data[field];
|
|
67
|
+
if (typeof value !== 'string') {return false;}
|
|
68
|
+
return value.length <= arg;
|
|
69
|
+
},
|
|
70
|
+
'alphanumeric' : async (data, field) => {
|
|
71
|
+
const value = data[field];
|
|
72
|
+
if (typeof value !== 'string') {return false;}
|
|
73
|
+
// string must contain only letters and numbers
|
|
74
|
+
return /^[a-zA-Z0-9]+$/.test(value);
|
|
75
|
+
},
|
|
76
|
+
'validEmail' : async (data, field) => {
|
|
77
|
+
const value = data[field];
|
|
78
|
+
if (typeof value !== 'string') {return false;}
|
|
79
|
+
return /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/.test(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// functions that return error messages
|
|
84
|
+
decorators: {
|
|
85
|
+
[validatorName: string] : ValidatorErrorDecorator
|
|
86
|
+
} = {
|
|
87
|
+
'required' : (fieldHuman) => {
|
|
88
|
+
return `${fieldHuman} is required`;
|
|
89
|
+
},
|
|
90
|
+
'number' : (fieldHuman) => {
|
|
91
|
+
return `${fieldHuman} has to be a whole number`;
|
|
92
|
+
},
|
|
93
|
+
'float' : (fieldHuman) => {
|
|
94
|
+
return `${fieldHuman} has to be a decimal number`;
|
|
95
|
+
},
|
|
96
|
+
'numeric' : (fieldHuman) => {
|
|
97
|
+
return `${fieldHuman} has to contain a numeric value`;
|
|
98
|
+
},
|
|
99
|
+
'min' : (fieldHuman, data, field, arg) => {
|
|
100
|
+
return `${fieldHuman} has to be a value greater than ${arg}`;
|
|
101
|
+
},
|
|
102
|
+
'max' : (fieldHuman, data, field, arg) => {
|
|
103
|
+
return `${fieldHuman} has to be a value lower than ${arg}`;
|
|
104
|
+
},
|
|
105
|
+
'minLength' : (fieldHuman, data, field, arg) => {
|
|
106
|
+
return `${fieldHuman} has to contain at least ${arg} characters`;
|
|
107
|
+
},
|
|
108
|
+
'maxLength' : (fieldHuman, data, field, arg) => {
|
|
109
|
+
return `${fieldHuman} has to contain no more than ${arg} characters`;
|
|
110
|
+
},
|
|
111
|
+
'alphanumeric' : (fieldHuman) => {
|
|
112
|
+
return `${fieldHuman} can contain only letter and numbers`;
|
|
113
|
+
},
|
|
114
|
+
'validEmail' : () => {
|
|
115
|
+
return `Please enter a valid email address`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public addRule(fieldName: string, nameHumanReadable: string, rules: Array<string|ValidationRuleWithArguments|ValidatorFunction>): void {
|
|
120
|
+
const rule: FormValidationEntry = {
|
|
121
|
+
field: [fieldName, nameHumanReadable],
|
|
122
|
+
rules
|
|
123
|
+
}
|
|
124
|
+
this.fieldRules.push(rule);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// register new/override existing validator
|
|
128
|
+
public registerValidator(name: string, validator: ValidatorFunction, decorator?: ValidatorErrorDecorator): void {
|
|
129
|
+
this.validators[name] = validator;
|
|
130
|
+
|
|
131
|
+
// if decorator is provided, store it
|
|
132
|
+
if (typeof decorator === 'function') {
|
|
133
|
+
this.decorators[name] = decorator;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
public registerDecorator(name: string, decorator: ValidatorErrorDecorator): void {
|
|
138
|
+
this.decorators[name] = decorator;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public async validate(data: PostedDataDecoded): Promise<ValidationResult> {
|
|
142
|
+
|
|
143
|
+
const result: ValidationResult = {
|
|
144
|
+
valid: true,
|
|
145
|
+
errors: {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// run all validation rules
|
|
149
|
+
for (let i = 0; i < this.fieldRules.length; i++) {
|
|
150
|
+
const entry = this.fieldRules[i];
|
|
151
|
+
|
|
152
|
+
const isRequired = entry.rules.includes('required');
|
|
153
|
+
|
|
154
|
+
const value = data[entry.field[0]];
|
|
155
|
+
const possiblyValidDataExists = typeof value === 'string';
|
|
156
|
+
|
|
157
|
+
// content - a non required field that is not passed or is blank
|
|
158
|
+
// will pass all checks, for example rules ['numeric']
|
|
159
|
+
// we expect the field to contain a numeric value, but we don't expect the field to exist in the first place
|
|
160
|
+
// se all validators are skipped for content entries
|
|
161
|
+
const isContent = ! isRequired && (! possiblyValidDataExists || value.trim().length === 0);
|
|
162
|
+
|
|
163
|
+
if (! isContent) {
|
|
164
|
+
for (let j = 0; j < entry.rules.length; j++) {
|
|
165
|
+
const rule = entry.rules[j];
|
|
166
|
+
|
|
167
|
+
if (typeof rule === 'function') {
|
|
168
|
+
// custom callback (ValidatorFunction)
|
|
169
|
+
const valid = await rule.apply(this, [data, entry.field[0], 0, entry.rules]);
|
|
170
|
+
if (! valid) {
|
|
171
|
+
await this.addError(result.errors, data, entry.field, 'callback');
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// uses a validator
|
|
175
|
+
if (typeof rule === 'string') {
|
|
176
|
+
// no arguments
|
|
177
|
+
if (this.validators[rule]) {
|
|
178
|
+
const valid = await this.validators[rule].apply(this, [data, entry.field[0], 0, entry.rules]);
|
|
179
|
+
if (! valid) {
|
|
180
|
+
await this.addError(result.errors, data, entry.field, rule);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
// rule with arguments
|
|
185
|
+
const validatorName = rule[0];
|
|
186
|
+
const arg = rule[1];
|
|
187
|
+
if (this.validators[validatorName]) {
|
|
188
|
+
const valid = await this.validators[validatorName].apply(this, [data, entry.field[0], arg, entry.rules]);
|
|
189
|
+
if (! valid) {
|
|
190
|
+
await this.addError(result.errors, data, entry.field, validatorName, arg);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// valid if no errors
|
|
202
|
+
result.valid = Object.keys(result.errors).length == 0;
|
|
203
|
+
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async addError(errors: ValidationErrors|ValidationErrorsSingle, data: PostedDataDecoded, field: [string, string], rule: string, arg?: any): Promise<void> {
|
|
208
|
+
// error will be a human readable error returned by decorator
|
|
209
|
+
// if no decorator is found for the rule, rule itself becomes the error
|
|
210
|
+
let errorMessage = '';
|
|
211
|
+
if (this.decorators[rule]) {
|
|
212
|
+
errorMessage = await this.decorators[rule](field[1], data, field[0], arg);
|
|
213
|
+
} else {
|
|
214
|
+
errorMessage = rule;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (! this.singleError) {
|
|
218
|
+
if (! errors[field[0]]) {
|
|
219
|
+
errors[field[0]] = [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
(errors as ValidationErrors)[field[0]].push(errorMessage);
|
|
223
|
+
} else {
|
|
224
|
+
// single error mode
|
|
225
|
+
if (! errors[field[0]]) {
|
|
226
|
+
errors[field[0]] = errorMessage;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { HelperDelegate } from "handlebars";
|
|
2
|
+
import { default as HandlebarsInstance } from 'handlebars';
|
|
3
|
+
import { LooseObject } from "../Types.js";
|
|
4
|
+
|
|
5
|
+
// handlebars helper manager
|
|
6
|
+
export class Handlebars {
|
|
7
|
+
|
|
8
|
+
readonly instance: typeof HandlebarsInstance = HandlebarsInstance;
|
|
9
|
+
readonly helpers: Record<string, HelperDelegate> = {};
|
|
10
|
+
|
|
11
|
+
// register a handlebars helper
|
|
12
|
+
// registers it to this.instance and stores it to this.helpers
|
|
13
|
+
// if needed later all helpers can be applied to another instance of handlebars
|
|
14
|
+
public register(name: string, helper: HelperDelegate): void {
|
|
15
|
+
this.helpers[name] = helper;
|
|
16
|
+
this.instance.registerHelper(name, helper);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// load helpers from given path and register them
|
|
20
|
+
public async loadHelpers(path: string): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
const helpers = await import(path) as {
|
|
23
|
+
default?: Record<string, HelperDelegate>
|
|
24
|
+
};
|
|
25
|
+
if (! ('default' in helpers)) {
|
|
26
|
+
throw new Error('File has no default export, expected default: Record<string, HelperDelegate>');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// register helpers
|
|
30
|
+
for (const name in helpers.default) {
|
|
31
|
+
this.register(name, helpers.default[name]);
|
|
32
|
+
}
|
|
33
|
+
} catch(e) {
|
|
34
|
+
throw new Error(e.message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// apply all registered helpers to given Handlebars instance
|
|
39
|
+
public applyTo(handlebarsInstance: typeof HandlebarsInstance): void {
|
|
40
|
+
for (const name in this.helpers) {
|
|
41
|
+
handlebarsInstance.registerHelper(name, this.helpers[name]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// given a HTML template that uses handlebars synthax and data
|
|
46
|
+
// compile and return resulting HTML
|
|
47
|
+
public compile(html: string, data: LooseObject): string {
|
|
48
|
+
const template = this.instance.compile(html);
|
|
49
|
+
return template(data);
|
|
50
|
+
}
|
|
51
|
+
}
|