asjs-express 1.2.0 → 1.3.0
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/README.md +296 -0
- package/lib/asjs.js +306 -13
- package/lib/client/asjs-router.js +46 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -75,6 +75,74 @@ app.get('/', asjs.page('home', { title: 'Hello ASJS' }))
|
|
|
75
75
|
app.listen(3000)
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### Super simple one-page starter
|
|
79
|
+
|
|
80
|
+
If you want the lowest-friction starting point, use a single-page structure like this:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
my-app/
|
|
84
|
+
app.js
|
|
85
|
+
views/
|
|
86
|
+
layouts/
|
|
87
|
+
main.asjs
|
|
88
|
+
home.asjs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
const express = require('express')
|
|
93
|
+
const { setupAsjs } = require('asjs-express')
|
|
94
|
+
|
|
95
|
+
const app = express()
|
|
96
|
+
const asjs = setupAsjs(app, {
|
|
97
|
+
rootDir: __dirname,
|
|
98
|
+
defaultLayout: 'layouts/main',
|
|
99
|
+
navItems: [
|
|
100
|
+
{ href: '/', label: 'Home' }
|
|
101
|
+
],
|
|
102
|
+
transitions: 'fade',
|
|
103
|
+
prefetch: true,
|
|
104
|
+
loadingBar: true
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
app.get('/', asjs.page('home', {
|
|
108
|
+
title: 'My first ASJS page',
|
|
109
|
+
headline: 'ASJS works with almost no setup.',
|
|
110
|
+
description: 'Header, router, loading bar, and SPA-ready page transitions are already connected.'
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
app.use(asjs.errors())
|
|
114
|
+
app.listen(3000)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```asjs
|
|
118
|
+
<!DOCTYPE html>
|
|
119
|
+
<html>
|
|
120
|
+
<head>
|
|
121
|
+
<meta charset="UTF-8">
|
|
122
|
+
<title><%= title %></title>
|
|
123
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
124
|
+
</head>
|
|
125
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
126
|
+
<%- asjs.progressMarkup() %>
|
|
127
|
+
<%- asjs.header() %>
|
|
128
|
+
<main<%- asjs.viewAttrs() %>>
|
|
129
|
+
<%- body %>
|
|
130
|
+
</main>
|
|
131
|
+
</body>
|
|
132
|
+
</html>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```asjs
|
|
136
|
+
<section>
|
|
137
|
+
<h1><%= headline %></h1>
|
|
138
|
+
<p><%= description %></p>
|
|
139
|
+
</section>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This is enough for a working ASJS page.
|
|
143
|
+
The repository also ships the same idea as a real folder under `example-minimal/`.
|
|
144
|
+
You can run it with `npm run example:minimal`.
|
|
145
|
+
|
|
78
146
|
### Layout usage
|
|
79
147
|
|
|
80
148
|
```asjs
|
|
@@ -97,6 +165,86 @@ app.listen(3000)
|
|
|
97
165
|
`asjs.clientTags()` injects the built-in ASJS stylesheet and router script for you.
|
|
98
166
|
`theme: true` optionally loads the packaged light, corporate WebAS theme.
|
|
99
167
|
|
|
168
|
+
### Easiest Express SPA setup
|
|
169
|
+
|
|
170
|
+
If you want the easiest integration, build it in 3 files: `app.js`, `views/layouts/main.asjs`, and one page such as `views/home.asjs`.
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
const express = require('express')
|
|
174
|
+
const { setupAsjs } = require('asjs-express')
|
|
175
|
+
|
|
176
|
+
const app = express()
|
|
177
|
+
const asjs = setupAsjs(app, {
|
|
178
|
+
rootDir: __dirname,
|
|
179
|
+
defaultLayout: 'layouts/main',
|
|
180
|
+
navItems: [
|
|
181
|
+
{ href: '/', label: 'Home' },
|
|
182
|
+
{ href: '/about', label: 'About' },
|
|
183
|
+
{ href: '/contact', label: 'Contact' }
|
|
184
|
+
],
|
|
185
|
+
transitions: 'fade',
|
|
186
|
+
prefetch: true,
|
|
187
|
+
loadingBar: true
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const buildPage = asjs.createPageModel({
|
|
191
|
+
pageDescription: 'My first ASJS page',
|
|
192
|
+
renderSummary: []
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
app.get('/', asjs.createPageRoute('home', {
|
|
196
|
+
buildPage,
|
|
197
|
+
renderState: {
|
|
198
|
+
delay: 120,
|
|
199
|
+
label: 'Home page ready',
|
|
200
|
+
narrative: 'ASJS prepared this page model before the HTML response was sent.'
|
|
201
|
+
}
|
|
202
|
+
}, () => ({
|
|
203
|
+
title: 'Home',
|
|
204
|
+
heroTitle: 'Hello ASJS',
|
|
205
|
+
heroText: 'This page is server-rendered and SPA navigation is already active.'
|
|
206
|
+
})))
|
|
207
|
+
|
|
208
|
+
app.listen(3000)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```asjs
|
|
212
|
+
<!DOCTYPE html>
|
|
213
|
+
<html>
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="UTF-8">
|
|
216
|
+
<title><%= title %></title>
|
|
217
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
218
|
+
</head>
|
|
219
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
220
|
+
<%- asjs.progressMarkup() %>
|
|
221
|
+
<%- asjs.header() %>
|
|
222
|
+
<main<%- asjs.viewAttrs() %>>
|
|
223
|
+
<%- body %>
|
|
224
|
+
</main>
|
|
225
|
+
</body>
|
|
226
|
+
</html>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Default SPA loading and page transition animations are already included.
|
|
230
|
+
If you do not add a custom class, ASJS keeps the built-in loading bar and transition look.
|
|
231
|
+
If you want to style on top of them later, pass a class name instead of replacing the system.
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
const asjs = setupAsjs(app, {
|
|
235
|
+
rootDir: __dirname,
|
|
236
|
+
defaultLayout: 'layouts/main',
|
|
237
|
+
transitions: {
|
|
238
|
+
name: 'fade',
|
|
239
|
+
className: 'my-page-motion'
|
|
240
|
+
},
|
|
241
|
+
loadingBar: {
|
|
242
|
+
enabled: true,
|
|
243
|
+
className: 'my-loading-bar'
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
```
|
|
247
|
+
|
|
100
248
|
### Built-in SPA header
|
|
101
249
|
|
|
102
250
|
ASJS now ships with a built-in header helper for the default SPA flow.
|
|
@@ -589,6 +737,74 @@ app.get('/', asjs.page('home', { title: 'Merhaba ASJS' }))
|
|
|
589
737
|
app.listen(3000)
|
|
590
738
|
```
|
|
591
739
|
|
|
740
|
+
### Aşırı basit tek sayfa başlangıcı
|
|
741
|
+
|
|
742
|
+
En az sürtünmeli başlangıç için şu kadar basit bir yapı yeterlidir:
|
|
743
|
+
|
|
744
|
+
```text
|
|
745
|
+
my-app/
|
|
746
|
+
app.js
|
|
747
|
+
views/
|
|
748
|
+
layouts/
|
|
749
|
+
main.asjs
|
|
750
|
+
home.asjs
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
```js
|
|
754
|
+
const express = require('express')
|
|
755
|
+
const { setupAsjs } = require('asjs-express')
|
|
756
|
+
|
|
757
|
+
const app = express()
|
|
758
|
+
const asjs = setupAsjs(app, {
|
|
759
|
+
rootDir: __dirname,
|
|
760
|
+
defaultLayout: 'layouts/main',
|
|
761
|
+
navItems: [
|
|
762
|
+
{ href: '/', label: 'Ana Sayfa' }
|
|
763
|
+
],
|
|
764
|
+
transitions: 'fade',
|
|
765
|
+
prefetch: true,
|
|
766
|
+
loadingBar: true
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
app.get('/', asjs.page('home', {
|
|
770
|
+
title: 'İlk ASJS sayfam',
|
|
771
|
+
headline: 'ASJS neredeyse kurulum istemeden çalışır.',
|
|
772
|
+
description: 'Header, router, loading bar ve SPA hazır sayfa geçişleri zaten bağlı gelir.'
|
|
773
|
+
}))
|
|
774
|
+
|
|
775
|
+
app.use(asjs.errors())
|
|
776
|
+
app.listen(3000)
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
```asjs
|
|
780
|
+
<!DOCTYPE html>
|
|
781
|
+
<html>
|
|
782
|
+
<head>
|
|
783
|
+
<meta charset="UTF-8">
|
|
784
|
+
<title><%= title %></title>
|
|
785
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
786
|
+
</head>
|
|
787
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
788
|
+
<%- asjs.progressMarkup() %>
|
|
789
|
+
<%- asjs.header() %>
|
|
790
|
+
<main<%- asjs.viewAttrs() %>>
|
|
791
|
+
<%- body %>
|
|
792
|
+
</main>
|
|
793
|
+
</body>
|
|
794
|
+
</html>
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
```asjs
|
|
798
|
+
<section>
|
|
799
|
+
<h1><%= headline %></h1>
|
|
800
|
+
<p><%= description %></p>
|
|
801
|
+
</section>
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Bu kadarı çalışan bir ASJS sayfası için yeterlidir.
|
|
805
|
+
Aynı mantığın gerçek klasör örneği repo içinde `example-minimal/` altında da var.
|
|
806
|
+
Çalıştırmak için `npm run example:minimal` kullanabilirsin.
|
|
807
|
+
|
|
592
808
|
### Layout kullanımı
|
|
593
809
|
|
|
594
810
|
```asjs
|
|
@@ -611,6 +827,86 @@ app.listen(3000)
|
|
|
611
827
|
`asjs.clientTags()` çağrısı dahili ASJS stil ve router etiketlerini otomatik üretir.
|
|
612
828
|
`theme: true` ise açık renkli, kurumsal WebAS temasını yükler.
|
|
613
829
|
|
|
830
|
+
### En kolay Express SPA kurulumu
|
|
831
|
+
|
|
832
|
+
En kolay entegrasyon için 3 dosya yeterlidir: `app.js`, `views/layouts/main.asjs` ve örnek olarak `views/home.asjs`.
|
|
833
|
+
|
|
834
|
+
```js
|
|
835
|
+
const express = require('express')
|
|
836
|
+
const { setupAsjs } = require('asjs-express')
|
|
837
|
+
|
|
838
|
+
const app = express()
|
|
839
|
+
const asjs = setupAsjs(app, {
|
|
840
|
+
rootDir: __dirname,
|
|
841
|
+
defaultLayout: 'layouts/main',
|
|
842
|
+
navItems: [
|
|
843
|
+
{ href: '/', label: 'Ana Sayfa' },
|
|
844
|
+
{ href: '/about', label: 'Hakkında' },
|
|
845
|
+
{ href: '/contact', label: 'İletişim' }
|
|
846
|
+
],
|
|
847
|
+
transitions: 'fade',
|
|
848
|
+
prefetch: true,
|
|
849
|
+
loadingBar: true
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
const buildPage = asjs.createPageModel({
|
|
853
|
+
pageDescription: 'İlk ASJS sayfam',
|
|
854
|
+
renderSummary: []
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
app.get('/', asjs.createPageRoute('home', {
|
|
858
|
+
buildPage,
|
|
859
|
+
renderState: {
|
|
860
|
+
delay: 120,
|
|
861
|
+
label: 'Ana sayfa hazır',
|
|
862
|
+
narrative: 'ASJS bu sayfa modelini HTML cevabı gitmeden önce hazırladı.'
|
|
863
|
+
}
|
|
864
|
+
}, () => ({
|
|
865
|
+
title: 'Ana Sayfa',
|
|
866
|
+
heroTitle: 'Merhaba ASJS',
|
|
867
|
+
heroText: 'Bu sayfa server-rendered çalışır ve SPA gezinme hemen aktiftir.'
|
|
868
|
+
})))
|
|
869
|
+
|
|
870
|
+
app.listen(3000)
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
```asjs
|
|
874
|
+
<!DOCTYPE html>
|
|
875
|
+
<html>
|
|
876
|
+
<head>
|
|
877
|
+
<meta charset="UTF-8">
|
|
878
|
+
<title><%= title %></title>
|
|
879
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
880
|
+
</head>
|
|
881
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
882
|
+
<%- asjs.progressMarkup() %>
|
|
883
|
+
<%- asjs.header() %>
|
|
884
|
+
<main<%- asjs.viewAttrs() %>>
|
|
885
|
+
<%- body %>
|
|
886
|
+
</main>
|
|
887
|
+
</body>
|
|
888
|
+
</html>
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Varsayılan SPA yükleme ve sayfa geçiş animasyonları zaten dahildir.
|
|
892
|
+
Ek bir class vermezsen ASJS dahili loading bar ve transition görünümünü korur.
|
|
893
|
+
Sonradan üstüne kendi stilini yazmak istersen sistemi komple değiştirmek yerine sadece class eklersin.
|
|
894
|
+
|
|
895
|
+
```js
|
|
896
|
+
const asjs = setupAsjs(app, {
|
|
897
|
+
rootDir: __dirname,
|
|
898
|
+
defaultLayout: 'layouts/main',
|
|
899
|
+
transitions: {
|
|
900
|
+
name: 'fade',
|
|
901
|
+
className: 'my-page-motion'
|
|
902
|
+
},
|
|
903
|
+
loadingBar: {
|
|
904
|
+
enabled: true,
|
|
905
|
+
className: 'my-loading-bar'
|
|
906
|
+
}
|
|
907
|
+
})
|
|
908
|
+
```
|
|
909
|
+
|
|
614
910
|
### Dahili SPA header sistemi
|
|
615
911
|
|
|
616
912
|
ASJS artık varsayılan SPA akışı için dahili bir header helper ile geliyor.
|
package/lib/asjs.js
CHANGED
|
@@ -25,6 +25,192 @@ function normalizeNumberOption(value, fallback) {
|
|
|
25
25
|
return Number.isFinite(normalized) && normalized >= 0 ? normalized : fallback;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function delay(duration) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
setTimeout(resolve, normalizeNumberOption(duration, 0));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flattenClassNames(value) {
|
|
35
|
+
if (!value) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return value.flatMap(flattenClassNames);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return String(value)
|
|
44
|
+
.split(/\s+/)
|
|
45
|
+
.map((item) => item.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function joinClassNames(...values) {
|
|
50
|
+
return [...new Set(values.flatMap(flattenClassNames))].join(' ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatTimestamp(value = new Date(), options = {}) {
|
|
54
|
+
const settings = options && typeof options === 'object' ? options : {};
|
|
55
|
+
const locale = settings.locale || 'en-US';
|
|
56
|
+
const formatOptions = settings.formatOptions && typeof settings.formatOptions === 'object'
|
|
57
|
+
? settings.formatOptions
|
|
58
|
+
: {
|
|
59
|
+
dateStyle: 'medium',
|
|
60
|
+
timeStyle: 'short'
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return new Intl.DateTimeFormat(locale, formatOptions).format(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeFieldValue(value) {
|
|
67
|
+
return String(value || '').trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeFields(input = {}, fields = []) {
|
|
71
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
72
|
+
const list = Array.isArray(fields)
|
|
73
|
+
? fields
|
|
74
|
+
: Object.keys(fields && typeof fields === 'object' ? fields : {});
|
|
75
|
+
|
|
76
|
+
return list.reduce((result, fieldName) => {
|
|
77
|
+
result[fieldName] = normalizeFieldValue(source[fieldName]);
|
|
78
|
+
return result;
|
|
79
|
+
}, {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveValidationMessage(ruleValue, fallback) {
|
|
83
|
+
if (typeof ruleValue === 'string' && ruleValue) {
|
|
84
|
+
return ruleValue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ruleValue && typeof ruleValue === 'object' && typeof ruleValue.message === 'string' && ruleValue.message) {
|
|
88
|
+
return ruleValue.message;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateFields(values = {}, schema = {}) {
|
|
95
|
+
const input = values && typeof values === 'object' ? values : {};
|
|
96
|
+
const rules = schema && typeof schema === 'object' ? schema : {};
|
|
97
|
+
const errors = {};
|
|
98
|
+
|
|
99
|
+
Object.entries(rules).forEach(([fieldName, fieldRules]) => {
|
|
100
|
+
const config = fieldRules && typeof fieldRules === 'object' ? fieldRules : {};
|
|
101
|
+
const label = config.label || fieldName;
|
|
102
|
+
const rawValue = Object.prototype.hasOwnProperty.call(input, fieldName) ? input[fieldName] : '';
|
|
103
|
+
const value = typeof rawValue === 'string' ? rawValue : normalizeFieldValue(rawValue);
|
|
104
|
+
|
|
105
|
+
if (config.required && !value) {
|
|
106
|
+
errors[fieldName] = resolveValidationMessage(config.required, `${label} is required.`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (config.minLength) {
|
|
111
|
+
const minLength = normalizeNumberOption(
|
|
112
|
+
typeof config.minLength === 'object' ? config.minLength.value : config.minLength,
|
|
113
|
+
0
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (minLength > 0 && value.length < minLength) {
|
|
117
|
+
errors[fieldName] = resolveValidationMessage(
|
|
118
|
+
config.minLength,
|
|
119
|
+
`${label} must be at least ${minLength} characters.`
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (config.pattern) {
|
|
126
|
+
const pattern = config.pattern instanceof RegExp
|
|
127
|
+
? config.pattern
|
|
128
|
+
: (config.pattern && config.pattern.value instanceof RegExp ? config.pattern.value : null);
|
|
129
|
+
|
|
130
|
+
if (pattern && value && !pattern.test(value)) {
|
|
131
|
+
errors[fieldName] = resolveValidationMessage(config.pattern, `${label} format is invalid.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof config.validate === 'function') {
|
|
137
|
+
const validationResult = config.validate(value, input);
|
|
138
|
+
|
|
139
|
+
if (typeof validationResult === 'string' && validationResult) {
|
|
140
|
+
errors[fieldName] = validationResult;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return errors;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hasValidationErrors(errors) {
|
|
149
|
+
return Boolean(errors && typeof errors === 'object' && Object.keys(errors).length);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderInlineResponse(options = {}) {
|
|
153
|
+
const settings = options && typeof options === 'object' ? options : {};
|
|
154
|
+
const facts = Array.isArray(settings.facts) ? settings.facts : [];
|
|
155
|
+
const tone = settings.tone === 'error'
|
|
156
|
+
? 'is-error'
|
|
157
|
+
: (settings.tone === 'neutral' ? 'is-neutral' : 'is-success');
|
|
158
|
+
const rootClassName = joinClassNames('asjs-inline-response', tone, settings.className);
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
`<div class="${escapeHtml(rootClassName)}">`,
|
|
162
|
+
settings.title ? ` <strong>${escapeHtml(settings.title)}</strong>` : '',
|
|
163
|
+
` <p>${escapeHtml(settings.message || 'The request was checked successfully.')}</p>`,
|
|
164
|
+
facts.length
|
|
165
|
+
? ' <div class="inline-response-meta">' + facts.map((item) => (`<span><strong>${escapeHtml(item.label)}</strong>${escapeHtml(item.value)}</span>`)).join('') + '</div>'
|
|
166
|
+
: '',
|
|
167
|
+
'</div>'
|
|
168
|
+
].join('');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createPageModel(defaults = {}) {
|
|
172
|
+
const baseDefaults = defaults && typeof defaults === 'object' ? defaults : {};
|
|
173
|
+
|
|
174
|
+
return function buildPageModel(pageData = {}) {
|
|
175
|
+
const input = pageData && typeof pageData === 'object' ? pageData : {};
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
...baseDefaults,
|
|
179
|
+
...input
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function createPageRoute(pageHandlerFactory, viewName, options, pageFactory) {
|
|
185
|
+
const hasOptionsObject = options && typeof options === 'object' && !Array.isArray(options);
|
|
186
|
+
const settings = hasOptionsObject ? options : {};
|
|
187
|
+
const resolver = hasOptionsObject ? pageFactory : options;
|
|
188
|
+
const buildPage = typeof settings.buildPage === 'function'
|
|
189
|
+
? settings.buildPage
|
|
190
|
+
: createPageModel(settings.pageDefaults || {});
|
|
191
|
+
const renderStateOptions = settings.renderState === false
|
|
192
|
+
? false
|
|
193
|
+
: (settings.renderState && typeof settings.renderState === 'object' ? settings.renderState : null);
|
|
194
|
+
|
|
195
|
+
return pageHandlerFactory(viewName, async (req, res, next) => {
|
|
196
|
+
const renderState = renderStateOptions ? await createRenderState(req, renderStateOptions) : {};
|
|
197
|
+
const resolvedPageData = await Promise.resolve(
|
|
198
|
+
typeof resolver === 'function' ? resolver(req, res, next) : resolver
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return buildPage({
|
|
202
|
+
...renderState,
|
|
203
|
+
...(resolvedPageData && typeof resolvedPageData === 'object' ? resolvedPageData : {})
|
|
204
|
+
}, {
|
|
205
|
+
next,
|
|
206
|
+
renderState,
|
|
207
|
+
req,
|
|
208
|
+
res,
|
|
209
|
+
viewName
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
28
214
|
function normalizeAssetMountPath(value) {
|
|
29
215
|
const rawValue = value === undefined || value === null || value === ''
|
|
30
216
|
? '_asjs'
|
|
@@ -289,6 +475,42 @@ function normalizeSiteCtaOptions(value, fallback = null) {
|
|
|
289
475
|
return defaults;
|
|
290
476
|
}
|
|
291
477
|
|
|
478
|
+
function normalizeLoadingBarOptions(value, fallback = { enabled: true, className: '' }) {
|
|
479
|
+
const base = fallback && typeof fallback === 'object'
|
|
480
|
+
? {
|
|
481
|
+
enabled: fallback.enabled !== false,
|
|
482
|
+
className: typeof fallback.className === 'string' ? fallback.className : ''
|
|
483
|
+
}
|
|
484
|
+
: { enabled: true, className: '' };
|
|
485
|
+
|
|
486
|
+
if (value === undefined) {
|
|
487
|
+
return base;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (typeof value === 'boolean') {
|
|
491
|
+
return {
|
|
492
|
+
...base,
|
|
493
|
+
enabled: value
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (typeof value === 'string') {
|
|
498
|
+
return {
|
|
499
|
+
enabled: true,
|
|
500
|
+
className: value.trim()
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (value && typeof value === 'object') {
|
|
505
|
+
return {
|
|
506
|
+
enabled: value.enabled !== false,
|
|
507
|
+
className: typeof value.className === 'string' ? value.className.trim() : base.className
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return base;
|
|
512
|
+
}
|
|
513
|
+
|
|
292
514
|
function resolveHeaderState(config, sourceLocals = {}, options = {}) {
|
|
293
515
|
const locals = sourceLocals && typeof sourceLocals === 'object' ? sourceLocals : {};
|
|
294
516
|
const input = options && typeof options === 'object' ? options : {};
|
|
@@ -571,6 +793,7 @@ function renderBodyAttrs(asjs, extraAttributes = {}) {
|
|
|
571
793
|
'data-asjs-prefetch': asjs.prefetch ? 'true' : 'false',
|
|
572
794
|
'data-asjs-prefetch-ttl': asjs.prefetchTtl,
|
|
573
795
|
'data-asjs-loading-bar': asjs.loadingBar ? 'true' : 'false',
|
|
796
|
+
'data-asjs-loading-class': asjs.loadingBarClassName || undefined,
|
|
574
797
|
'data-asjs-forms': asjs.forms && asjs.forms.enabled ? 'true' : 'false',
|
|
575
798
|
'data-asjs-form-selector': asjs.forms ? asjs.forms.selector : undefined,
|
|
576
799
|
'data-asjs-form-mode': asjs.forms ? asjs.forms.mode : undefined,
|
|
@@ -583,16 +806,29 @@ function renderBodyAttrs(asjs, extraAttributes = {}) {
|
|
|
583
806
|
}
|
|
584
807
|
|
|
585
808
|
function renderViewAttrs(asjs, extraAttributes = {}) {
|
|
809
|
+
const attributes = extraAttributes && typeof extraAttributes === 'object' ? { ...extraAttributes } : {};
|
|
810
|
+
|
|
586
811
|
return serializeHtmlAttributes({
|
|
812
|
+
...attributes,
|
|
813
|
+
class: joinClassNames('asjs-view', asjs.transition.className, attributes.class),
|
|
587
814
|
'data-asjs-view': true,
|
|
588
815
|
'data-asjs-transition': asjs.transition.enabled ? asjs.transition.name : 'none',
|
|
589
816
|
'data-asjs-transition-duration': asjs.transition.duration,
|
|
590
|
-
...extraAttributes
|
|
591
817
|
});
|
|
592
818
|
}
|
|
593
819
|
|
|
594
|
-
function renderProgressMarkup() {
|
|
595
|
-
|
|
820
|
+
function renderProgressMarkup(asjs = {}, options = {}) {
|
|
821
|
+
const settings = options && typeof options === 'object' ? options : {};
|
|
822
|
+
const customClassName = joinClassNames(asjs.loadingBarClassName, settings.className);
|
|
823
|
+
const rootClassName = joinClassNames('asjs-progress', customClassName);
|
|
824
|
+
const barClassName = joinClassNames('asjs-progress-bar', settings.barClassName);
|
|
825
|
+
|
|
826
|
+
return `<div${serializeHtmlAttributes({
|
|
827
|
+
class: rootClassName,
|
|
828
|
+
'data-asjs-progress': true,
|
|
829
|
+
'data-asjs-progress-class': customClassName || undefined,
|
|
830
|
+
'aria-hidden': 'true'
|
|
831
|
+
})}><span${serializeHtmlAttributes({ class: barClassName })}></span></div>`;
|
|
596
832
|
}
|
|
597
833
|
|
|
598
834
|
function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
@@ -601,6 +837,18 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
601
837
|
const transition = Object.prototype.hasOwnProperty.call(overrides, 'transition')
|
|
602
838
|
? normalizeTransitionOptions(overrides.transition, config.transitions)
|
|
603
839
|
: normalizeTransitionOptions(current.transition, config.transitions);
|
|
840
|
+
const loadingBarState = normalizeLoadingBarOptions(
|
|
841
|
+
Object.prototype.hasOwnProperty.call(current, 'loadingBarClassName')
|
|
842
|
+
? {
|
|
843
|
+
enabled: Object.prototype.hasOwnProperty.call(current, 'loadingBar') ? current.loadingBar : config.loadingBar,
|
|
844
|
+
className: current.loadingBarClassName
|
|
845
|
+
}
|
|
846
|
+
: current.loadingBar,
|
|
847
|
+
{
|
|
848
|
+
enabled: config.loadingBar,
|
|
849
|
+
className: config.loadingBarClassName
|
|
850
|
+
}
|
|
851
|
+
);
|
|
604
852
|
|
|
605
853
|
const viewModel = {
|
|
606
854
|
...current,
|
|
@@ -612,9 +860,8 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
612
860
|
prefetchTtl: Object.prototype.hasOwnProperty.call(current, 'prefetchTtl')
|
|
613
861
|
? normalizeNumberOption(current.prefetchTtl, config.prefetchTtl)
|
|
614
862
|
: config.prefetchTtl,
|
|
615
|
-
loadingBar:
|
|
616
|
-
|
|
617
|
-
: config.loadingBar,
|
|
863
|
+
loadingBar: loadingBarState.enabled,
|
|
864
|
+
loadingBarClassName: loadingBarState.className,
|
|
618
865
|
forms: Object.prototype.hasOwnProperty.call(current, 'forms')
|
|
619
866
|
? normalizeFormOptions(current.forms, config.forms)
|
|
620
867
|
: normalizeFormOptions(undefined, config.forms),
|
|
@@ -629,7 +876,7 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
629
876
|
viewModel.bodyAttrs = (extraAttributes) => renderBodyAttrs(viewModel, extraAttributes);
|
|
630
877
|
viewModel.viewAttrs = (extraAttributes) => renderViewAttrs(viewModel, extraAttributes);
|
|
631
878
|
viewModel.formAttrs = (options) => renderFormAttrs(viewModel, options);
|
|
632
|
-
viewModel.progressMarkup = () => renderProgressMarkup();
|
|
879
|
+
viewModel.progressMarkup = (options) => renderProgressMarkup(viewModel, options);
|
|
633
880
|
viewModel.header = (options) => renderHeaderMarkup(resolveHeaderState(config, {}, options));
|
|
634
881
|
|
|
635
882
|
return viewModel;
|
|
@@ -908,19 +1155,19 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
908
1155
|
if (value === undefined) {
|
|
909
1156
|
return fallback
|
|
910
1157
|
? { ...fallback }
|
|
911
|
-
: { enabled: false, name: 'none', duration: 0 };
|
|
1158
|
+
: { enabled: false, name: 'none', duration: 0, className: '' };
|
|
912
1159
|
}
|
|
913
1160
|
|
|
914
1161
|
if (value === false || value === null || value === 'none') {
|
|
915
|
-
return { enabled: false, name: 'none', duration: 0 };
|
|
1162
|
+
return { enabled: false, name: 'none', duration: 0, className: '' };
|
|
916
1163
|
}
|
|
917
1164
|
|
|
918
1165
|
if (value === true) {
|
|
919
|
-
return { enabled: true, name: 'fade', duration: 260 };
|
|
1166
|
+
return { enabled: true, name: 'fade', duration: 260, className: '' };
|
|
920
1167
|
}
|
|
921
1168
|
|
|
922
1169
|
if (typeof value === 'string') {
|
|
923
|
-
return { enabled: true, name: value, duration: 260 };
|
|
1170
|
+
return { enabled: true, name: value, duration: 260, className: '' };
|
|
924
1171
|
}
|
|
925
1172
|
|
|
926
1173
|
if (typeof value === 'object') {
|
|
@@ -931,6 +1178,7 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
931
1178
|
|
|
932
1179
|
return {
|
|
933
1180
|
enabled,
|
|
1181
|
+
className: value.className ? String(value.className).trim() : '',
|
|
934
1182
|
name: enabled ? String(value.name || 'fade') : 'none',
|
|
935
1183
|
duration: enabled ? duration : 0
|
|
936
1184
|
};
|
|
@@ -938,7 +1186,7 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
938
1186
|
|
|
939
1187
|
return fallback
|
|
940
1188
|
? { ...fallback }
|
|
941
|
-
: { enabled: false, name: 'none', duration: 0 };
|
|
1189
|
+
: { enabled: false, name: 'none', duration: 0, className: '' };
|
|
942
1190
|
}
|
|
943
1191
|
|
|
944
1192
|
function resolveTemplatePath(name, currentFile, viewsDir, extension) {
|
|
@@ -1162,7 +1410,8 @@ function createAsjsConfig(options = {}) {
|
|
|
1162
1410
|
transitions: normalizeTransitionOptions(options.transitions),
|
|
1163
1411
|
prefetch: normalizeBooleanOption(options.prefetch, true),
|
|
1164
1412
|
prefetchTtl: normalizeNumberOption(options.prefetchTtl, 30000),
|
|
1165
|
-
loadingBar:
|
|
1413
|
+
loadingBar: normalizeLoadingBarOptions(options.loadingBar).enabled,
|
|
1414
|
+
loadingBarClassName: normalizeLoadingBarOptions(options.loadingBar).className,
|
|
1166
1415
|
forms: normalizeFormOptions(
|
|
1167
1416
|
Object.prototype.hasOwnProperty.call(options, 'forms') ? options.forms : true
|
|
1168
1417
|
),
|
|
@@ -1612,6 +1861,30 @@ function buildPageLocals(req, config, pageData = {}) {
|
|
|
1612
1861
|
};
|
|
1613
1862
|
}
|
|
1614
1863
|
|
|
1864
|
+
async function createRenderState(req, options = {}) {
|
|
1865
|
+
const settings = options && typeof options === 'object' ? options : {};
|
|
1866
|
+
const startedAt = Date.now();
|
|
1867
|
+
|
|
1868
|
+
await delay(settings.delay);
|
|
1869
|
+
|
|
1870
|
+
const elapsed = Date.now() - startedAt;
|
|
1871
|
+
const summary = Array.isArray(settings.summary)
|
|
1872
|
+
? settings.summary
|
|
1873
|
+
: [
|
|
1874
|
+
{ label: 'Mode', value: settings.mode || 'res.render()' },
|
|
1875
|
+
{ label: 'Prepared by', value: settings.preparedBy || 'Async route callback' },
|
|
1876
|
+
{ label: 'Ready in', value: `${elapsed} ms` },
|
|
1877
|
+
{ label: 'Route', value: req.originalUrl || req.url }
|
|
1878
|
+
];
|
|
1879
|
+
|
|
1880
|
+
return {
|
|
1881
|
+
renderLabel: settings.label || 'Server-prepared page model',
|
|
1882
|
+
renderNarrative: settings.narrative || 'This page used an async ASJS callback so data work finished before res.render() produced the final HTML.',
|
|
1883
|
+
renderSummary: summary,
|
|
1884
|
+
renderTimestamp: formatTimestamp(new Date(), settings.timestamp || settings)
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1615
1888
|
function setupAsjs(app, options = {}) {
|
|
1616
1889
|
const config = createAsjsConfig(options);
|
|
1617
1890
|
|
|
@@ -1748,8 +2021,18 @@ function setupAsjs(app, options = {}) {
|
|
|
1748
2021
|
app,
|
|
1749
2022
|
config,
|
|
1750
2023
|
clearCache,
|
|
2024
|
+
createPageModel,
|
|
2025
|
+
createPageRoute(viewName, routeOptions, pageFactory) {
|
|
2026
|
+
return createPageRoute(page, viewName, routeOptions, pageFactory);
|
|
2027
|
+
},
|
|
2028
|
+
createRenderState,
|
|
1751
2029
|
express,
|
|
2030
|
+
formatTimestamp,
|
|
2031
|
+
hasValidationErrors,
|
|
2032
|
+
normalizeFields,
|
|
2033
|
+
normalizeFieldValue,
|
|
1752
2034
|
render,
|
|
2035
|
+
renderInlineResponse,
|
|
1753
2036
|
page,
|
|
1754
2037
|
route: page,
|
|
1755
2038
|
notFound,
|
|
@@ -1758,6 +2041,7 @@ function setupAsjs(app, options = {}) {
|
|
|
1758
2041
|
packagePaths: config.packagePaths,
|
|
1759
2042
|
extendLocals,
|
|
1760
2043
|
defineComponents,
|
|
2044
|
+
validateFields,
|
|
1761
2045
|
addHook,
|
|
1762
2046
|
use(plugin) {
|
|
1763
2047
|
applyAsjsPlugin(plugin, api);
|
|
@@ -1792,13 +2076,22 @@ function setupAsjs(app, options = {}) {
|
|
|
1792
2076
|
}
|
|
1793
2077
|
|
|
1794
2078
|
module.exports = {
|
|
2079
|
+
createPageModel,
|
|
2080
|
+
createPageRoute,
|
|
1795
2081
|
createAsjsConfig,
|
|
1796
2082
|
createAsjsEngine,
|
|
2083
|
+
createRenderState,
|
|
1797
2084
|
escapeHtml,
|
|
2085
|
+
formatTimestamp,
|
|
1798
2086
|
getAsjsPackagePaths,
|
|
2087
|
+
hasValidationErrors,
|
|
2088
|
+
normalizeFields,
|
|
2089
|
+
normalizeFieldValue,
|
|
1799
2090
|
renderClientTags,
|
|
1800
2091
|
renderDebugErrorPage,
|
|
1801
2092
|
renderHeaderMarkup,
|
|
2093
|
+
renderInlineResponse,
|
|
2094
|
+
validateFields,
|
|
1802
2095
|
validateComponentProps,
|
|
1803
2096
|
setupAsjs
|
|
1804
2097
|
};
|
|
@@ -26,6 +26,26 @@
|
|
|
26
26
|
return Number.isFinite(normalized) && normalized >= 0 ? normalized : fallback;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function flattenClassNames(value) {
|
|
30
|
+
if (!value) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
return value.reduce((result, item) => result.concat(flattenClassNames(item)), []);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return String(value)
|
|
39
|
+
.split(/\s+/)
|
|
40
|
+
.map((item) => item.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function joinClassNames() {
|
|
45
|
+
const values = Array.prototype.slice.call(arguments);
|
|
46
|
+
return Array.from(new Set(values.reduce((result, value) => result.concat(flattenClassNames(value)), []))).join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
function normalizePathname(value) {
|
|
30
50
|
const normalized = String(value || '/').replace(/\/+$/, '');
|
|
31
51
|
return normalized || '/';
|
|
@@ -258,6 +278,7 @@
|
|
|
258
278
|
this.prefetchEnabled = parseBooleanAttribute(root && root.dataset.asjsPrefetch, this.defaultPrefetch);
|
|
259
279
|
this.prefetchTtl = parseNumberAttribute(root && root.dataset.asjsPrefetchTtl, this.defaultPrefetchTtl);
|
|
260
280
|
this.loadingBarEnabled = parseBooleanAttribute(root && root.dataset.asjsLoadingBar, this.defaultLoadingBar);
|
|
281
|
+
this.loadingBarClassName = (root && root.dataset.asjsLoadingClass) || '';
|
|
261
282
|
this.formsEnabled = parseBooleanAttribute(root && root.dataset.asjsForms, this.defaultForms);
|
|
262
283
|
this.formSelector = (root && root.dataset.asjsFormSelector) || this.defaultFormSelector;
|
|
263
284
|
this.formMode = (root && root.dataset.asjsFormMode) || this.defaultFormMode;
|
|
@@ -265,17 +286,41 @@
|
|
|
265
286
|
this.formResetOnSuccess = parseBooleanAttribute(root && root.dataset.asjsFormResetOnSuccess, this.defaultFormResetOnSuccess);
|
|
266
287
|
this.formTarget = (root && root.dataset.asjsFormTarget) || this.defaultFormTarget;
|
|
267
288
|
this.formSwap = (root && root.dataset.asjsFormSwap) || this.defaultFormSwap;
|
|
289
|
+
this.syncProgressRootClassName();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getProgressRootClassName() {
|
|
293
|
+
const progressClassName = this.progressRoot && this.progressRoot.dataset
|
|
294
|
+
? this.progressRoot.dataset.asjsProgressClass || ''
|
|
295
|
+
: '';
|
|
296
|
+
|
|
297
|
+
return joinClassNames('asjs-progress', this.loadingBarClassName, progressClassName);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
syncProgressRootClassName() {
|
|
301
|
+
if (!this.progressRoot) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const isActive = this.progressRoot.classList.contains('is-active');
|
|
306
|
+
this.progressRoot.className = this.getProgressRootClassName();
|
|
307
|
+
|
|
308
|
+
if (isActive) {
|
|
309
|
+
this.progressRoot.classList.add('is-active');
|
|
310
|
+
}
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
ensureProgressBar() {
|
|
271
314
|
if (!this.progressRoot) {
|
|
272
315
|
this.progressRoot = document.createElement('div');
|
|
273
|
-
this.progressRoot.className =
|
|
316
|
+
this.progressRoot.className = this.getProgressRootClassName();
|
|
274
317
|
this.progressRoot.dataset.asjsProgress = 'true';
|
|
275
318
|
this.progressRoot.innerHTML = '<span class="asjs-progress-bar"></span>';
|
|
276
319
|
document.body.prepend(this.progressRoot);
|
|
277
320
|
}
|
|
278
321
|
|
|
322
|
+
this.syncProgressRootClassName();
|
|
323
|
+
|
|
279
324
|
this.progressBar = this.progressRoot.querySelector('.asjs-progress-bar');
|
|
280
325
|
return Boolean(this.progressRoot && this.progressBar);
|
|
281
326
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "asjs-express",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Lightweight Express view engine with EJS-like templates, layouts, async page rendering, form enhancement, and a built-in client router.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"demo": "node example-express/app.js",
|
|
31
31
|
"dev": "node example-express/app.js",
|
|
32
32
|
"example:express": "node example-express/app.js",
|
|
33
|
+
"example:minimal": "node example-minimal/app.js",
|
|
33
34
|
"pack:check": "npm pack --dry-run"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|