asjs-express 1.1.1 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +148 -0
  2. package/lib/asjs.js +227 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -97,6 +97,80 @@ app.listen(3000)
97
97
  `asjs.clientTags()` injects the built-in ASJS stylesheet and router script for you.
98
98
  `theme: true` optionally loads the packaged light, corporate WebAS theme.
99
99
 
100
+ ### Built-in SPA header
101
+
102
+ ASJS now ships with a built-in header helper for the default SPA flow.
103
+ 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.
104
+
105
+ ```js
106
+ const express = require('express')
107
+ const { setupAsjs } = require('asjs-express')
108
+
109
+ const app = express()
110
+
111
+ const asjs = setupAsjs(app, {
112
+ rootDir: __dirname,
113
+ defaultLayout: 'layouts/main',
114
+ navItems: [
115
+ { href: '/', label: 'Home', activeMode: 'exact' },
116
+ { href: '/products', label: 'Products', activeMode: 'exact' },
117
+ { href: '/contact', label: 'Contact', activeMode: 'exact' }
118
+ ],
119
+ transitions: 'fade',
120
+ prefetch: true,
121
+ loadingBar: true
122
+ })
123
+ ```
124
+
125
+ ```asjs
126
+ <!DOCTYPE html>
127
+ <html>
128
+ <head>
129
+ <meta charset="UTF-8">
130
+ <title><%= title %></title>
131
+ <%- asjs.clientTags({ preload: true, theme: true }) %>
132
+ </head>
133
+ <body<%- asjs.bodyAttrs() %>>
134
+ <%- asjs.progressMarkup() %>
135
+ <%- asjs.header() %>
136
+ <main<%- asjs.viewAttrs() %>>
137
+ <%- body %>
138
+ </main>
139
+ </body>
140
+ </html>
141
+ ```
142
+
143
+ The built-in header is SPA-aware by default.
144
+ It renders `data-asjs-link`, uses the current request path automatically, and applies active states internally.
145
+
146
+ If you do not define a brand, ASJS generates a safe default brand object.
147
+ If you do not define `siteCta`, the header still works and simply renders without the CTA button.
148
+ If you want to customize them later, you can still pass them through `setupAsjs()`.
149
+
150
+ ```js
151
+ const asjs = setupAsjs(app, {
152
+ rootDir: __dirname,
153
+ defaultLayout: 'layouts/main',
154
+ navItems: [
155
+ { href: '/', label: 'Overview' },
156
+ { href: '/contact', label: 'Contact' }
157
+ ],
158
+ brand: {
159
+ href: '/',
160
+ mark: 'WA',
161
+ name: 'WebAS',
162
+ tagline: 'Shared interface structure for Express projects'
163
+ },
164
+ siteCta: {
165
+ href: '/contact',
166
+ label: 'Start a project',
167
+ transition: 'slide'
168
+ }
169
+ })
170
+ ```
171
+
172
+ If you still prefer a custom partial, ASJS now also injects `brand`, `siteCta`, `currentPath`, and `isActiveNavItem` into the view locals automatically.
173
+
100
174
  ### Work before the page loads
101
175
 
102
176
  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)`.
@@ -537,6 +611,80 @@ app.listen(3000)
537
611
  `asjs.clientTags()` çağrısı dahili ASJS stil ve router etiketlerini otomatik üretir.
538
612
  `theme: true` ise açık renkli, kurumsal WebAS temasını yükler.
539
613
 
614
+ ### Dahili SPA header sistemi
615
+
616
+ ASJS artık varsayılan SPA akışı için dahili bir header helper ile geliyor.
617
+ Ç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.
618
+
619
+ ```js
620
+ const express = require('express')
621
+ const { setupAsjs } = require('asjs-express')
622
+
623
+ const app = express()
624
+
625
+ const asjs = setupAsjs(app, {
626
+ rootDir: __dirname,
627
+ defaultLayout: 'layouts/main',
628
+ navItems: [
629
+ { href: '/', label: 'Ana Sayfa', activeMode: 'exact' },
630
+ { href: '/products', label: 'Ürünler', activeMode: 'exact' },
631
+ { href: '/contact', label: 'İletişim', activeMode: 'exact' }
632
+ ],
633
+ transitions: 'fade',
634
+ prefetch: true,
635
+ loadingBar: true
636
+ })
637
+ ```
638
+
639
+ ```asjs
640
+ <!DOCTYPE html>
641
+ <html>
642
+ <head>
643
+ <meta charset="UTF-8">
644
+ <title><%= title %></title>
645
+ <%- asjs.clientTags({ preload: true, theme: true }) %>
646
+ </head>
647
+ <body<%- asjs.bodyAttrs() %>>
648
+ <%- asjs.progressMarkup() %>
649
+ <%- asjs.header() %>
650
+ <main<%- asjs.viewAttrs() %>>
651
+ <%- body %>
652
+ </main>
653
+ </body>
654
+ </html>
655
+ ```
656
+
657
+ Bu dahili header varsayılan olarak SPA uyumludur.
658
+ Linkleri `data-asjs-link` ile üretir, mevcut request path bilgisini kendi alır ve aktif link durumunu içeride hesaplar.
659
+
660
+ `brand` tanımlamazsan ASJS güvenli bir varsayılan brand nesnesi üretir.
661
+ `siteCta` tanımlamazsan header bozulmaz; sadece sağ taraftaki CTA düğmesi çizilmez.
662
+ Daha sonra özelleştirmek istersen bunları yine `setupAsjs()` üzerinden verebilirsin.
663
+
664
+ ```js
665
+ const asjs = setupAsjs(app, {
666
+ rootDir: __dirname,
667
+ defaultLayout: 'layouts/main',
668
+ navItems: [
669
+ { href: '/', label: 'Genel Bakış' },
670
+ { href: '/contact', label: 'İletişim' }
671
+ ],
672
+ brand: {
673
+ href: '/',
674
+ mark: 'WA',
675
+ name: 'WebAS',
676
+ tagline: 'Express projeleri için ortak arayüz yapısı'
677
+ },
678
+ siteCta: {
679
+ href: '/contact',
680
+ label: 'Proje başlat',
681
+ transition: 'slide'
682
+ }
683
+ })
684
+ ```
685
+
686
+ Kendi partial yapını yazmak istersen ASJS artık `brand`, `siteCta`, `currentPath` ve `isActiveNavItem` alanlarını da view locals içine otomatik koyar.
687
+
540
688
  ### Sayfa yüklenmeden önce işlem yapmak
541
689
 
542
690
  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
@@ -184,6 +184,196 @@ function renderClientTags(assets, options = {}) {
184
184
  return tags.join('\n');
185
185
  }
186
186
 
187
+ function normalizePathname(value) {
188
+ const normalized = String(value || '/').replace(/\/+$/, '');
189
+ return normalized || '/';
190
+ }
191
+
192
+ function isActiveNavItem(currentPath, item = {}) {
193
+ const pathname = normalizePathname(currentPath);
194
+ const targetPath = normalizePathname(item.matchPath || item.href || '/');
195
+ const activeMode = String(item.activeMode || 'exact').toLowerCase();
196
+
197
+ if (activeMode === 'prefix') {
198
+ if (targetPath === '/') {
199
+ return pathname === '/';
200
+ }
201
+
202
+ return pathname === targetPath || pathname.startsWith(`${targetPath}/`);
203
+ }
204
+
205
+ return pathname === targetPath;
206
+ }
207
+
208
+ function normalizeNavItems(value) {
209
+ if (!Array.isArray(value)) {
210
+ return [];
211
+ }
212
+
213
+ return value
214
+ .filter((item) => item && typeof item === 'object')
215
+ .map((item) => ({
216
+ ...item,
217
+ href: String(item.href || '/'),
218
+ label: item.label ? String(item.label) : String(item.href || '/'),
219
+ activeMode: String(item.activeMode || 'exact').toLowerCase()
220
+ }));
221
+ }
222
+
223
+ function normalizeBrandOptions(value, fallback = {}) {
224
+ const defaults = {
225
+ href: '/',
226
+ mark: 'AS',
227
+ name: fallback.name || 'ASJS',
228
+ tagline: fallback.tagline || 'Server-rendered interface shell for Express',
229
+ caption: fallback.caption || '',
230
+ transition: 'fade'
231
+ };
232
+
233
+ if (typeof value === 'string' && value) {
234
+ return {
235
+ ...defaults,
236
+ name: value
237
+ };
238
+ }
239
+
240
+ if (value && typeof value === 'object') {
241
+ return {
242
+ ...defaults,
243
+ ...value,
244
+ href: String(value.href || defaults.href),
245
+ mark: value.mark ? String(value.mark) : defaults.mark,
246
+ name: value.name ? String(value.name) : defaults.name,
247
+ tagline: value.tagline ? String(value.tagline) : defaults.tagline,
248
+ caption: value.caption ? String(value.caption) : defaults.caption,
249
+ transition: value.transition ? String(value.transition) : defaults.transition
250
+ };
251
+ }
252
+
253
+ return defaults;
254
+ }
255
+
256
+ function normalizeSiteCtaOptions(value, fallback = null) {
257
+ const defaults = fallback && typeof fallback === 'object'
258
+ ? {
259
+ href: String(fallback.href || '/'),
260
+ label: fallback.label ? String(fallback.label) : 'Get Started',
261
+ transition: fallback.transition ? String(fallback.transition) : 'fade'
262
+ }
263
+ : null;
264
+
265
+ if (value === undefined) {
266
+ return defaults;
267
+ }
268
+
269
+ if (value === false || value === null) {
270
+ return null;
271
+ }
272
+
273
+ if (typeof value === 'string' && value) {
274
+ return {
275
+ href: value,
276
+ label: defaults ? defaults.label : 'Get Started',
277
+ transition: defaults ? defaults.transition : 'fade'
278
+ };
279
+ }
280
+
281
+ if (value && typeof value === 'object') {
282
+ return {
283
+ href: String(value.href || (defaults ? defaults.href : '/')),
284
+ label: value.label ? String(value.label) : (defaults ? defaults.label : 'Get Started'),
285
+ transition: value.transition ? String(value.transition) : (defaults ? defaults.transition : 'fade')
286
+ };
287
+ }
288
+
289
+ return defaults;
290
+ }
291
+
292
+ function resolveHeaderState(config, sourceLocals = {}, options = {}) {
293
+ const locals = sourceLocals && typeof sourceLocals === 'object' ? sourceLocals : {};
294
+ const input = options && typeof options === 'object' ? options : {};
295
+ const siteName = input.siteName || locals.siteName || config.locals.siteName || 'ASJS';
296
+ const navItems = normalizeNavItems(
297
+ Array.isArray(input.navItems)
298
+ ? input.navItems
299
+ : (Array.isArray(locals.navItems) ? locals.navItems : config.navItems)
300
+ );
301
+ const brand = normalizeBrandOptions(
302
+ Object.prototype.hasOwnProperty.call(input, 'brand')
303
+ ? input.brand
304
+ : (Object.prototype.hasOwnProperty.call(locals, 'brand') ? locals.brand : config.brand),
305
+ { name: siteName }
306
+ );
307
+ const siteCta = normalizeSiteCtaOptions(
308
+ Object.prototype.hasOwnProperty.call(input, 'siteCta')
309
+ ? input.siteCta
310
+ : (Object.prototype.hasOwnProperty.call(locals, 'siteCta') ? locals.siteCta : config.siteCta),
311
+ config.siteCta
312
+ );
313
+
314
+ return {
315
+ brand,
316
+ currentPath: input.currentPath || locals.currentPath || '/',
317
+ isActiveNavItem: typeof input.isActiveNavItem === 'function'
318
+ ? input.isActiveNavItem
319
+ : (typeof locals.isActiveNavItem === 'function' ? locals.isActiveNavItem : isActiveNavItem),
320
+ navItems,
321
+ note: input.note || locals.headerNote || '',
322
+ noteLabel: input.noteLabel || locals.headerNoteLabel || '',
323
+ siteCta,
324
+ siteName
325
+ };
326
+ }
327
+
328
+ function renderHeaderMarkup(options = {}) {
329
+ const brand = options.brand || normalizeBrandOptions();
330
+ const currentPath = options.currentPath || '/';
331
+ const resolveActive = typeof options.isActiveNavItem === 'function' ? options.isActiveNavItem : isActiveNavItem;
332
+ const navigation = normalizeNavItems(options.navItems);
333
+ const note = options.note ? String(options.note) : '';
334
+ const noteLabel = options.noteLabel ? String(options.noteLabel) : '';
335
+ const cta = normalizeSiteCtaOptions(options.siteCta);
336
+
337
+ return [
338
+ '<header class="example-header">',
339
+ note
340
+ ? ` <div class="header-note"><span class="example-pill">${escapeHtml(noteLabel || 'Site Header')}</span><p>${escapeHtml(note)}</p></div>`
341
+ : '',
342
+ ' <div class="header-main">',
343
+ ` <a${serializeHtmlAttributes({
344
+ class: 'brand-link',
345
+ href: brand.href || '/',
346
+ 'data-asjs-link': true,
347
+ ...(brand.transition ? { 'data-asjs-transition': brand.transition } : {})
348
+ })}><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>`,
349
+ navigation.length
350
+ ? ` <nav class="site-nav" aria-label="Primary navigation">${navigation.map((item) => {
351
+ const isActive = resolveActive(currentPath, item);
352
+
353
+ return `<a${serializeHtmlAttributes({
354
+ href: item.href,
355
+ class: `site-link${isActive ? ' is-active' : ''}`,
356
+ 'data-asjs-link': true,
357
+ 'data-asjs-active': item.activeMode || 'exact',
358
+ ...(item.matchPath ? { 'data-asjs-match-path': item.matchPath } : {}),
359
+ ...(item.transition ? { 'data-asjs-transition': item.transition } : {}),
360
+ ...(isActive ? { 'aria-current': 'page' } : {})
361
+ })}><span>${escapeHtml(item.label)}</span>${item.description ? `<small>${escapeHtml(item.description)}</small>` : ''}</a>`;
362
+ }).join('')}</nav>`
363
+ : '',
364
+ cta
365
+ ? ` <a${serializeHtmlAttributes({
366
+ class: 'button button-secondary header-cta',
367
+ href: cta.href,
368
+ 'data-asjs-link': true,
369
+ ...(cta.transition ? { 'data-asjs-transition': cta.transition } : {})
370
+ })}>${escapeHtml(cta.label)}</a>`
371
+ : '',
372
+ ' </div>',
373
+ '</header>'
374
+ ].filter(Boolean).join('');
375
+ }
376
+
187
377
  function normalizeFormMode(value, fallback = 'view') {
188
378
  const normalized = String(value || fallback || 'view').toLowerCase();
189
379
  return ['view', 'json'].includes(normalized) ? normalized : (fallback || 'view');
@@ -440,6 +630,7 @@ function createAsjsViewModel(inputAsjs, config, overrides = {}) {
440
630
  viewModel.viewAttrs = (extraAttributes) => renderViewAttrs(viewModel, extraAttributes);
441
631
  viewModel.formAttrs = (options) => renderFormAttrs(viewModel, options);
442
632
  viewModel.progressMarkup = () => renderProgressMarkup();
633
+ viewModel.header = (options) => renderHeaderMarkup(resolveHeaderState(config, {}, options));
443
634
 
444
635
  return viewModel;
445
636
  }
@@ -957,7 +1148,14 @@ function createAsjsConfig(options = {}) {
957
1148
  defaultLayout: options.defaultLayout || null,
958
1149
  debug: Boolean(options.debug),
959
1150
  cache: normalizeBooleanOption(options.cache, !options.debug),
960
- navItems: Array.isArray(options.navItems) ? options.navItems : [],
1151
+ navItems: normalizeNavItems(options.navItems),
1152
+ brand: normalizeBrandOptions(
1153
+ Object.prototype.hasOwnProperty.call(options, 'brand') ? options.brand : undefined,
1154
+ { name: options.locals && options.locals.siteName ? options.locals.siteName : 'ASJS' }
1155
+ ),
1156
+ siteCta: normalizeSiteCtaOptions(
1157
+ Object.prototype.hasOwnProperty.call(options, 'siteCta') ? options.siteCta : undefined
1158
+ ),
961
1159
  locals: options.locals && typeof options.locals === 'object' ? options.locals : {},
962
1160
  components: normalizeComponentRegistry(options.components || {}, extension),
963
1161
  templateCache: new Map(),
@@ -1378,9 +1576,35 @@ function buildPageLocals(req, config, pageData = {}) {
1378
1576
  delete locals.transition;
1379
1577
  delete locals.asjs;
1380
1578
 
1381
- locals.navItems = Array.isArray(input.navItems) ? input.navItems : config.navItems;
1579
+ const headerOverrides = {
1580
+ currentPath: input.currentPath || req.path
1581
+ };
1582
+
1583
+ if (Array.isArray(input.navItems)) {
1584
+ headerOverrides.navItems = input.navItems;
1585
+ }
1586
+
1587
+ if (Object.prototype.hasOwnProperty.call(input, 'brand')) {
1588
+ headerOverrides.brand = input.brand;
1589
+ }
1590
+
1591
+ if (Object.prototype.hasOwnProperty.call(input, 'siteCta')) {
1592
+ headerOverrides.siteCta = input.siteCta;
1593
+ }
1594
+
1595
+ if (typeof input.isActiveNavItem === 'function') {
1596
+ headerOverrides.isActiveNavItem = input.isActiveNavItem;
1597
+ }
1598
+
1599
+ const headerState = resolveHeaderState(config, locals, headerOverrides);
1600
+
1601
+ locals.brand = headerState.brand;
1602
+ locals.isActiveNavItem = headerState.isActiveNavItem;
1603
+ locals.navItems = headerState.navItems;
1382
1604
  locals.currentPath = input.currentPath || req.path;
1383
1605
  locals.asjs = createAsjsViewModel(pageAsjs, config, { transition });
1606
+ locals.siteCta = headerState.siteCta;
1607
+ locals.asjs.header = (options) => renderHeaderMarkup(resolveHeaderState(config, locals, options));
1384
1608
 
1385
1609
  return {
1386
1610
  locals,
@@ -1574,6 +1798,7 @@ module.exports = {
1574
1798
  getAsjsPackagePaths,
1575
1799
  renderClientTags,
1576
1800
  renderDebugErrorPage,
1801
+ renderHeaderMarkup,
1577
1802
  validateComponentProps,
1578
1803
  setupAsjs
1579
1804
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asjs-express",
3
- "version": "1.1.1",
3
+ "version": "1.2.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": {