asjs-express 1.1.1 → 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 +444 -0
- package/lib/asjs.js +533 -15
- 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,160 @@ 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
|
+
|
|
248
|
+
### Built-in SPA header
|
|
249
|
+
|
|
250
|
+
ASJS now ships with a built-in header helper for the default SPA flow.
|
|
251
|
+
You do not have to define `brand`, `siteCta`, or `isActiveNavItem` just to make a working header. If you want a standard header, `navItems` is the only thing you will usually customize.
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
const express = require('express')
|
|
255
|
+
const { setupAsjs } = require('asjs-express')
|
|
256
|
+
|
|
257
|
+
const app = express()
|
|
258
|
+
|
|
259
|
+
const asjs = setupAsjs(app, {
|
|
260
|
+
rootDir: __dirname,
|
|
261
|
+
defaultLayout: 'layouts/main',
|
|
262
|
+
navItems: [
|
|
263
|
+
{ href: '/', label: 'Home', activeMode: 'exact' },
|
|
264
|
+
{ href: '/products', label: 'Products', activeMode: 'exact' },
|
|
265
|
+
{ href: '/contact', label: 'Contact', activeMode: 'exact' }
|
|
266
|
+
],
|
|
267
|
+
transitions: 'fade',
|
|
268
|
+
prefetch: true,
|
|
269
|
+
loadingBar: true
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```asjs
|
|
274
|
+
<!DOCTYPE html>
|
|
275
|
+
<html>
|
|
276
|
+
<head>
|
|
277
|
+
<meta charset="UTF-8">
|
|
278
|
+
<title><%= title %></title>
|
|
279
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
280
|
+
</head>
|
|
281
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
282
|
+
<%- asjs.progressMarkup() %>
|
|
283
|
+
<%- asjs.header() %>
|
|
284
|
+
<main<%- asjs.viewAttrs() %>>
|
|
285
|
+
<%- body %>
|
|
286
|
+
</main>
|
|
287
|
+
</body>
|
|
288
|
+
</html>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The built-in header is SPA-aware by default.
|
|
292
|
+
It renders `data-asjs-link`, uses the current request path automatically, and applies active states internally.
|
|
293
|
+
|
|
294
|
+
If you do not define a brand, ASJS generates a safe default brand object.
|
|
295
|
+
If you do not define `siteCta`, the header still works and simply renders without the CTA button.
|
|
296
|
+
If you want to customize them later, you can still pass them through `setupAsjs()`.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
const asjs = setupAsjs(app, {
|
|
300
|
+
rootDir: __dirname,
|
|
301
|
+
defaultLayout: 'layouts/main',
|
|
302
|
+
navItems: [
|
|
303
|
+
{ href: '/', label: 'Overview' },
|
|
304
|
+
{ href: '/contact', label: 'Contact' }
|
|
305
|
+
],
|
|
306
|
+
brand: {
|
|
307
|
+
href: '/',
|
|
308
|
+
mark: 'WA',
|
|
309
|
+
name: 'WebAS',
|
|
310
|
+
tagline: 'Shared interface structure for Express projects'
|
|
311
|
+
},
|
|
312
|
+
siteCta: {
|
|
313
|
+
href: '/contact',
|
|
314
|
+
label: 'Start a project',
|
|
315
|
+
transition: 'slide'
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
If you still prefer a custom partial, ASJS now also injects `brand`, `siteCta`, `currentPath`, and `isActiveNavItem` into the view locals automatically.
|
|
321
|
+
|
|
100
322
|
### Work before the page loads
|
|
101
323
|
|
|
102
324
|
Yes. If you pass an async callback to `asjs.page()`, ASJS waits for that work to finish and only then calls `res.render(viewName, locals)`.
|
|
@@ -515,6 +737,74 @@ app.get('/', asjs.page('home', { title: 'Merhaba ASJS' }))
|
|
|
515
737
|
app.listen(3000)
|
|
516
738
|
```
|
|
517
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
|
+
|
|
518
808
|
### Layout kullanımı
|
|
519
809
|
|
|
520
810
|
```asjs
|
|
@@ -537,6 +827,160 @@ app.listen(3000)
|
|
|
537
827
|
`asjs.clientTags()` çağrısı dahili ASJS stil ve router etiketlerini otomatik üretir.
|
|
538
828
|
`theme: true` ise açık renkli, kurumsal WebAS temasını yükler.
|
|
539
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
|
+
|
|
910
|
+
### Dahili SPA header sistemi
|
|
911
|
+
|
|
912
|
+
ASJS artık varsayılan SPA akışı için dahili bir header helper ile geliyor.
|
|
913
|
+
Çalışan bir header kurmak için `brand`, `siteCta` veya `isActiveNavItem` tanımlamak zorunda değilsin. Standart bir header istiyorsan çoğu durumda sadece `navItems` alanını düzenlemen yeterlidir.
|
|
914
|
+
|
|
915
|
+
```js
|
|
916
|
+
const express = require('express')
|
|
917
|
+
const { setupAsjs } = require('asjs-express')
|
|
918
|
+
|
|
919
|
+
const app = express()
|
|
920
|
+
|
|
921
|
+
const asjs = setupAsjs(app, {
|
|
922
|
+
rootDir: __dirname,
|
|
923
|
+
defaultLayout: 'layouts/main',
|
|
924
|
+
navItems: [
|
|
925
|
+
{ href: '/', label: 'Ana Sayfa', activeMode: 'exact' },
|
|
926
|
+
{ href: '/products', label: 'Ürünler', activeMode: 'exact' },
|
|
927
|
+
{ href: '/contact', label: 'İletişim', activeMode: 'exact' }
|
|
928
|
+
],
|
|
929
|
+
transitions: 'fade',
|
|
930
|
+
prefetch: true,
|
|
931
|
+
loadingBar: true
|
|
932
|
+
})
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
```asjs
|
|
936
|
+
<!DOCTYPE html>
|
|
937
|
+
<html>
|
|
938
|
+
<head>
|
|
939
|
+
<meta charset="UTF-8">
|
|
940
|
+
<title><%= title %></title>
|
|
941
|
+
<%- asjs.clientTags({ preload: true, theme: true }) %>
|
|
942
|
+
</head>
|
|
943
|
+
<body<%- asjs.bodyAttrs() %>>
|
|
944
|
+
<%- asjs.progressMarkup() %>
|
|
945
|
+
<%- asjs.header() %>
|
|
946
|
+
<main<%- asjs.viewAttrs() %>>
|
|
947
|
+
<%- body %>
|
|
948
|
+
</main>
|
|
949
|
+
</body>
|
|
950
|
+
</html>
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
Bu dahili header varsayılan olarak SPA uyumludur.
|
|
954
|
+
Linkleri `data-asjs-link` ile üretir, mevcut request path bilgisini kendi alır ve aktif link durumunu içeride hesaplar.
|
|
955
|
+
|
|
956
|
+
`brand` tanımlamazsan ASJS güvenli bir varsayılan brand nesnesi üretir.
|
|
957
|
+
`siteCta` tanımlamazsan header bozulmaz; sadece sağ taraftaki CTA düğmesi çizilmez.
|
|
958
|
+
Daha sonra özelleştirmek istersen bunları yine `setupAsjs()` üzerinden verebilirsin.
|
|
959
|
+
|
|
960
|
+
```js
|
|
961
|
+
const asjs = setupAsjs(app, {
|
|
962
|
+
rootDir: __dirname,
|
|
963
|
+
defaultLayout: 'layouts/main',
|
|
964
|
+
navItems: [
|
|
965
|
+
{ href: '/', label: 'Genel Bakış' },
|
|
966
|
+
{ href: '/contact', label: 'İletişim' }
|
|
967
|
+
],
|
|
968
|
+
brand: {
|
|
969
|
+
href: '/',
|
|
970
|
+
mark: 'WA',
|
|
971
|
+
name: 'WebAS',
|
|
972
|
+
tagline: 'Express projeleri için ortak arayüz yapısı'
|
|
973
|
+
},
|
|
974
|
+
siteCta: {
|
|
975
|
+
href: '/contact',
|
|
976
|
+
label: 'Proje başlat',
|
|
977
|
+
transition: 'slide'
|
|
978
|
+
}
|
|
979
|
+
})
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
Kendi partial yapını yazmak istersen ASJS artık `brand`, `siteCta`, `currentPath` ve `isActiveNavItem` alanlarını da view locals içine otomatik koyar.
|
|
983
|
+
|
|
540
984
|
### Sayfa yüklenmeden önce işlem yapmak
|
|
541
985
|
|
|
542
986
|
Evet, mümkün. `asjs.page()` içine async bir callback verirsen ASJS bu işlemin bitmesini bekler, ardından `res.render(viewName, locals)` çağrısını yapar.
|
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'
|
|
@@ -184,6 +370,232 @@ function renderClientTags(assets, options = {}) {
|
|
|
184
370
|
return tags.join('\n');
|
|
185
371
|
}
|
|
186
372
|
|
|
373
|
+
function normalizePathname(value) {
|
|
374
|
+
const normalized = String(value || '/').replace(/\/+$/, '');
|
|
375
|
+
return normalized || '/';
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isActiveNavItem(currentPath, item = {}) {
|
|
379
|
+
const pathname = normalizePathname(currentPath);
|
|
380
|
+
const targetPath = normalizePathname(item.matchPath || item.href || '/');
|
|
381
|
+
const activeMode = String(item.activeMode || 'exact').toLowerCase();
|
|
382
|
+
|
|
383
|
+
if (activeMode === 'prefix') {
|
|
384
|
+
if (targetPath === '/') {
|
|
385
|
+
return pathname === '/';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return pathname === targetPath || pathname.startsWith(`${targetPath}/`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return pathname === targetPath;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function normalizeNavItems(value) {
|
|
395
|
+
if (!Array.isArray(value)) {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return value
|
|
400
|
+
.filter((item) => item && typeof item === 'object')
|
|
401
|
+
.map((item) => ({
|
|
402
|
+
...item,
|
|
403
|
+
href: String(item.href || '/'),
|
|
404
|
+
label: item.label ? String(item.label) : String(item.href || '/'),
|
|
405
|
+
activeMode: String(item.activeMode || 'exact').toLowerCase()
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizeBrandOptions(value, fallback = {}) {
|
|
410
|
+
const defaults = {
|
|
411
|
+
href: '/',
|
|
412
|
+
mark: 'AS',
|
|
413
|
+
name: fallback.name || 'ASJS',
|
|
414
|
+
tagline: fallback.tagline || 'Server-rendered interface shell for Express',
|
|
415
|
+
caption: fallback.caption || '',
|
|
416
|
+
transition: 'fade'
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (typeof value === 'string' && value) {
|
|
420
|
+
return {
|
|
421
|
+
...defaults,
|
|
422
|
+
name: value
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (value && typeof value === 'object') {
|
|
427
|
+
return {
|
|
428
|
+
...defaults,
|
|
429
|
+
...value,
|
|
430
|
+
href: String(value.href || defaults.href),
|
|
431
|
+
mark: value.mark ? String(value.mark) : defaults.mark,
|
|
432
|
+
name: value.name ? String(value.name) : defaults.name,
|
|
433
|
+
tagline: value.tagline ? String(value.tagline) : defaults.tagline,
|
|
434
|
+
caption: value.caption ? String(value.caption) : defaults.caption,
|
|
435
|
+
transition: value.transition ? String(value.transition) : defaults.transition
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return defaults;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function normalizeSiteCtaOptions(value, fallback = null) {
|
|
443
|
+
const defaults = fallback && typeof fallback === 'object'
|
|
444
|
+
? {
|
|
445
|
+
href: String(fallback.href || '/'),
|
|
446
|
+
label: fallback.label ? String(fallback.label) : 'Get Started',
|
|
447
|
+
transition: fallback.transition ? String(fallback.transition) : 'fade'
|
|
448
|
+
}
|
|
449
|
+
: null;
|
|
450
|
+
|
|
451
|
+
if (value === undefined) {
|
|
452
|
+
return defaults;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (value === false || value === null) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (typeof value === 'string' && value) {
|
|
460
|
+
return {
|
|
461
|
+
href: value,
|
|
462
|
+
label: defaults ? defaults.label : 'Get Started',
|
|
463
|
+
transition: defaults ? defaults.transition : 'fade'
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (value && typeof value === 'object') {
|
|
468
|
+
return {
|
|
469
|
+
href: String(value.href || (defaults ? defaults.href : '/')),
|
|
470
|
+
label: value.label ? String(value.label) : (defaults ? defaults.label : 'Get Started'),
|
|
471
|
+
transition: value.transition ? String(value.transition) : (defaults ? defaults.transition : 'fade')
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return defaults;
|
|
476
|
+
}
|
|
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
|
+
|
|
514
|
+
function resolveHeaderState(config, sourceLocals = {}, options = {}) {
|
|
515
|
+
const locals = sourceLocals && typeof sourceLocals === 'object' ? sourceLocals : {};
|
|
516
|
+
const input = options && typeof options === 'object' ? options : {};
|
|
517
|
+
const siteName = input.siteName || locals.siteName || config.locals.siteName || 'ASJS';
|
|
518
|
+
const navItems = normalizeNavItems(
|
|
519
|
+
Array.isArray(input.navItems)
|
|
520
|
+
? input.navItems
|
|
521
|
+
: (Array.isArray(locals.navItems) ? locals.navItems : config.navItems)
|
|
522
|
+
);
|
|
523
|
+
const brand = normalizeBrandOptions(
|
|
524
|
+
Object.prototype.hasOwnProperty.call(input, 'brand')
|
|
525
|
+
? input.brand
|
|
526
|
+
: (Object.prototype.hasOwnProperty.call(locals, 'brand') ? locals.brand : config.brand),
|
|
527
|
+
{ name: siteName }
|
|
528
|
+
);
|
|
529
|
+
const siteCta = normalizeSiteCtaOptions(
|
|
530
|
+
Object.prototype.hasOwnProperty.call(input, 'siteCta')
|
|
531
|
+
? input.siteCta
|
|
532
|
+
: (Object.prototype.hasOwnProperty.call(locals, 'siteCta') ? locals.siteCta : config.siteCta),
|
|
533
|
+
config.siteCta
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
brand,
|
|
538
|
+
currentPath: input.currentPath || locals.currentPath || '/',
|
|
539
|
+
isActiveNavItem: typeof input.isActiveNavItem === 'function'
|
|
540
|
+
? input.isActiveNavItem
|
|
541
|
+
: (typeof locals.isActiveNavItem === 'function' ? locals.isActiveNavItem : isActiveNavItem),
|
|
542
|
+
navItems,
|
|
543
|
+
note: input.note || locals.headerNote || '',
|
|
544
|
+
noteLabel: input.noteLabel || locals.headerNoteLabel || '',
|
|
545
|
+
siteCta,
|
|
546
|
+
siteName
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function renderHeaderMarkup(options = {}) {
|
|
551
|
+
const brand = options.brand || normalizeBrandOptions();
|
|
552
|
+
const currentPath = options.currentPath || '/';
|
|
553
|
+
const resolveActive = typeof options.isActiveNavItem === 'function' ? options.isActiveNavItem : isActiveNavItem;
|
|
554
|
+
const navigation = normalizeNavItems(options.navItems);
|
|
555
|
+
const note = options.note ? String(options.note) : '';
|
|
556
|
+
const noteLabel = options.noteLabel ? String(options.noteLabel) : '';
|
|
557
|
+
const cta = normalizeSiteCtaOptions(options.siteCta);
|
|
558
|
+
|
|
559
|
+
return [
|
|
560
|
+
'<header class="example-header">',
|
|
561
|
+
note
|
|
562
|
+
? ` <div class="header-note"><span class="example-pill">${escapeHtml(noteLabel || 'Site Header')}</span><p>${escapeHtml(note)}</p></div>`
|
|
563
|
+
: '',
|
|
564
|
+
' <div class="header-main">',
|
|
565
|
+
` <a${serializeHtmlAttributes({
|
|
566
|
+
class: 'brand-link',
|
|
567
|
+
href: brand.href || '/',
|
|
568
|
+
'data-asjs-link': true,
|
|
569
|
+
...(brand.transition ? { 'data-asjs-transition': brand.transition } : {})
|
|
570
|
+
})}><span class="brand-mark">${escapeHtml(brand.mark || 'AS')}</span><span class="brand-copy"><strong>${escapeHtml(brand.name || options.siteName || 'ASJS')}</strong><span>${escapeHtml(brand.tagline || 'Server-rendered interface shell for Express')}</span>${brand.caption ? `<small>${escapeHtml(brand.caption)}</small>` : ''}</span></a>`,
|
|
571
|
+
navigation.length
|
|
572
|
+
? ` <nav class="site-nav" aria-label="Primary navigation">${navigation.map((item) => {
|
|
573
|
+
const isActive = resolveActive(currentPath, item);
|
|
574
|
+
|
|
575
|
+
return `<a${serializeHtmlAttributes({
|
|
576
|
+
href: item.href,
|
|
577
|
+
class: `site-link${isActive ? ' is-active' : ''}`,
|
|
578
|
+
'data-asjs-link': true,
|
|
579
|
+
'data-asjs-active': item.activeMode || 'exact',
|
|
580
|
+
...(item.matchPath ? { 'data-asjs-match-path': item.matchPath } : {}),
|
|
581
|
+
...(item.transition ? { 'data-asjs-transition': item.transition } : {}),
|
|
582
|
+
...(isActive ? { 'aria-current': 'page' } : {})
|
|
583
|
+
})}><span>${escapeHtml(item.label)}</span>${item.description ? `<small>${escapeHtml(item.description)}</small>` : ''}</a>`;
|
|
584
|
+
}).join('')}</nav>`
|
|
585
|
+
: '',
|
|
586
|
+
cta
|
|
587
|
+
? ` <a${serializeHtmlAttributes({
|
|
588
|
+
class: 'button button-secondary header-cta',
|
|
589
|
+
href: cta.href,
|
|
590
|
+
'data-asjs-link': true,
|
|
591
|
+
...(cta.transition ? { 'data-asjs-transition': cta.transition } : {})
|
|
592
|
+
})}>${escapeHtml(cta.label)}</a>`
|
|
593
|
+
: '',
|
|
594
|
+
' </div>',
|
|
595
|
+
'</header>'
|
|
596
|
+
].filter(Boolean).join('');
|
|
597
|
+
}
|
|
598
|
+
|
|
187
599
|
function normalizeFormMode(value, fallback = 'view') {
|
|
188
600
|
const normalized = String(value || fallback || 'view').toLowerCase();
|
|
189
601
|
return ['view', 'json'].includes(normalized) ? normalized : (fallback || 'view');
|
|
@@ -381,6 +793,7 @@ function renderBodyAttrs(asjs, extraAttributes = {}) {
|
|
|
381
793
|
'data-asjs-prefetch': asjs.prefetch ? 'true' : 'false',
|
|
382
794
|
'data-asjs-prefetch-ttl': asjs.prefetchTtl,
|
|
383
795
|
'data-asjs-loading-bar': asjs.loadingBar ? 'true' : 'false',
|
|
796
|
+
'data-asjs-loading-class': asjs.loadingBarClassName || undefined,
|
|
384
797
|
'data-asjs-forms': asjs.forms && asjs.forms.enabled ? 'true' : 'false',
|
|
385
798
|
'data-asjs-form-selector': asjs.forms ? asjs.forms.selector : undefined,
|
|
386
799
|
'data-asjs-form-mode': asjs.forms ? asjs.forms.mode : undefined,
|
|
@@ -393,16 +806,29 @@ function renderBodyAttrs(asjs, extraAttributes = {}) {
|
|
|
393
806
|
}
|
|
394
807
|
|
|
395
808
|
function renderViewAttrs(asjs, extraAttributes = {}) {
|
|
809
|
+
const attributes = extraAttributes && typeof extraAttributes === 'object' ? { ...extraAttributes } : {};
|
|
810
|
+
|
|
396
811
|
return serializeHtmlAttributes({
|
|
812
|
+
...attributes,
|
|
813
|
+
class: joinClassNames('asjs-view', asjs.transition.className, attributes.class),
|
|
397
814
|
'data-asjs-view': true,
|
|
398
815
|
'data-asjs-transition': asjs.transition.enabled ? asjs.transition.name : 'none',
|
|
399
816
|
'data-asjs-transition-duration': asjs.transition.duration,
|
|
400
|
-
...extraAttributes
|
|
401
817
|
});
|
|
402
818
|
}
|
|
403
819
|
|
|
404
|
-
function renderProgressMarkup() {
|
|
405
|
-
|
|
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>`;
|
|
406
832
|
}
|
|
407
833
|
|
|
408
834
|
function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
@@ -411,6 +837,18 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
411
837
|
const transition = Object.prototype.hasOwnProperty.call(overrides, 'transition')
|
|
412
838
|
? normalizeTransitionOptions(overrides.transition, config.transitions)
|
|
413
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
|
+
);
|
|
414
852
|
|
|
415
853
|
const viewModel = {
|
|
416
854
|
...current,
|
|
@@ -422,9 +860,8 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
422
860
|
prefetchTtl: Object.prototype.hasOwnProperty.call(current, 'prefetchTtl')
|
|
423
861
|
? normalizeNumberOption(current.prefetchTtl, config.prefetchTtl)
|
|
424
862
|
: config.prefetchTtl,
|
|
425
|
-
loadingBar:
|
|
426
|
-
|
|
427
|
-
: config.loadingBar,
|
|
863
|
+
loadingBar: loadingBarState.enabled,
|
|
864
|
+
loadingBarClassName: loadingBarState.className,
|
|
428
865
|
forms: Object.prototype.hasOwnProperty.call(current, 'forms')
|
|
429
866
|
? normalizeFormOptions(current.forms, config.forms)
|
|
430
867
|
: normalizeFormOptions(undefined, config.forms),
|
|
@@ -439,7 +876,8 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
|
|
|
439
876
|
viewModel.bodyAttrs = (extraAttributes) => renderBodyAttrs(viewModel, extraAttributes);
|
|
440
877
|
viewModel.viewAttrs = (extraAttributes) => renderViewAttrs(viewModel, extraAttributes);
|
|
441
878
|
viewModel.formAttrs = (options) => renderFormAttrs(viewModel, options);
|
|
442
|
-
viewModel.progressMarkup = () => renderProgressMarkup();
|
|
879
|
+
viewModel.progressMarkup = (options) => renderProgressMarkup(viewModel, options);
|
|
880
|
+
viewModel.header = (options) => renderHeaderMarkup(resolveHeaderState(config, {}, options));
|
|
443
881
|
|
|
444
882
|
return viewModel;
|
|
445
883
|
}
|
|
@@ -717,19 +1155,19 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
717
1155
|
if (value === undefined) {
|
|
718
1156
|
return fallback
|
|
719
1157
|
? { ...fallback }
|
|
720
|
-
: { enabled: false, name: 'none', duration: 0 };
|
|
1158
|
+
: { enabled: false, name: 'none', duration: 0, className: '' };
|
|
721
1159
|
}
|
|
722
1160
|
|
|
723
1161
|
if (value === false || value === null || value === 'none') {
|
|
724
|
-
return { enabled: false, name: 'none', duration: 0 };
|
|
1162
|
+
return { enabled: false, name: 'none', duration: 0, className: '' };
|
|
725
1163
|
}
|
|
726
1164
|
|
|
727
1165
|
if (value === true) {
|
|
728
|
-
return { enabled: true, name: 'fade', duration: 260 };
|
|
1166
|
+
return { enabled: true, name: 'fade', duration: 260, className: '' };
|
|
729
1167
|
}
|
|
730
1168
|
|
|
731
1169
|
if (typeof value === 'string') {
|
|
732
|
-
return { enabled: true, name: value, duration: 260 };
|
|
1170
|
+
return { enabled: true, name: value, duration: 260, className: '' };
|
|
733
1171
|
}
|
|
734
1172
|
|
|
735
1173
|
if (typeof value === 'object') {
|
|
@@ -740,6 +1178,7 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
740
1178
|
|
|
741
1179
|
return {
|
|
742
1180
|
enabled,
|
|
1181
|
+
className: value.className ? String(value.className).trim() : '',
|
|
743
1182
|
name: enabled ? String(value.name || 'fade') : 'none',
|
|
744
1183
|
duration: enabled ? duration : 0
|
|
745
1184
|
};
|
|
@@ -747,7 +1186,7 @@ function normalizeTransitionOptions(value, fallback = null) {
|
|
|
747
1186
|
|
|
748
1187
|
return fallback
|
|
749
1188
|
? { ...fallback }
|
|
750
|
-
: { enabled: false, name: 'none', duration: 0 };
|
|
1189
|
+
: { enabled: false, name: 'none', duration: 0, className: '' };
|
|
751
1190
|
}
|
|
752
1191
|
|
|
753
1192
|
function resolveTemplatePath(name, currentFile, viewsDir, extension) {
|
|
@@ -957,14 +1396,22 @@ function createAsjsConfig(options = {}) {
|
|
|
957
1396
|
defaultLayout: options.defaultLayout || null,
|
|
958
1397
|
debug: Boolean(options.debug),
|
|
959
1398
|
cache: normalizeBooleanOption(options.cache, !options.debug),
|
|
960
|
-
navItems:
|
|
1399
|
+
navItems: normalizeNavItems(options.navItems),
|
|
1400
|
+
brand: normalizeBrandOptions(
|
|
1401
|
+
Object.prototype.hasOwnProperty.call(options, 'brand') ? options.brand : undefined,
|
|
1402
|
+
{ name: options.locals && options.locals.siteName ? options.locals.siteName : 'ASJS' }
|
|
1403
|
+
),
|
|
1404
|
+
siteCta: normalizeSiteCtaOptions(
|
|
1405
|
+
Object.prototype.hasOwnProperty.call(options, 'siteCta') ? options.siteCta : undefined
|
|
1406
|
+
),
|
|
961
1407
|
locals: options.locals && typeof options.locals === 'object' ? options.locals : {},
|
|
962
1408
|
components: normalizeComponentRegistry(options.components || {}, extension),
|
|
963
1409
|
templateCache: new Map(),
|
|
964
1410
|
transitions: normalizeTransitionOptions(options.transitions),
|
|
965
1411
|
prefetch: normalizeBooleanOption(options.prefetch, true),
|
|
966
1412
|
prefetchTtl: normalizeNumberOption(options.prefetchTtl, 30000),
|
|
967
|
-
loadingBar:
|
|
1413
|
+
loadingBar: normalizeLoadingBarOptions(options.loadingBar).enabled,
|
|
1414
|
+
loadingBarClassName: normalizeLoadingBarOptions(options.loadingBar).className,
|
|
968
1415
|
forms: normalizeFormOptions(
|
|
969
1416
|
Object.prototype.hasOwnProperty.call(options, 'forms') ? options.forms : true
|
|
970
1417
|
),
|
|
@@ -1378,9 +1825,35 @@ function buildPageLocals(req, config, pageData = {}) {
|
|
|
1378
1825
|
delete locals.transition;
|
|
1379
1826
|
delete locals.asjs;
|
|
1380
1827
|
|
|
1381
|
-
|
|
1828
|
+
const headerOverrides = {
|
|
1829
|
+
currentPath: input.currentPath || req.path
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
if (Array.isArray(input.navItems)) {
|
|
1833
|
+
headerOverrides.navItems = input.navItems;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (Object.prototype.hasOwnProperty.call(input, 'brand')) {
|
|
1837
|
+
headerOverrides.brand = input.brand;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
if (Object.prototype.hasOwnProperty.call(input, 'siteCta')) {
|
|
1841
|
+
headerOverrides.siteCta = input.siteCta;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (typeof input.isActiveNavItem === 'function') {
|
|
1845
|
+
headerOverrides.isActiveNavItem = input.isActiveNavItem;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const headerState = resolveHeaderState(config, locals, headerOverrides);
|
|
1849
|
+
|
|
1850
|
+
locals.brand = headerState.brand;
|
|
1851
|
+
locals.isActiveNavItem = headerState.isActiveNavItem;
|
|
1852
|
+
locals.navItems = headerState.navItems;
|
|
1382
1853
|
locals.currentPath = input.currentPath || req.path;
|
|
1383
1854
|
locals.asjs = createAsjsViewModel(pageAsjs, config, { transition });
|
|
1855
|
+
locals.siteCta = headerState.siteCta;
|
|
1856
|
+
locals.asjs.header = (options) => renderHeaderMarkup(resolveHeaderState(config, locals, options));
|
|
1384
1857
|
|
|
1385
1858
|
return {
|
|
1386
1859
|
locals,
|
|
@@ -1388,6 +1861,30 @@ function buildPageLocals(req, config, pageData = {}) {
|
|
|
1388
1861
|
};
|
|
1389
1862
|
}
|
|
1390
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
|
+
|
|
1391
1888
|
function setupAsjs(app, options = {}) {
|
|
1392
1889
|
const config = createAsjsConfig(options);
|
|
1393
1890
|
|
|
@@ -1524,8 +2021,18 @@ function setupAsjs(app, options = {}) {
|
|
|
1524
2021
|
app,
|
|
1525
2022
|
config,
|
|
1526
2023
|
clearCache,
|
|
2024
|
+
createPageModel,
|
|
2025
|
+
createPageRoute(viewName, routeOptions, pageFactory) {
|
|
2026
|
+
return createPageRoute(page, viewName, routeOptions, pageFactory);
|
|
2027
|
+
},
|
|
2028
|
+
createRenderState,
|
|
1527
2029
|
express,
|
|
2030
|
+
formatTimestamp,
|
|
2031
|
+
hasValidationErrors,
|
|
2032
|
+
normalizeFields,
|
|
2033
|
+
normalizeFieldValue,
|
|
1528
2034
|
render,
|
|
2035
|
+
renderInlineResponse,
|
|
1529
2036
|
page,
|
|
1530
2037
|
route: page,
|
|
1531
2038
|
notFound,
|
|
@@ -1534,6 +2041,7 @@ function setupAsjs(app, options = {}) {
|
|
|
1534
2041
|
packagePaths: config.packagePaths,
|
|
1535
2042
|
extendLocals,
|
|
1536
2043
|
defineComponents,
|
|
2044
|
+
validateFields,
|
|
1537
2045
|
addHook,
|
|
1538
2046
|
use(plugin) {
|
|
1539
2047
|
applyAsjsPlugin(plugin, api);
|
|
@@ -1568,12 +2076,22 @@ function setupAsjs(app, options = {}) {
|
|
|
1568
2076
|
}
|
|
1569
2077
|
|
|
1570
2078
|
module.exports = {
|
|
2079
|
+
createPageModel,
|
|
2080
|
+
createPageRoute,
|
|
1571
2081
|
createAsjsConfig,
|
|
1572
2082
|
createAsjsEngine,
|
|
2083
|
+
createRenderState,
|
|
1573
2084
|
escapeHtml,
|
|
2085
|
+
formatTimestamp,
|
|
1574
2086
|
getAsjsPackagePaths,
|
|
2087
|
+
hasValidationErrors,
|
|
2088
|
+
normalizeFields,
|
|
2089
|
+
normalizeFieldValue,
|
|
1575
2090
|
renderClientTags,
|
|
1576
2091
|
renderDebugErrorPage,
|
|
2092
|
+
renderHeaderMarkup,
|
|
2093
|
+
renderInlineResponse,
|
|
2094
|
+
validateFields,
|
|
1577
2095
|
validateComponentProps,
|
|
1578
2096
|
setupAsjs
|
|
1579
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": [
|