webspresso 0.0.97 โ†’ 0.0.99

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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -53,7 +53,10 @@ All routes are automatically discovered and registered from the `pages/` directo
53
53
  | `pages/index.njk` | `GET` | `/` | Home page (SSR Nunjucks) |
54
54
  | `pages/about.njk` | `GET` | `/about` | Static SSR page |
55
55
  | `pages/notes/[id].njk` | `GET` | `/notes/:id` | Dynamic SSR route with param |
56
+ | `pages/(marketing)/about.njk` | `GET` | `/about` | Route Group page (omits `(marketing)`) |
57
+ | `pages/(auth)/login.njk` | `GET` | `/login` | Route Group with isolated layout & middleware |
56
58
  | `pages/api/notes.get.js` | `GET` | `/api/notes` | List notes API endpoint |
59
+ | `pages/api/(v1)/notes.get.js` | `GET` | `/api/notes` | API route inside Route Group |
57
60
  | `pages/api/notes.post.js` | `POST` | `/api/notes` | Create note API endpoint |
58
61
  | `pages/api/notes/[id].get.js` | `GET` | `/api/notes/:id` | Get single note API endpoint |
59
62
  | `pages/api/notes/[id].put.js` | `PUT` | `/api/notes/:id` | Update note API endpoint |
@@ -148,7 +151,50 @@ module.exports = {
148
151
 
149
152
  ---
150
153
 
151
- ## 4. SSR Page Routes (`load()` Loader)
154
+ ## 4. Route Groups `(group)` Syntax & Layout Isolation
155
+
156
+ Webspresso supports Next.js / Nuxt-style **Route Groups**. Any directory name wrapped in parentheses `(groupName)` is treated as an organizational group:
157
+ - **Clean Public URLs**: The parenthesized directory name is completely omitted from the generated URL path.
158
+ - **Layout & Middleware Isolation**: The directory hierarchy is preserved for cascading middleware (`_middleware.js`) and nested layouts (`_layout.njk`), allowing distinct shells and security guards without polluting public route URLs.
159
+ - **Nesting & Dynamic Route Support**: Route groups can be nested (e.g. `(shop)/(checkout)/cart.njk` โ†’ `/cart`), combined with dynamic segments (e.g. `(app)/users/[id].njk` โ†’ `/users/:id`), and applied to API routes (`pages/api/(v1)/items.get.js` โ†’ `/api/items` or `pages/(marketing)/api/newsletter.post.js` โ†’ `/api/newsletter`).
160
+
161
+ ### 4.1 Route Group Structure Example
162
+
163
+ ```
164
+ pages/
165
+ โ”œโ”€โ”€ (marketing)/
166
+ โ”‚ โ”œโ”€โ”€ _layout.njk # Marketing layout (Hero header, footer)
167
+ โ”‚ โ”œโ”€โ”€ _middleware.js # Track analytics / marketing headers
168
+ โ”‚ โ”œโ”€โ”€ index.njk -> GET /
169
+ โ”‚ โ”œโ”€โ”€ about.njk -> GET /about
170
+ โ”‚ โ””โ”€โ”€ api/
171
+ โ”‚ โ””โ”€โ”€ newsletter.post.js -> POST /api/newsletter
172
+ โ”œโ”€โ”€ (auth)/
173
+ โ”‚ โ”œโ”€โ”€ _layout.njk # Centered card layout (no nav/footer)
174
+ โ”‚ โ”œโ”€โ”€ _middleware.js # Guest-only guard (redirects authenticated users)
175
+ โ”‚ โ”œโ”€โ”€ login.njk -> GET /login
176
+ โ”‚ โ””โ”€โ”€ register.njk -> GET /register
177
+ โ””โ”€โ”€ (app)/
178
+ โ”œโ”€โ”€ _layout.njk # App shell layout with sidebar
179
+ โ”œโ”€โ”€ _middleware.js # Strict session auth guard
180
+ โ””โ”€โ”€ dashboard.njk -> GET /dashboard
181
+ ```
182
+
183
+ ### 4.2 Programmatic Utilities
184
+ Webspresso exports core helpers to inspect and transform route group paths:
185
+ ```javascript
186
+ const { isRouteGroupSegment, stripRouteGroups } = require('webspresso');
187
+
188
+ isRouteGroupSegment('(marketing)'); // true
189
+ isRouteGroupSegment('about'); // false
190
+
191
+ stripRouteGroups('/(marketing)/about'); // '/about'
192
+ stripRouteGroups('/(shop)/(checkout)/cart'); // '/cart'
193
+ ```
194
+
195
+ ---
196
+
197
+ ## 5. SSR Page Routes (`load()` Loader)
152
198
 
153
199
  For server-rendered Nunjucks HTML pages, use `.njk` templates and companion `.js` loader files (or inline `async function load` in a `.js` file):
154
200
 
@@ -174,7 +220,7 @@ module.exports = {
174
220
 
175
221
  ---
176
222
 
177
- ## 5. Module & File-Based Discovery (`src/pages`, `src/api`, `modules/{name}`)
223
+ ## 6. Module & File-Based Discovery (`src/pages`, `src/api`, `modules/{name}`)
178
224
 
179
225
  Webspresso supports clean, domain-driven module discovery alongside root-level pages and APIs:
180
226
 
@@ -204,7 +250,7 @@ src/
204
250
  auth-guard.js -> module-local named middleware 'authGuard'
205
251
  ```
206
252
 
207
- ### 5.1 `definePage()` Contract
253
+ ### 6.1 `definePage()` Contract
208
254
  ```javascript
209
255
  const { definePage } = require('webspresso');
210
256
 
@@ -230,7 +276,7 @@ module.exports = definePage({
230
276
  });
231
277
  ```
232
278
 
233
- ### 5.2 `defineApi()` Contract
279
+ ### 6.2 `defineApi()` Contract
234
280
  ```javascript
235
281
  const { defineApi, z } = require('webspresso');
236
282
 
@@ -252,7 +298,7 @@ module.exports = defineApi({
252
298
  });
253
299
  ```
254
300
 
255
- ### 5.3 `defineModule()` Contract
301
+ ### 6.3 `defineModule()` Contract
256
302
  ```javascript
257
303
  const { defineModule } = require('webspresso');
258
304
 
@@ -270,7 +316,7 @@ module.exports = defineModule({
270
316
  });
271
317
  ```
272
318
 
273
- ### 5.4 Route Introspection (`app.routes.list()`)
319
+ ### 6.4 Route Introspection (`app.routes.list()`)
274
320
  Compiled routes are accessible at runtime for tooling, CLI inspection, and documentation:
275
321
  ```javascript
276
322
  const routeList = app.routes.list();
@@ -280,9 +326,83 @@ const routeList = app.routes.list();
280
326
  // ]
281
327
  ```
282
328
 
329
+ ### 6.5 Server-Sent Events (SSE) Streaming (`res.sse`)
330
+ For real-time event streaming and LLM token outputs, use the native `res.sse()` helper (or `{ sse }` from `ctx`):
331
+
332
+ ```javascript
333
+ // File: pages/api/ai/chat.post.js
334
+ const { defineApi } = require('webspresso');
335
+
336
+ module.exports = defineApi({
337
+ handler: async (req, res, { sse }) => {
338
+ // 1. Controller mode with automatic keepalive & reconnection directives
339
+ const stream = sse({ heartbeat: 15000, retry: 3000 });
340
+
341
+ await stream.send({ delta: 'Hello ' });
342
+ await stream.send({ event: 'delta', data: { text: 'world!' }, id: '1' });
343
+ stream.close();
344
+
345
+ // 2. OR stream an async generator / LLM stream directly:
346
+ // return res.sse(async function*() {
347
+ // for await (const chunk of llmStream) {
348
+ // yield { token: chunk.text };
349
+ // }
350
+ // });
351
+ },
352
+ });
353
+ ```
354
+
355
+ ### 6.6 Cascading Middleware (`_middleware.js`) & Nested Layouts (`_layout.njk`)
356
+
357
+ Webspresso supports folder-level cascading middleware inheritance and nested layouts across directory trees:
358
+
359
+ #### 1. Cascading Middleware (`_middleware.js`)
360
+ Any directory under `pages/` can declare a `_middleware.js` file:
361
+ - **Execution Order**: Middlewares execute in top-down ancestral order (from root down to route leaf) before route handlers and route-specific middleware.
362
+ - **Early Termination**: If any middleware sends headers (e.g. `res.status(401).json(...)` or `res.redirect('/login')`), execution halts immediately and downstream middlewares/handlers are skipped.
363
+ - **Export Formats**: Standard Express middleware function `(req, res, next)`, async function, named middleware string/array `['auth', 'cors']`, or middleware tuples with options `[['rateLimit', { max: 100 }]]`.
364
+
365
+ ```
366
+ pages/
367
+ โ”œโ”€โ”€ _middleware.js # Runs for ALL routes (adds global headers / request ID)
368
+ โ”œโ”€โ”€ api/
369
+ โ”‚ โ””โ”€โ”€ admin/
370
+ โ”‚ โ”œโ”€โ”€ _middleware.js # Protects all /api/admin/* endpoints
371
+ โ”‚ โ””โ”€โ”€ metrics.get.js # Inherits root + admin middleware
372
+ โ””โ”€โ”€ dashboard/
373
+ โ”œโ”€โ”€ _middleware.js # Auth guard for /dashboard/* pages
374
+ โ””โ”€โ”€ settings.njk
375
+ ```
376
+
377
+ #### 2. Nested Layouts (`_layout.njk` & `_layout.js`)
378
+ Pages under `pages/` automatically inherit ancestor layouts:
379
+ - **Wrapping Order**: Child template is rendered first, then wrapped by layouts from innermost to outermost using `{{ content | safe }}`.
380
+ - **Layout Loaders (`_layout.js`)**: Companion `_layout.js` files can export `async function load(req, ctx)` to supply shared data (e.g., site title, navigation, breadcrumbs) in top-down order before the page loader runs.
381
+ - **Opt-Out**: A page can opt out of automatic layout wrapping by setting `layout: false` in its companion `.js` config/loader, or by explicitly using `{% extends "..." %}` in its Nunjucks template.
382
+
383
+ ```html
384
+ {# pages/_layout.njk #}
385
+ <!DOCTYPE html>
386
+ <html>
387
+ <head><title>{{ siteTitle }}</title></head>
388
+ <body>
389
+ <header>Global Nav</header>
390
+ {{ content | safe }}
391
+ </body>
392
+ </html>
393
+ ```
394
+
395
+ ```html
396
+ {# pages/dashboard/_layout.njk #}
397
+ <div class="dashboard-shell">
398
+ <aside>Dashboard Sidebar</aside>
399
+ <main>{{ content | safe }}</main>
400
+ </div>
401
+ ```
402
+
283
403
  ---
284
404
 
285
- ## 6. Summary Checklist for Agents
405
+ ## 7. Summary Checklist for Agents
286
406
 
287
407
  When a user asks to add or modify endpoints:
288
408
  1. Identify the URL and method (e.g. `POST /api/auth/register` or `POST /auth/login`).
package/AGENTS.md CHANGED
@@ -142,6 +142,7 @@ Plugins expose `name`, `version`, `dependencies`, and lifecycle hooks (`register
142
142
  - `restResourcePlugin`: Zero-boilerplate RESTful CRUD API generator directly from ORM models with eager loading, sanitization, filtering, pagination, and soft-delete scoping.
143
143
  - `xlsxPlugin`: Excel spreadsheet generation, parsing, ORM streaming export (`res.xlsx`), and built-in services (`xlsx.generate`, `xlsx.parse`, `xlsx.exportModel`).
144
144
  - `csvPlugin`: RFC 4180 CSV generator, parser, Excel UTF-8 BOM compatibility, formula sanitization, streaming writer (`res.csv`), and built-in services (`csv.generate`, `csv.parse`, `csv.exportModel`).
145
+ - `apiKeyPlugin`: Zero-dependency B2B & Developer API Key Management with SHA-256 secure storage, constant-time verification, granular wildcard scopes, named route middleware (`ctx.middlewares.apiKey`), and non-blocking usage tracking.
145
146
 
146
147
  ---
147
148
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Add esbuild Command
3
+ * Add esbuild client bundling and incremental watch to the project
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function registerCommand(program) {
10
+ program
11
+ .command('add esbuild')
12
+ .alias('add:esbuild')
13
+ .description('Add esbuild client bundling and incremental watch to the project')
14
+ .action(async () => {
15
+ if (!fs.existsSync('package.json')) {
16
+ console.error('โŒ Not a Webspresso project! Run this command in your project directory.');
17
+ process.exit(1);
18
+ }
19
+
20
+ console.log('\nโšก Adding esbuild client bundler to your project...\n');
21
+
22
+ // Read package.json
23
+ const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
24
+ packageJson.devDependencies = packageJson.devDependencies || {};
25
+ packageJson.devDependencies['esbuild'] = '^0.25.0';
26
+
27
+ packageJson.scripts = packageJson.scripts || {};
28
+ packageJson.scripts['build:js'] = 'esbuild src/client/main.js --bundle --outdir=public/js --minify --sourcemap';
29
+ packageJson.scripts['watch:js'] = 'esbuild src/client/main.js --bundle --outdir=public/js --sourcemap --watch';
30
+
31
+ fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
32
+ console.log('โœ… Updated package.json (esbuild devDependency and build:js / watch:js scripts)');
33
+
34
+ // Create src/client directory
35
+ if (!fs.existsSync('src/client')) {
36
+ fs.mkdirSync('src/client', { recursive: true });
37
+ }
38
+
39
+ // Create src/client/main.js if not exists
40
+ const mainJsPath = path.join('src', 'client', 'main.js');
41
+ if (!fs.existsSync(mainJsPath)) {
42
+ const clientStarterCode = `// Client-side JavaScript entry point
43
+ console.log('โšก Webspresso client script loaded');
44
+
45
+ document.addEventListener('DOMContentLoaded', () => {
46
+ // Client interactivity
47
+ });
48
+ `;
49
+ fs.writeFileSync(mainJsPath, clientStarterCode);
50
+ console.log('โœ… Created src/client/main.js');
51
+ }
52
+
53
+ // Create public/js directory
54
+ if (!fs.existsSync(path.join('public', 'js'))) {
55
+ fs.mkdirSync(path.join('public', 'js'), { recursive: true });
56
+ }
57
+
58
+ // Update layout.njk to include script if not present
59
+ const layoutPath = path.join('views', 'layout.njk');
60
+ if (fs.existsSync(layoutPath)) {
61
+ let layoutContent = fs.readFileSync(layoutPath, 'utf-8');
62
+ if (!layoutContent.includes('/js/main.js')) {
63
+ layoutContent = layoutContent.replace(
64
+ /(<\/head>)/,
65
+ ' <script defer src="/js/main.js"></script>\n$1'
66
+ );
67
+ fs.writeFileSync(layoutPath, layoutContent);
68
+ console.log('โœ… Updated views/layout.njk (<script defer src="/js/main.js"></script>)');
69
+ }
70
+ }
71
+
72
+ console.log('\nโœ… esbuild setup complete!\n');
73
+ console.log('Next steps:');
74
+ console.log(' npm install');
75
+ console.log(' npm run build:js');
76
+ console.log(' npm run dev\n');
77
+ });
78
+ }
79
+
80
+ module.exports = { registerCommand };
@@ -10,14 +10,26 @@ const { execSync } = require('child_process');
10
10
  function registerCommand(program) {
11
11
  program
12
12
  .command('add tailwind')
13
+ .alias('add:tailwind')
13
14
  .description('Add Tailwind CSS to the project with build process')
14
- .action(async () => {
15
+ .option('--v4', 'Use Tailwind CSS v4 (Oxide engine, LightningCSS, zero postcss config)')
16
+ .option('--v3', 'Use Tailwind CSS v3 (legacy PostCSS engine)')
17
+ .option('-v, --tailwind-version <version>', 'Tailwind version (3 or 4)', '3')
18
+ .option('--upgrade', 'Upgrade existing Tailwind v3 project to Tailwind v4')
19
+ .action(async (...actionArgs) => {
20
+ const cmd = actionArgs[actionArgs.length - 1];
21
+ const options = (cmd && typeof cmd.opts === 'function')
22
+ ? cmd.opts()
23
+ : (actionArgs.find(a => a && typeof a === 'object') || {});
24
+
15
25
  if (!fs.existsSync('package.json')) {
16
26
  console.error('โŒ Not a Webspresso project! Run this command in your project directory.');
17
27
  process.exit(1);
18
28
  }
19
29
 
20
- console.log('\n๐ŸŽจ Adding Tailwind CSS to your project...\n');
30
+ const isV4 = Boolean(options.v4 || options.upgrade || options.tailwindVersion === '4');
31
+
32
+ console.log(`\n๐ŸŽจ Adding Tailwind CSS ${isV4 ? 'v4 (Oxide engine, zero-config)' : 'v3'} to your project...\n`);
21
33
 
22
34
  // Read package.json
23
35
  const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
@@ -27,17 +39,26 @@ function registerCommand(program) {
27
39
  packageJson.devDependencies = {};
28
40
  }
29
41
 
30
- packageJson.devDependencies['tailwindcss'] = '^3.4.1';
31
- packageJson.devDependencies['postcss'] = '^8.4.35';
32
- packageJson.devDependencies['autoprefixer'] = '^10.4.17';
33
-
34
- // Add build scripts
35
42
  if (!packageJson.scripts) {
36
43
  packageJson.scripts = {};
37
44
  }
38
-
39
- packageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
40
- packageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
45
+
46
+ if (isV4) {
47
+ packageJson.devDependencies['@tailwindcss/cli'] = '^4.0.0';
48
+ packageJson.devDependencies['tailwindcss'] = '^4.0.0';
49
+ delete packageJson.devDependencies['postcss'];
50
+ delete packageJson.devDependencies['autoprefixer'];
51
+
52
+ packageJson.scripts['build:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --minify';
53
+ packageJson.scripts['watch:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --watch';
54
+ } else {
55
+ packageJson.devDependencies['tailwindcss'] = '^3.4.1';
56
+ packageJson.devDependencies['postcss'] = '^8.4.35';
57
+ packageJson.devDependencies['autoprefixer'] = '^10.4.17';
58
+
59
+ packageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
60
+ packageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
61
+ }
41
62
 
42
63
  // Update dev script to include CSS watch
43
64
  if (packageJson.scripts.dev) {
@@ -50,24 +71,48 @@ function registerCommand(program) {
50
71
  }
51
72
 
52
73
  fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
53
- console.log('โœ… Updated package.json');
74
+ console.log(`โœ… Updated package.json (${isV4 ? 'Tailwind v4' : 'Tailwind v3'})`);
54
75
 
55
76
  // Create src directory if it doesn't exist
56
77
  if (!fs.existsSync('src')) {
57
78
  fs.mkdirSync('src', { recursive: true });
58
79
  }
59
80
 
60
- // Create input.css
61
- const inputCss = `@tailwind base;
81
+ if (isV4) {
82
+ // Create v4 input.css
83
+ const inputCss = `@import "tailwindcss";
84
+
85
+ @source "../pages/**/*.{njk,js}";
86
+ @source "../views/**/*.njk";
87
+ @source "../src/**/*.js";
88
+ `;
89
+ fs.writeFileSync('src/input.css', inputCss);
90
+ console.log('โœ… Created src/input.css (@import "tailwindcss"; + @source directives)');
91
+
92
+ // If upgrading from v3, clean up obsolete configs
93
+ if (fs.existsSync('postcss.config.js')) {
94
+ try {
95
+ fs.unlinkSync('postcss.config.js');
96
+ console.log('๐Ÿงน Removed obsolete postcss.config.js (not needed in v4)');
97
+ } catch (_) {}
98
+ }
99
+ if (fs.existsSync('tailwind.config.js')) {
100
+ try {
101
+ fs.renameSync('tailwind.config.js', 'tailwind.config.js.backup');
102
+ console.log('โ„น๏ธ Renamed tailwind.config.js to tailwind.config.js.backup (v4 is CSS-first)');
103
+ } catch (_) {}
104
+ }
105
+ } else {
106
+ // Create v3 input.css
107
+ const inputCss = `@tailwind base;
62
108
  @tailwind components;
63
109
  @tailwind utilities;
64
110
  `;
65
-
66
- fs.writeFileSync('src/input.css', inputCss);
67
- console.log('โœ… Created src/input.css');
68
-
69
- // Create tailwind.config.js
70
- const tailwindConfig = `/** @type {import('tailwindcss').Config} */
111
+ fs.writeFileSync('src/input.css', inputCss);
112
+ console.log('โœ… Created src/input.css');
113
+
114
+ // Create tailwind.config.js
115
+ const tailwindConfig = `/** @type {import('tailwindcss').Config} */
71
116
  module.exports = {
72
117
  content: [
73
118
  './pages/**/*.{njk,js}',
@@ -80,21 +125,20 @@ module.exports = {
80
125
  plugins: [],
81
126
  }
82
127
  `;
83
-
84
- fs.writeFileSync('tailwind.config.js', tailwindConfig);
85
- console.log('โœ… Created tailwind.config.js');
86
-
87
- // Create postcss.config.js
88
- const postcssConfig = `module.exports = {
128
+ fs.writeFileSync('tailwind.config.js', tailwindConfig);
129
+ console.log('โœ… Created tailwind.config.js');
130
+
131
+ // Create postcss.config.js
132
+ const postcssConfig = `module.exports = {
89
133
  plugins: {
90
134
  tailwindcss: {},
91
135
  autoprefixer: {},
92
136
  },
93
137
  }
94
138
  `;
95
-
96
- fs.writeFileSync('postcss.config.js', postcssConfig);
97
- console.log('โœ… Created postcss.config.js');
139
+ fs.writeFileSync('postcss.config.js', postcssConfig);
140
+ console.log('โœ… Created postcss.config.js');
141
+ }
98
142
 
99
143
  // Check if layout.njk exists and update it (before creating CSS)
100
144
  const layoutPath = 'views/layout.njk';
@@ -128,9 +172,10 @@ module.exports = {
128
172
  fs.writeFileSync('public/css/style.css', '/* Run npm run build:css */\n');
129
173
  console.log('โœ… Created public/css/style.css');
130
174
 
131
- // Try to build CSS if tailwindcss is already installed
175
+ // Try to build CSS if tailwindcss / @tailwindcss/cli is already installed
132
176
  const tailwindBin = path.join(process.cwd(), 'node_modules', '.bin', 'tailwindcss');
133
- if (fs.existsSync(tailwindBin)) {
177
+ const tailwindCliBin = path.join(process.cwd(), 'node_modules', '.bin', '@tailwindcss/cli');
178
+ if (fs.existsSync(tailwindBin) || fs.existsSync(tailwindCliBin)) {
134
179
  try {
135
180
  console.log('\n๐ŸŽจ Building Tailwind CSS from your templates...');
136
181
  execSync('npm run build:css', { stdio: 'inherit', cwd: process.cwd() });
@@ -139,7 +184,7 @@ module.exports = {
139
184
  console.log('\nโš ๏ธ CSS build failed. Run "npm run build:css" manually.\n');
140
185
  }
141
186
  } else {
142
- console.log('\nโœ… Tailwind CSS added successfully!\n');
187
+ console.log(`\nโœ… Tailwind CSS ${isV4 ? 'v4' : 'v3'} added successfully!\n`);
143
188
  console.log('Next steps:');
144
189
  console.log(' npm install');
145
190
  console.log(' npm run build:css');
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Prune old site analytics records (CLI)
3
+ */
4
+
5
+ const { loadDbConfig, createDbInstance } = require('../utils/db');
6
+
7
+ function registerCommand(program) {
8
+ program
9
+ .command('analytics:prune')
10
+ .description('Delete analytics page views and client errors older than the given retention window')
11
+ .option('--days <n>', 'Delete page views older than this many days', '90')
12
+ .option('--error-days <n>', 'Delete client errors older than this many days', '30')
13
+ .option('--table <name>', 'Page views table name', 'analytics_page_views')
14
+ .option('--errors-table <name>', 'Client errors table name', 'analytics_client_errors')
15
+ .option('-e, --env <environment>', 'Environment (development, production)', 'development')
16
+ .option('-c, --config <path>', 'Path to database config file')
17
+ .action(async (options) => {
18
+ const days = parseInt(options.days, 10);
19
+ if (Number.isNaN(days) || days < 1) {
20
+ console.error('โŒ --days must be a positive integer');
21
+ process.exit(1);
22
+ }
23
+
24
+ const errorDays = parseInt(options.errorDays, 10);
25
+ if (Number.isNaN(errorDays) || errorDays < 1) {
26
+ console.error('โŒ --error-days must be a positive integer');
27
+ process.exit(1);
28
+ }
29
+
30
+ const { config, path: configPath } = loadDbConfig(options.config);
31
+ console.log(`\n๐Ÿ“ฆ Using config: ${configPath}`);
32
+ console.log(` Environment: ${options.env}\n`);
33
+
34
+ const knex = await createDbInstance(config, options.env);
35
+
36
+ try {
37
+ const { purgeAnalyticsData } = require('../../plugins/site-analytics/purge');
38
+ const result = await purgeAnalyticsData(knex, {
39
+ days,
40
+ errorDays,
41
+ tableName: options.table,
42
+ errorsTableName: options.errorsTable,
43
+ });
44
+
45
+ console.log(`โœ… Analytics purge completed:`);
46
+ console.log(` - Page views deleted: ${result.viewsDeleted} (cutoff: ${result.viewsCutoff})`);
47
+ console.log(` - Client errors deleted: ${result.errorsDeleted} (cutoff: ${result.errorsCutoff})\n`);
48
+ } catch (err) {
49
+ console.error('โŒ analytics:prune failed:', err.message);
50
+ process.exit(1);
51
+ } finally {
52
+ await knex.destroy();
53
+ }
54
+ });
55
+ }
56
+
57
+ module.exports = { registerCommand };