asjs-express 1.2.0 → 1.3.0

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