webspresso 0.0.64 โ†’ 0.0.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -187,6 +187,7 @@ var AnalyticsPage = {
187
187
  vnode.state.viewsOverTime = [];
188
188
  vnode.state.topPages = [];
189
189
  vnode.state.botActivity = [];
190
+ vnode.state.referrerSources = [];
190
191
  vnode.state.countries = [];
191
192
  vnode.state.recent = [];
192
193
  vnode.state.chartLoaded = false;
@@ -217,6 +218,7 @@ var AnalyticsPage = {
217
218
  analyticsApi('views-over-time', days),
218
219
  analyticsApi('top-pages', days),
219
220
  analyticsApi('bot-activity', days),
221
+ analyticsApi('referrer-sources', days),
220
222
  analyticsApi('countries', days),
221
223
  analyticsApi('client-errors', days),
222
224
  analyticsApi('recent', days),
@@ -225,9 +227,10 @@ var AnalyticsPage = {
225
227
  vnode.state.viewsOverTime = results[1];
226
228
  vnode.state.topPages = results[2];
227
229
  vnode.state.botActivity = results[3];
228
- vnode.state.countries = results[4];
229
- vnode.state.clientErrors = results[5];
230
- vnode.state.recent = results[6];
230
+ vnode.state.referrerSources = results[4];
231
+ vnode.state.countries = results[5];
232
+ vnode.state.clientErrors = results[6];
233
+ vnode.state.recent = results[7];
231
234
  vnode.state.loading = false;
232
235
 
233
236
  if (chartInstance) {
@@ -279,11 +282,13 @@ var AnalyticsPage = {
279
282
  ? m('div.flex.justify-center.py-24', m(Spinner))
280
283
  : [
281
284
  // Stat cards
282
- m('div.grid.grid-cols-1.sm:grid-cols-2.lg:grid-cols-4.gap-4.mb-6', [
285
+ m('div.grid.grid-cols-2.sm:grid-cols-3.lg:grid-cols-3.xl:grid-cols-6.gap-4.mb-6', [
283
286
  m(StatCard, { icon: '๐Ÿ‘', label: 'Views', value: s.stats?.views, bgClass: 'bg-blue-100' }),
284
287
  m(StatCard, { icon: '๐Ÿ‘ค', label: 'Visitors', value: s.stats?.visitors, bgClass: 'bg-green-100' }),
285
288
  m(StatCard, { icon: '๐Ÿ“„', label: 'Unique Pages', value: s.stats?.uniquePages, bgClass: 'bg-yellow-100' }),
286
289
  m(StatCard, { icon: '๐Ÿ”—', label: 'Sessions', value: s.stats?.sessions, bgClass: 'bg-purple-100' }),
290
+ m(StatCard, { icon: '๐Ÿ ', label: 'Direct traffic', value: s.stats?.directViews, bgClass: 'bg-slate-100' }),
291
+ m(StatCard, { icon: '๐ŸŒ', label: 'With referrer', value: s.stats?.referredViews, bgClass: 'bg-cyan-100' }),
287
292
  ]),
288
293
 
289
294
  // Chart + Bot Activity row
@@ -413,6 +418,31 @@ var AnalyticsPage = {
413
418
  ]),
414
419
  ]),
415
420
 
421
+ // Referrer sources (hostname-level)
422
+ m('div.bg-white.rounded-lg.shadow.mb-6', [
423
+ m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
424
+ m('h3.text-sm.font-semibold.text-gray-900', 'Referrer sources'),
425
+ m('span.text-xs.text-gray-400', 'By hostname ยท last ' + s.days + ' days'),
426
+ ]),
427
+ m('div.p-4', [
428
+ !s.referrerSources || s.referrerSources.length === 0
429
+ ? m('p.text-gray-400.text-sm.text-center.py-4', 'No external referrer data (direct visits or missing Referer header)')
430
+ : (function() {
431
+ var maxV = s.referrerSources[0]?.views || 1;
432
+ return m('div.grid.grid-cols-1.sm:grid-cols-2.gap-x-8.gap-y-2', s.referrerSources.map(function(r) {
433
+ return m('div.flex.items-center.gap-3.py-1', [
434
+ m('span.text-sm.text-gray-700.flex-1.min-w-0.truncate', { title: r.source }, r.source),
435
+ m('div.w-32.shrink-0', m(HBar, {
436
+ pct: (r.views / maxV) * 100,
437
+ color: 'bg-sky-500',
438
+ })),
439
+ m('span.text-xs.text-gray-500.w-12.text-right.tabular-nums', formatNumber(r.views)),
440
+ ]);
441
+ }));
442
+ })(),
443
+ ]),
444
+ ]),
445
+
416
446
  // Country Stats
417
447
  m('div.bg-white.rounded-lg.shadow.mb-6', [
418
448
  m('div.px-5.py-4.border-b.border-gray-100.flex.items-center.justify-between', [
@@ -29,6 +29,24 @@ function createAnalyticsApiHandlers(options) {
29
29
  return d.toISOString();
30
30
  }
31
31
 
32
+ /**
33
+ * Extract registrable hostname from Referer URL (strip www.)
34
+ * @param {string|null|undefined} referrer
35
+ * @returns {string|null}
36
+ */
37
+ function hostnameFromReferrer(referrer) {
38
+ if (!referrer || typeof referrer !== 'string' || !referrer.trim()) return null;
39
+ try {
40
+ const u = new URL(referrer);
41
+ const h = u.hostname;
42
+ if (!h) return null;
43
+ const norm = h.replace(/^www\./i, '').toLowerCase();
44
+ return norm || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
32
50
  /**
33
51
  * GET /stats - Summary statistics
34
52
  */
@@ -42,6 +60,18 @@ function createAnalyticsApiHandlers(options) {
42
60
  .where('is_bot', false)
43
61
  .count('id as count');
44
62
 
63
+ const totalViews = parseInt(views.count) || 0;
64
+
65
+ const [directRow] = await knex(tableName)
66
+ .where('created_at', '>=', since)
67
+ .where('is_bot', false)
68
+ .where((qb) => {
69
+ qb.whereNull('referrer').orWhere('referrer', '');
70
+ })
71
+ .count('id as count');
72
+
73
+ const directViews = parseInt(directRow.count) || 0;
74
+
45
75
  const [visitors] = await knex(tableName)
46
76
  .where('created_at', '>=', since)
47
77
  .where('is_bot', false)
@@ -58,7 +88,9 @@ function createAnalyticsApiHandlers(options) {
58
88
  .countDistinct('session_id as count');
59
89
 
60
90
  res.json({
61
- views: parseInt(views.count) || 0,
91
+ views: totalViews,
92
+ directViews,
93
+ referredViews: Math.max(0, totalViews - directViews),
62
94
  visitors: parseInt(visitors.count) || 0,
63
95
  uniquePages: parseInt(uniquePages.count) || 0,
64
96
  sessions: parseInt(sessions.count) || 0,
@@ -183,6 +215,46 @@ function createAnalyticsApiHandlers(options) {
183
215
  }
184
216
  }
185
217
 
218
+ /**
219
+ * GET /referrer-sources - Top referrer hostnames (aggregated from full Referer URLs)
220
+ */
221
+ async function getReferrerSources(req, res) {
222
+ try {
223
+ const days = parseDays(req);
224
+ const since = sinceDate(days);
225
+ const limit = Math.min(parseInt(req.query.limit) || 20, 50);
226
+ /** Max distinct full Referer URLs read before hostname merge (long tail capped). */
227
+ const referrerGroupLimit = 400;
228
+
229
+ const rows = await knex(tableName)
230
+ .select('referrer')
231
+ .where('created_at', '>=', since)
232
+ .where('is_bot', false)
233
+ .whereNotNull('referrer')
234
+ .where('referrer', '!=', '')
235
+ .count('id as views')
236
+ .groupBy('referrer')
237
+ .orderBy('views', 'desc')
238
+ .limit(referrerGroupLimit);
239
+
240
+ const byHost = new Map();
241
+ for (const r of rows) {
242
+ const host = hostnameFromReferrer(r.referrer) || 'Other';
243
+ const n = parseInt(r.views) || 0;
244
+ byHost.set(host, (byHost.get(host) || 0) + n);
245
+ }
246
+
247
+ const list = [...byHost.entries()]
248
+ .map(([source, views]) => ({ source, views }))
249
+ .sort((a, b) => b.views - a.views)
250
+ .slice(0, limit);
251
+
252
+ res.json(list);
253
+ } catch (e) {
254
+ res.status(500).json({ error: e.message });
255
+ }
256
+ }
257
+
186
258
  /**
187
259
  * GET /countries - Country breakdown
188
260
  */
@@ -262,6 +334,7 @@ function createAnalyticsApiHandlers(options) {
262
334
  getViewsOverTime,
263
335
  getTopPages,
264
336
  getBotActivity,
337
+ getReferrerSources,
265
338
  getCountries,
266
339
  getClientErrors,
267
340
  getRecent,
@@ -115,6 +115,7 @@ function siteAnalyticsPlugin(options = {}) {
115
115
  { method: 'get', path: '/views-over-time', handler: handlers.getViewsOverTime },
116
116
  { method: 'get', path: '/top-pages', handler: handlers.getTopPages },
117
117
  { method: 'get', path: '/bot-activity', handler: handlers.getBotActivity },
118
+ { method: 'get', path: '/referrer-sources', handler: handlers.getReferrerSources },
118
119
  { method: 'get', path: '/countries', handler: handlers.getCountries },
119
120
  { method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
120
121
  { method: 'get', path: '/recent', handler: handlers.getRecent },