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 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
- return '<div class="asjs-progress" data-asjs-progress aria-hidden="true"><span class="asjs-progress-bar"></span></div>';
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: Object.prototype.hasOwnProperty.call(current, 'loadingBar')
426
- ? Boolean(current.loadingBar)
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: Array.isArray(options.navItems) ? options.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: normalizeBooleanOption(options.loadingBar, true),
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
- locals.navItems = Array.isArray(input.navItems) ? input.navItems : config.navItems;
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 = 'asjs-progress';
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.1.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": [