also looking at this
chore(deps): upgrade vendored Stacks framework to 0.70.163
#29
224 files
+3516
-874
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -22,92 +22,95 @@ | ||
| 22 | 22 | }, |
| 23 | 23 | "storage/framework/core": { |
| 24 | 24 | "name": "stacks", |
| 25 | "version": "0.70.162", | |
| 25 | "version": "0.70.163", | |
| 26 | 26 | "dependencies": { |
| 27 | "@stacksjs/actions": "^0.70.162", | |
| 28 | "@stacksjs/ai": "^0.70.162", | |
| 29 | "@stacksjs/alias": "^0.70.162", | |
| 30 | "@stacksjs/analytics": "^0.70.162", | |
| 31 | "@stacksjs/api": "^0.70.162", | |
| 32 | "@stacksjs/arrays": "^0.70.162", | |
| 33 | "@stacksjs/auth": "^0.70.162", | |
| 34 | "@stacksjs/browser": "^0.70.162", | |
| 35 | "@stacksjs/browser-extension": "^0.70.162", | |
| 36 | "@stacksjs/buddy": "^0.70.162", | |
| 37 | "@stacksjs/build": "^0.70.162", | |
| 38 | "@stacksjs/cache": "^0.70.162", | |
| 39 | "@stacksjs/calendar-api": "^0.70.162", | |
| 40 | "@stacksjs/charts": "^0.70.162", | |
| 41 | "@stacksjs/chat": "^0.70.162", | |
| 42 | "@stacksjs/cli": "^0.70.162", | |
| 43 | "@stacksjs/cloud": "^0.70.162", | |
| 44 | "@stacksjs/cms": "^0.70.162", | |
| 45 | "@stacksjs/collections": "^0.70.162", | |
| 46 | "@stacksjs/commerce": "^0.70.162", | |
| 47 | "@stacksjs/composables": "^0.70.162", | |
| 48 | "@stacksjs/config": "^0.70.162", | |
| 49 | "@stacksjs/cron": "^0.70.162", | |
| 50 | "@stacksjs/database": "^0.70.162", | |
| 51 | "@stacksjs/datetime": "^0.70.162", | |
| 52 | "@stacksjs/defaults": "^0.70.162", | |
| 53 | "@stacksjs/desktop": "^0.70.162", | |
| 54 | "@stacksjs/dns": "^0.70.162", | |
| 55 | "@stacksjs/docs": "^0.70.162", | |
| 56 | "@stacksjs/email": "^0.70.162", | |
| 57 | "@stacksjs/enums": "^0.70.162", | |
| 58 | "@stacksjs/env": "^0.70.162", | |
| 59 | "@stacksjs/error-handling": "^0.70.162", | |
| 60 | "@stacksjs/events": "^0.70.162", | |
| 61 | "@stacksjs/faker": "^0.70.162", | |
| 62 | "@stacksjs/feature-flags": "^0.70.162", | |
| 63 | "@stacksjs/git": "^0.70.162", | |
| 64 | "@stacksjs/github": "^0.70.162", | |
| 65 | "@stacksjs/health": "^0.70.162", | |
| 66 | "@stacksjs/http": "^0.70.162", | |
| 67 | "@stacksjs/i18n": "^0.70.162", | |
| 68 | "@stacksjs/lint": "^0.70.162", | |
| 69 | "@stacksjs/logging": "^0.70.162", | |
| 70 | "@stacksjs/newsletter": "^0.70.162", | |
| 71 | "@stacksjs/notifications": "^0.70.162", | |
| 72 | "@stacksjs/objects": "^0.70.162", | |
| 73 | "@stacksjs/orm": "^0.70.162", | |
| 74 | "@stacksjs/path": "^0.70.162", | |
| 75 | "@stacksjs/payments": "^0.70.162", | |
| 76 | "@stacksjs/push": "^0.70.162", | |
| 77 | "@stacksjs/query-builder": "^0.70.162", | |
| 78 | "@stacksjs/queue": "^0.70.162", | |
| 79 | "@stacksjs/realtime": "^0.70.162", | |
| 80 | "@stacksjs/registry": "^0.70.162", | |
| 81 | "@stacksjs/repl": "^0.70.162", | |
| 82 | "@stacksjs/router": "^0.70.162", | |
| 83 | "@stacksjs/scheduler": "^0.70.162", | |
| 84 | "@stacksjs/search-engine": "^0.70.162", | |
| 85 | "@stacksjs/security": "^0.70.162", | |
| 86 | "@stacksjs/server": "^0.70.162", | |
| 87 | "@stacksjs/shell": "^0.70.162", | |
| 88 | "@stacksjs/skills": "^0.70.162", | |
| 89 | "@stacksjs/slug": "^0.70.162", | |
| 90 | "@stacksjs/sms": "^0.70.162", | |
| 91 | "@stacksjs/socials": "^0.70.162", | |
| 92 | "@stacksjs/storage": "^0.70.162", | |
| 93 | "@stacksjs/strings": "^0.70.162", | |
| 94 | "@stacksjs/testing": "^0.70.162", | |
| 95 | "@stacksjs/tinker": "^0.70.162", | |
| 27 | "@stacksjs/actions": "^0.70.163", | |
| 28 | "@stacksjs/ai": "^0.70.163", | |
| 29 | "@stacksjs/alias": "^0.70.163", | |
| 30 | "@stacksjs/analytics": "^0.70.163", | |
| 31 | "@stacksjs/api": "^0.70.163", | |
| 32 | "@stacksjs/arrays": "^0.70.163", | |
| 33 | "@stacksjs/audio": "^0.70.163", | |
| 34 | "@stacksjs/auth": "^0.70.163", | |
| 35 | "@stacksjs/browser": "^0.70.163", | |
| 36 | "@stacksjs/browser-extension": "^0.70.163", | |
| 37 | "@stacksjs/buddy": "^0.70.163", | |
| 38 | "@stacksjs/build": "^0.70.163", | |
| 39 | "@stacksjs/cache": "^0.70.163", | |
| 40 | "@stacksjs/calendar-api": "^0.70.163", | |
| 41 | "@stacksjs/charts": "^0.70.163", | |
| 42 | "@stacksjs/chat": "^0.70.163", | |
| 43 | "@stacksjs/cli": "^0.70.163", | |
| 44 | "@stacksjs/cloud": "^0.70.163", | |
| 45 | "@stacksjs/cms": "^0.70.163", | |
| 46 | "@stacksjs/collections": "^0.70.163", | |
| 47 | "@stacksjs/commerce": "^0.70.163", | |
| 48 | "@stacksjs/composables": "^0.70.163", | |
| 49 | "@stacksjs/config": "^0.70.163", | |
| 50 | "@stacksjs/cron": "^0.70.163", | |
| 51 | "@stacksjs/database": "^0.70.163", | |
| 52 | "@stacksjs/datetime": "^0.70.163", | |
| 53 | "@stacksjs/defaults": "^0.70.163", | |
| 54 | "@stacksjs/desktop": "^0.70.163", | |
| 55 | "@stacksjs/dns": "^0.70.163", | |
| 56 | "@stacksjs/docs": "^0.70.163", | |
| 57 | "@stacksjs/email": "^0.70.163", | |
| 58 | "@stacksjs/enums": "^0.70.163", | |
| 59 | "@stacksjs/env": "^0.70.163", | |
| 60 | "@stacksjs/error-handling": "^0.70.163", | |
| 61 | "@stacksjs/events": "^0.70.163", | |
| 62 | "@stacksjs/faker": "^0.70.163", | |
| 63 | "@stacksjs/feature-flags": "^0.70.163", | |
| 64 | "@stacksjs/git": "^0.70.163", | |
| 65 | "@stacksjs/github": "^0.70.163", | |
| 66 | "@stacksjs/health": "^0.70.163", | |
| 67 | "@stacksjs/http": "^0.70.163", | |
| 68 | "@stacksjs/i18n": "^0.70.163", | |
| 69 | "@stacksjs/image": "^0.70.163", | |
| 70 | "@stacksjs/lint": "^0.70.163", | |
| 71 | "@stacksjs/logging": "^0.70.163", | |
| 72 | "@stacksjs/newsletter": "^0.70.163", | |
| 73 | "@stacksjs/notifications": "^0.70.163", | |
| 74 | "@stacksjs/objects": "^0.70.163", | |
| 75 | "@stacksjs/orm": "^0.70.163", | |
| 76 | "@stacksjs/path": "^0.70.163", | |
| 77 | "@stacksjs/payments": "^0.70.163", | |
| 78 | "@stacksjs/push": "^0.70.163", | |
| 79 | "@stacksjs/query-builder": "^0.70.163", | |
| 80 | "@stacksjs/queue": "^0.70.163", | |
| 81 | "@stacksjs/realtime": "^0.70.163", | |
| 82 | "@stacksjs/registry": "^0.70.163", | |
| 83 | "@stacksjs/repl": "^0.70.163", | |
| 84 | "@stacksjs/router": "^0.70.163", | |
| 85 | "@stacksjs/scheduler": "^0.70.163", | |
| 86 | "@stacksjs/search-engine": "^0.70.163", | |
| 87 | "@stacksjs/security": "^0.70.163", | |
| 88 | "@stacksjs/server": "^0.70.163", | |
| 89 | "@stacksjs/shell": "^0.70.163", | |
| 90 | "@stacksjs/skills": "^0.70.163", | |
| 91 | "@stacksjs/slug": "^0.70.163", | |
| 92 | "@stacksjs/sms": "^0.70.163", | |
| 93 | "@stacksjs/socials": "^0.70.163", | |
| 94 | "@stacksjs/storage": "^0.70.163", | |
| 95 | "@stacksjs/strings": "^0.70.163", | |
| 96 | "@stacksjs/testing": "^0.70.163", | |
| 97 | "@stacksjs/tinker": "^0.70.163", | |
| 96 | 98 | "@stacksjs/tlsx": "^0.13.0", |
| 97 | 99 | "@stacksjs/ts-cloud": "catalog:", |
| 98 | "@stacksjs/tunnel": "^0.70.162", | |
| 99 | "@stacksjs/types": "^0.70.162", | |
| 100 | "@stacksjs/ui": "^0.70.162", | |
| 101 | "@stacksjs/utils": "^0.70.162", | |
| 102 | "@stacksjs/validation": "^0.70.162", | |
| 103 | "@stacksjs/whois": "^0.70.162", | |
| 100 | "@stacksjs/tunnel": "^0.70.163", | |
| 101 | "@stacksjs/types": "^0.70.163", | |
| 102 | "@stacksjs/ui": "^0.70.163", | |
| 103 | "@stacksjs/utils": "^0.70.163", | |
| 104 | "@stacksjs/validation": "^0.70.163", | |
| 105 | "@stacksjs/video": "^0.70.163", | |
| 106 | "@stacksjs/whois": "^0.70.163", | |
| 104 | 107 | "better-dx": "catalog:", |
| 105 | 108 | "bun-query-builder": "catalog:", |
| 106 | 109 | }, |
| 107 | 110 | }, |
| 108 | 111 | "storage/framework/core/actions": { |
| 109 | 112 | "name": "@stacksjs/actions", |
| 110 | "version": "0.70.162", | |
| 113 | "version": "0.70.163", | |
| 111 | 114 | "dependencies": { |
| 112 | 115 | "@stacksjs/bumpx": "^0.2.6", |
| 113 | 116 | "@stacksjs/bunpress": "^0.1.18", |
| @@ -145,7 +148,7 @@ | ||
| 145 | 148 | }, |
| 146 | 149 | "storage/framework/core/ai": { |
| 147 | 150 | "name": "@stacksjs/ai", |
| 148 | "version": "0.70.162", | |
| 151 | "version": "0.70.163", | |
| 149 | 152 | "dependencies": { |
| 150 | 153 | "@stacksjs/ts-cloud": "catalog:", |
| 151 | 154 | }, |
| @@ -162,7 +165,7 @@ | ||
| 162 | 165 | }, |
| 163 | 166 | "storage/framework/core/alias": { |
| 164 | 167 | "name": "@stacksjs/alias", |
| 165 | "version": "0.70.162", | |
| 168 | "version": "0.70.163", | |
| 166 | 169 | "devDependencies": { |
| 167 | 170 | "@stacksjs/path": "workspace:*", |
| 168 | 171 | "better-dx": "catalog:", |
| @@ -170,14 +173,14 @@ | ||
| 170 | 173 | }, |
| 171 | 174 | "storage/framework/core/analytics": { |
| 172 | 175 | "name": "@stacksjs/analytics", |
| 173 | "version": "0.70.162", | |
| 176 | "version": "0.70.163", | |
| 174 | 177 | "devDependencies": { |
| 175 | 178 | "better-dx": "catalog:", |
| 176 | 179 | }, |
| 177 | 180 | }, |
| 178 | 181 | "storage/framework/core/api": { |
| 179 | 182 | "name": "@stacksjs/api", |
| 180 | "version": "0.70.162", | |
| 183 | "version": "0.70.163", | |
| 181 | 184 | "devDependencies": { |
| 182 | 185 | "@stacksjs/utils": "workspace:*", |
| 183 | 186 | "better-dx": "catalog:", |
| @@ -185,15 +188,26 @@ | ||
| 185 | 188 | }, |
| 186 | 189 | "storage/framework/core/arrays": { |
| 187 | 190 | "name": "@stacksjs/arrays", |
| 188 | "version": "0.70.162", | |
| 191 | "version": "0.70.163", | |
| 189 | 192 | "devDependencies": { |
| 190 | 193 | "@stacksjs/utils": "workspace:*", |
| 191 | 194 | "better-dx": "catalog:", |
| 192 | 195 | }, |
| 193 | 196 | }, |
| 197 | "storage/framework/core/audio": { | |
| 198 | "name": "@stacksjs/audio", | |
| 199 | "version": "0.70.163", | |
| 200 | "dependencies": { | |
| 201 | "@ts-audio/core": "^0.1.1", | |
| 202 | "ts-video-player": "^0.1.0", | |
| 203 | }, | |
| 204 | "devDependencies": { | |
| 205 | "better-dx": "catalog:", | |
| 206 | }, | |
| 207 | }, | |
| 194 | 208 | "storage/framework/core/auth": { |
| 195 | 209 | "name": "@stacksjs/auth", |
| 196 | "version": "0.70.162", | |
| 210 | "version": "0.70.163", | |
| 197 | 211 | "dependencies": { |
| 198 | 212 | "@stacksjs/bun-router": "^0.0.20", |
| 199 | 213 | "@stacksjs/ts-auth": "^0.4.3", |
| @@ -206,7 +220,7 @@ | ||
| 206 | 220 | }, |
| 207 | 221 | "storage/framework/core/browser": { |
| 208 | 222 | "name": "@stacksjs/browser", |
| 209 | "version": "0.70.162", | |
| 223 | "version": "0.70.163", | |
| 210 | 224 | "dependencies": { |
| 211 | 225 | "@stacksjs/composables": "workspace:*", |
| 212 | 226 | "@stacksjs/stx": "catalog:", |
| @@ -227,7 +241,7 @@ | ||
| 227 | 241 | }, |
| 228 | 242 | "storage/framework/core/browser-extension": { |
| 229 | 243 | "name": "@stacksjs/browser-extension", |
| 230 | "version": "0.70.162", | |
| 244 | "version": "0.70.163", | |
| 231 | 245 | "dependencies": { |
| 232 | 246 | "web-ext": "catalog:", |
| 233 | 247 | }, |
| @@ -237,10 +251,9 @@ | ||
| 237 | 251 | }, |
| 238 | 252 | "storage/framework/core/buddy": { |
| 239 | 253 | "name": "@stacksjs/buddy", |
| 240 | "version": "0.70.162", | |
| 254 | "version": "0.70.163", | |
| 241 | 255 | "bin": { |
| 242 | 256 | "stacks": "dist/cli.js", |
| 243 | "stx": "dist/cli.js", | |
| 244 | 257 | "buddy": "dist/cli.js", |
| 245 | 258 | "bud": "dist/cli.js", |
| 246 | 259 | }, |
| @@ -298,7 +311,7 @@ | ||
| 298 | 311 | }, |
| 299 | 312 | "storage/framework/core/build": { |
| 300 | 313 | "name": "@stacksjs/build", |
| 301 | "version": "0.70.162", | |
| 314 | "version": "0.70.163", | |
| 302 | 315 | "devDependencies": { |
| 303 | 316 | "@stacksjs/cli": "workspace:*", |
| 304 | 317 | "@stacksjs/path": "workspace:*", |
| @@ -343,7 +356,7 @@ | ||
| 343 | 356 | }, |
| 344 | 357 | "storage/framework/core/cache": { |
| 345 | 358 | "name": "@stacksjs/cache", |
| 346 | "version": "0.70.162", | |
| 359 | "version": "0.70.163", | |
| 347 | 360 | "devDependencies": { |
| 348 | 361 | "@stacksjs/config": "workspace:*", |
| 349 | 362 | "@stacksjs/ts-cache": "^0.1.5", |
| @@ -351,7 +364,7 @@ | ||
| 351 | 364 | }, |
| 352 | 365 | "storage/framework/core/calendar-api": { |
| 353 | 366 | "name": "@stacksjs/calendar-api", |
| 354 | "version": "0.70.162", | |
| 367 | "version": "0.70.163", | |
| 355 | 368 | "dependencies": { |
| 356 | 369 | "@stacksjs/stx": "catalog:", |
| 357 | 370 | }, |
| @@ -363,7 +376,7 @@ | ||
| 363 | 376 | }, |
| 364 | 377 | "storage/framework/core/charts": { |
| 365 | 378 | "name": "@stacksjs/charts", |
| 366 | "version": "0.70.162", | |
| 379 | "version": "0.70.163", | |
| 367 | 380 | "dependencies": { |
| 368 | 381 | "@ts-charts/format": "^0.2.0", |
| 369 | 382 | "@ts-charts/scale": "^0.2.0", |
| @@ -375,7 +388,7 @@ | ||
| 375 | 388 | }, |
| 376 | 389 | "storage/framework/core/chat": { |
| 377 | 390 | "name": "@stacksjs/chat", |
| 378 | "version": "0.70.162", | |
| 391 | "version": "0.70.163", | |
| 379 | 392 | "devDependencies": { |
| 380 | 393 | "@stacksjs/cli": "workspace:*", |
| 381 | 394 | "@stacksjs/config": "workspace:*", |
| @@ -386,7 +399,7 @@ | ||
| 386 | 399 | }, |
| 387 | 400 | "storage/framework/core/cli": { |
| 388 | 401 | "name": "@stacksjs/cli", |
| 389 | "version": "0.70.162", | |
| 402 | "version": "0.70.163", | |
| 390 | 403 | "dependencies": { |
| 391 | 404 | "@stacksjs/clapp": "catalog:", |
| 392 | 405 | }, |
| @@ -404,7 +417,7 @@ | ||
| 404 | 417 | }, |
| 405 | 418 | "storage/framework/core/cloud": { |
| 406 | 419 | "name": "@stacksjs/cloud", |
| 407 | "version": "0.70.162", | |
| 420 | "version": "0.70.163", | |
| 408 | 421 | "dependencies": { |
| 409 | 422 | "@stacksjs/ts-cloud": "catalog:", |
| 410 | 423 | "@stacksjs/ts-xml": "^0.1.0", |
| @@ -425,7 +438,7 @@ | ||
| 425 | 438 | }, |
| 426 | 439 | "storage/framework/core/cms": { |
| 427 | 440 | "name": "@stacksjs/cms", |
| 428 | "version": "0.70.162", | |
| 441 | "version": "0.70.163", | |
| 429 | 442 | "dependencies": { |
| 430 | 443 | "ts-slug": "^0.1.0", |
| 431 | 444 | "ts-spreadsheets": "^0.2.0", |
| @@ -436,7 +449,7 @@ | ||
| 436 | 449 | }, |
| 437 | 450 | "storage/framework/core/collections": { |
| 438 | 451 | "name": "@stacksjs/collections", |
| 439 | "version": "0.70.162", | |
| 452 | "version": "0.70.163", | |
| 440 | 453 | "dependencies": { |
| 441 | 454 | "ts-collect": "^0.4.3", |
| 442 | 455 | }, |
| @@ -446,7 +459,7 @@ | ||
| 446 | 459 | }, |
| 447 | 460 | "storage/framework/core/commerce": { |
| 448 | 461 | "name": "@stacksjs/commerce", |
| 449 | "version": "0.70.162", | |
| 462 | "version": "0.70.163", | |
| 450 | 463 | "dependencies": { |
| 451 | 464 | "ts-spreadsheets": "^0.2.0", |
| 452 | 465 | }, |
| @@ -456,7 +469,7 @@ | ||
| 456 | 469 | }, |
| 457 | 470 | "storage/framework/core/composables": { |
| 458 | 471 | "name": "@stacksjs/composables", |
| 459 | "version": "0.70.162", | |
| 472 | "version": "0.70.163", | |
| 460 | 473 | "dependencies": { |
| 461 | 474 | "@stacksjs/datetime": "workspace:*", |
| 462 | 475 | "@stacksjs/stx": "catalog:", |
| @@ -467,7 +480,7 @@ | ||
| 467 | 480 | }, |
| 468 | 481 | "storage/framework/core/config": { |
| 469 | 482 | "name": "@stacksjs/config", |
| 470 | "version": "0.70.162", | |
| 483 | "version": "0.70.163", | |
| 471 | 484 | "dependencies": { |
| 472 | 485 | "ts-pantry": "^0.10.34", |
| 473 | 486 | }, |
| @@ -480,14 +493,14 @@ | ||
| 480 | 493 | }, |
| 481 | 494 | "storage/framework/core/cron": { |
| 482 | 495 | "name": "@stacksjs/cron", |
| 483 | "version": "0.70.162", | |
| 496 | "version": "0.70.163", | |
| 484 | 497 | "devDependencies": { |
| 485 | 498 | "better-dx": "catalog:", |
| 486 | 499 | }, |
| 487 | 500 | }, |
| 488 | 501 | "storage/framework/core/database": { |
| 489 | 502 | "name": "@stacksjs/database", |
| 490 | "version": "0.70.162", | |
| 503 | "version": "0.70.163", | |
| 491 | 504 | "dependencies": { |
| 492 | 505 | "@stacksjs/ts-validation": "^0.5.0", |
| 493 | 506 | "bun-query-builder": "catalog:", |
| @@ -508,7 +521,7 @@ | ||
| 508 | 521 | }, |
| 509 | 522 | "storage/framework/core/datetime": { |
| 510 | 523 | "name": "@stacksjs/datetime", |
| 511 | "version": "0.70.162", | |
| 524 | "version": "0.70.163", | |
| 512 | 525 | "devDependencies": { |
| 513 | 526 | "@stacksjs/utils": "workspace:*", |
| 514 | 527 | "better-dx": "catalog:", |
| @@ -516,21 +529,21 @@ | ||
| 516 | 529 | }, |
| 517 | 530 | "storage/framework/core/defaults": { |
| 518 | 531 | "name": "@stacksjs/defaults", |
| 519 | "version": "0.70.162", | |
| 532 | "version": "0.70.163", | |
| 520 | 533 | "dependencies": { |
| 521 | 534 | "@iconify-json/f7": "^1.2.2", |
| 522 | 535 | }, |
| 523 | 536 | }, |
| 524 | 537 | "storage/framework/core/deploy": { |
| 525 | 538 | "name": "@stacksjs/deploy", |
| 526 | "version": "0.70.162", | |
| 539 | "version": "0.70.163", | |
| 527 | 540 | "dependencies": { |
| 528 | 541 | "@stacksjs/ts-cloud": "catalog:", |
| 529 | 542 | }, |
| 530 | 543 | }, |
| 531 | 544 | "storage/framework/core/desktop": { |
| 532 | 545 | "name": "@stacksjs/desktop", |
| 533 | "version": "0.70.162", | |
| 546 | "version": "0.70.163", | |
| 534 | 547 | "devDependencies": { |
| 535 | 548 | "@stacksjs/build": "workspace:*", |
| 536 | 549 | "@stacksjs/server": "workspace:*", |
| @@ -539,11 +552,11 @@ | ||
| 539 | 552 | }, |
| 540 | 553 | "storage/framework/core/development": { |
| 541 | 554 | "name": "@stacksjs/development", |
| 542 | "version": "0.70.162", | |
| 555 | "version": "0.70.163", | |
| 543 | 556 | }, |
| 544 | 557 | "storage/framework/core/dns": { |
| 545 | 558 | "name": "@stacksjs/dns", |
| 546 | "version": "0.70.162", | |
| 559 | "version": "0.70.163", | |
| 547 | 560 | "dependencies": { |
| 548 | 561 | "@stacksjs/dnsx": "^0.2.3", |
| 549 | 562 | "@stacksjs/ts-cloud": "catalog:", |
| @@ -560,7 +573,7 @@ | ||
| 560 | 573 | }, |
| 561 | 574 | "storage/framework/core/docs": { |
| 562 | 575 | "name": "@stacksjs/docs", |
| 563 | "version": "0.70.162", | |
| 576 | "version": "0.70.163", | |
| 564 | 577 | "dependencies": { |
| 565 | 578 | "@stacksjs/bunpress": "^0.1.18", |
| 566 | 579 | }, |
| @@ -574,7 +587,7 @@ | ||
| 574 | 587 | }, |
| 575 | 588 | "storage/framework/core/email": { |
| 576 | 589 | "name": "@stacksjs/email", |
| 577 | "version": "0.70.162", | |
| 590 | "version": "0.70.163", | |
| 578 | 591 | "dependencies": { |
| 579 | 592 | "@stacksjs/ts-cloud": "catalog:", |
| 580 | 593 | }, |
| @@ -588,14 +601,14 @@ | ||
| 588 | 601 | }, |
| 589 | 602 | "storage/framework/core/enums": { |
| 590 | 603 | "name": "@stacksjs/enums", |
| 591 | "version": "0.70.162", | |
| 604 | "version": "0.70.163", | |
| 592 | 605 | "devDependencies": { |
| 593 | 606 | "better-dx": "catalog:", |
| 594 | 607 | }, |
| 595 | 608 | }, |
| 596 | 609 | "storage/framework/core/env": { |
| 597 | 610 | "name": "@stacksjs/env", |
| 598 | "version": "0.70.162", | |
| 611 | "version": "0.70.163", | |
| 599 | 612 | "devDependencies": { |
| 600 | 613 | "@stacksjs/path": "workspace:*", |
| 601 | 614 | "@stacksjs/validation": "workspace:*", |
| @@ -604,7 +617,7 @@ | ||
| 604 | 617 | }, |
| 605 | 618 | "storage/framework/core/error-handling": { |
| 606 | 619 | "name": "@stacksjs/error-handling", |
| 607 | "version": "0.70.162", | |
| 620 | "version": "0.70.163", | |
| 608 | 621 | "dependencies": { |
| 609 | 622 | "@stacksjs/stx": "catalog:", |
| 610 | 623 | "ts-error-handling": "^0.1.1", |
| @@ -621,14 +634,14 @@ | ||
| 621 | 634 | }, |
| 622 | 635 | "storage/framework/core/events": { |
| 623 | 636 | "name": "@stacksjs/events", |
| 624 | "version": "0.70.162", | |
| 637 | "version": "0.70.163", | |
| 625 | 638 | "devDependencies": { |
| 626 | 639 | "better-dx": "catalog:", |
| 627 | 640 | }, |
| 628 | 641 | }, |
| 629 | 642 | "storage/framework/core/faker": { |
| 630 | 643 | "name": "@stacksjs/faker", |
| 631 | "version": "0.70.162", | |
| 644 | "version": "0.70.163", | |
| 632 | 645 | "devDependencies": { |
| 633 | 646 | "@stacksjs/cli": "workspace:*", |
| 634 | 647 | "@stacksjs/config": "workspace:*", |
| @@ -638,7 +651,7 @@ | ||
| 638 | 651 | }, |
| 639 | 652 | "storage/framework/core/feature-flags": { |
| 640 | 653 | "name": "@stacksjs/feature-flags", |
| 641 | "version": "0.70.162", | |
| 654 | "version": "0.70.163", | |
| 642 | 655 | "devDependencies": { |
| 643 | 656 | "@stacksjs/config": "workspace:*", |
| 644 | 657 | "@stacksjs/database": "workspace:*", |
| @@ -648,7 +661,7 @@ | ||
| 648 | 661 | }, |
| 649 | 662 | "storage/framework/core/git": { |
| 650 | 663 | "name": "@stacksjs/git", |
| 651 | "version": "0.70.162", | |
| 664 | "version": "0.70.163", | |
| 652 | 665 | "dependencies": { |
| 653 | 666 | "cz-git": "^1.13.1", |
| 654 | 667 | }, |
| @@ -660,14 +673,14 @@ | ||
| 660 | 673 | }, |
| 661 | 674 | "storage/framework/core/github": { |
| 662 | 675 | "name": "@stacksjs/github", |
| 663 | "version": "0.70.162", | |
| 676 | "version": "0.70.163", | |
| 664 | 677 | "devDependencies": { |
| 665 | 678 | "better-dx": "catalog:", |
| 666 | 679 | }, |
| 667 | 680 | }, |
| 668 | 681 | "storage/framework/core/health": { |
| 669 | 682 | "name": "@stacksjs/health", |
| 670 | "version": "0.70.162", | |
| 683 | "version": "0.70.163", | |
| 671 | 684 | "devDependencies": { |
| 672 | 685 | "@stacksjs/notifications": "workspace:*", |
| 673 | 686 | "better-dx": "catalog:", |
| @@ -675,7 +688,7 @@ | ||
| 675 | 688 | }, |
| 676 | 689 | "storage/framework/core/http": { |
| 677 | 690 | "name": "@stacksjs/http", |
| 678 | "version": "0.70.162", | |
| 691 | "version": "0.70.163", | |
| 679 | 692 | "dependencies": { |
| 680 | 693 | "@stacksjs/httx": "^0.1.10", |
| 681 | 694 | }, |
| @@ -685,7 +698,7 @@ | ||
| 685 | 698 | }, |
| 686 | 699 | "storage/framework/core/i18n": { |
| 687 | 700 | "name": "@stacksjs/i18n", |
| 688 | "version": "0.70.162", | |
| 701 | "version": "0.70.163", | |
| 689 | 702 | "dependencies": { |
| 690 | 703 | "@stacksjs/ts-i18n": "^0.1.9", |
| 691 | 704 | }, |
| @@ -693,9 +706,19 @@ | ||
| 693 | 706 | "@stacksjs/development": "workspace:*", |
| 694 | 707 | }, |
| 695 | 708 | }, |
| 709 | "storage/framework/core/image": { | |
| 710 | "name": "@stacksjs/image", | |
| 711 | "version": "0.70.163", | |
| 712 | "dependencies": { | |
| 713 | "ts-images": "^0.2.1", | |
| 714 | }, | |
| 715 | "devDependencies": { | |
| 716 | "better-dx": "catalog:", | |
| 717 | }, | |
| 718 | }, | |
| 696 | 719 | "storage/framework/core/lint": { |
| 697 | 720 | "name": "@stacksjs/lint", |
| 698 | "version": "0.70.162", | |
| 721 | "version": "0.70.163", | |
| 699 | 722 | "devDependencies": { |
| 700 | 723 | "@stacksjs/gitlint": "catalog:", |
| 701 | 724 | "better-dx": "catalog:", |
| @@ -704,7 +727,7 @@ | ||
| 704 | 727 | }, |
| 705 | 728 | "storage/framework/core/logging": { |
| 706 | 729 | "name": "@stacksjs/logging", |
| 707 | "version": "0.70.162", | |
| 730 | "version": "0.70.163", | |
| 708 | 731 | "devDependencies": { |
| 709 | 732 | "@stacksjs/clarity": "^0.3.31", |
| 710 | 733 | "@stacksjs/cli": "workspace:*", |
| @@ -718,7 +741,7 @@ | ||
| 718 | 741 | }, |
| 719 | 742 | "storage/framework/core/newsletter": { |
| 720 | 743 | "name": "@stacksjs/newsletter", |
| 721 | "version": "0.70.162", | |
| 744 | "version": "0.70.163", | |
| 722 | 745 | "devDependencies": { |
| 723 | 746 | "@stacksjs/cli": "workspace:*", |
| 724 | 747 | "@stacksjs/config": "workspace:*", |
| @@ -729,7 +752,7 @@ | ||
| 729 | 752 | }, |
| 730 | 753 | "storage/framework/core/notifications": { |
| 731 | 754 | "name": "@stacksjs/notifications", |
| 732 | "version": "0.70.162", | |
| 755 | "version": "0.70.163", | |
| 733 | 756 | "devDependencies": { |
| 734 | 757 | "@stacksjs/chat": "workspace:*", |
| 735 | 758 | "@stacksjs/cli": "workspace:*", |
| @@ -744,7 +767,7 @@ | ||
| 744 | 767 | }, |
| 745 | 768 | "storage/framework/core/objects": { |
| 746 | 769 | "name": "@stacksjs/objects", |
| 747 | "version": "0.70.162", | |
| 770 | "version": "0.70.163", | |
| 748 | 771 | "devDependencies": { |
| 749 | 772 | "@stacksjs/collections": "workspace:*", |
| 750 | 773 | "@stacksjs/types": "workspace:*", |
| @@ -755,7 +778,7 @@ | ||
| 755 | 778 | }, |
| 756 | 779 | "storage/framework/core/orm": { |
| 757 | 780 | "name": "@stacksjs/orm", |
| 758 | "version": "0.70.162", | |
| 781 | "version": "0.70.163", | |
| 759 | 782 | "dependencies": { |
| 760 | 783 | "@stacksjs/ts-validation": "^0.5.0", |
| 761 | 784 | "bun-query-builder": "catalog:", |
| @@ -769,14 +792,14 @@ | ||
| 769 | 792 | }, |
| 770 | 793 | "storage/framework/core/path": { |
| 771 | 794 | "name": "@stacksjs/path", |
| 772 | "version": "0.70.162", | |
| 795 | "version": "0.70.163", | |
| 773 | 796 | "devDependencies": { |
| 774 | 797 | "better-dx": "catalog:", |
| 775 | 798 | }, |
| 776 | 799 | }, |
| 777 | 800 | "storage/framework/core/payments": { |
| 778 | 801 | "name": "@stacksjs/payments", |
| 779 | "version": "0.70.162", | |
| 802 | "version": "0.70.163", | |
| 780 | 803 | "devDependencies": { |
| 781 | 804 | "@stacksjs/config": "workspace:*", |
| 782 | 805 | "@stacksjs/utils": "workspace:*", |
| @@ -795,7 +818,7 @@ | ||
| 795 | 818 | }, |
| 796 | 819 | "storage/framework/core/push": { |
| 797 | 820 | "name": "@stacksjs/push", |
| 798 | "version": "0.70.162", | |
| 821 | "version": "0.70.163", | |
| 799 | 822 | "devDependencies": { |
| 800 | 823 | "@stacksjs/cli": "workspace:*", |
| 801 | 824 | "@stacksjs/config": "workspace:*", |
| @@ -805,7 +828,7 @@ | ||
| 805 | 828 | }, |
| 806 | 829 | "storage/framework/core/query-builder": { |
| 807 | 830 | "name": "@stacksjs/query-builder", |
| 808 | "version": "0.70.162", | |
| 831 | "version": "0.70.163", | |
| 809 | 832 | "dependencies": { |
| 810 | 833 | "bun-query-builder": "catalog:", |
| 811 | 834 | }, |
| @@ -817,7 +840,7 @@ | ||
| 817 | 840 | }, |
| 818 | 841 | "storage/framework/core/queue": { |
| 819 | 842 | "name": "@stacksjs/queue", |
| 820 | "version": "0.70.162", | |
| 843 | "version": "0.70.163", | |
| 821 | 844 | "dependencies": { |
| 822 | 845 | "@stacksjs/bun-queue": "^0.1.7", |
| 823 | 846 | }, |
| @@ -834,7 +857,7 @@ | ||
| 834 | 857 | }, |
| 835 | 858 | "storage/framework/core/realtime": { |
| 836 | 859 | "name": "@stacksjs/realtime", |
| 837 | "version": "0.70.162", | |
| 860 | "version": "0.70.163", | |
| 838 | 861 | "dependencies": { |
| 839 | 862 | "ts-broadcasting": "^0.0.8", |
| 840 | 863 | }, |
| @@ -844,7 +867,7 @@ | ||
| 844 | 867 | }, |
| 845 | 868 | "storage/framework/core/registry": { |
| 846 | 869 | "name": "@stacksjs/registry", |
| 847 | "version": "0.70.162", | |
| 870 | "version": "0.70.163", | |
| 848 | 871 | "dependencies": { |
| 849 | 872 | "@stacksjs/types": "workspace:*", |
| 850 | 873 | }, |
| @@ -854,7 +877,7 @@ | ||
| 854 | 877 | }, |
| 855 | 878 | "storage/framework/core/repl": { |
| 856 | 879 | "name": "@stacksjs/repl", |
| 857 | "version": "0.70.162", | |
| 880 | "version": "0.70.163", | |
| 858 | 881 | "devDependencies": { |
| 859 | 882 | "@stacksjs/tinker": "workspace:*", |
| 860 | 883 | "better-dx": "catalog:", |
| @@ -862,7 +885,7 @@ | ||
| 862 | 885 | }, |
| 863 | 886 | "storage/framework/core/router": { |
| 864 | 887 | "name": "@stacksjs/router", |
| 865 | "version": "0.70.162", | |
| 888 | "version": "0.70.163", | |
| 866 | 889 | "dependencies": { |
| 867 | 890 | "@stacksjs/bun-router": "^0.0.20", |
| 868 | 891 | "ts-rate-limiter": "^0.4.2", |
| @@ -882,7 +905,7 @@ | ||
| 882 | 905 | }, |
| 883 | 906 | "storage/framework/core/scheduler": { |
| 884 | 907 | "name": "@stacksjs/scheduler", |
| 885 | "version": "0.70.162", | |
| 908 | "version": "0.70.163", | |
| 886 | 909 | "devDependencies": { |
| 887 | 910 | "@stacksjs/actions": "workspace:*", |
| 888 | 911 | "@stacksjs/cron": "workspace:*", |
| @@ -891,7 +914,7 @@ | ||
| 891 | 914 | }, |
| 892 | 915 | "storage/framework/core/search-engine": { |
| 893 | 916 | "name": "@stacksjs/search-engine", |
| 894 | "version": "0.70.162", | |
| 917 | "version": "0.70.163", | |
| 895 | 918 | "devDependencies": { |
| 896 | 919 | "@stacksjs/config": "workspace:*", |
| 897 | 920 | "better-dx": "catalog:", |
| @@ -906,7 +929,7 @@ | ||
| 906 | 929 | }, |
| 907 | 930 | "storage/framework/core/security": { |
| 908 | 931 | "name": "@stacksjs/security", |
| 909 | "version": "0.70.162", | |
| 932 | "version": "0.70.163", | |
| 910 | 933 | "dependencies": { |
| 911 | 934 | "ts-security-crypto": "0.0.2", |
| 912 | 935 | }, |
| @@ -920,7 +943,7 @@ | ||
| 920 | 943 | }, |
| 921 | 944 | "storage/framework/core/server": { |
| 922 | 945 | "name": "@stacksjs/server", |
| 923 | "version": "0.70.162", | |
| 946 | "version": "0.70.163", | |
| 924 | 947 | "dependencies": { |
| 925 | 948 | "bun-plugin-auto-imports": "^0.4.0", |
| 926 | 949 | }, |
| @@ -934,14 +957,14 @@ | ||
| 934 | 957 | }, |
| 935 | 958 | "storage/framework/core/shell": { |
| 936 | 959 | "name": "@stacksjs/shell", |
| 937 | "version": "0.70.162", | |
| 960 | "version": "0.70.163", | |
| 938 | 961 | "devDependencies": { |
| 939 | 962 | "better-dx": "catalog:", |
| 940 | 963 | }, |
| 941 | 964 | }, |
| 942 | 965 | "storage/framework/core/skills": { |
| 943 | 966 | "name": "@stacksjs/skills", |
| 944 | "version": "0.70.162", | |
| 967 | "version": "0.70.163", | |
| 945 | 968 | "dependencies": { |
| 946 | 969 | "@stacksjs/path": "workspace:*", |
| 947 | 970 | "@stacksjs/storage": "workspace:*", |
| @@ -953,7 +976,7 @@ | ||
| 953 | 976 | }, |
| 954 | 977 | "storage/framework/core/slug": { |
| 955 | 978 | "name": "@stacksjs/slug", |
| 956 | "version": "0.70.162", | |
| 979 | "version": "0.70.163", | |
| 957 | 980 | "dependencies": { |
| 958 | 981 | "ts-slug": "^0.1.0", |
| 959 | 982 | }, |
| @@ -975,7 +998,7 @@ | ||
| 975 | 998 | }, |
| 976 | 999 | "storage/framework/core/sms": { |
| 977 | 1000 | "name": "@stacksjs/sms", |
| 978 | "version": "0.70.162", | |
| 1001 | "version": "0.70.163", | |
| 979 | 1002 | "devDependencies": { |
| 980 | 1003 | "@stacksjs/cli": "workspace:*", |
| 981 | 1004 | "@stacksjs/config": "workspace:*", |
| @@ -986,7 +1009,7 @@ | ||
| 986 | 1009 | }, |
| 987 | 1010 | "storage/framework/core/socials": { |
| 988 | 1011 | "name": "@stacksjs/socials", |
| 989 | "version": "0.70.162", | |
| 1012 | "version": "0.70.163", | |
| 990 | 1013 | "devDependencies": { |
| 991 | 1014 | "@stacksjs/error-handling": "workspace:*", |
| 992 | 1015 | "@stacksjs/router": "workspace:*", |
| @@ -995,9 +1018,10 @@ | ||
| 995 | 1018 | }, |
| 996 | 1019 | "storage/framework/core/storage": { |
| 997 | 1020 | "name": "@stacksjs/storage", |
| 998 | "version": "0.70.162", | |
| 1021 | "version": "0.70.163", | |
| 999 | 1022 | "dependencies": { |
| 1000 | 1023 | "@stacksjs/ts-cloud": "catalog:", |
| 1024 | "ts-images": "^0.1.11", | |
| 1001 | 1025 | }, |
| 1002 | 1026 | "devDependencies": { |
| 1003 | 1027 | "@stacksjs/arrays": "workspace:*", |
| @@ -1006,18 +1030,11 @@ | ||
| 1006 | 1030 | "@stacksjs/strings": "workspace:*", |
| 1007 | 1031 | "@stacksjs/types": "workspace:*", |
| 1008 | 1032 | "better-dx": "catalog:", |
| 1009 | "sharp": "^0.35.3", | |
| 1010 | 1033 | }, |
| 1011 | "peerDependencies": { | |
| 1012 | "sharp": "^0.35.3", | |
| 1013 | }, | |
| 1014 | "optionalPeers": [ | |
| 1015 | "sharp", | |
| 1016 | ], | |
| 1017 | 1034 | }, |
| 1018 | 1035 | "storage/framework/core/strings": { |
| 1019 | 1036 | "name": "@stacksjs/strings", |
| 1020 | "version": "0.70.162", | |
| 1037 | "version": "0.70.163", | |
| 1021 | 1038 | "devDependencies": { |
| 1022 | 1039 | "@stacksjs/alias": "workspace:*", |
| 1023 | 1040 | "@stacksjs/types": "workspace:*", |
| @@ -1026,7 +1043,7 @@ | ||
| 1026 | 1043 | }, |
| 1027 | 1044 | "storage/framework/core/testing": { |
| 1028 | 1045 | "name": "@stacksjs/testing", |
| 1029 | "version": "0.70.162", | |
| 1046 | "version": "0.70.163", | |
| 1030 | 1047 | "dependencies": { |
| 1031 | 1048 | "@stacksjs/ts-cloud": "catalog:", |
| 1032 | 1049 | }, |
| @@ -1038,14 +1055,14 @@ | ||
| 1038 | 1055 | }, |
| 1039 | 1056 | "storage/framework/core/tinker": { |
| 1040 | 1057 | "name": "@stacksjs/tinker", |
| 1041 | "version": "0.70.162", | |
| 1058 | "version": "0.70.163", | |
| 1042 | 1059 | "devDependencies": { |
| 1043 | 1060 | "better-dx": "catalog:", |
| 1044 | 1061 | }, |
| 1045 | 1062 | }, |
| 1046 | 1063 | "storage/framework/core/tunnel": { |
| 1047 | 1064 | "name": "@stacksjs/tunnel", |
| 1048 | "version": "0.70.162", | |
| 1065 | "version": "0.70.163", | |
| 1049 | 1066 | "dependencies": { |
| 1050 | 1067 | "localtunnels": "^0.2.7", |
| 1051 | 1068 | }, |
| @@ -1055,7 +1072,7 @@ | ||
| 1055 | 1072 | }, |
| 1056 | 1073 | "storage/framework/core/types": { |
| 1057 | 1074 | "name": "@stacksjs/types", |
| 1058 | "version": "0.70.162", | |
| 1075 | "version": "0.70.163", | |
| 1059 | 1076 | "dependencies": { |
| 1060 | 1077 | "@stacksjs/bunpress": "^0.1.18", |
| 1061 | 1078 | "@stacksjs/clapp": "catalog:", |
| @@ -1078,7 +1095,7 @@ | ||
| 1078 | 1095 | }, |
| 1079 | 1096 | "storage/framework/core/ui": { |
| 1080 | 1097 | "name": "@stacksjs/ui", |
| 1081 | "version": "0.70.162", | |
| 1098 | "version": "0.70.163", | |
| 1082 | 1099 | "dependencies": { |
| 1083 | 1100 | "@cwcss/crosswind": "^0.2.6", |
| 1084 | 1101 | "@stacksjs/components": "catalog:", |
| @@ -1092,7 +1109,7 @@ | ||
| 1092 | 1109 | }, |
| 1093 | 1110 | "storage/framework/core/utils": { |
| 1094 | 1111 | "name": "@stacksjs/utils", |
| 1095 | "version": "0.70.162", | |
| 1112 | "version": "0.70.163", | |
| 1096 | 1113 | "dependencies": { |
| 1097 | 1114 | "@stacksjs/browser": "workspace:*", |
| 1098 | 1115 | }, |
| @@ -1113,7 +1130,7 @@ | ||
| 1113 | 1130 | }, |
| 1114 | 1131 | "storage/framework/core/validation": { |
| 1115 | 1132 | "name": "@stacksjs/validation", |
| 1116 | "version": "0.70.162", | |
| 1133 | "version": "0.70.163", | |
| 1117 | 1134 | "dependencies": { |
| 1118 | 1135 | "@stacksjs/ts-validation": "^0.5.0", |
| 1119 | 1136 | }, |
| @@ -1123,9 +1140,20 @@ | ||
| 1123 | 1140 | "better-dx": "catalog:", |
| 1124 | 1141 | }, |
| 1125 | 1142 | }, |
| 1143 | "storage/framework/core/video": { | |
| 1144 | "name": "@stacksjs/video", | |
| 1145 | "version": "0.70.163", | |
| 1146 | "dependencies": { | |
| 1147 | "ts-video-player": "^0.1.0", | |
| 1148 | "ts-videos": "^0.1.1", | |
| 1149 | }, | |
| 1150 | "devDependencies": { | |
| 1151 | "better-dx": "catalog:", | |
| 1152 | }, | |
| 1153 | }, | |
| 1126 | 1154 | "storage/framework/core/whois": { |
| 1127 | 1155 | "name": "@stacksjs/whois", |
| 1128 | "version": "0.70.162", | |
| 1156 | "version": "0.70.163", | |
| 1129 | 1157 | "devDependencies": { |
| 1130 | 1158 | "better-dx": "catalog:", |
| 1131 | 1159 | }, |
| @@ -1196,8 +1224,6 @@ | ||
| 1196 | 1224 | |
| 1197 | 1225 | "@devicefarmer/adbkit-monkey": ["@devicefarmer/adbkit-monkey@1.2.1", "", {}, "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg=="], |
| 1198 | 1226 | |
| 1199 | "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], | |
| 1200 | ||
| 1201 | 1227 | "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], |
| 1202 | 1228 | |
| 1203 | 1229 | "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], |
| @@ -1238,60 +1264,6 @@ | ||
| 1238 | 1264 | |
| 1239 | 1265 | "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], |
| 1240 | 1266 | |
| 1241 | "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], | |
| 1242 | ||
| 1243 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], | |
| 1244 | ||
| 1245 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], | |
| 1246 | ||
| 1247 | "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], | |
| 1248 | ||
| 1249 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], | |
| 1250 | ||
| 1251 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], | |
| 1252 | ||
| 1253 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], | |
| 1254 | ||
| 1255 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], | |
| 1256 | ||
| 1257 | "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], | |
| 1258 | ||
| 1259 | "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], | |
| 1260 | ||
| 1261 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], | |
| 1262 | ||
| 1263 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], | |
| 1264 | ||
| 1265 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], | |
| 1266 | ||
| 1267 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], | |
| 1268 | ||
| 1269 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], | |
| 1270 | ||
| 1271 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], | |
| 1272 | ||
| 1273 | "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], | |
| 1274 | ||
| 1275 | "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], | |
| 1276 | ||
| 1277 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], | |
| 1278 | ||
| 1279 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], | |
| 1280 | ||
| 1281 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], | |
| 1282 | ||
| 1283 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], | |
| 1284 | ||
| 1285 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], | |
| 1286 | ||
| 1287 | "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], | |
| 1288 | ||
| 1289 | "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], | |
| 1290 | ||
| 1291 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], | |
| 1292 | ||
| 1293 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], | |
| 1294 | ||
| 1295 | 1267 | "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], |
| 1296 | 1268 | |
| 1297 | 1269 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], |
| @@ -1342,6 +1314,8 @@ | ||
| 1342 | 1314 | |
| 1343 | 1315 | "@stacksjs/arrays": ["@stacksjs/arrays@workspace:storage/framework/core/arrays"], |
| 1344 | 1316 | |
| 1317 | "@stacksjs/audio": ["@stacksjs/audio@workspace:storage/framework/core/audio"], | |
| 1318 | ||
| 1345 | 1319 | "@stacksjs/auth": ["@stacksjs/auth@workspace:storage/framework/core/auth"], |
| 1346 | 1320 | |
| 1347 | 1321 | "@stacksjs/browser": ["@stacksjs/browser@workspace:storage/framework/core/browser"], |
| @@ -1440,6 +1414,8 @@ | ||
| 1440 | 1414 | |
| 1441 | 1415 | "@stacksjs/i18n": ["@stacksjs/i18n@workspace:storage/framework/core/i18n"], |
| 1442 | 1416 | |
| 1417 | "@stacksjs/image": ["@stacksjs/image@workspace:storage/framework/core/image"], | |
| 1418 | ||
| 1443 | 1419 | "@stacksjs/lint": ["@stacksjs/lint@workspace:storage/framework/core/lint"], |
| 1444 | 1420 | |
| 1445 | 1421 | "@stacksjs/logging": ["@stacksjs/logging@workspace:storage/framework/core/logging"], |
| @@ -1550,10 +1526,24 @@ | ||
| 1550 | 1526 | |
| 1551 | 1527 | "@stacksjs/validation": ["@stacksjs/validation@workspace:storage/framework/core/validation"], |
| 1552 | 1528 | |
| 1529 | "@stacksjs/video": ["@stacksjs/video@workspace:storage/framework/core/video"], | |
| 1530 | ||
| 1553 | 1531 | "@stacksjs/whois": ["@stacksjs/whois@workspace:storage/framework/core/whois"], |
| 1554 | 1532 | |
| 1555 | 1533 | "@stripe/stripe-js": ["@stripe/stripe-js@9.12.0", "", {}, "sha512-wCUYZNYo7E/6B3Rv2i/g/Rmj2mTiOX6HEwWYUWUuNUKwEhzTo/SXuQ1IoNo3mknWIHUQyqdakv1Nl2SiYPrCrw=="], |
| 1556 | 1534 | |
| 1535 | "@ts-audio/aac": ["@ts-audio/aac@0.1.1", "", { "peerDependencies": { "@ts-audio/core": "0.1.1" } }, "sha512-o0A42KdIMEa9rd1ltignqBy+SsctLaa4npxILsLuDIpdkIHk2Dv9VZi3WC20w10bbodFCcfN+RgLkFdLHC0CBQ=="], | |
| 1536 | ||
| 1537 | "@ts-audio/core": ["@ts-audio/core@0.1.1", "", { "optionalDependencies": { "@ts-audio/aac": "0.1.1", "@ts-audio/flac": "0.1.1", "@ts-audio/mp3": "0.1.1", "@ts-audio/ogg": "0.1.1", "@ts-audio/wav": "0.1.1" }, "bin": { "ts-audio": "./dist/bin/cli.js" } }, "sha512-ZaIYu/WVKWfS4hwaR3O1ZBn6iXLkr02ZNb4AnCqfrv+N7py5LQaO4HbFZNHykf806kNXgQn7WBMy4UrmCJopGg=="], | |
| 1538 | ||
| 1539 | "@ts-audio/flac": ["@ts-audio/flac@0.1.1", "", { "peerDependencies": { "@ts-audio/core": "0.1.1" } }, "sha512-UB8Bh9wW5Z1gYVsOFoWLr9OObXcmbmM3GH1ZJtyGhjQObX8BAaRTcw02c34GqzS/fkotp5dTiHLil/nNR5tUDA=="], | |
| 1540 | ||
| 1541 | "@ts-audio/mp3": ["@ts-audio/mp3@0.1.1", "", { "peerDependencies": { "@ts-audio/core": "0.1.1" } }, "sha512-wEWTNWiTW/qoxSROAMhhBAya3wsrjXu6Ne3F99HyqODIB/kWVxptZ/6exZkDU3OM5jn4Y+5VgxBPFbz9rk+lCg=="], | |
| 1542 | ||
| 1543 | "@ts-audio/ogg": ["@ts-audio/ogg@0.1.1", "", { "peerDependencies": { "@ts-audio/core": "0.1.1" } }, "sha512-ELGSURh2iR3OrtWtd9PkPoUBkM0aAi176HYM+y1BONuDQY0f/wNgMlEPxwF+OzOpJupCUsLFnwTFIrZnlmJNmw=="], | |
| 1544 | ||
| 1545 | "@ts-audio/wav": ["@ts-audio/wav@0.1.1", "", { "peerDependencies": { "@ts-audio/core": "0.1.1" } }, "sha512-a938ygyDzRwL7km/0QuSAq1/NhpPd8GpEJBMc9MRhX+2jvWrTXoip5VwgahEnQIBIJjyRcighuON/5ydMB0vgw=="], | |
| 1546 | ||
| 1557 | 1547 | "@ts-charts/array": ["@ts-charts/array@0.2.0", "", { "dependencies": { "@ts-charts/internmap": "0.2.0" } }, "sha512-rcYZWzYx8pjqpcJOLyNWcxeCwEGFte1HANtMxTiiXswQ990VkWYK1iVY13W4EwUwJU0TGXdg1e3DGjVXtrwc5w=="], |
| 1558 | 1548 | |
| 1559 | 1549 | "@ts-charts/color": ["@ts-charts/color@0.2.0", "", {}, "sha512-Oe/gbCmTUhlkqQk1xFg+MSObDm5qEAlvBxmGiCDRsPZaPNrTlGlAiuiynPUfb9nl5ByZ39y3aPJFQjmq/RG+WA=="], |
| @@ -1578,6 +1568,10 @@ | ||
| 1578 | 1568 | |
| 1579 | 1569 | "@ts-cloud/core": ["@ts-cloud/core@0.7.56", "", { "dependencies": { "@ts-cloud/aws-types": "0.7.56" } }, "sha512-DG4DFeIePeKJFd1BUg9bbImLf439KD1o9kmtmNDLpf8jjNk10qLQQH4pyv8nx7S1IhWXuBImpsWtlnVkklYYmA=="], |
| 1580 | 1570 | |
| 1571 | "@ts-videos/mp4": ["@ts-videos/mp4@0.1.1", "", { "peerDependencies": { "ts-videos": "0.1.1" } }, "sha512-QbNeELrRjlR/FnR0JTEiYd54RFaM2VgxiIwHz3azzyeSB2ZZlJfHmIcx0ir5ZDU4zCaI4ygqqhJTn5HsHGd+/A=="], | |
| 1572 | ||
| 1573 | "@ts-videos/webm": ["@ts-videos/webm@0.1.1", "", { "peerDependencies": { "ts-videos": "0.1.1" } }, "sha512-TAwdUlxNKE5tf0BY4wHSq0zp1zTqYqfdzaBanhxQE+QDPwD5zV96KMRj6qbI0ANi2FdgzsbpiRW5PMB3g5CO7g=="], | |
| 1574 | ||
| 1581 | 1575 | "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], |
| 1582 | 1576 | |
| 1583 | 1577 | "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], |
| @@ -1826,8 +1820,6 @@ | ||
| 1826 | 1820 | |
| 1827 | 1821 | "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], |
| 1828 | 1822 | |
| 1829 | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], | |
| 1830 | ||
| 1831 | 1823 | "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], |
| 1832 | 1824 | |
| 1833 | 1825 | "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], |
| @@ -2296,8 +2288,6 @@ | ||
| 2296 | 2288 | |
| 2297 | 2289 | "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], |
| 2298 | 2290 | |
| 2299 | "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], | |
| 2300 | ||
| 2301 | 2291 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], |
| 2302 | 2292 | |
| 2303 | 2293 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], |
| @@ -2396,7 +2386,7 @@ | ||
| 2396 | 2386 | |
| 2397 | 2387 | "ts-gif": ["ts-gif@0.1.2", "", { "dependencies": { "data-uri-to-buffer": "^6.0.2" } }, "sha512-jbraplO1qqJtrtkFeepNPUGPad7w8FQHWIQFsn1OJbHeWKUbdqdkrX18XzNXI5Fw7+s6A3nbNTdj+Ukn4d3f8g=="], |
| 2398 | 2388 | |
| 2399 | "ts-images": ["ts-images@0.1.11", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-b5g1uADQR1Ytg4mS0EyFEce9jWhxC/kj1f5ChTVnnpPZV8NhI+aEW8YbH+LMDRCzCE1HRJiUIweUPqMkMAytIA=="], | |
| 2389 | "ts-images": ["ts-images@0.2.1", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-/LfcNBbS/E1aoHG9N9hyyD+4uOUkXwhtFzBRe3Wv7IntP16nSYSq6C8SRePxQj3603j2hYiKy1GL4W0p3K4X0g=="], | |
| 2400 | 2390 | |
| 2401 | 2391 | "ts-jpeg": ["ts-jpeg@0.3.1", "", {}, "sha512-KIGTJj4UIlkGoCFEofx8jHHQEkJfSLFJY0MHz60Mu4Scjrcj7K/0UJGCbL9QvfIaFGAOGdf0xNFpYSuzUpimwA=="], |
| 2402 | 2392 | |
| @@ -2412,9 +2402,11 @@ | ||
| 2412 | 2402 | |
| 2413 | 2403 | "ts-syntax-highlighter": ["ts-syntax-highlighter@0.2.1", "", { "bin": { "ts-syntax-highlighter": "dist/bin/cli.js" } }, "sha512-HEfxaVKifwvnX0NaOE8nfs4VFdZo6WdREj1KRehelfYsO1N+m9vOIj5exhlOqFpxOPWd4BlalU8y0WWS7yPWjw=="], |
| 2414 | 2404 | |
| 2415 | "ts-web-scraper": ["ts-web-scraper@0.1.1", "", { "bin": { "scraper": "dist/bin/cli.js" } }, "sha512-js+gSRe95yDD9U3+GmjvIKBYLAN9WoZKV84x7HcSI7dqdWEIoZSeSGR/Jbr8f5hCeV9FD70KdTPIS2LQwviQXQ=="], | |
| 2405 | "ts-video-player": ["ts-video-player@0.1.0", "", { "peerDependencies": { "stx": ">=0.65.0" }, "optionalPeers": ["stx"] }, "sha512-sTUeYpvm+vKkkN0g9JJny1OHGDHlip50Dp+NvWd5dWfRh1Yu+PRv4Rf+2/e5jOfXoGeYPglr+itIFC0cgmekiA=="], | |
| 2416 | 2406 | |
| 2417 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], | |
| 2407 | "ts-videos": ["ts-videos@0.1.1", "", { "dependencies": { "bunfig": "^0.15.6" }, "optionalDependencies": { "@ts-videos/mp4": "0.1.1", "@ts-videos/webm": "0.1.1" }, "bin": { "ts-videos": "./dist/bin/cli.js" } }, "sha512-bGYnXqbGEnWPMFsDzq/p+gqPSzXZ2+yyExd26cC644b4Fecu5qWdP56vHoS136mbEoQBl8tFFJ2f7r5KVF7InQ=="], | |
| 2408 | ||
| 2409 | "ts-web-scraper": ["ts-web-scraper@0.1.1", "", { "bin": { "scraper": "dist/bin/cli.js" } }, "sha512-js+gSRe95yDD9U3+GmjvIKBYLAN9WoZKV84x7HcSI7dqdWEIoZSeSGR/Jbr8f5hCeV9FD70KdTPIS2LQwviQXQ=="], | |
| 2418 | 2410 | |
| 2419 | 2411 | "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], |
| 2420 | 2412 | |
| @@ -2540,6 +2532,8 @@ | ||
| 2540 | 2532 | |
| 2541 | 2533 | "@stacksjs/arrays/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2542 | 2534 | |
| 2535 | "@stacksjs/audio/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], | |
| 2536 | ||
| 2543 | 2537 | "@stacksjs/auth/@stacksjs/bun-router": ["@stacksjs/bun-router@0.0.20", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "ts-rate-limiter": "^0.4.2" }, "bin": { "bun-router": "./dist/cli.js", "router": "./bin/cli.ts" } }, "sha512-PwP5nPF2owS56HtpKB7o2WpCqcxaCrSbyFmjTTMOpj91gY6AzyC2xjjXKTxrP6wWF2WWuaj+SHODbthjMLJ5Rw=="], |
| 2544 | 2538 | |
| 2545 | 2539 | "@stacksjs/auth/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| @@ -2610,6 +2604,8 @@ | ||
| 2610 | 2604 | |
| 2611 | 2605 | "@stacksjs/http/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2612 | 2606 | |
| 2607 | "@stacksjs/image/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], | |
| 2608 | ||
| 2613 | 2609 | "@stacksjs/lint/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2614 | 2610 | |
| 2615 | 2611 | "@stacksjs/logsmith/bunfig": ["bunfig@0.15.14", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./bin/cli.js" } }, "sha512-9yzw5/MkTBF3X8Dc96DJUPpZDl/jIAYVlY6XxGrP8+o5fEf8sGgoIc53GkSFucyMoqC5jF1qM++3bD1LL7/N1Q=="], |
| @@ -2662,10 +2658,14 @@ | ||
| 2662 | 2658 | |
| 2663 | 2659 | "@stacksjs/storage/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2664 | 2660 | |
| 2661 | "@stacksjs/storage/ts-images": ["ts-images@0.1.11", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-b5g1uADQR1Ytg4mS0EyFEce9jWhxC/kj1f5ChTVnnpPZV8NhI+aEW8YbH+LMDRCzCE1HRJiUIweUPqMkMAytIA=="], | |
| 2662 | ||
| 2665 | 2663 | "@stacksjs/strings/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2666 | 2664 | |
| 2667 | 2665 | "@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.99", "", { "peerDependencies": { "craft": "*" }, "optionalPeers": ["craft"] }, "sha512-CQwp77Y50Qbz3ih2KBGfIqUhmUm3U5wkPHDGtQM6hIlBYexbyxtdZMBp/uZkvD4wYrtojMtqnzb/TxBvxwQ9uA=="], |
| 2668 | 2666 | |
| 2667 | "@stacksjs/stx/ts-images": ["ts-images@0.1.11", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-b5g1uADQR1Ytg4mS0EyFEce9jWhxC/kj1f5ChTVnnpPZV8NhI+aEW8YbH+LMDRCzCE1HRJiUIweUPqMkMAytIA=="], | |
| 2668 | ||
| 2669 | 2669 | "@stacksjs/testing/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2670 | 2670 | |
| 2671 | 2671 | "@stacksjs/tinker/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| @@ -2688,6 +2688,8 @@ | ||
| 2688 | 2688 | |
| 2689 | 2689 | "@stacksjs/validation/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2690 | 2690 | |
| 2691 | "@stacksjs/video/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], | |
| 2692 | ||
| 2691 | 2693 | "@stacksjs/whois/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2692 | 2694 | |
| 2693 | 2695 | "better-dx/@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.5", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.1", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-Q2vzPPrxKLBeXU3H+06he7Lr3JiPFp4xVUw9JDcgIXpj++vIHywCQT7WD6uCr4KoV9dVviWwiD83Jh8rkMGiow=="], |
| @@ -2786,8 +2788,6 @@ | ||
| 2786 | 2788 | |
| 2787 | 2789 | "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| 2788 | 2790 | |
| 2789 | "stacks/@stacksjs/tlsx": ["@stacksjs/tlsx@0.13.6", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "bin": { "tlsx": "./dist/bin/cli.js" } }, "sha512-xQAiSxdYZJRFbWPSLn+IHPyaAte4wKVrkEZRecu3bVeZQuZ8NAsoBMWJFew96JnBW8RnWk9FqH+TcKGFSeo5Uw=="], | |
| 2790 | ||
| 2791 | 2791 | "stacks/better-dx": ["better-dx@0.2.20", "", { "dependencies": { "@stacksjs/bumpx": "^0.2.5", "@stacksjs/bunpress": "^0.1.4", "@stacksjs/clapp": "^0.2.6", "@stacksjs/clarity": "^0.3.28", "@stacksjs/gitlint": "^0.1.5", "@stacksjs/logsmith": "^0.2.1", "@types/bun": "^1.3.13", "buddy-bot": "^0.9.17", "bun-git-hooks": "^0.3.2", "bun-plugin-dtsx": "^0.11.1", "bunfig": "^0.15.11", "pickier": "^0.1.28", "ts-error-handling": "^0.1.1", "typescript": "^7.0.2" } }, "sha512-zuKbuW9c89gl77tb7UPYTDxEPyws6BJ4lt8VwCVweLPxoRa8X7aCbE+SKgEFHZdNyvqHsJ1WEZ15phv+ZizLjg=="], |
| 2792 | 2792 | |
| 2793 | 2793 | "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], |
| @@ -2874,8 +2874,6 @@ | ||
| 2874 | 2874 | |
| 2875 | 2875 | "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], |
| 2876 | 2876 | |
| 2877 | "stacks/@stacksjs/tlsx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.10", "", {}, "sha512-l/2F0u5ihpae8R4kJ3Pdh6tV7aEpSxu6hCyafhw38Fl9+x2YKxZfUK+fF/G5AVwSzJar3dkKuMxbMcQ0PXKGRg=="], | |
| 2878 | ||
| 2879 | 2877 | "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], |
| 2880 | 2878 | |
| 2881 | 2879 | "widest-line/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], |
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | export { increment, decrement, reset, count } from '../../../resources/functions/counter' |
| 4 | 4 | export { toggleDark, isDark, preferredDark } from '../../../resources/functions/dark' |
| 5 | export { fetchBlogPosts, fetchBlogPost, saveBlogPost, deleteBlogPost } from '../defaults/functions/blog' | |
| 6 | export type { BlogPost, BlogPostDraft, BlogListResponse } from '../defaults/functions/blog' | |
| 5 | 7 | export { useDirectUpload } from '../defaults/functions/uploads' |
| 6 | 8 | export type { PresignResponse, DirectUploadResult, UseDirectUploadOptions, UseDirectUploadHandle } from '../defaults/functions/uploads' |
| 7 | 9 | export { useAnalytics } from '../defaults/functions/analytics' |
| @@ -1,3 +1,5 @@ | ||
| 1 | 1 | // Generated by bun-plugin-auto-imports |
| 2 | export { default as PublishScheduledPosts } from '../../../app/Jobs/PublishScheduledPosts' | |
| 3 | export { default as SyncEngagementMetrics } from '../../../app/Jobs/SyncEngagementMetrics' | |
| 2 | 4 | export { default as SendWelcomeEmail } from '../../../app/Jobs/SendWelcomeEmail' |
| 3 | 5 | export { default as Inspire } from '../../../app/Jobs/Inspire' |
| @@ -4,6 +4,7 @@ export { default as PostTarget } from '../../../app/Models/PostTarget' | ||
| 4 | 4 | export { default as SocialIdentity } from '../../../app/Models/SocialIdentity' |
| 5 | 5 | export { default as MediaAsset } from '../../../app/Models/MediaAsset' |
| 6 | 6 | export { default as TimelineItem } from '../../../app/Models/TimelineItem' |
| 7 | export { default as BlogPost } from '../../../app/Models/BlogPost' | |
| 7 | 8 | export { default as SocialDriver } from '../../../app/Models/SocialDriver' |
| 8 | 9 | export { default as Post } from '../../../app/Models/Post' |
| 9 | 10 | export { default as Tag } from '../defaults/app/Models/Tag' |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.151Z] local.INFO: Building @stacksjs/actions... | |
| 2 | [2026-07-23T13:13:16.378Z] local.INFO: Building @stacksjs/actions... | |
| 3 | [2026-07-23T13:13:36.533Z] local.INFO: Building @stacksjs/actions... | |
| 1 | [2026-07-23T16:49:35.677Z] local.INFO: Building @stacksjs/actions... | |
| 2 | [2026-07-23T16:49:38.958Z] local.INFO: Building @stacksjs/actions... | |
| @@ -35,6 +35,36 @@ const client = useAI() | ||
| 35 | 35 | // client... |
| 36 | 36 | ``` |
| 37 | 37 | |
| 38 | ## Compact project context | |
| 39 | ||
| 40 | Stacks conventions reduce the amount of application-owned code a coding model | |
| 41 | must generate. A model can describe domain shape once, use known Model-View-Action | |
| 42 | locations, and rely on framework traits and generators for migrations, validation, | |
| 43 | routes, API artifacts, and types where those paths are supported. Installed | |
| 44 | dependencies still exist, but their `node_modules` implementation is package-manager | |
| 45 | state, not application context an LLM should ingest or reproduce. | |
| 46 | ||
| 47 | Generate a deterministic context payload for any coding model or agent: | |
| 48 | ||
| 49 | ```bash | |
| 50 | buddy ai:context | |
| 51 | buddy ai:context --json | |
| 52 | buddy ai:context --json --output .stacks/ai-context.json | |
| 53 | buddy ai:context --max-chars 4000 --model claude-sonnet-4 | |
| 54 | ``` | |
| 55 | ||
| 56 | The versioned JSON contract identifies canonical source roles, instruction files, | |
| 57 | available application surfaces, safe package metadata, and representative source | |
| 58 | paths. It excludes dependency, build, cache, lock, environment, credential, key, | |
| 59 | and secret paths by default. Existing Buddy AI calls consume the same compact text | |
| 60 | representation. | |
| 61 | ||
| 62 | Output metrics include the enforced character budget and a heuristic token estimate. | |
| 63 | They also compare the payload with the previous unstructured context shape. This is | |
| 64 | evidence about prompt size only. It is not evidence that a particular model will | |
| 65 | write correct code, and exact billing tokens require the selected provider's | |
| 66 | tokenizer. | |
| 67 | ||
| 38 | 68 | Learn more in the docs. |
| 39 | 69 | |
| 40 | 70 | ## 🧪 Testing |
| @@ -16,11 +16,12 @@ import type { | ||
| 16 | 16 | RepoState, |
| 17 | 17 | StreamingResult, |
| 18 | 18 | } from './types' |
| 19 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 19 | import { existsSync, mkdirSync, writeFileSync } from 'node:fs' | |
| 20 | 20 | import { homedir } from 'node:os' |
| 21 | 21 | import { dirname, join } from 'node:path' |
| 22 | 22 | import { claudeAgent } from './agents' |
| 23 | 23 | import { createAnthropicDriver, createClaudeAgentSDKDriver, createOllamaDriver, createOpenAIDriver } from './drivers' |
| 24 | import { buildProjectContext } from './context' | |
| 24 | 25 | |
| 25 | 26 | // ============================================================================= |
| 26 | 27 | // Configuration |
| @@ -250,29 +251,7 @@ export function getAvailableDrivers(): string[] { | ||
| 250 | 251 | * Get repository structure for context |
| 251 | 252 | */ |
| 252 | 253 | export async function getRepoContext(repoPath: string): Promise<string> { |
| 253 | const { $: _$ } = await import('bun') | |
| 254 | const treeResult = await _$`cd ${repoPath} && find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -name "*.lock" | head -50`.quiet() | |
| 255 | const files = treeResult.text().trim() | |
| 256 | ||
| 257 | let readme = '' | |
| 258 | const readmePath = join(repoPath, 'README.md') | |
| 259 | if (existsSync(readmePath)) { | |
| 260 | readme = readFileSync(readmePath, 'utf-8').slice(0, 2000) | |
| 261 | } | |
| 262 | ||
| 263 | let packageJson = '' | |
| 264 | const packagePath = join(repoPath, 'package.json') | |
| 265 | if (existsSync(packagePath)) { | |
| 266 | packageJson = readFileSync(packagePath, 'utf-8') | |
| 267 | } | |
| 268 | ||
| 269 | return ` | |
| 270 | Repository Structure: | |
| 271 | ${files} | |
| 272 | ||
| 273 | ${readme ? `README.md (excerpt):\n${readme}\n` : ''} | |
| 274 | ${packageJson ? `package.json:\n${packageJson}\n` : ''} | |
| 275 | `.trim() | |
| 254 | return buildProjectContext(repoPath).text | |
| 276 | 255 | } |
| 277 | 256 | |
| 278 | 257 | /** |
| @@ -0,0 +1,202 @@ | ||
| 1 | import type { | |
| 2 | AIMessage, | |
| 3 | AIMessageContent, | |
| 4 | AIProvider, | |
| 5 | AIResult, | |
| 6 | ChatCompletionOptions, | |
| 7 | ConfiguredAIClient, | |
| 8 | ConfiguredAIOptions, | |
| 9 | GenerateObjectOptions, | |
| 10 | } from './types' | |
| 11 | import * as anthropic from './drivers/anthropic' | |
| 12 | import * as ollama from './drivers/ollama' | |
| 13 | import * as openai from './drivers/openai' | |
| 14 | ||
| 15 | function textContent(content: string | AIMessageContent[]): string { | |
| 16 | if (typeof content === 'string') | |
| 17 | return content | |
| 18 | return content.filter(block => block.type === 'text').map(block => block.text ?? '').join('\n') | |
| 19 | } | |
| 20 | ||
| 21 | function providerFromConfig(config: ConfiguredAIOptions, override?: AIProvider): AIProvider { | |
| 22 | if (override) | |
| 23 | return override | |
| 24 | const configured = String(config.default || '').toLowerCase() | |
| 25 | if (configured === 'anthropic' || configured.startsWith('anthropic.')) | |
| 26 | return 'anthropic' | |
| 27 | if (configured === 'openai' || configured.startsWith('gpt-') || configured.startsWith('o1') || configured.startsWith('o3')) | |
| 28 | return 'openai' | |
| 29 | if (configured === 'ollama') | |
| 30 | return 'ollama' | |
| 31 | throw new Error(`Unsupported configured AI driver: ${config.default || '(empty)'}. Expected anthropic, openai, or ollama.`) | |
| 32 | } | |
| 33 | ||
| 34 | function parseObject(content: string): unknown { | |
| 35 | const trimmed = content.trim() | |
| 36 | const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '') | |
| 37 | try { | |
| 38 | return JSON.parse(unfenced) | |
| 39 | } | |
| 40 | catch { | |
| 41 | const start = unfenced.indexOf('{') | |
| 42 | const end = unfenced.lastIndexOf('}') | |
| 43 | if (start >= 0 && end > start) | |
| 44 | return JSON.parse(unfenced.slice(start, end + 1)) | |
| 45 | throw new Error('AI response did not contain a valid JSON object') | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | function typeMatches(value: unknown, type: string): boolean { | |
| 50 | if (type === 'array') return Array.isArray(value) | |
| 51 | if (type === 'integer') return typeof value === 'number' && Number.isInteger(value) | |
| 52 | if (type === 'object') return typeof value === 'object' && value !== null && !Array.isArray(value) | |
| 53 | if (type === 'null') return value === null | |
| 54 | return typeof value === type | |
| 55 | } | |
| 56 | ||
| 57 | function validateSchema(value: unknown, schema: Record<string, any>, path = '$'): string[] { | |
| 58 | const errors: string[] = [] | |
| 59 | if (schema.type) { | |
| 60 | const allowed = Array.isArray(schema.type) ? schema.type : [schema.type] | |
| 61 | if (!allowed.some((type: string) => typeMatches(value, type))) | |
| 62 | return [`${path} must be ${allowed.join(' or ')}`] | |
| 63 | } | |
| 64 | if (schema.enum && !schema.enum.some((candidate: unknown) => Object.is(candidate, value))) | |
| 65 | errors.push(`${path} must be one of ${schema.enum.join(', ')}`) | |
| 66 | if (typeof value === 'string') { | |
| 67 | if (schema.minLength !== undefined && value.length < schema.minLength) | |
| 68 | errors.push(`${path} must contain at least ${schema.minLength} characters`) | |
| 69 | if (schema.maxLength !== undefined && value.length > schema.maxLength) | |
| 70 | errors.push(`${path} must contain at most ${schema.maxLength} characters`) | |
| 71 | } | |
| 72 | if (typeof value === 'number') { | |
| 73 | if (schema.minimum !== undefined && value < schema.minimum) | |
| 74 | errors.push(`${path} must be at least ${schema.minimum}`) | |
| 75 | if (schema.maximum !== undefined && value > schema.maximum) | |
| 76 | errors.push(`${path} must be at most ${schema.maximum}`) | |
| 77 | } | |
| 78 | if (Array.isArray(value)) { | |
| 79 | if (schema.minItems !== undefined && value.length < schema.minItems) | |
| 80 | errors.push(`${path} must contain at least ${schema.minItems} items`) | |
| 81 | if (schema.maxItems !== undefined && value.length > schema.maxItems) | |
| 82 | errors.push(`${path} must contain at most ${schema.maxItems} items`) | |
| 83 | if (schema.items) { | |
| 84 | value.forEach((item, index) => errors.push(...validateSchema(item, schema.items, `${path}[${index}]`))) | |
| 85 | } | |
| 86 | } | |
| 87 | if (typeof value === 'object' && value !== null && !Array.isArray(value)) { | |
| 88 | const record = value as Record<string, unknown> | |
| 89 | for (const key of schema.required ?? []) { | |
| 90 | if (!(key in record)) | |
| 91 | errors.push(`${path}.${key} is required`) | |
| 92 | } | |
| 93 | for (const [key, child] of Object.entries(schema.properties ?? {})) { | |
| 94 | if (key in record) | |
| 95 | errors.push(...validateSchema(record[key], child as Record<string, any>, `${path}.${key}`)) | |
| 96 | } | |
| 97 | if (schema.additionalProperties === false) { | |
| 98 | const known = new Set(Object.keys(schema.properties ?? {})) | |
| 99 | for (const key of Object.keys(record)) { | |
| 100 | if (!known.has(key)) | |
| 101 | errors.push(`${path}.${key} is not allowed`) | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | return errors | |
| 106 | } | |
| 107 | ||
| 108 | function createGenerate(config: ConfiguredAIOptions, provider: AIProvider) { | |
| 109 | if (provider === 'anthropic') { | |
| 110 | const driver = config.drivers?.anthropic ?? {} | |
| 111 | anthropic.configure({ | |
| 112 | apiKey: driver.apiKey || process.env.ANTHROPIC_API_KEY || '', | |
| 113 | model: driver.model, | |
| 114 | maxTokens: driver.maxTokens, | |
| 115 | anthropicVersion: driver.anthropicVersion, | |
| 116 | }) | |
| 117 | return async (messages: AIMessage[], options: ChatCompletionOptions & { system?: string } = {}): Promise<AIResult> => { | |
| 118 | const systemMessages = messages.filter(message => message.role === 'system').map(message => textContent(message.content)) | |
| 119 | const nonSystem = messages.filter(message => message.role !== 'system') | |
| 120 | return anthropic.chat(nonSystem, { | |
| 121 | ...options, | |
| 122 | system: [options.system, ...systemMessages].filter(Boolean).join('\n\n') || undefined, | |
| 123 | }) | |
| 124 | } | |
| 125 | } | |
| 126 | if (provider === 'openai') { | |
| 127 | const driver = config.drivers?.openai ?? {} | |
| 128 | openai.configure({ | |
| 129 | apiKey: driver.apiKey || process.env.OPENAI_API_KEY || '', | |
| 130 | model: driver.model, | |
| 131 | maxTokens: driver.maxTokens, | |
| 132 | baseUrl: driver.baseUrl, | |
| 133 | embeddingModel: driver.embeddingModel, | |
| 134 | }) | |
| 135 | return (messages: AIMessage[], options: ChatCompletionOptions & { system?: string } = {}): Promise<AIResult> => { | |
| 136 | const finalMessages = options.system | |
| 137 | ? [{ role: 'system' as const, content: options.system }, ...messages] | |
| 138 | : messages | |
| 139 | return openai.chat(finalMessages, options) | |
| 140 | } | |
| 141 | } | |
| 142 | const driver = config.drivers?.ollama ?? {} | |
| 143 | ollama.configure({ | |
| 144 | host: driver.host || driver.baseUrl, | |
| 145 | model: driver.model, | |
| 146 | embeddingModel: driver.embeddingModel, | |
| 147 | }) | |
| 148 | return (messages: AIMessage[], options: ChatCompletionOptions & { system?: string } = {}): Promise<AIResult> => { | |
| 149 | const finalMessages = options.system | |
| 150 | ? [{ role: 'system' as const, content: options.system }, ...messages] | |
| 151 | : messages | |
| 152 | return ollama.chat(finalMessages, options) | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Create a provider-neutral AI client from an application's `config/ai.ts`. | |
| 158 | * Secrets remain server-side and may be supplied by config or provider env vars. | |
| 159 | */ | |
| 160 | export function createAIClient(config: ConfiguredAIOptions, override?: AIProvider): ConfiguredAIClient { | |
| 161 | const provider = providerFromConfig(config, override) | |
| 162 | const generate = createGenerate(config, provider) | |
| 163 | return { | |
| 164 | provider, | |
| 165 | generate, | |
| 166 | async generateObject<T>(messages: AIMessage[], schema: Record<string, unknown>, options: GenerateObjectOptions = {}) { | |
| 167 | const { attempts = 2, system, ...completionOptions } = options | |
| 168 | const history = [...messages] | |
| 169 | let lastError: Error | undefined | |
| 170 | for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) { | |
| 171 | const result = await generate(history, { | |
| 172 | ...completionOptions, | |
| 173 | system, | |
| 174 | responseFormat: { | |
| 175 | type: 'json_schema', | |
| 176 | json_schema: { name: 'structured_output', schema, strict: true }, | |
| 177 | }, | |
| 178 | }) | |
| 179 | try { | |
| 180 | const data = parseObject(result.content) | |
| 181 | const errors = validateSchema(data, schema) | |
| 182 | if (errors.length) | |
| 183 | throw new Error(`AI response failed schema validation: ${errors.slice(0, 8).join('; ')}`) | |
| 184 | return { data: data as T, result } | |
| 185 | } | |
| 186 | catch (error) { | |
| 187 | lastError = error instanceof Error ? error : new Error(String(error)) | |
| 188 | if (attempt < attempts) { | |
| 189 | history.push({ role: 'assistant', content: result.content }) | |
| 190 | history.push({ | |
| 191 | role: 'user', | |
| 192 | content: `Return only a corrected JSON object. Validation error: ${lastError.message}`, | |
| 193 | }) | |
| 194 | } | |
| 195 | } | |
| 196 | } | |
| 197 | throw lastError ?? new Error('AI object generation failed') | |
| 198 | }, | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | export { parseObject as parseAIObject, validateSchema as validateAISchema } | |
| @@ -0,0 +1,300 @@ | ||
| 1 | import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs' | |
| 2 | import { basename, relative, resolve, sep } from 'node:path' | |
| 3 | import { estimateTokens } from './utils/tokens' | |
| 4 | ||
| 5 | export const STACKS_PROJECT_CONTEXT_SCHEMA = 'https://stacksjs.org/schemas/ai-project-context/v1' | |
| 6 | ||
| 7 | const DEFAULT_MAX_CHARS = 4000 | |
| 8 | const DEFAULT_MAX_FILES = 30 | |
| 9 | ||
| 10 | const excludedSegments = new Set([ | |
| 11 | '.git', | |
| 12 | '.bun', | |
| 13 | '.cache', | |
| 14 | '.next', | |
| 15 | '.output', | |
| 16 | '.turbo', | |
| 17 | '.vite', | |
| 18 | 'build', | |
| 19 | 'coverage', | |
| 20 | 'dist', | |
| 21 | 'node_modules', | |
| 22 | 'tmp', | |
| 23 | 'vendor', | |
| 24 | ]) | |
| 25 | ||
| 26 | const sensitiveNames = /^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|credentials(?:\..*)?|id_(?:rsa|ed25519)(?:\.pub)?|secrets?(?:\..*)?)$/i | |
| 27 | const lockNames = /(?:^|\.)(?:bun|package-lock|pnpm-lock|yarn|pantry)\.?(?:lock|yaml)?$/i | |
| 28 | ||
| 29 | export interface ProjectContextOptions { | |
| 30 | maxChars?: number | |
| 31 | maxFiles?: number | |
| 32 | model?: string | |
| 33 | } | |
| 34 | ||
| 35 | export interface ProjectContextSurface { | |
| 36 | id: string | |
| 37 | purpose: string | |
| 38 | paths: string[] | |
| 39 | } | |
| 40 | ||
| 41 | export interface StacksProjectContext { | |
| 42 | schema: typeof STACKS_PROJECT_CONTEXT_SCHEMA | |
| 43 | schemaVersion: '1.0.0' | |
| 44 | framework: 'stacks' | |
| 45 | architecture: { | |
| 46 | pattern: 'Model-View-Action' | |
| 47 | overrideRule: string | |
| 48 | authoringOrder: string[] | |
| 49 | principles: string[] | |
| 50 | } | |
| 51 | project: { | |
| 52 | name: string | null | |
| 53 | version: string | null | |
| 54 | scripts: string[] | |
| 55 | dependencies: string[] | |
| 56 | } | |
| 57 | instructionFiles: string[] | |
| 58 | surfaces: ProjectContextSurface[] | |
| 59 | representativeFiles: string[] | |
| 60 | exclusions: string[] | |
| 61 | } | |
| 62 | ||
| 63 | export interface ProjectContextMetrics { | |
| 64 | candidateFiles: number | |
| 65 | includedFiles: number | |
| 66 | maxCharacters: number | |
| 67 | outputCharacters: number | |
| 68 | estimatedTokens: number | |
| 69 | model: string | |
| 70 | truncated: boolean | |
| 71 | baselineMethod: 'sorted-first-50-paths+readme-2000+package-json' | |
| 72 | baselineCharacters: number | |
| 73 | baselineEstimatedTokens: number | |
| 74 | estimatedTokenReductionPercent: number | null | |
| 75 | tokenEstimateIsHeuristic: true | |
| 76 | } | |
| 77 | ||
| 78 | export interface ProjectContextResult { | |
| 79 | context: StacksProjectContext | |
| 80 | text: string | |
| 81 | metrics: ProjectContextMetrics | |
| 82 | } | |
| 83 | ||
| 84 | interface SurfaceDefinition { | |
| 85 | id: string | |
| 86 | purpose: string | |
| 87 | prefixes: string[] | |
| 88 | } | |
| 89 | ||
| 90 | const surfaceDefinitions: SurfaceDefinition[] = [ | |
| 91 | { id: 'models', purpose: 'Domain schema, validation, relationships, factories, and traits.', prefixes: ['app/Models/', 'storage/framework/defaults/app/Models/'] }, | |
| 92 | { id: 'actions', purpose: 'Transport-independent application behavior.', prefixes: ['app/Actions/', 'storage/framework/defaults/app/Actions/'] }, | |
| 93 | { id: 'routes', purpose: 'HTTP and API route registration.', prefixes: ['routes/', 'app/Routes.ts'] }, | |
| 94 | { id: 'jobs', purpose: 'Background and inline work.', prefixes: ['app/Jobs/', 'storage/framework/defaults/app/Jobs/'] }, | |
| 95 | { id: 'configuration', purpose: 'Typed application and provider configuration.', prefixes: ['config/'] }, | |
| 96 | { id: 'views', purpose: 'STX views, layouts, components, and client functions.', prefixes: ['resources/'] }, | |
| 97 | { id: 'database', purpose: 'Reviewable migrations and seed data.', prefixes: ['database/'] }, | |
| 98 | { id: 'tests', purpose: 'Unit, feature, contract, and end-to-end evidence.', prefixes: ['tests/', 'storage/framework/core/'] }, | |
| 99 | ] | |
| 100 | ||
| 101 | function normalizePath(path: string): string { | |
| 102 | return path.split(sep).join('/') | |
| 103 | } | |
| 104 | ||
| 105 | function isExcluded(path: string): boolean { | |
| 106 | const segments = path.split('/') | |
| 107 | const name = basename(path) | |
| 108 | return segments.some(segment => excludedSegments.has(segment)) | |
| 109 | || sensitiveNames.test(name) | |
| 110 | || lockNames.test(name) | |
| 111 | || name.endsWith('.lock') | |
| 112 | } | |
| 113 | ||
| 114 | function collectFiles(root: string): string[] { | |
| 115 | const files: string[] = [] | |
| 116 | ||
| 117 | function visit(directory: string): void { | |
| 118 | const entries = readdirSync(directory, { withFileTypes: true }) | |
| 119 | .sort((a, b) => a.name.localeCompare(b.name)) | |
| 120 | ||
| 121 | for (const entry of entries) { | |
| 122 | const fullPath = resolve(directory, entry.name) | |
| 123 | const projectPath = normalizePath(relative(root, fullPath)) | |
| 124 | if (isExcluded(projectPath)) continue | |
| 125 | if (entry.isDirectory()) visit(fullPath) | |
| 126 | else if (entry.isFile() && !lstatSync(fullPath).isSymbolicLink()) files.push(projectPath) | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | visit(root) | |
| 131 | return files.sort((a, b) => a.localeCompare(b)) | |
| 132 | } | |
| 133 | ||
| 134 | function readPackageSummary(root: string): StacksProjectContext['project'] { | |
| 135 | const packagePath = resolve(root, 'package.json') | |
| 136 | if (!existsSync(packagePath)) return { name: null, version: null, scripts: [], dependencies: [] } | |
| 137 | ||
| 138 | try { | |
| 139 | const manifest = JSON.parse(readFileSync(packagePath, 'utf8')) as Record<string, unknown> | |
| 140 | const dependencies = [ | |
| 141 | ...Object.keys((manifest.dependencies || {}) as Record<string, unknown>), | |
| 142 | ...Object.keys((manifest.devDependencies || {}) as Record<string, unknown>), | |
| 143 | ...Object.keys((manifest.peerDependencies || {}) as Record<string, unknown>), | |
| 144 | ] | |
| 145 | return { | |
| 146 | name: typeof manifest.name === 'string' ? manifest.name : null, | |
| 147 | version: typeof manifest.version === 'string' ? manifest.version : null, | |
| 148 | scripts: Object.keys((manifest.scripts || {}) as Record<string, unknown>).sort().slice(0, 25), | |
| 149 | dependencies: [...new Set(dependencies)].sort().slice(0, 30), | |
| 150 | } | |
| 151 | } | |
| 152 | catch { | |
| 153 | return { name: null, version: null, scripts: [], dependencies: [] } | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | function filePriority(path: string): number { | |
| 158 | if (/^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$/.test(path)) return 0 | |
| 159 | if (path === 'package.json' || path === 'app/Routes.ts' || path === 'app/Commands.ts') return 1 | |
| 160 | if (/^(?:app|routes|config)\//.test(path)) return 2 | |
| 161 | if (/^(?:resources|database|tests)\//.test(path)) return 3 | |
| 162 | if (/^docs\//.test(path)) return 4 | |
| 163 | if (/^storage\/framework\/defaults\//.test(path)) return 5 | |
| 164 | return 6 | |
| 165 | } | |
| 166 | ||
| 167 | function representativeFiles(files: string[], maxFiles: number): string[] { | |
| 168 | return [...files] | |
| 169 | .sort((a, b) => filePriority(a) - filePriority(b) || a.localeCompare(b)) | |
| 170 | .slice(0, maxFiles) | |
| 171 | .sort((a, b) => a.localeCompare(b)) | |
| 172 | } | |
| 173 | ||
| 174 | function surfacePaths(files: string[], prefixes: string[]): string[] { | |
| 175 | return files | |
| 176 | .filter(file => prefixes.some(prefix => prefix.endsWith('/') ? file.startsWith(prefix) : file === prefix)) | |
| 177 | .slice(0, 4) | |
| 178 | } | |
| 179 | ||
| 180 | function renderContext(context: StacksProjectContext): string { | |
| 181 | const lines = [ | |
| 182 | 'Stacks AI Project Context v1', | |
| 183 | '', | |
| 184 | `Project: ${context.project.name || 'unnamed'}${context.project.version ? `@${context.project.version}` : ''}`, | |
| 185 | 'Architecture: Model-View-Action', | |
| 186 | `Override rule: ${context.architecture.overrideRule}`, | |
| 187 | `Authoring order: ${context.architecture.authoringOrder.join(' -> ')}`, | |
| 188 | '', | |
| 189 | 'Authoring principles:', | |
| 190 | ...context.architecture.principles.map(principle => `- ${principle}`), | |
| 191 | '', | |
| 192 | 'Instruction files:', | |
| 193 | ...(context.instructionFiles.length ? context.instructionFiles.map(file => `- ${file}`) : ['- none detected']), | |
| 194 | '', | |
| 195 | 'Available surfaces:', | |
| 196 | ...context.surfaces.flatMap(surface => [ | |
| 197 | `- ${surface.id}: ${surface.purpose}`, | |
| 198 | ...surface.paths.map(path => ` - ${path}`), | |
| 199 | ]), | |
| 200 | '', | |
| 201 | `Scripts: ${context.project.scripts.join(', ') || 'none detected'}`, | |
| 202 | `Dependencies: ${context.project.dependencies.join(', ') || 'none detected'}`, | |
| 203 | '', | |
| 204 | 'Representative files:', | |
| 205 | ...context.representativeFiles.map(file => `- ${file}`), | |
| 206 | ] | |
| 207 | return lines.join('\n') | |
| 208 | } | |
| 209 | ||
| 210 | function fitToBudget(text: string, maxChars: number): { text: string, truncated: boolean } { | |
| 211 | if (text.length <= maxChars) return { text, truncated: false } | |
| 212 | const marker = '\n[context truncated by character budget]' | |
| 213 | if (maxChars <= marker.length) return { text: marker.slice(0, maxChars), truncated: true } | |
| 214 | const available = maxChars - marker.length | |
| 215 | const prefix = text.slice(0, available) | |
| 216 | const lastLine = prefix.lastIndexOf('\n') | |
| 217 | return { | |
| 218 | text: `${prefix.slice(0, lastLine > 0 ? lastLine : available)}${marker}`, | |
| 219 | truncated: true, | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | function unstructuredBaseline(root: string, files: string[]): string { | |
| 224 | const readmePath = resolve(root, 'README.md') | |
| 225 | const packagePath = resolve(root, 'package.json') | |
| 226 | const readme = existsSync(readmePath) ? readFileSync(readmePath, 'utf8').slice(0, 2000) : '' | |
| 227 | const packageJson = existsSync(packagePath) ? readFileSync(packagePath, 'utf8') : '' | |
| 228 | return [ | |
| 229 | 'Repository Structure:', | |
| 230 | files.slice(0, 50).join('\n'), | |
| 231 | readme ? `README.md (excerpt):\n${readme}` : '', | |
| 232 | packageJson ? `package.json:\n${packageJson}` : '', | |
| 233 | ].filter(Boolean).join('\n\n') | |
| 234 | } | |
| 235 | ||
| 236 | export function buildProjectContext(repoPath: string, options: ProjectContextOptions = {}): ProjectContextResult { | |
| 237 | const root = resolve(repoPath) | |
| 238 | const maxChars = options.maxChars ?? DEFAULT_MAX_CHARS | |
| 239 | const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES | |
| 240 | const model = options.model || 'gpt-4o' | |
| 241 | if (!Number.isInteger(maxChars) || maxChars < 256) throw new Error('maxChars must be an integer of at least 256') | |
| 242 | if (!Number.isInteger(maxFiles) || maxFiles < 1) throw new Error('maxFiles must be a positive integer') | |
| 243 | ||
| 244 | const files = collectFiles(root) | |
| 245 | const included = representativeFiles(files, maxFiles) | |
| 246 | const context: StacksProjectContext = { | |
| 247 | schema: STACKS_PROJECT_CONTEXT_SCHEMA, | |
| 248 | schemaVersion: '1.0.0', | |
| 249 | framework: 'stacks', | |
| 250 | architecture: { | |
| 251 | pattern: 'Model-View-Action', | |
| 252 | overrideRule: 'Application files under app/ override matching framework defaults; do not edit defaults for application customization.', | |
| 253 | authoringOrder: ['model', 'migration', 'action', 'route', 'test'], | |
| 254 | principles: [ | |
| 255 | 'Prefer conventional paths and framework generators over bespoke glue code.', | |
| 256 | 'Declare domain shape once and derive validation, migrations, API surfaces, and types where supported.', | |
| 257 | 'Keep Actions transport-independent and make unsupported drivers fail loudly.', | |
| 258 | 'Read repository instruction files before changing source.', | |
| 259 | ], | |
| 260 | }, | |
| 261 | project: readPackageSummary(root), | |
| 262 | instructionFiles: files.filter(file => /(?:^|\/)(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$/.test(file)), | |
| 263 | surfaces: surfaceDefinitions | |
| 264 | .map(surface => ({ id: surface.id, purpose: surface.purpose, paths: surfacePaths(files, surface.prefixes) })) | |
| 265 | .filter(surface => surface.paths.length > 0), | |
| 266 | representativeFiles: included, | |
| 267 | exclusions: [ | |
| 268 | 'dependency, build, cache, coverage, temporary, and vendor directories', | |
| 269 | 'lockfiles', | |
| 270 | 'environment, credential, private-key, and secret files', | |
| 271 | ], | |
| 272 | } | |
| 273 | ||
| 274 | const fitted = fitToBudget(renderContext(context), maxChars) | |
| 275 | const baseline = unstructuredBaseline(root, files) | |
| 276 | const outputTokens = estimateTokens(fitted.text, model) | |
| 277 | const baselineTokens = estimateTokens(baseline, model) | |
| 278 | const reduction = baselineTokens > 0 | |
| 279 | ? Math.round((1 - outputTokens / baselineTokens) * 1000) / 10 | |
| 280 | : null | |
| 281 | ||
| 282 | return { | |
| 283 | context, | |
| 284 | text: fitted.text, | |
| 285 | metrics: { | |
| 286 | candidateFiles: files.length, | |
| 287 | includedFiles: included.length, | |
| 288 | maxCharacters: maxChars, | |
| 289 | outputCharacters: fitted.text.length, | |
| 290 | estimatedTokens: outputTokens, | |
| 291 | model, | |
| 292 | truncated: fitted.truncated, | |
| 293 | baselineMethod: 'sorted-first-50-paths+readme-2000+package-json', | |
| 294 | baselineCharacters: baseline.length, | |
| 295 | baselineEstimatedTokens: baselineTokens, | |
| 296 | estimatedTokenReductionPercent: reduction, | |
| 297 | tokenEstimateIsHeuristic: true, | |
| 298 | }, | |
| 299 | } | |
| 300 | } | |
| @@ -159,8 +159,15 @@ export async function chat( | ||
| 159 | 159 | temperature, |
| 160 | 160 | topP, |
| 161 | 161 | stop, |
| 162 | responseFormat, | |
| 162 | 163 | } = options |
| 163 | 164 | |
| 165 | const format = responseFormat?.type === 'json_schema' | |
| 166 | ? responseFormat.json_schema.schema | |
| 167 | : responseFormat?.type === 'json_object' | |
| 168 | ? 'json' | |
| 169 | : undefined | |
| 170 | ||
| 164 | 171 | const response = await fetch(`${config.host}/api/chat`, { |
| 165 | 172 | method: 'POST', |
| 166 | 173 | headers: { 'Content-Type': 'application/json' }, |
| @@ -168,6 +175,7 @@ export async function chat( | ||
| 168 | 175 | model, |
| 169 | 176 | messages, |
| 170 | 177 | stream: false, |
| 178 | format, | |
| 171 | 179 | options: { |
| 172 | 180 | temperature, |
| 173 | 181 | top_p: topP, |
| @@ -217,6 +217,9 @@ export async function chat( | ||
| 217 | 217 | temperature, |
| 218 | 218 | topP, |
| 219 | 219 | stop, |
| 220 | tools, | |
| 221 | toolChoice, | |
| 222 | responseFormat, | |
| 220 | 223 | } = options |
| 221 | 224 | |
| 222 | 225 | // Normalize content arrays into OpenAI's wire format |
| @@ -228,20 +231,42 @@ export async function chat( | ||
| 228 | 231 | // Track wall-clock duration for usage reporters (#1878 A-6). |
| 229 | 232 | const startedAt = Date.now() |
| 230 | 233 | |
| 234 | const body: Record<string, unknown> = { | |
| 235 | model, | |
| 236 | max_tokens: maxTokens, | |
| 237 | temperature, | |
| 238 | top_p: topP, | |
| 239 | stop, | |
| 240 | messages: normalizedMessages, | |
| 241 | } | |
| 242 | ||
| 243 | if (tools?.length) { | |
| 244 | body.tools = tools.map(tool => ({ | |
| 245 | type: 'function', | |
| 246 | function: { | |
| 247 | name: tool.name, | |
| 248 | description: tool.description, | |
| 249 | parameters: tool.parameters ?? { type: 'object', properties: {} }, | |
| 250 | }, | |
| 251 | })) | |
| 252 | } | |
| 253 | ||
| 254 | if (toolChoice !== undefined) { | |
| 255 | body.tool_choice = typeof toolChoice === 'object' | |
| 256 | ? { type: 'function', function: { name: toolChoice.name } } | |
| 257 | : toolChoice | |
| 258 | } | |
| 259 | ||
| 260 | if (responseFormat && responseFormat.type !== 'text') | |
| 261 | body.response_format = responseFormat | |
| 262 | ||
| 231 | 263 | const response = await fetchWithRetry(`${config.baseUrl || DEFAULT_BASE_URL}/chat/completions`, { |
| 232 | 264 | method: 'POST', |
| 233 | 265 | headers: { |
| 234 | 266 | 'Content-Type': 'application/json', |
| 235 | 267 | 'Authorization': `Bearer ${config.apiKey}`, |
| 236 | 268 | }, |
| 237 | body: JSON.stringify({ | |
| 238 | model, | |
| 239 | max_tokens: maxTokens, | |
| 240 | temperature, | |
| 241 | top_p: topP, | |
| 242 | stop, | |
| 243 | messages: normalizedMessages, | |
| 244 | }), | |
| 269 | body: JSON.stringify(body), | |
| 245 | 270 | }) |
| 246 | 271 | |
| 247 | 272 | if (!response.ok) { |
| @@ -4,12 +4,18 @@ export * from './types' | ||
| 4 | 4 | // Drivers |
| 5 | 5 | export * from './drivers' |
| 6 | 6 | |
| 7 | // Provider-neutral, config-driven client | |
| 8 | export * from './client' | |
| 9 | ||
| 7 | 10 | // Agents |
| 8 | 11 | export * from './agents' |
| 9 | 12 | |
| 10 | 13 | // Buddy - Voice AI Code Assistant |
| 11 | 14 | export * from './buddy' |
| 12 | 15 | |
| 16 | // Deterministic, budgeted project context for coding models and agents | |
| 17 | export * from './context' | |
| 18 | ||
| 13 | 19 | // Text utilities |
| 14 | 20 | export * from './text' |
| 15 | 21 | |
| @@ -216,7 +216,7 @@ export interface MCPConfig { | ||
| 216 | 216 | |
| 217 | 217 | // AI module config (used by @stacksjs/config) |
| 218 | 218 | export interface AIConfig { |
| 219 | default?: string | |
| 219 | default?: AIProvider | string | |
| 220 | 220 | models?: string[] |
| 221 | 221 | drivers?: { |
| 222 | 222 | anthropic?: AIDriverConfig & { anthropicVersion?: string } |
| @@ -227,3 +227,30 @@ export interface AIConfig { | ||
| 227 | 227 | search?: SearchConfig |
| 228 | 228 | mcp?: MCPConfig |
| 229 | 229 | } |
| 230 | ||
| 231 | export type AIProvider = 'anthropic' | 'openai' | 'ollama' | |
| 232 | ||
| 233 | export interface ConfiguredAIOptions extends AIConfig { | |
| 234 | drivers?: { | |
| 235 | anthropic?: AIDriverConfig & { anthropicVersion?: string } | |
| 236 | openai?: AIDriverConfig & { embeddingModel?: string } | |
| 237 | ollama?: AIDriverConfig & { host?: string, embeddingModel?: string } | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | export interface GenerateObjectOptions extends ChatCompletionOptions { | |
| 242 | /** Maximum total generation attempts. Defaults to 2. */ | |
| 243 | attempts?: number | |
| 244 | /** Optional system instruction kept separate for providers that require it. */ | |
| 245 | system?: string | |
| 246 | } | |
| 247 | ||
| 248 | export interface ConfiguredAIClient { | |
| 249 | provider: AIProvider | |
| 250 | generate: (messages: AIMessage[], options?: ChatCompletionOptions & { system?: string }) => Promise<AIResult> | |
| 251 | generateObject: <T>( | |
| 252 | messages: AIMessage[], | |
| 253 | schema: Record<string, unknown>, | |
| 254 | options?: GenerateObjectOptions, | |
| 255 | ) => Promise<{ data: T, result: AIResult }> | |
| 256 | } | |
| @@ -0,0 +1,79 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { createAIClient, parseAIObject, validateAISchema } from '../src/client' | |
| 3 | ||
| 4 | const originalFetch = globalThis.fetch | |
| 5 | ||
| 6 | afterEach(() => { | |
| 7 | globalThis.fetch = originalFetch | |
| 8 | }) | |
| 9 | ||
| 10 | describe('configured AI client', () => { | |
| 11 | test('selects the configured driver and forwards OpenAI JSON schema', async () => { | |
| 12 | let requestBody: any | |
| 13 | globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { | |
| 14 | requestBody = JSON.parse(String(init?.body)) | |
| 15 | return new Response(JSON.stringify({ | |
| 16 | model: 'gpt-test', | |
| 17 | choices: [{ message: { content: '{"answer":"fixed"}' }, finish_reason: 'stop' }], | |
| 18 | usage: { prompt_tokens: 2, completion_tokens: 2, total_tokens: 4 }, | |
| 19 | }), { status: 200 }) | |
| 20 | }) as typeof fetch | |
| 21 | ||
| 22 | const client = createAIClient({ | |
| 23 | default: 'openai', | |
| 24 | drivers: { openai: { apiKey: 'test', model: 'gpt-test' } }, | |
| 25 | }) | |
| 26 | const schema = { | |
| 27 | type: 'object', | |
| 28 | required: ['answer'], | |
| 29 | additionalProperties: false, | |
| 30 | properties: { answer: { type: 'string' } }, | |
| 31 | } | |
| 32 | const { data } = await client.generateObject<{ answer: string }>([ | |
| 33 | { role: 'user', content: 'Fix it' }, | |
| 34 | ], schema) | |
| 35 | ||
| 36 | expect(client.provider).toBe('openai') | |
| 37 | expect(data.answer).toBe('fixed') | |
| 38 | expect(requestBody.response_format).toEqual({ | |
| 39 | type: 'json_schema', | |
| 40 | json_schema: { name: 'structured_output', schema, strict: true }, | |
| 41 | }) | |
| 42 | }) | |
| 43 | ||
| 44 | test('retries once when generated JSON fails validation', async () => { | |
| 45 | let calls = 0 | |
| 46 | globalThis.fetch = (async () => { | |
| 47 | calls++ | |
| 48 | const content = calls === 1 ? '{"wrong":true}' : '{"answer":"fixed"}' | |
| 49 | return new Response(JSON.stringify({ | |
| 50 | model: 'gpt-test', | |
| 51 | choices: [{ message: { content }, finish_reason: 'stop' }], | |
| 52 | usage: {}, | |
| 53 | }), { status: 200 }) | |
| 54 | }) as typeof fetch | |
| 55 | ||
| 56 | const client = createAIClient({ default: 'openai', drivers: { openai: { apiKey: 'test' } } }) | |
| 57 | const { data } = await client.generateObject<{ answer: string }>([{ role: 'user', content: 'Fix it' }], { | |
| 58 | type: 'object', | |
| 59 | required: ['answer'], | |
| 60 | properties: { answer: { type: 'string' } }, | |
| 61 | }) | |
| 62 | ||
| 63 | expect(calls).toBe(2) | |
| 64 | expect(data.answer).toBe('fixed') | |
| 65 | }) | |
| 66 | ||
| 67 | test('parses fenced JSON and validates nested values', () => { | |
| 68 | expect(parseAIObject('```json\n{"files":[{"path":"src/a.ts"}]}\n```')).toEqual({ files: [{ path: 'src/a.ts' }] }) | |
| 69 | expect(validateAISchema({ files: [] }, { | |
| 70 | type: 'object', | |
| 71 | required: ['files'], | |
| 72 | properties: { files: { type: 'array', minItems: 1 } }, | |
| 73 | })).toEqual(['$.files must contain at least 1 items']) | |
| 74 | }) | |
| 75 | ||
| 76 | test('rejects unsupported legacy model defaults with an actionable error', () => { | |
| 77 | expect(() => createAIClient({ default: 'meta.llama3-70b-instruct-v1:0' })).toThrow('Unsupported configured AI driver') | |
| 78 | }) | |
| 79 | }) | |
| @@ -0,0 +1,78 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildProjectContext } from '../src/context' | |
| 6 | import { getRepoContext } from '../src/buddy' | |
| 7 | ||
| 8 | const temporaryRoots: string[] = [] | |
| 9 | ||
| 10 | function fixture(): string { | |
| 11 | const root = mkdtempSync(join(tmpdir(), 'stacks-ai-context-')) | |
| 12 | temporaryRoots.push(root) | |
| 13 | for (const directory of ['app/Actions', 'app/Models', 'config', 'node_modules/noisy', 'routes', 'tests']) | |
| 14 | mkdirSync(join(root, directory), { recursive: true }) | |
| 15 | ||
| 16 | writeFileSync(join(root, 'package.json'), JSON.stringify({ | |
| 17 | name: 'example-stacks-app', | |
| 18 | version: '1.2.3', | |
| 19 | scripts: { build: 'SECRET_TOKEN=do-not-copy bun build', test: 'bun test' }, | |
| 20 | dependencies: { '@stacksjs/actions': '1.0.0', '@stacksjs/orm': '1.0.0' }, | |
| 21 | }, null, 2)) | |
| 22 | writeFileSync(join(root, 'AGENTS.md'), 'Use the Stacks conventions.') | |
| 23 | writeFileSync(join(root, '.env'), 'SECRET_TOKEN=visible-nowhere') | |
| 24 | writeFileSync(join(root, 'bun.lock'), 'large lock') | |
| 25 | writeFileSync(join(root, 'node_modules/noisy/index.ts'), 'ignore me') | |
| 26 | writeFileSync(join(root, 'app/Actions/CreatePost.ts'), 'export default {}') | |
| 27 | writeFileSync(join(root, 'app/Models/Post.ts'), 'export default {}') | |
| 28 | writeFileSync(join(root, 'config/app.ts'), 'export default {}') | |
| 29 | writeFileSync(join(root, 'routes/api.ts'), 'export default {}') | |
| 30 | writeFileSync(join(root, 'tests/post.test.ts'), 'export default {}') | |
| 31 | writeFileSync(join(root, 'README.md'), 'A'.repeat(2000)) | |
| 32 | return root | |
| 33 | } | |
| 34 | ||
| 35 | afterEach(() => { | |
| 36 | for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) | |
| 37 | }) | |
| 38 | ||
| 39 | describe('Stacks AI project context', () => { | |
| 40 | it('builds deterministic high-signal context without secret or dependency paths', () => { | |
| 41 | const root = fixture() | |
| 42 | const first = buildProjectContext(root) | |
| 43 | const second = buildProjectContext(root) | |
| 44 | ||
| 45 | expect(first).toEqual(second) | |
| 46 | expect(first.context.schemaVersion).toBe('1.0.0') | |
| 47 | expect(first.context.architecture.pattern).toBe('Model-View-Action') | |
| 48 | expect(first.context.instructionFiles).toEqual(['AGENTS.md']) | |
| 49 | expect(first.context.project.scripts).toEqual(['build', 'test']) | |
| 50 | expect(first.context.project.dependencies).toEqual(['@stacksjs/actions', '@stacksjs/orm']) | |
| 51 | expect(first.text).toContain('app/Models/Post.ts') | |
| 52 | expect(first.text).not.toContain('SECRET_TOKEN') | |
| 53 | expect(first.text).not.toContain('.env') | |
| 54 | expect(first.text).not.toContain('node_modules') | |
| 55 | expect(first.text).not.toContain('bun.lock') | |
| 56 | }) | |
| 57 | ||
| 58 | it('enforces the prompt character budget and reports heuristic savings', () => { | |
| 59 | const result = buildProjectContext(fixture(), { maxChars: 512, model: 'claude-sonnet-4' }) | |
| 60 | ||
| 61 | expect(result.text.length).toBeLessThanOrEqual(512) | |
| 62 | expect(result.metrics.outputCharacters).toBe(result.text.length) | |
| 63 | expect(result.metrics.truncated).toBeTrue() | |
| 64 | expect(result.metrics.estimatedTokens).toBeGreaterThan(0) | |
| 65 | expect(result.metrics.baselineEstimatedTokens).toBeGreaterThan(result.metrics.estimatedTokens) | |
| 66 | expect(result.metrics.estimatedTokenReductionPercent).toBeGreaterThan(0) | |
| 67 | expect(result.metrics.tokenEstimateIsHeuristic).toBeTrue() | |
| 68 | }) | |
| 69 | ||
| 70 | it('keeps the existing Buddy API on the compact representation', async () => { | |
| 71 | const root = fixture() | |
| 72 | expect(await getRepoContext(root)).toBe(buildProjectContext(root).text) | |
| 73 | }) | |
| 74 | ||
| 75 | it('rejects unusable budgets', () => { | |
| 76 | expect(() => buildProjectContext(fixture(), { maxChars: 255 })).toThrow('at least 256') | |
| 77 | }) | |
| 78 | }) | |
| @@ -24,6 +24,8 @@ export const alias: Record<string, string> = { | ||
| 24 | 24 | 'stacks/arrays': p.arraysPath('src/index.ts'), |
| 25 | 25 | '@stacksjs/arrays/*': p.arraysPath('src/*'), |
| 26 | 26 | 'stacks/arrays/*': p.arraysPath('src/*'), |
| 27 | '@stacksjs/audio': p.frameworkPath('core/audio/src/index.ts'), | |
| 28 | 'stacks/audio': p.frameworkPath('core/audio/src/index.ts'), | |
| 27 | 29 | '@stacksjs/auth': p.authPath('src/index.ts'), |
| 28 | 30 | 'stacks/auth': p.authPath('src/index.ts'), |
| 29 | 31 | '@stacksjs/api/*': p.coreApiPath('src/*'), |
| @@ -123,6 +125,8 @@ export const alias: Record<string, string> = { | ||
| 123 | 125 | '@stacksjs/git/*': p.gitPath('src/*'), |
| 124 | 126 | 'stacks/git/*': p.gitPath('src/*'), |
| 125 | 127 | '@stacksjs/lint': p.lintPath('src/index.ts'), |
| 128 | '@stacksjs/image': p.frameworkPath('core/image/src/index.ts'), | |
| 129 | 'stacks/image': p.frameworkPath('core/image/src/index.ts'), | |
| 126 | 130 | 'stacks/lint': p.lintPath('src/index.ts'), |
| 127 | 131 | '@stacksjs/lint/*': p.lintPath('src/*'), |
| 128 | 132 | 'stacks/lint/*': p.lintPath('src/*'), |
| @@ -226,6 +230,8 @@ export const alias: Record<string, string> = { | ||
| 226 | 230 | 'stacks/validation': p.validationPath('src/index.ts'), |
| 227 | 231 | '@stacksjs/validation/*': p.validationPath('src/*'), |
| 228 | 232 | 'stacks/validation/*': p.validationPath('src/*'), |
| 233 | '@stacksjs/video': p.frameworkPath('core/video/src/index.ts'), | |
| 234 | 'stacks/video': p.frameworkPath('core/video/src/index.ts'), | |
| 229 | 235 | |
| 230 | 236 | 'framework/*': p.frameworkPath('core/*'), |
| 231 | 237 | '~/app/*': p.appPath('*'), |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/analytics", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Stacks Analytics. Privacy-friendly.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -0,0 +1,66 @@ | ||
| 1 | # @stacksjs/audio | |
| 2 | ||
| 3 | Capability-aware audio planning, format negotiation, waveforms, and transcript normalization for Stacks. | |
| 4 | ||
| 5 | ## Process music, podcasts, and voice | |
| 6 | ||
| 7 | ```ts | |
| 8 | import { detectAudioRuntimeCapabilities } from '@ts-audio/core' | |
| 9 | import { audio } from '@stacksjs/audio' | |
| 10 | ||
| 11 | const derivatives = await audio('uploads/episode.flac') | |
| 12 | .profile({ | |
| 13 | codec: 'flac', | |
| 14 | container: 'flac', | |
| 15 | duration: 1842, | |
| 16 | sampleRate: 48_000, | |
| 17 | channels: 2, | |
| 18 | }) | |
| 19 | .content('speech') | |
| 20 | .output(['opus', 'aac', 'mp3']) | |
| 21 | .loudness(-16) | |
| 22 | .runtime(await detectAudioRuntimeCapabilities()) | |
| 23 | .process({ signal: request.signal }) | |
| 24 | ``` | |
| 25 | ||
| 26 | Use `.content('music')` for music bitrate guidance, `.content('speech')` for podcasts and voice, or `.content('general')` for mixed material. AAC output uses a native ADTS `.aac` container, Opus uses Ogg, and MP3 uses its native container. `.generate()` can inspect the plan without processing it. | |
| 27 | ||
| 28 | ## Negotiation, waveform, and transcripts | |
| 29 | ||
| 30 | ```ts | |
| 31 | import { createWaveform, negotiateAudioOutput, normalizeTranscript } from '@stacksjs/audio' | |
| 32 | ||
| 33 | const selected = negotiateAudioOutput(plan.outputs, request.headers.get('accept') ?? '*/*') | |
| 34 | const waveform = createWaveform(decodedChannels, sampleRate, 1200) | |
| 35 | const transcript = normalizeTranscript('en', providerSegments) | |
| 36 | ``` | |
| 37 | ||
| 38 | Transcription remains provider-neutral. Call the provider explicitly, bind its cache key to the source hash, language, provider version, and options, then store the normalized WebVTT beside the private media. | |
| 39 | ||
| 40 | ## HLS, private CDN, and DRM | |
| 41 | ||
| 42 | ```ts | |
| 43 | import { createProtectedAudioPlaylist } from '@stacksjs/audio' | |
| 44 | ||
| 45 | const delivery = await createProtectedAudioPlaylist(segments, { | |
| 46 | key, | |
| 47 | keyUri: '/media/keys/episode', | |
| 48 | }) | |
| 49 | ``` | |
| 50 | ||
| 51 | Native HLS AES-128 uses Web Crypto. Proprietary DRM metadata is accepted only for already encrypted media. Publish immutable audio segments to S3 and protect manifests, keys, waveform data, transcripts, and metadata with the same CloudFront signed URL, signed cookie, or application authorization boundary. | |
| 52 | ||
| 53 | ## Default STX player | |
| 54 | ||
| 55 | ```stx | |
| 56 | <Audio | |
| 57 | src="/media/episode/index.m3u8" | |
| 58 | title="Episode 12" | |
| 59 | artist="Stacks" | |
| 60 | waveform="/media/episode/waveform.svg" | |
| 61 | :sources="progressiveFallbacks" | |
| 62 | :tracks="chapterAndTranscriptTracks" | |
| 63 | /> | |
| 64 | ``` | |
| 65 | ||
| 66 | The default UI includes play, seek, time, mute, volume, speed, settings, waveform seeking, AirPlay, and Remote Playback. Native audio controls remain available before custom elements load and when JavaScript is unavailable. | |
| @@ -0,0 +1,4 @@ | ||
| 1 | import { frameworkExternal, intro, outro, transpilePackage } from '../build/src' | |
| 2 | const { startTime } = await intro({ dir: import.meta.dir }) | |
| 3 | await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() }) | |
| 4 | await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } }) | |
| @@ -1,34 +1,23 @@ | ||
| 1 | 1 | { |
| 2 | "name": "@stacksjs/registry", | |
| 2 | "name": "@stacksjs/audio", | |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 6 | "description": "The Stacks Registry.", | |
| 5 | "version": "0.70.163", | |
| 6 | "description": "Native audio delivery planning for Stacks.", | |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | "contributors": [ | |
| 9 | "Chris Breuer <chris@stacksjs.com>" | |
| 10 | ], | |
| 11 | 8 | "license": "MIT", |
| 12 | 9 | "funding": "https://github.com/sponsors/chrisbbreuer", |
| 13 | "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme", | |
| 14 | 10 | "repository": { |
| 15 | 11 | "type": "git", |
| 16 | 12 | "url": "git+https://github.com/stacksjs/stacks.git", |
| 17 | "directory": "./storage/framework/core/registry" | |
| 18 | }, | |
| 19 | "bugs": { | |
| 20 | "url": "https://github.com/stacksjs/stacks/issues" | |
| 13 | "directory": "./storage/framework/core/audio" | |
| 21 | 14 | }, |
| 22 | "keywords": [ | |
| 23 | "registry", | |
| 24 | "stacks" | |
| 25 | ], | |
| 26 | 15 | "exports": { |
| 27 | 16 | ".": { |
| 17 | "types": "./dist/index.d.ts", | |
| 28 | 18 | "development": "./src/index.ts", |
| 29 | 19 | "bun": "./dist/index.js", |
| 30 | "import": "./dist/index.js", | |
| 31 | "default": "./dist/index.js" | |
| 20 | "import": "./dist/index.js" | |
| 32 | 21 | } |
| 33 | 22 | }, |
| 34 | 23 | "module": "dist/index.js", |
| @@ -37,14 +26,15 @@ | ||
| 37 | 26 | "README.md", |
| 38 | 27 | "dist" |
| 39 | 28 | ], |
| 40 | "dependencies": { | |
| 41 | "@stacksjs/types": "workspace:*" | |
| 42 | }, | |
| 43 | 29 | "scripts": { |
| 44 | 30 | "build": "bun build.ts", |
| 45 | 31 | "typecheck": "bun tsc --noEmit", |
| 46 | 32 | "prepublishOnly": "bun run build" |
| 47 | 33 | }, |
| 34 | "dependencies": { | |
| 35 | "@ts-audio/core": "^0.1.1", | |
| 36 | "ts-video-player": "^0.1.0" | |
| 37 | }, | |
| 48 | 38 | "devDependencies": { |
| 49 | 39 | "better-dx": "catalog:" |
| 50 | 40 | } |
| @@ -0,0 +1,122 @@ | ||
| 1 | import { createHmac, timingSafeEqual } from 'node:crypto' | |
| 2 | ||
| 3 | export type AudioFormat = 'opus' | 'aac' | 'mp3' | |
| 4 | export type AudioContent = 'speech' | 'music' | 'general' | |
| 5 | export type AudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'alac' | 'pcm_s16le' | 'pcm_s24le' | 'pcm_s32le' | 'pcm_f32le' | 'pcm_f64le' | |
| 6 | export interface AudioProfile { codec: AudioCodec, container: string, duration: number, sampleRate: number, channels: number, bitrate?: number } | |
| 7 | export interface AudioCapabilities { encoder: boolean, codecs: AudioFormat[] } | |
| 8 | export interface AudioOutput { format: AudioFormat, container: 'ogg' | 'aac' | 'mp3', mimeType: 'audio/ogg; codecs=opus' | 'audio/aac' | 'audio/mpeg', extension: 'ogg' | 'aac' | 'mp3', bitrate: number, action: 'copy' | 'transcode', available: boolean, reason?: string } | |
| 9 | export interface AudioPlan { source: string, profile: AudioProfile, outputs: AudioOutput[], loudness: number } | |
| 10 | export interface AudioProcessOptions { batchSize?: number, signal?: AbortSignal } | |
| 11 | export interface ProcessedAudioDerivative { output: AudioOutput, bytes: Uint8Array } | |
| 12 | export interface Waveform { version: 1, channels: number, sampleRate: number, duration: number, samplesPerPeak: number, peaks: number[][] } | |
| 13 | export interface TranscriptSegment { startTime: number, endTime: number, text: string, confidence?: number, speaker?: string } | |
| 14 | export interface Transcript { language: string, segments: Array<TranscriptSegment & { id: number }>, vtt: string } | |
| 15 | const details: Record<AudioFormat, Pick<AudioOutput, 'container' | 'mimeType' | 'extension'>> = { opus: { container: 'ogg', mimeType: 'audio/ogg; codecs=opus', extension: 'ogg' }, aac: { container: 'aac', mimeType: 'audio/aac', extension: 'aac' }, mp3: { container: 'mp3', mimeType: 'audio/mpeg', extension: 'mp3' } } | |
| 16 | function validate(profile: AudioProfile): void { | |
| 17 | for (const [name, value] of Object.entries({ duration: profile.duration, sampleRate: profile.sampleRate, channels: profile.channels })) { | |
| 18 | if (!Number.isFinite(value) || value <= 0) throw new TypeError(`Audio ${name} must be positive`) | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | export function recommendAudioBitrate(format: AudioFormat, source: AudioProfile, content: AudioContent): number { | |
| 23 | const channels = Math.min(2, Math.max(1, source.channels)); const rate = source.sampleRate <= 24000 ? .75 : source.sampleRate >= 88200 ? 1.15 : 1 | |
| 24 | const base = content === 'speech' ? channels === 1 ? 48000 : 64000 : content === 'music' ? channels === 1 ? 96000 : 192000 : channels === 1 ? 64000 : 128000 | |
| 25 | return Math.max(32000, Math.min(source.bitrate ?? Infinity, Math.round(base * rate * (format === 'opus' ? .8 : format === 'mp3' ? 1.2 : 1) / 1000) * 1000)) | |
| 26 | } | |
| 27 | export class AudioBuilder { | |
| 28 | private inspected?: AudioProfile; private formats: AudioFormat[] = ['opus', 'aac', 'mp3']; private contentType: AudioContent = 'general'; private targetLoudness = -16; private capabilities: AudioCapabilities = { encoder: false, codecs: [] } | |
| 29 | constructor(private readonly source: string) {} | |
| 30 | profile(value: AudioProfile): this { validate(value); this.inspected = value; return this } | |
| 31 | output(value: AudioFormat[]): this { if (!value.length) throw new TypeError('Audio formats are required'); this.formats = [...new Set(value)]; return this } | |
| 32 | content(value: AudioContent): this { this.contentType = value; return this } | |
| 33 | loudness(value: number): this { if (!Number.isFinite(value) || value < -70 || value > 0) throw new TypeError('Audio loudness must be between -70 and 0 LUFS'); this.targetLoudness = value; return this } | |
| 34 | runtime(value: AudioCapabilities): this { this.capabilities = value; return this } | |
| 35 | generate(): AudioPlan { | |
| 36 | if (!this.inspected) throw new Error('Audio inspection is required; pass its profile with .profile()') | |
| 37 | const profile = this.inspected; const outputs = this.formats.map((format): AudioOutput => { const info = details[format]; const copy = profile.codec === format && (profile.container === info.container || profile.container === info.extension); const available = copy || this.capabilities.encoder && this.capabilities.codecs.includes(format); return { format, ...info, bitrate: recommendAudioBitrate(format, profile, this.contentType), action: copy ? 'copy' : 'transcode', available, reason: available ? undefined : `Native ${format} encoding is unavailable` } }) | |
| 38 | return { source: this.source, profile, outputs, loudness: this.targetLoudness } | |
| 39 | } | |
| 40 | async process(options: AudioProcessOptions = {}): Promise<ProcessedAudioDerivative[]> { return processAudioPlan(this.generate(), options) } | |
| 41 | } | |
| 42 | export function audio(source: string): AudioBuilder { return new AudioBuilder(source) } | |
| 43 | export async function processAudioPlan(plan: AudioPlan, options: AudioProcessOptions = {}): Promise<ProcessedAudioDerivative[]> { | |
| 44 | assertAudioPlanExecutable(plan) | |
| 45 | const { generateAudioDerivatives } = await import('@ts-audio/core/native-transcode') | |
| 46 | return generateAudioDerivatives(plan.source, { source: plan.profile, outputs: plan.outputs }, options) | |
| 47 | } | |
| 48 | export function assertAudioPlanExecutable(plan: AudioPlan): void { | |
| 49 | const missing = plan.outputs.filter(value => !value.available) | |
| 50 | if (missing.length) throw new Error(missing.map(value => `${value.format}: ${value.reason}`).join('; ')) | |
| 51 | } | |
| 52 | function q(accept: string, mime: string): number { | |
| 53 | const [type = '', subtype = ''] = (mime.split(';', 1)[0] ?? '').split('/') | |
| 54 | let best = 0 | |
| 55 | for (const item of accept.split(',')) { | |
| 56 | const [range = '*/*', ...params] = item.trim().toLowerCase().split(';').map(value => value.trim()) | |
| 57 | const [a = '*', b = '*'] = range.split('/') | |
| 58 | if ((a === '*' || a === type) && (b === '*' || b === subtype)) { | |
| 59 | const raw = params.find(value => value.startsWith('q=')) | |
| 60 | best = Math.max(best, raw ? Number.parseFloat(raw.slice(2)) || 0 : 1) | |
| 61 | } | |
| 62 | } | |
| 63 | return best | |
| 64 | } | |
| 65 | export function negotiateAudioOutput(outputs: readonly AudioOutput[], accept = '*/*'): AudioOutput | undefined { | |
| 66 | return outputs.filter(value => value.available).map((output, index) => ({ output, index, q: q(accept || '*/*', output.mimeType) })).filter(value => value.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.output | |
| 67 | } | |
| 68 | export function createWaveform(channels: readonly Float32Array[], sampleRate: number, samples = 1000, precision = 4): Waveform { | |
| 69 | if (!channels.length || sampleRate <= 0) throw new TypeError('Waveform requires channels and a positive sample rate'); const frames = channels[0]!.length; if (channels.some(channel => channel.length !== frames)) throw new TypeError('Waveform channels must have equal lengths') | |
| 70 | const size = Math.max(1, Math.ceil(frames / Math.max(1, Math.min(frames || 1, Math.floor(samples))))); let absolute = 0 | |
| 71 | const raw = channels.map((channel) => { const peaks: Array<[number, number]> = []; for (let start = 0; start < frames; start += size) { let min = 1; let max = -1; for (let index = start; index < Math.min(frames, start + size); index++) { const sample = channel[index] ?? 0; const value = Number.isFinite(sample) ? Math.max(-1, Math.min(1, sample)) : 0; min = Math.min(min, value); max = Math.max(max, value); absolute = Math.max(absolute, Math.abs(value)) } peaks.push([min, max]) } return peaks }); const divisor = absolute || 1; const round = (value: number): number => Number((value / divisor).toFixed(Math.max(0, Math.min(6, precision)))) | |
| 72 | return { version: 1, channels: channels.length, sampleRate, duration: frames / sampleRate, samplesPerPeak: size, peaks: raw.map(channel => channel.flatMap(([min, max]) => [round(min), round(max)])) } | |
| 73 | } | |
| 74 | function time(seconds: number): string { | |
| 75 | const ms = Math.round(seconds * 1000) | |
| 76 | return `${String(Math.floor(ms / 3600000)).padStart(2, '0')}:${String(Math.floor(ms % 3600000 / 60000)).padStart(2, '0')}:${String(Math.floor(ms % 60000 / 1000)).padStart(2, '0')}.${String(ms % 1000).padStart(3, '0')}` | |
| 77 | } | |
| 78 | export function normalizeTranscript(language: string, input: readonly TranscriptSegment[]): Transcript { | |
| 79 | if (!language.trim()) throw new TypeError('Transcript language is required') | |
| 80 | let end = 0 | |
| 81 | const segments = input.map((item, index) => { | |
| 82 | if (item.startTime < end || item.startTime < 0 || item.endTime <= item.startTime || !item.text.trim()) throw new TypeError(`Invalid transcript segment ${index}`) | |
| 83 | if (item.confidence !== undefined && (item.confidence < 0 || item.confidence > 1)) throw new TypeError(`Invalid transcript confidence ${index}`) | |
| 84 | end = item.endTime | |
| 85 | return { ...item, text: item.text.trim(), id: index + 1 } | |
| 86 | }) | |
| 87 | const lines = ['WEBVTT', ''] | |
| 88 | for (const item of segments) lines.push(String(item.id), `${time(item.startTime)} --> ${time(item.endTime)}`, item.speaker ? `<v ${item.speaker}>${item.text}` : item.text, '') | |
| 89 | return { language: language.trim().toLowerCase(), segments, vtt: lines.join('\n') } | |
| 90 | } | |
| 91 | export function audioResponseHeaders(bytes: number, etag: string, contentType: string): Record<string, string> { | |
| 92 | return { 'Accept-Ranges': 'bytes', 'Content-Length': String(bytes), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', 'ETag': `"${etag}"`, 'Vary': 'Accept' } | |
| 93 | } | |
| 94 | export function signAudioAsset(path: string, expires: number, secret: string): string { | |
| 95 | return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url') | |
| 96 | } | |
| 97 | export function verifyAudioAsset(path: string, expires: number, signature: string, secret: string, now: number = Date.now()): boolean { if (!Number.isInteger(expires) || expires * 1000 <= now) return false; const expected = Buffer.from(signAudioAsset(path, expires, secret)); const actual = Buffer.from(signature); return expected.length === actual.length && timingSafeEqual(expected, actual) } | |
| 98 | ||
| 99 | export interface AudioSegment { uri: string, duration: number, data: Uint8Array } | |
| 100 | export interface AudioHlsProtection { key: Uint8Array, keyUri: string } | |
| 101 | export async function createProtectedAudioPlaylist(segments: AudioSegment[], protection?: AudioHlsProtection): Promise<{ playlist: string, files: Record<string, Uint8Array>, encrypted: boolean }> { | |
| 102 | if (!segments.length) throw new TypeError('Audio playlist requires segments') | |
| 103 | if (protection && /[\r\n"]/.test(protection.keyUri)) throw new TypeError('Invalid audio key URI') | |
| 104 | const files: Record<string, Uint8Array> = {}; const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map(item => item.duration)))}`, '#EXT-X-PLAYLIST-TYPE:VOD'] | |
| 105 | for (const [index, segment] of segments.entries()) { | |
| 106 | if (!Number.isFinite(segment.duration) || segment.duration <= 0 || /[\r\n"]/.test(segment.uri)) throw new TypeError(`Invalid audio segment ${index}`) | |
| 107 | let data = segment.data | |
| 108 | if (protection) { | |
| 109 | if (protection.key.byteLength !== 16) throw new TypeError('Audio HLS AES-128 key must contain 16 bytes') | |
| 110 | const iv = new Uint8Array(16) | |
| 111 | new DataView(iv.buffer).setBigUint64(8, BigInt(index)) | |
| 112 | const key = Uint8Array.from(protection.key) | |
| 113 | const input = Uint8Array.from(data) | |
| 114 | const cryptoKey = await crypto.subtle.importKey('raw', key.buffer, { name: 'AES-CBC' }, false, ['encrypt']) | |
| 115 | data = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: iv.buffer }, cryptoKey, input.buffer)) | |
| 116 | const ivHex = [...iv].map(byte => byte.toString(16).padStart(2, '0')).join('') | |
| 117 | lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${ivHex}`) | |
| 118 | } | |
| 119 | lines.push(`#EXTINF:${segment.duration.toFixed(6)},`, segment.uri); files[segment.uri] = data | |
| 120 | } | |
| 121 | lines.push('#EXT-X-ENDLIST', ''); return { playlist: lines.join('\n'), files, encrypted: !!protection } | |
| 122 | } | |
| @@ -0,0 +1,1 @@ | ||
| 1 | [2026-07-23T16:49:25.484Z] local.INFO: Building @stacksjs/audio... | |
| @@ -0,0 +1,9 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { assertAudioPlanExecutable, audio, createProtectedAudioPlaylist, createWaveform, negotiateAudioOutput, normalizeTranscript } from '../src' | |
| 3 | const profile = { codec: 'flac', container: 'flac', duration: 60, sampleRate: 48000, channels: 2, bitrate: 800000 } | |
| 4 | describe('@stacksjs/audio', () => { | |
| 5 | test('reports unavailable encoding honestly', () => { const plan = audio('episode.flac').profile(profile).content('speech').generate(); expect(plan.outputs.every(value => value.action === 'transcode' && !value.available)).toBe(true); expect(() => assertAudioPlanExecutable(plan)).toThrow(/unavailable/) }) | |
| 6 | test('negotiates formats by q-value', () => { const plan = audio('episode.flac').profile(profile).runtime({ encoder: true, codecs: ['opus', 'aac', 'mp3'] }).generate(); expect(negotiateAudioOutput(plan.outputs, 'audio/mpeg;q=.5,audio/ogg;q=1')?.format).toBe('opus') }) | |
| 7 | test('normalizes waveforms and transcripts', () => { expect(createWaveform([new Float32Array([-.5, .25, -1, .75])], 4, 2).peaks).toEqual([[-.5, .25, -1, .75]]); expect(normalizeTranscript('EN', [{ startTime: 0, endTime: 1.25, text: ' Hello ' }]).vtt).toContain('00:00:01.250') }) | |
| 8 | test('encrypts audio HLS segments', async () => { const source = new Uint8Array([1, 2, 3]); const result = await createProtectedAudioPlaylist([{ uri: 'one.m4s', duration: 4, data: source }], { key: new Uint8Array(16).fill(4), keyUri: '/keys/audio' }); expect(result.files['one.m4s']).not.toEqual(source); expect(result.playlist).toContain('METHOD=AES-128') }) | |
| 9 | }) | |
| @@ -0,0 +1,20 @@ | ||
| 1 | import { expect, mock, test } from 'bun:test' | |
| 2 | ||
| 3 | mock.module('@ts-audio/core/native-transcode', () => ({ | |
| 4 | generateAudioDerivatives: async (source: string, plan: { source: { codec: string }, outputs: unknown[] }): Promise<unknown[]> => { | |
| 5 | expect(source).toBe('episode.flac') | |
| 6 | expect(plan.source.codec).toBe('flac') | |
| 7 | expect(plan.outputs).toHaveLength(3) | |
| 8 | return [{ output: plan.outputs[0], bytes: new Uint8Array([4, 5, 6]) }] | |
| 9 | }, | |
| 10 | })) | |
| 11 | ||
| 12 | const { audio } = await import('../src') | |
| 13 | ||
| 14 | test('processes a generated audio plan through ts-audio', async () => { | |
| 15 | const result = await audio('episode.flac') | |
| 16 | .profile({ codec: 'flac', container: 'flac', duration: 60, sampleRate: 48_000, channels: 2 }) | |
| 17 | .runtime({ encoder: true, codecs: ['opus', 'aac', 'mp3'] }) | |
| 18 | .process() | |
| 19 | expect(result[0]?.bytes).toEqual(new Uint8Array([4, 5, 6])) | |
| 20 | }) | |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.138Z] local.INFO: Building @stacksjs/auth... | |
| 2 | [2026-07-23T13:13:13.364Z] local.INFO: Building @stacksjs/auth... | |
| 3 | [2026-07-23T13:13:36.585Z] local.INFO: Building @stacksjs/auth... | |
| 1 | [2026-07-23T16:49:26.226Z] local.INFO: Building @stacksjs/auth... | |
| 2 | [2026-07-23T16:49:39.106Z] local.INFO: Building @stacksjs/auth... | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@stacksjs/browser-extension", |
| 3 | 3 | "type": "module", |
| 4 | "version": "0.70.162", | |
| 4 | "version": "0.70.163", | |
| 5 | 5 | "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.", |
| 6 | 6 | "author": "Chris Breuer", |
| 7 | 7 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/browser", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Stacks core frontend/browser functionalities.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/buddy", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Meet Buddy. The Stacks runtime.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -64,7 +64,6 @@ | ||
| 64 | 64 | "types": "dist/index.d.ts", |
| 65 | 65 | "bin": { |
| 66 | 66 | "stacks": "dist/cli.js", |
| 67 | "stx": "dist/cli.js", | |
| 68 | 67 | "buddy": "dist/cli.js", |
| 69 | 68 | "bud": "dist/cli.js" |
| 70 | 69 | }, |
| @@ -92,51 +91,51 @@ | ||
| 92 | 91 | "prepublishOnly": "bun run build" |
| 93 | 92 | }, |
| 94 | 93 | "dependencies": { |
| 95 | "@stacksjs/actions": "^0.70.162", | |
| 96 | "@stacksjs/ai": "^0.70.162", | |
| 97 | "@stacksjs/alias": "^0.70.162", | |
| 98 | "@stacksjs/arrays": "^0.70.162", | |
| 99 | "@stacksjs/auth": "^0.70.162", | |
| 100 | "@stacksjs/build": "^0.70.162", | |
| 101 | "@stacksjs/cache": "^0.70.162", | |
| 102 | "@stacksjs/cli": "^0.70.162", | |
| 94 | "@stacksjs/actions": "^0.70.163", | |
| 95 | "@stacksjs/ai": "^0.70.163", | |
| 96 | "@stacksjs/alias": "^0.70.163", | |
| 97 | "@stacksjs/arrays": "^0.70.163", | |
| 98 | "@stacksjs/auth": "^0.70.163", | |
| 99 | "@stacksjs/build": "^0.70.163", | |
| 100 | "@stacksjs/cache": "^0.70.163", | |
| 101 | "@stacksjs/cli": "^0.70.163", | |
| 103 | 102 | "@stacksjs/clapp": "catalog:", |
| 104 | "@stacksjs/cloud": "^0.70.162", | |
| 105 | "@stacksjs/collections": "^0.70.162", | |
| 106 | "@stacksjs/config": "^0.70.162", | |
| 107 | "@stacksjs/database": "^0.70.162", | |
| 108 | "@stacksjs/desktop": "^0.70.162", | |
| 109 | "@stacksjs/dns": "^0.70.162", | |
| 110 | "@stacksjs/email": "^0.70.162", | |
| 111 | "@stacksjs/enums": "^0.70.162", | |
| 112 | "@stacksjs/error-handling": "^0.70.162", | |
| 113 | "@stacksjs/events": "^0.70.162", | |
| 114 | "@stacksjs/git": "^0.70.162", | |
| 103 | "@stacksjs/cloud": "^0.70.163", | |
| 104 | "@stacksjs/collections": "^0.70.163", | |
| 105 | "@stacksjs/config": "^0.70.163", | |
| 106 | "@stacksjs/database": "^0.70.163", | |
| 107 | "@stacksjs/desktop": "^0.70.163", | |
| 108 | "@stacksjs/dns": "^0.70.163", | |
| 109 | "@stacksjs/email": "^0.70.163", | |
| 110 | "@stacksjs/enums": "^0.70.163", | |
| 111 | "@stacksjs/error-handling": "^0.70.163", | |
| 112 | "@stacksjs/events": "^0.70.163", | |
| 113 | "@stacksjs/git": "^0.70.163", | |
| 115 | 114 | "@stacksjs/gitit": "^0.2.5", |
| 116 | "@stacksjs/health": "^0.70.162", | |
| 115 | "@stacksjs/health": "^0.70.163", | |
| 117 | 116 | "@stacksjs/dnsx": "^0.2.3", |
| 118 | 117 | "@stacksjs/httx": "^0.1.10", |
| 119 | "@stacksjs/lint": "^0.70.162", | |
| 120 | "@stacksjs/logging": "^0.70.162", | |
| 121 | "@stacksjs/notifications": "^0.70.162", | |
| 122 | "@stacksjs/objects": "^0.70.162", | |
| 123 | "@stacksjs/orm": "^0.70.162", | |
| 124 | "@stacksjs/path": "^0.70.162", | |
| 125 | "@stacksjs/payments": "^0.70.162", | |
| 126 | "@stacksjs/realtime": "^0.70.162", | |
| 127 | "@stacksjs/router": "^0.70.162", | |
| 118 | "@stacksjs/lint": "^0.70.163", | |
| 119 | "@stacksjs/logging": "^0.70.163", | |
| 120 | "@stacksjs/notifications": "^0.70.163", | |
| 121 | "@stacksjs/objects": "^0.70.163", | |
| 122 | "@stacksjs/orm": "^0.70.163", | |
| 123 | "@stacksjs/path": "^0.70.163", | |
| 124 | "@stacksjs/payments": "^0.70.163", | |
| 125 | "@stacksjs/realtime": "^0.70.163", | |
| 126 | "@stacksjs/router": "^0.70.163", | |
| 128 | 127 | "@stacksjs/rpx": "^0.11.31", |
| 129 | "@stacksjs/search-engine": "^0.70.162", | |
| 130 | "@stacksjs/security": "^0.70.162", | |
| 131 | "@stacksjs/server": "^0.70.162", | |
| 132 | "@stacksjs/storage": "^0.70.162", | |
| 133 | "@stacksjs/strings": "^0.70.162", | |
| 134 | "@stacksjs/testing": "^0.70.162", | |
| 135 | "@stacksjs/tunnel": "^0.70.162", | |
| 136 | "@stacksjs/types": "^0.70.162", | |
| 137 | "@stacksjs/ui": "^0.70.162", | |
| 138 | "@stacksjs/utils": "^0.70.162", | |
| 139 | "@stacksjs/validation": "^0.70.162", | |
| 128 | "@stacksjs/search-engine": "^0.70.163", | |
| 129 | "@stacksjs/security": "^0.70.163", | |
| 130 | "@stacksjs/server": "^0.70.163", | |
| 131 | "@stacksjs/storage": "^0.70.163", | |
| 132 | "@stacksjs/strings": "^0.70.163", | |
| 133 | "@stacksjs/testing": "^0.70.163", | |
| 134 | "@stacksjs/tunnel": "^0.70.163", | |
| 135 | "@stacksjs/types": "^0.70.163", | |
| 136 | "@stacksjs/ui": "^0.70.163", | |
| 137 | "@stacksjs/utils": "^0.70.163", | |
| 138 | "@stacksjs/validation": "^0.70.163", | |
| 140 | 139 | "@stacksjs/ts-cloud": "catalog:" |
| 141 | 140 | }, |
| 142 | 141 | "devDependencies": { |
| @@ -0,0 +1,54 @@ | ||
| 1 | import type { CLI, CliOptions } from '@stacksjs/types' | |
| 2 | import { mkdirSync, writeFileSync } from 'node:fs' | |
| 3 | import { dirname, resolve } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 5 | import { buildProjectContext } from '@stacksjs/ai' | |
| 6 | import { onUnknownSubcommand } from '@stacksjs/cli' | |
| 7 | ||
| 8 | export interface AiContextCommandOptions extends CliOptions { | |
| 9 | json?: boolean | |
| 10 | output?: string | |
| 11 | maxChars?: string | number | |
| 12 | model?: string | |
| 13 | } | |
| 14 | ||
| 15 | export interface AiContextCommandResult { | |
| 16 | output: string | |
| 17 | outputPath: string | null | |
| 18 | } | |
| 19 | ||
| 20 | export function generateAiContextOutput( | |
| 21 | options: AiContextCommandOptions = {}, | |
| 22 | projectRoot: string = process.cwd(), | |
| 23 | ): AiContextCommandResult { | |
| 24 | const maxChars = options.maxChars === undefined ? undefined : Number(options.maxChars) | |
| 25 | const result = buildProjectContext(projectRoot, { maxChars, model: options.model }) | |
| 26 | const output = options.json | |
| 27 | ? `${JSON.stringify(result, null, 2)}\n` | |
| 28 | : `${result.text}\n\nContext metrics:\n${JSON.stringify(result.metrics, null, 2)}\n` | |
| 29 | ||
| 30 | if (!options.output) return { output, outputPath: null } | |
| 31 | const outputPath = resolve(projectRoot, String(options.output)) | |
| 32 | mkdirSync(dirname(outputPath), { recursive: true }) | |
| 33 | writeFileSync(outputPath, output) | |
| 34 | return { output, outputPath } | |
| 35 | } | |
| 36 | ||
| 37 | export function aiContext(buddy: CLI): void { | |
| 38 | buddy | |
| 39 | .command('ai:context', 'Generate compact deterministic project context for coding models') | |
| 40 | .option('-J, --json', 'Emit the versioned machine-readable context contract', { default: false }) | |
| 41 | .option('-o, --output [path]', 'Write output to a file instead of stdout') | |
| 42 | .option('--max-chars [characters]', 'Maximum characters in the prompt context payload', { default: 4000 }) | |
| 43 | .option('--model [model]', 'Model family used for the heuristic token estimate', { default: 'gpt-4o' }) | |
| 44 | .example('buddy ai:context') | |
| 45 | .example('buddy ai:context --json') | |
| 46 | .example('buddy ai:context --json --output .stacks/ai-context.json') | |
| 47 | .action((options: AiContextCommandOptions) => { | |
| 48 | const generated = generateAiContextOutput(options) | |
| 49 | if (generated.outputPath) console.log(`Wrote AI project context to ${generated.outputPath}`) | |
| 50 | else process.stdout.write(generated.output) | |
| 51 | }) | |
| 52 | ||
| 53 | onUnknownSubcommand(buddy, 'ai:context') | |
| 54 | } | |
| @@ -569,15 +569,16 @@ function fmtDuration(secs: number): string { | ||
| 569 | 569 | } |
| 570 | 570 | |
| 571 | 571 | /** |
| 572 | * Poll `check()` until it stops throwing or the timeout elapses, emitting a | |
| 573 | * heartbeat every ~30s so a multi-minute wait never looks frozen (and, when | |
| 574 | * backgrounded, so the caller can see it is still alive). | |
| 572 | * Poll `check()` (awaited each attempt, so it may be sync or async) until it | |
| 573 | * stops throwing or the timeout elapses, emitting a heartbeat every ~30s so a | |
| 574 | * multi-minute wait never looks frozen (and, when backgrounded, so the caller | |
| 575 | * can see it is still alive). | |
| 575 | 576 | */ |
| 576 | 577 | async function pollUntil(opts: { |
| 577 | 578 | label: string |
| 578 | 579 | timeoutSecs: number |
| 579 | 580 | intervalMs?: number |
| 580 | check: () => void | |
| 581 | check: () => unknown | Promise<unknown> | |
| 581 | 582 | timeoutMessage: (elapsedSecs: number) => string |
| 582 | 583 | }): Promise<void> { |
| 583 | 584 | log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`) |
| @@ -586,7 +587,7 @@ async function pollUntil(opts: { | ||
| 586 | 587 | let lastHeartbeat = 0 |
| 587 | 588 | for (;;) { |
| 588 | 589 | try { |
| 589 | opts.check() | |
| 590 | await opts.check() | |
| 590 | 591 | return |
| 591 | 592 | } |
| 592 | 593 | catch { |
| @@ -613,33 +614,24 @@ async function pollUntil(opts: { | ||
| 613 | 614 | * TS_CLOUD_SSH_WAIT_SECS (default 480 = 8m) — SSH reachability |
| 614 | 615 | * TS_CLOUD_BOOT_WAIT_SECS (default 720 = 12m) — cloud-init + bun on PATH |
| 615 | 616 | */ |
| 616 | async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> { | |
| 617 | const { execSync } = await import('node:child_process') | |
| 618 | // Cloud providers recycle IPs, so a freshly-provisioned box often reuses an IP | |
| 619 | // whose OLD host key is still in ~/.ssh/known_hosts. `accept-new` only accepts | |
| 620 | // brand-new hosts — a *changed* key fails verification and the readiness check | |
| 621 | // wrongly reports "SSH not reachable". Ignore the known_hosts file entirely for | |
| 622 | // this ephemeral check (matches the actual deploy's SSH args). | |
| 623 | const sshArgs = [ | |
| 624 | '-o', 'StrictHostKeyChecking=no', | |
| 625 | '-o', 'UserKnownHostsFile=/dev/null', | |
| 626 | '-o', 'BatchMode=yes', | |
| 627 | '-o', 'ConnectTimeout=10', | |
| 628 | `root@${ip}`, | |
| 629 | ] | |
| 630 | ||
| 631 | const run = (remote: string): string => | |
| 632 | execSync(`ssh ${sshArgs.map(a => `'${a}'`).join(' ')} '${remote.replace(/'/g, `'\\''`)}'`, { | |
| 633 | encoding: 'utf8', | |
| 634 | stdio: ['ignore', 'pipe', verbose ? 'inherit' : 'pipe'], | |
| 635 | }) | |
| 617 | async function waitForRemoteReady(ip: string): Promise<void> { | |
| 618 | const { sshExecOrThrow } = await import('@stacksjs/ts-cloud') | |
| 619 | ||
| 620 | // Delegate the SSH exec to ts-cloud's helper. It disables host-key checking | |
| 621 | // (StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null), the same args the | |
| 622 | // real deploy uses. That also covers the recycled-IP case: cloud providers | |
| 623 | // reuse an IP whose OLD host key still sits in ~/.ssh/known_hosts, and a | |
| 624 | // changed key would otherwise fail verification and wrongly report "SSH not | |
| 625 | // reachable". ConnectTimeout=10 matches the previous inline check. | |
| 626 | const run = (remote: string): Promise<string> => | |
| 627 | sshExecOrThrow(ip, remote, { user: 'root', connectTimeoutSec: 10 }) | |
| 636 | 628 | |
| 637 | 629 | // 1) Wait for SSH to accept connections (server may still be booting). |
| 638 | 630 | const sshWaitSecs = readWaitSecs('TS_CLOUD_SSH_WAIT_SECS', 8 * 60) |
| 639 | 631 | await pollUntil({ |
| 640 | 632 | label: 'Waiting for SSH to come up', |
| 641 | 633 | timeoutSecs: sshWaitSecs, |
| 642 | check: () => { run('true') }, | |
| 634 | check: () => run('true'), | |
| 643 | 635 | timeoutMessage: elapsed => |
| 644 | 636 | `SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). ` |
| 645 | 637 | + `The box may still be booting — raise TS_CLOUD_SSH_WAIT_SECS and retry.`, |
| @@ -649,7 +641,7 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> { | ||
| 649 | 641 | // 2) Block on cloud-init, then confirm bun landed on PATH. |
| 650 | 642 | log.info('Waiting for cloud-init (installing bun + caddy)...') |
| 651 | 643 | try { |
| 652 | run('cloud-init status --wait || true') | |
| 644 | await run('cloud-init status --wait || true') | |
| 653 | 645 | } |
| 654 | 646 | catch (err) { |
| 655 | 647 | log.debug('cloud-init status --wait returned non-zero (continuing):', err) |
| @@ -659,7 +651,7 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> { | ||
| 659 | 651 | await pollUntil({ |
| 660 | 652 | label: 'Waiting for the bun runtime', |
| 661 | 653 | timeoutSecs: bootWaitSecs, |
| 662 | check: () => { run('test -x /usr/local/bin/bun') }, | |
| 654 | check: () => run('test -x /usr/local/bin/bun'), | |
| 663 | 655 | timeoutMessage: elapsed => |
| 664 | 656 | `bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). ` |
| 665 | 657 | + `cloud-init may have failed — SSH in and check /var/log/cloud-init-output.log; ` |
| @@ -1168,7 +1160,7 @@ async function runHetznerDeploy(args: { | ||
| 1168 | 1160 | process.exit(ExitCode.FatalError) |
| 1169 | 1161 | } |
| 1170 | 1162 | |
| 1171 | await waitForRemoteReady(ip, verbose) | |
| 1163 | await waitForRemoteReady(ip) | |
| 1172 | 1164 | |
| 1173 | 1165 | // Package each site as source-only: dependencies are NOT shipped. They are |
| 1174 | 1166 | // installed on the server from the committed lockfile via the site's |
| @@ -386,7 +386,8 @@ export function migrate(buddy: CLI): void { | ||
| 386 | 386 | } |
| 387 | 387 | } |
| 388 | 388 | catch (error) { |
| 389 | log.error('Failed to preview migrations:', error) | |
| 389 | // await: guarantee the error flushes before the outro + exit below. | |
| 390 | await log.error('Failed to preview migrations:', error) | |
| 390 | 391 | } |
| 391 | 392 | await outro('Diff complete — no changes applied.', { startTime: perf, useSeconds: true }) |
| 392 | 393 | process.exit(ExitCode.Success) |
| @@ -404,6 +405,9 @@ export function migrate(buddy: CLI): void { | ||
| 404 | 405 | } |
| 405 | 406 | else { |
| 406 | 407 | const APP_ENV = process.env.APP_ENV || 'local' |
| 408 | // Flush buffered async logs (e.g. the intro banner) so they paint | |
| 409 | // before this prompt instead of under it (see migrate:fresh below). | |
| 410 | await log.flush() | |
| 407 | 411 | const proceed = await confirm({ |
| 408 | 412 | message: `Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`, |
| 409 | 413 | initial: true, |
| @@ -421,7 +425,10 @@ export function migrate(buddy: CLI): void { | ||
| 421 | 425 | // the migration table by both inserting the same row name. |
| 422 | 426 | const lock = acquireMigrationLock() |
| 423 | 427 | if (!lock.acquired) { |
| 424 | log.error('Another migration is already running (.stacks/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.') | |
| 428 | // syncError, not the async log.error: process.exit below fires before | |
| 429 | // an async logger flushes, so an async call would exit 1 with no | |
| 430 | // message — leaving a stale lockfile looking like a silent crash. | |
| 431 | log.syncError('Another migration is already running (.stacks/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.') | |
| 425 | 432 | process.exit(ExitCode.FatalError) |
| 426 | 433 | } |
| 427 | 434 | |
| @@ -610,7 +617,9 @@ export function migrate(buddy: CLI): void { | ||
| 610 | 617 | |
| 611 | 618 | // Hard kill-switch — the command refuses to run at all. |
| 612 | 619 | if (guards.migrateFresh === 'disabled') { |
| 613 | log.error( | |
| 620 | // await: this carries the actionable detail (how to re-enable); the | |
| 621 | // outro one-liner below isn't enough on its own, so guarantee it flushes. | |
| 622 | await log.error( | |
| 614 | 623 | `\`buddy migrate:fresh\` is disabled by your migration safety guards (it DROPS every table).\n` |
| 615 | 624 | + ` Target: ${APP_ENV} database "${dbLabel}"\n` |
| 616 | 625 | + ` To allow it, set database.safety.migrateFresh to 'allow' in config/database.ts,\n` |
| @@ -628,12 +637,17 @@ export function migrate(buddy: CLI): void { | ||
| 628 | 637 | const hint = guards.migrateFresh === 'confirm' |
| 629 | 638 | ? 'Guard is "confirm": migrate:fresh must be run interactively.' |
| 630 | 639 | : 'Re-run with --force to drop the database non-interactively.' |
| 631 | log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`) | |
| 640 | await log.error(`Refusing to drop the ${APP_ENV} database "${dbLabel}" in a non-interactive environment. ${hint}`) | |
| 632 | 641 | await outro('migrate:fresh cancelled.', { startTime: perf, useSeconds: true }) |
| 633 | 642 | process.exit(ExitCode.FatalError) |
| 634 | 643 | } |
| 635 | 644 | |
| 636 | 645 | log.warn(`This will DROP ALL TABLES in the ${APP_ENV} database "${dbLabel}" and rebuild them from scratch. All data will be lost.`) |
| 646 | // Drain buffered async log writes (this warn + the intro banner) so they | |
| 647 | // paint BEFORE the synchronous prompt below. The clarity logger flushes | |
| 648 | // on its own tick, so without this the warning lands *under* clapp's | |
| 649 | // text() prompt and the command looks hung waiting for input. | |
| 650 | await log.flush() | |
| 637 | 651 | const typed = await text({ message: `Type the database name "${dbLabel}" to confirm (blank to cancel):` }) |
| 638 | 652 | if (typed.trim() !== dbLabel) { |
| 639 | 653 | await outro('migrate:fresh cancelled — confirmation did not match.', { startTime: perf, useSeconds: true }) |
| @@ -16,6 +16,7 @@ interface CommandLoader { | ||
| 16 | 16 | const commandRegistry: Record<string, CommandLoader> = { |
| 17 | 17 | 'about': { path: './commands/about.ts', exportName: 'about' }, |
| 18 | 18 | 'add': { path: './commands/add.ts', exportName: 'add' }, |
| 19 | 'ai:context': { path: './commands/ai-context.ts', exportName: 'aiContext' }, | |
| 19 | 20 | 'auth': { path: './commands/auth.ts', exportName: 'auth' }, |
| 20 | 21 | 'build': { path: './commands/build.ts', exportName: 'build' }, |
| 21 | 22 | 'cd': { path: './commands/cd.ts', exportName: 'cd' }, |
| @@ -0,0 +1,53 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { cli } from '@stacksjs/cli' | |
| 6 | import { aiContext, generateAiContextOutput } from '../src/commands/ai-context' | |
| 7 | import { commandInventoryEntry } from '../src/commands/list' | |
| 8 | import { shouldSkipAppKeyCheck } from '../src/project-setup' | |
| 9 | ||
| 10 | const temporaryRoots: string[] = [] | |
| 11 | ||
| 12 | function project(): string { | |
| 13 | const root = mkdtempSync(join(tmpdir(), 'buddy-ai-context-')) | |
| 14 | temporaryRoots.push(root) | |
| 15 | mkdirSync(join(root, 'app/Models'), { recursive: true }) | |
| 16 | writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'buddy-context-test' })) | |
| 17 | writeFileSync(join(root, 'app/Models/User.ts'), 'export default {}') | |
| 18 | return root | |
| 19 | } | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) | |
| 23 | }) | |
| 24 | ||
| 25 | describe('buddy ai:context', () => { | |
| 26 | it('registers JSON, output, budget, and model controls', () => { | |
| 27 | const buddy = cli('buddy') | |
| 28 | aiContext(buddy) | |
| 29 | const command = buddy.commands.find(candidate => candidate.namespace === 'ai' && candidate.name === 'context') | |
| 30 | const optionNames = command?.options.flatMap(option => option.names) | |
| 31 | ||
| 32 | expect(command && commandInventoryEntry(command).name).toBe('ai:context') | |
| 33 | expect(optionNames).toContain('json') | |
| 34 | expect(optionNames).toContain('output') | |
| 35 | expect(optionNames).toContain('maxChars') | |
| 36 | expect(optionNames).toContain('model') | |
| 37 | }) | |
| 38 | ||
| 39 | it('emits the versioned JSON contract and writes it explicitly', () => { | |
| 40 | const root = project() | |
| 41 | const target = '.stacks/ai-context.json' | |
| 42 | const generated = generateAiContextOutput({ json: true, output: target, maxChars: 1000 }, root) | |
| 43 | const outputPath = join(root, target) | |
| 44 | ||
| 45 | expect(generated.outputPath).toBe(outputPath) | |
| 46 | expect(existsSync(outputPath)).toBeTrue() | |
| 47 | expect(JSON.parse(readFileSync(outputPath, 'utf8')).context.schemaVersion).toBe('1.0.0') | |
| 48 | }) | |
| 49 | ||
| 50 | it('does not require an application key', () => { | |
| 51 | expect(shouldSkipAppKeyCheck('ai:context')).toBeTrue() | |
| 52 | }) | |
| 53 | }) | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/build", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks framework build tools and configurations.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/charts", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Chart rendering for Stacks. Built on ts-charts (D3 in TypeScript).", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/cli", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "TypeScript framework for CLI artisans. Build beautiful console apps with ease.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/cloud", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks cloud/serverless integration & implementation.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/composables", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Stacks composables powered by stx reactivity.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.146Z] local.INFO: Building @stacksjs/database... | |
| 2 | [2026-07-23T13:13:13.793Z] local.INFO: Building @stacksjs/database... | |
| 3 | [2026-07-23T13:13:36.558Z] local.INFO: Building @stacksjs/database... | |
| 1 | [2026-07-23T16:49:28.804Z] local.INFO: Building @stacksjs/database... | |
| 2 | [2026-07-23T16:49:38.960Z] local.INFO: Building @stacksjs/database... | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/defaults", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Default Stacks scaffold resources (views, layouts, components, preloader) — shipped so apps consuming the framework from node_modules get the same fallback resources a vendored checkout provides. Source of truth: storage/framework/defaults.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "license": "MIT", |
| @@ -0,0 +1,92 @@ | ||
| 1 | <script server> | |
| 2 | interface AudioSource { src: string, type?: string } | |
| 3 | interface AudioDrm { | |
| 4 | widevine?: { url: string, headers?: Record<string, string> } | |
| 5 | playready?: { url: string, headers?: Record<string, string> } | |
| 6 | fairplay?: { url: string, certificateUrl: string, headers?: Record<string, string> } | |
| 7 | } | |
| 8 | interface AudioTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean } | |
| 9 | interface AudioProps { | |
| 10 | src: string | |
| 11 | sources?: AudioSource[] | |
| 12 | drm?: AudioDrm | |
| 13 | tracks?: AudioTrack[] | |
| 14 | title?: string | |
| 15 | artist?: string | |
| 16 | artwork?: string | |
| 17 | waveform?: string | |
| 18 | controls?: boolean | |
| 19 | autoplay?: boolean | |
| 20 | loop?: boolean | |
| 21 | muted?: boolean | |
| 22 | preload?: 'none' | 'metadata' | 'auto' | |
| 23 | crossorigin?: 'anonymous' | 'use-credentials' | |
| 24 | class?: string | |
| 25 | } | |
| 26 | const props = defineProps<AudioProps>() | |
| 27 | const src = props.src || '' | |
| 28 | const sources = props.sources || [] | |
| 29 | const tracks = props.tracks || [] | |
| 30 | const title = props.title || 'Audio player' | |
| 31 | const artist = props.artist || '' | |
| 32 | const artwork = props.artwork || '' | |
| 33 | const waveform = props.waveform || '' | |
| 34 | const controls = props.controls !== false | |
| 35 | const preload = props.preload || 'metadata' | |
| 36 | const crossorigin = props.crossorigin || '' | |
| 37 | const className = props.class || '' | |
| 38 | const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source) | |
| 39 | const playerTracks = tracks.map(track => ({ ...track, language: track.srclang })) | |
| 40 | const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026') | |
| 41 | </script> | |
| 42 | ||
| 43 | <script client> | |
| 44 | import { registerElements } from 'ts-video-player/elements' | |
| 45 | registerElements() | |
| 46 | </script> | |
| 47 | ||
| 48 | <figure class="m-0 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200 dark:bg-stone-950 dark:ring-stone-800 "> | |
| 49 | <div class="flex gap-4 items-center p-4 sm:p-5"> | |
| 50 | @if(artwork) | |
| 51 | <img src="" alt="" width="72" height="72" loading="lazy" decoding="async" class="object-cover shrink-0 h-18 w-18 ring-1 ring-black/5 rounded-xl dark:ring-white/10"> | |
| 52 | @else | |
| 53 | <div class="flex items-center justify-center shrink-0 h-18 w-18 text-stone-500 dark:text-stone-400 bg-stone-100 dark:bg-stone-900 rounded-xl" aria-hidden="true"><span class="h-7 w-7 i-hugeicons-music-note-01"></span></div> | |
| 54 | @endif | |
| 55 | <div class="flex-1 min-w-0"> | |
| 56 | <figcaption class="font-semibold text-sm text-stone-950 truncate dark:text-white">{{ title }}</figcaption> | |
| 57 | @if(artist)<p class="mt-0.5 text-sm text-stone-500 truncate dark:text-stone-400">{{ artist }}</p>@endif | |
| 58 | @if(waveform)<img src="" alt="" loading="lazy" decoding="async" class="object-cover mt-3 h-10 w-full opacity-70 dark:opacity-80">@endif | |
| 59 | </div> | |
| 60 | </div> | |
| 61 | <div class="p-3 bg-stone-950 border-stone-100 border-t dark:border-stone-800"> | |
| 62 | <video-player src="" title="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" data-media-config="" @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif preload="" @if(crossorigin) crossorigin="" keep-alive aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="block overflow-hidden relative h-16 w-full text-white rounded-xl focus-within:ring-2 focus-within:ring-sky-500"> | |
| 63 | <media-error-display></media-error-display> | |
| 64 | <audio data-native-fallback src="" @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif preload="" @if(crossorigin) crossorigin="" @endif aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="block h-16 w-full accent-sky-500"> | |
| 65 | @foreach (sources as source)<source src="" @if(source.type) type="" @endif>@endforeach | |
| 66 | @foreach (tracks as track)<track src="" kind="" @if(track.srclang) srclang="" @endif @if(track.label) label="" @endif @if(track.default) default @endif>@endforeach | |
| 67 | Your browser does not support audio playback. | |
| 68 | </audio> | |
| 69 | @if(controls) | |
| 70 | <video-skin> | |
| 71 | <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button> | |
| 72 | <media-play-button slot="left" aria-label="Play or pause"></media-play-button> | |
| 73 | <media-seek-button slot="left" seconds="10" aria-label="Forward 10 seconds"></media-seek-button> | |
| 74 | <media-time-group slot="center" class="font-mono tabular-nums text-xs"></media-time-group> | |
| 75 | <media-progress-bar slot="center" @if(waveform) waveform="" @endif aria-label="Audio progress"></media-progress-bar> | |
| 76 | <media-mute-button slot="right" aria-label="Mute or unmute"></media-mute-button> | |
| 77 | <media-volume-slider slot="right" aria-label="Volume"></media-volume-slider> | |
| 78 | <media-playback-rate-button slot="right" rates="0.5,0.75,1,1.25,1.5,2" aria-label="Playback speed"></media-playback-rate-button> | |
| 79 | <media-settings-menu slot="right" aria-label="Audio settings"></media-settings-menu> | |
| 80 | <media-airplay-button slot="right" aria-label="Play on AirPlay"></media-airplay-button> | |
| 81 | <media-remote-playback-button slot="right" aria-label="Play on another device"></media-remote-playback-button> | |
| 82 | </video-skin> | |
| 83 | @endif | |
| 84 | </video-player> | |
| 85 | </div> | |
| 86 | </figure> | |
| 87 | ||
| 88 | <style> | |
| 89 | video-player:not(:defined) video-skin { display: none; } | |
| 90 | video-player:defined > [data-native-fallback] { display: none; } | |
| 91 | @media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } } | |
| 92 | </style> | |
| @@ -0,0 +1,59 @@ | ||
| 1 | <script server> | |
| 2 | interface ImageSource { type: string, srcset: string, media?: string } | |
| 3 | interface ImageProps { | |
| 4 | src: string | |
| 5 | alt: string | |
| 6 | width?: number | |
| 7 | height?: number | |
| 8 | aspectRatio?: string | |
| 9 | srcset?: string | |
| 10 | sizes?: string | |
| 11 | sources?: ImageSource[] | |
| 12 | loading?: 'lazy' | 'eager' | |
| 13 | decoding?: 'async' | 'sync' | 'auto' | |
| 14 | fetchpriority?: 'high' | 'low' | 'auto' | |
| 15 | priority?: boolean | |
| 16 | decorative?: boolean | |
| 17 | fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' | |
| 18 | position?: string | |
| 19 | placeholder?: string | |
| 20 | class?: string | |
| 21 | } | |
| 22 | const props = defineProps<ImageProps>() | |
| 23 | if (!props.decorative && !props.alt?.trim()) throw new TypeError('Image alt text is required unless decorative is true') | |
| 24 | const src = props.src || '' | |
| 25 | const alt = props.decorative ? '' : props.alt || '' | |
| 26 | const width = props.width || '' | |
| 27 | const height = props.height || '' | |
| 28 | const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '') | |
| 29 | const srcset = props.srcset || '' | |
| 30 | const sizes = props.sizes || (srcset ? '100vw' : '') | |
| 31 | const sources = props.sources || [] | |
| 32 | const loading = props.priority ? 'eager' : props.loading || 'lazy' | |
| 33 | const decoding = props.decoding || 'async' | |
| 34 | const priority = props.priority ? 'high' : props.fetchpriority || 'auto' | |
| 35 | const fit = props.fit || 'cover' | |
| 36 | const position = props.position || 'center' | |
| 37 | const placeholder = props.placeholder || '' | |
| 38 | const className = props.class || '' | |
| 39 | </script> | |
| 40 | ||
| 41 | <picture class="relative block overflow-hidden bg-stone-100 dark:bg-stone-900 " @if(ratio) style="aspect-ratio: ;" @endif> | |
| 42 | @foreach (sources as source) | |
| 43 | <source type="" srcset="" @if(source.media) media="" @endif @if(sizes) sizes="" @endif> | |
| 44 | @endforeach | |
| 45 | <img | |
| 46 | src="" | |
| 47 | alt="" | |
| 48 | @if(width) width="" @endif | |
| 49 | @if(height) height="" @endif | |
| 50 | @if(srcset) srcset="" @endif | |
| 51 | @if(sizes) sizes="" @endif | |
| 52 | loading="" | |
| 53 | decoding="" | |
| 54 | fetchpriority="" | |
| 55 | @if(props.decorative) aria-hidden="true" @endif | |
| 56 | class="h-full w-full" | |
| 57 | style="object-fit: ; object-position: ; " | |
| 58 | > | |
| 59 | </picture> | |
| @@ -0,0 +1,108 @@ | ||
| 1 | <script server> | |
| 2 | interface VideoSource { src: string, type?: string } | |
| 3 | interface VideoDrm { | |
| 4 | widevine?: { url: string, headers?: Record<string, string> } | |
| 5 | playready?: { url: string, headers?: Record<string, string> } | |
| 6 | fairplay?: { url: string, certificateUrl: string, headers?: Record<string, string> } | |
| 7 | } | |
| 8 | interface VideoTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean } | |
| 9 | interface VideoProps { | |
| 10 | src: string | |
| 11 | sources?: VideoSource[] | |
| 12 | drm?: VideoDrm | |
| 13 | tracks?: VideoTrack[] | |
| 14 | poster?: string | |
| 15 | title?: string | |
| 16 | width?: number | |
| 17 | height?: number | |
| 18 | aspectRatio?: string | |
| 19 | controls?: boolean | |
| 20 | autoplay?: boolean | |
| 21 | loop?: boolean | |
| 22 | muted?: boolean | |
| 23 | playsinline?: boolean | |
| 24 | preload?: 'none' | 'metadata' | 'auto' | |
| 25 | crossorigin?: 'anonymous' | 'use-credentials' | |
| 26 | controlslist?: string | |
| 27 | disablePictureInPicture?: boolean | |
| 28 | class?: string | |
| 29 | } | |
| 30 | const props = defineProps<VideoProps>() | |
| 31 | const src = props.src || '' | |
| 32 | const sources = props.sources || [] | |
| 33 | const tracks = props.tracks || [] | |
| 34 | const title = props.title || 'Video player' | |
| 35 | const poster = props.poster || '' | |
| 36 | const width = props.width || '' | |
| 37 | const height = props.height || '' | |
| 38 | const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '16 / 9') | |
| 39 | const controls = props.controls !== false | |
| 40 | const inline = props.playsinline !== false | |
| 41 | const preload = props.preload || 'metadata' | |
| 42 | const crossorigin = props.crossorigin || '' | |
| 43 | const controlslist = props.controlslist || 'nodownload' | |
| 44 | const className = props.class || '' | |
| 45 | const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source) | |
| 46 | const playerTracks = tracks.map(track => ({ ...track, language: track.srclang })) | |
| 47 | const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026') | |
| 48 | </script> | |
| 49 | ||
| 50 | <script client> | |
| 51 | import { registerElements } from 'ts-video-player/elements' | |
| 52 | registerElements() | |
| 53 | </script> | |
| 54 | ||
| 55 | <figure class="m-0 "> | |
| 56 | <video-player | |
| 57 | src="" | |
| 58 | data-media-config="" | |
| 59 | @if(poster) poster="" @endif | |
| 60 | @if(controls) controls @endif | |
| 61 | @if(props.autoplay) autoplay @endif | |
| 62 | @if(props.loop) loop @endif | |
| 63 | @if(props.muted) muted @endif | |
| 64 | @if(inline) playsinline @endif | |
| 65 | preload="" | |
| 66 | @if(crossorigin) crossorigin="" @endif | |
| 67 | controlslist="" | |
| 68 | @if(props.disablePictureInPicture) disable-picture-in-picture @endif | |
| 69 | keep-alive | |
| 70 | aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" | |
| 71 | class="block overflow-hidden relative w-full bg-black ring-1 ring-black/10 rounded-2xl dark:ring-white/10 focus-within:ring-2 focus-within:ring-sky-500 shadow-sm group" | |
| 72 | style="aspect-ratio: ;" | |
| 73 | > | |
| 74 | <media-error-display></media-error-display> | |
| 75 | <video data-native-fallback src="" @if(poster) poster="" @endif @if(width) width="" @endif @if(height) height="" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="" @if(crossorigin) crossorigin="" @endif controlslist="" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="object-contain h-full w-full"> | |
| 76 | @foreach (sources as source) | |
| 77 | <source src="" @if(source.type) type="" @endif> | |
| 78 | @endforeach | |
| 79 | @foreach (tracks as track) | |
| 80 | <track src="" kind="" @if(track.srclang) srclang="" @endif @if(track.label) label="" @endif @if(track.default) default @endif> | |
| 81 | @endforeach | |
| 82 | Your browser does not support video playback. | |
| 83 | </video> | |
| 84 | <video-skin> | |
| 85 | <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button> | |
| 86 | <media-play-button slot="left" aria-label="Play or pause"></media-play-button> | |
| 87 | <media-seek-button slot="left" seconds="10" aria-label="Forward 10 seconds"></media-seek-button> | |
| 88 | <media-live-button slot="left" aria-label="Go to live edge"></media-live-button> | |
| 89 | <media-time-group slot="center" class="font-mono tabular-nums text-xs"></media-time-group> | |
| 90 | <media-progress-bar slot="center" previews="metadata" aria-label="Video progress"></media-progress-bar> | |
| 91 | <media-mute-button slot="right" aria-label="Mute or unmute"></media-mute-button> | |
| 92 | <media-volume-slider slot="right" aria-label="Volume"></media-volume-slider> | |
| 93 | <media-playback-rate-button slot="right" rates="0.5,0.75,1,1.25,1.5,2" aria-label="Playback speed"></media-playback-rate-button> | |
| 94 | <media-settings-menu slot="right" aria-label="Video settings"></media-settings-menu> | |
| 95 | <media-airplay-button slot="right" aria-label="Play on AirPlay"></media-airplay-button> | |
| 96 | <media-remote-playback-button slot="right" aria-label="Play on another device"></media-remote-playback-button> | |
| 97 | <media-pip-button slot="right" aria-label="Picture in Picture"></media-pip-button> | |
| 98 | <media-fullscreen-button slot="right" aria-label="Fullscreen"></media-fullscreen-button> | |
| 99 | </video-skin> | |
| 100 | </video-player> | |
| 101 | <figcaption class="sr-only">{{ title }}</figcaption> | |
| 102 | </figure> | |
| 103 | ||
| 104 | <style> | |
| 105 | video-player:not(:defined) video-skin { display: none; } | |
| 106 | video-player:defined > [data-native-fallback] { display: none; } | |
| 107 | @media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } } | |
| 108 | </style> | |
| @@ -11,3 +11,6 @@ export { default as SidebarModern } from './Dashboard/SidebarModern.stx' | ||
| 11 | 11 | |
| 12 | 12 | // Dashboard and UI components |
| 13 | 13 | export * from './Dashboard' |
| 14 | export { default as Audio } from './Audio.stx' | |
| 15 | export { default as Image } from './Image.stx' | |
| 16 | export { default as Video } from './Video.stx' | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/email", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks Email integration. Painlessly create & manage your inboxes, templates, and send emails.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.160Z] local.INFO: Building @stacksjs/email... | |
| 2 | [2026-07-23T13:13:13.727Z] local.INFO: Building @stacksjs/email... | |
| 3 | [2026-07-23T13:13:36.506Z] local.INFO: Building @stacksjs/email... | |
| 1 | [2026-07-23T16:49:27.019Z] local.INFO: Building @stacksjs/email... | |
| 2 | [2026-07-23T16:49:39.046Z] local.INFO: Building @stacksjs/email... | |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.500Z] local.INFO: Building @stacksjs/env... | |
| 2 | [2026-07-23T13:13:14.734Z] local.INFO: Building @stacksjs/env... | |
| 3 | [2026-07-23T13:13:36.568Z] local.INFO: Building @stacksjs/env... | |
| 1 | [2026-07-23T16:49:27.028Z] local.INFO: Building @stacksjs/env... | |
| 2 | [2026-07-23T16:49:39.122Z] local.INFO: Building @stacksjs/env... | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/feature-flags", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Typed, scoped feature flags for Stacks applications.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@stacksjs/github", |
| 3 | 3 | "type": "module", |
| 4 | "version": "0.70.162", | |
| 4 | "version": "0.70.163", | |
| 5 | 5 | "description": "GitHub API client used by Stacks framework features (dashboard CI surface, notifications, runner alerts).", |
| 6 | 6 | "author": "Chris Breuer", |
| 7 | 7 | "contributors": [ |
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | export const GITHUB_API = 'https://api.github.com' |
| 2 | 2 | |
| 3 | export interface GitHubClientOptions { | |
| 4 | token?: string | |
| 5 | apiUrl?: string | |
| 6 | fetch?: typeof globalThis.fetch | |
| 7 | } | |
| 8 | ||
| 3 | 9 | function getToken(): string { |
| 4 | 10 | const token = process.env.GITHUB_TOKEN |
| 5 | 11 | if (!token) |
| @@ -7,6 +13,12 @@ function getToken(): string { | ||
| 7 | 13 | return token |
| 8 | 14 | } |
| 9 | 15 | |
| 16 | function resolveToken(token?: string): string { | |
| 17 | if (token) | |
| 18 | return token | |
| 19 | return getToken() | |
| 20 | } | |
| 21 | ||
| 10 | 22 | export function ghHeaders(): Record<string, string> { |
| 11 | 23 | return { |
| 12 | 24 | 'Authorization': `Bearer ${getToken()}`, |
| @@ -15,6 +27,70 @@ export function ghHeaders(): Record<string, string> { | ||
| 15 | 27 | } |
| 16 | 28 | } |
| 17 | 29 | |
| 30 | export function githubHeaders(token?: string): Record<string, string> { | |
| 31 | return { | |
| 32 | 'Authorization': `Bearer ${resolveToken(token)}`, | |
| 33 | 'Accept': 'application/vnd.github+json', | |
| 34 | 'X-GitHub-Api-Version': '2022-11-28', | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | function retryDelay(res: Response, attempt: number): number | null { | |
| 39 | const isRateLimit = res.status === 429 | |
| 40 | || (res.status === 403 && (res.headers.get('x-ratelimit-remaining') === '0' || !!res.headers.get('retry-after'))) | |
| 41 | if (!isRateLimit) | |
| 42 | return null | |
| 43 | const retryAfterHeader = res.headers.get('retry-after') | |
| 44 | const resetHeader = res.headers.get('x-ratelimit-reset') | |
| 45 | if (retryAfterHeader) | |
| 46 | return Number(retryAfterHeader) * 1000 | |
| 47 | if (resetHeader) | |
| 48 | return Math.max(0, Number(resetHeader) * 1000 - Date.now()) + 500 | |
| 49 | return 1000 * 2 ** attempt | |
| 50 | } | |
| 51 | ||
| 52 | /** Provider-neutral GitHub request helper for both reads and writes. */ | |
| 53 | export async function githubRequest( | |
| 54 | path: string, | |
| 55 | init: RequestInit = {}, | |
| 56 | options: GitHubClientOptions = {}, | |
| 57 | attempt = 0, | |
| 58 | ): Promise<Response> { | |
| 59 | const fetcher = options.fetch ?? globalThis.fetch | |
| 60 | const url = path.startsWith('http') ? path : `${options.apiUrl ?? GITHUB_API}${path.startsWith('/') ? path : `/${path}`}` | |
| 61 | const response = await fetcher(url, { | |
| 62 | ...init, | |
| 63 | headers: { ...githubHeaders(options.token), ...init.headers }, | |
| 64 | }) | |
| 65 | if (response.ok || attempt >= 3) | |
| 66 | return response | |
| 67 | const waitMs = retryDelay(response, attempt) | |
| 68 | if (waitMs === null) | |
| 69 | return response | |
| 70 | await new Promise(resolve => setTimeout(resolve, Math.min(waitMs, 30_000))) | |
| 71 | return githubRequest(path, init, options, attempt + 1) | |
| 72 | } | |
| 73 | ||
| 74 | export async function githubJson<T>( | |
| 75 | path: string, | |
| 76 | init: RequestInit = {}, | |
| 77 | options: GitHubClientOptions = {}, | |
| 78 | ): Promise<T> { | |
| 79 | const response = await githubRequest(path, init, options) | |
| 80 | if (!response.ok) { | |
| 81 | const payload = await response.text() | |
| 82 | let message = payload | |
| 83 | try { | |
| 84 | message = (JSON.parse(payload) as { message?: string }).message || payload | |
| 85 | } | |
| 86 | catch {} | |
| 87 | throw new Error(`GitHub API ${response.status}: ${message || response.statusText}`) | |
| 88 | } | |
| 89 | if (response.status === 204) | |
| 90 | return undefined as T | |
| 91 | return await response.json() as T | |
| 92 | } | |
| 93 | ||
| 18 | 94 | /** |
| 19 | 95 | * `fetch` against the GitHub API that retries on secondary rate limits. |
| 20 | 96 | * |
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | export { fetchBotPRCounts } from './bots' |
| 12 | export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from './client' | |
| 12 | export { ghFetch, ghHeaders, githubHeaders, githubJson, githubRequest, GITHUB_API, mapWithConcurrency } from './client' | |
| 13 | export type { GitHubClientOptions } from './client' | |
| 13 | 14 | export { clearDashboardCache, getDashboardData } from './dashboard' |
| 14 | 15 | export { detectNewlyFailedRuns } from './failure-detector' |
| 15 | 16 | export type { DetectOptions, FailedTransition, PreviousRunState } from './failure-detector' |
| @@ -25,6 +26,14 @@ export type { | ||
| 25 | 26 | RunnerSample, |
| 26 | 27 | } from './runner-pressure-detector' |
| 27 | 28 | export { fetchFailedJobs, fetchRepoStatus } from './runs' |
| 29 | export { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from './pull-requests' | |
| 30 | export type { | |
| 31 | CreatedPullRequest, | |
| 32 | CreatePullRequestWithFilesOptions, | |
| 33 | RepositoryFileChange, | |
| 34 | RepositoryTree, | |
| 35 | RepositoryTreeEntry, | |
| 36 | } from './pull-requests' | |
| 28 | 37 | export type { |
| 29 | 38 | DashboardData, |
| 30 | 39 | DashboardOptions, |
| @@ -0,0 +1,169 @@ | ||
| 1 | import type { GitHubClientOptions } from './client' | |
| 2 | import { githubJson } from './client' | |
| 3 | ||
| 4 | export interface RepositoryFileChange { | |
| 5 | path: string | |
| 6 | content: string | |
| 7 | } | |
| 8 | ||
| 9 | export interface CreatePullRequestWithFilesOptions extends GitHubClientOptions { | |
| 10 | owner: string | |
| 11 | repo: string | |
| 12 | branch: string | |
| 13 | title: string | |
| 14 | body: string | |
| 15 | commitMessage: string | |
| 16 | files: RepositoryFileChange[] | |
| 17 | base?: string | |
| 18 | draft?: boolean | |
| 19 | } | |
| 20 | ||
| 21 | export interface CreatedPullRequest { | |
| 22 | number: number | |
| 23 | url: string | |
| 24 | branch: string | |
| 25 | base: string | |
| 26 | commitSha: string | |
| 27 | } | |
| 28 | ||
| 29 | export interface RepositoryTreeEntry { | |
| 30 | path: string | |
| 31 | mode: string | |
| 32 | type: 'blob' | 'tree' | 'commit' | |
| 33 | sha: string | |
| 34 | size?: number | |
| 35 | } | |
| 36 | ||
| 37 | export interface RepositoryTree { | |
| 38 | sha: string | |
| 39 | truncated: boolean | |
| 40 | entries: RepositoryTreeEntry[] | |
| 41 | } | |
| 42 | ||
| 43 | function repoPath(owner: string, repo: string): string { | |
| 44 | if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) | |
| 45 | throw new Error('GitHub owner and repository names contain invalid characters') | |
| 46 | return `/repos/${owner}/${repo}` | |
| 47 | } | |
| 48 | ||
| 49 | function validateBranch(branch: string): void { | |
| 50 | if (!branch || branch.length > 240 || branch.startsWith('/') || branch.endsWith('/') || branch.includes('..') || /[~^:?*[\\\s]/.test(branch)) | |
| 51 | throw new Error(`Invalid Git branch name: ${branch}`) | |
| 52 | } | |
| 53 | ||
| 54 | function validateFiles(files: RepositoryFileChange[]): void { | |
| 55 | if (!files.length) | |
| 56 | throw new Error('At least one file change is required') | |
| 57 | if (files.length > 100) | |
| 58 | throw new Error('A pull request may change at most 100 files') | |
| 59 | const seen = new Set<string>() | |
| 60 | let bytes = 0 | |
| 61 | for (const file of files) { | |
| 62 | if (!file.path || file.path.startsWith('/') || file.path.includes('\\') || file.path.split('/').includes('..')) | |
| 63 | throw new Error(`Unsafe repository path: ${file.path}`) | |
| 64 | if (seen.has(file.path)) | |
| 65 | throw new Error(`Duplicate repository path: ${file.path}`) | |
| 66 | seen.add(file.path) | |
| 67 | bytes += Buffer.byteLength(file.content) | |
| 68 | } | |
| 69 | if (bytes > 5 * 1024 * 1024) | |
| 70 | throw new Error('Combined file content exceeds the 5 MiB safety limit') | |
| 71 | } | |
| 72 | ||
| 73 | export async function fetchRepositoryTree( | |
| 74 | owner: string, | |
| 75 | repo: string, | |
| 76 | ref: string, | |
| 77 | options: GitHubClientOptions = {}, | |
| 78 | ): Promise<RepositoryTree> { | |
| 79 | const payload = await githubJson<{ sha: string, truncated?: boolean, tree?: RepositoryTreeEntry[] }>( | |
| 80 | `${repoPath(owner, repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`, | |
| 81 | {}, | |
| 82 | options, | |
| 83 | ) | |
| 84 | return { sha: payload.sha, truncated: !!payload.truncated, entries: payload.tree ?? [] } | |
| 85 | } | |
| 86 | ||
| 87 | export async function fetchRepositoryFile( | |
| 88 | owner: string, | |
| 89 | repo: string, | |
| 90 | path: string, | |
| 91 | ref: string, | |
| 92 | options: GitHubClientOptions & { maxBytes?: number } = {}, | |
| 93 | ): Promise<string> { | |
| 94 | validateFiles([{ path, content: '' }]) | |
| 95 | const payload = await githubJson<{ content?: string, encoding?: string, size?: number, type?: string }>( | |
| 96 | `${repoPath(owner, repo)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`, | |
| 97 | {}, | |
| 98 | options, | |
| 99 | ) | |
| 100 | if (payload.type !== 'file' || payload.encoding !== 'base64' || typeof payload.content !== 'string') | |
| 101 | throw new Error(`GitHub path is not a base64 encoded file: ${path}`) | |
| 102 | const maxBytes = options.maxBytes ?? 256 * 1024 | |
| 103 | if ((payload.size ?? 0) > maxBytes) | |
| 104 | throw new Error(`GitHub file exceeds the ${maxBytes} byte safety limit: ${path}`) | |
| 105 | const content = Buffer.from(payload.content.replace(/\n/g, ''), 'base64') | |
| 106 | if (content.byteLength > maxBytes) | |
| 107 | throw new Error(`Decoded GitHub file exceeds the ${maxBytes} byte safety limit: ${path}`) | |
| 108 | return content.toString('utf8') | |
| 109 | } | |
| 110 | ||
| 111 | /** | |
| 112 | * Create one commit containing all file replacements, expose it through a new | |
| 113 | * branch, and open a pull request. The branch is only created after every blob, | |
| 114 | * tree, and commit has succeeded, so partial generation never exposes half a fix. | |
| 115 | */ | |
| 116 | export async function createPullRequestWithFiles(options: CreatePullRequestWithFilesOptions): Promise<CreatedPullRequest> { | |
| 117 | validateBranch(options.branch) | |
| 118 | validateFiles(options.files) | |
| 119 | const basePath = repoPath(options.owner, options.repo) | |
| 120 | const client: GitHubClientOptions = { token: options.token, apiUrl: options.apiUrl, fetch: options.fetch } | |
| 121 | ||
| 122 | let base = options.base | |
| 123 | if (!base) { | |
| 124 | const repository = await githubJson<{ default_branch: string }>(basePath, {}, client) | |
| 125 | base = repository.default_branch | |
| 126 | } | |
| 127 | if (!base) | |
| 128 | throw new Error('GitHub repository has no default branch') | |
| 129 | ||
| 130 | const reference = await githubJson<{ object: { sha: string } }>( | |
| 131 | `${basePath}/git/ref/heads/${encodeURIComponent(base)}`, | |
| 132 | {}, | |
| 133 | client, | |
| 134 | ) | |
| 135 | const baseCommit = await githubJson<{ tree: { sha: string } }>(`${basePath}/git/commits/${reference.object.sha}`, {}, client) | |
| 136 | ||
| 137 | const blobs = await Promise.all(options.files.map(async (file) => { | |
| 138 | const blob = await githubJson<{ sha: string }>(`${basePath}/git/blobs`, { | |
| 139 | method: 'POST', | |
| 140 | body: JSON.stringify({ content: file.content, encoding: 'utf-8' }), | |
| 141 | }, client) | |
| 142 | return { path: file.path, mode: '100644', type: 'blob', sha: blob.sha } | |
| 143 | })) | |
| 144 | ||
| 145 | const tree = await githubJson<{ sha: string }>(`${basePath}/git/trees`, { | |
| 146 | method: 'POST', | |
| 147 | body: JSON.stringify({ base_tree: baseCommit.tree.sha, tree: blobs }), | |
| 148 | }, client) | |
| 149 | const commit = await githubJson<{ sha: string }>(`${basePath}/git/commits`, { | |
| 150 | method: 'POST', | |
| 151 | body: JSON.stringify({ message: options.commitMessage, tree: tree.sha, parents: [reference.object.sha] }), | |
| 152 | }, client) | |
| 153 | await githubJson(`${basePath}/git/refs`, { | |
| 154 | method: 'POST', | |
| 155 | body: JSON.stringify({ ref: `refs/heads/${options.branch}`, sha: commit.sha }), | |
| 156 | }, client) | |
| 157 | const pull = await githubJson<{ number: number, html_url: string }>(`${basePath}/pulls`, { | |
| 158 | method: 'POST', | |
| 159 | body: JSON.stringify({ | |
| 160 | title: options.title, | |
| 161 | body: options.body, | |
| 162 | head: options.branch, | |
| 163 | base, | |
| 164 | draft: options.draft ?? true, | |
| 165 | }), | |
| 166 | }, client) | |
| 167 | ||
| 168 | return { number: pull.number, url: pull.html_url, branch: options.branch, base, commitSha: commit.sha } | |
| 169 | } | |
| @@ -0,0 +1,76 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from '../src/pull-requests' | |
| 3 | ||
| 4 | function json(value: unknown, status = 200): Response { | |
| 5 | return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } }) | |
| 6 | } | |
| 7 | ||
| 8 | describe('GitHub pull request workflow', () => { | |
| 9 | test('creates a single commit, branch, and draft pull request', async () => { | |
| 10 | const calls: Array<{ url: string, method: string, body: any }> = [] | |
| 11 | const responses = [ | |
| 12 | json({ default_branch: 'main' }), | |
| 13 | json({ object: { sha: 'base-sha' } }), | |
| 14 | json({ tree: { sha: 'base-tree' } }), | |
| 15 | json({ sha: 'blob-a' }), | |
| 16 | json({ sha: 'blob-b' }), | |
| 17 | json({ sha: 'new-tree' }), | |
| 18 | json({ sha: 'commit-sha' }), | |
| 19 | json({ ref: 'refs/heads/bughq/fix-1' }, 201), | |
| 20 | json({ number: 42, html_url: 'https://github.com/stacksjs/bughq/pull/42' }, 201), | |
| 21 | ] | |
| 22 | const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { | |
| 23 | calls.push({ | |
| 24 | url: String(input), | |
| 25 | method: init?.method ?? 'GET', | |
| 26 | body: init?.body ? JSON.parse(String(init.body)) : undefined, | |
| 27 | }) | |
| 28 | return responses.shift()! | |
| 29 | }) as typeof fetch | |
| 30 | ||
| 31 | const result = await createPullRequestWithFiles({ | |
| 32 | owner: 'stacksjs', | |
| 33 | repo: 'bughq', | |
| 34 | branch: 'bughq/fix-1', | |
| 35 | title: 'fix: prevent crash', | |
| 36 | body: 'AI generated fix', | |
| 37 | commitMessage: 'fix: prevent crash', | |
| 38 | files: [ | |
| 39 | { path: 'src/a.ts', content: 'export const a = 1\n' }, | |
| 40 | { path: 'src/b.ts', content: 'export const b = 2\n' }, | |
| 41 | ], | |
| 42 | token: 'test-token', | |
| 43 | fetch: fetcher, | |
| 44 | }) | |
| 45 | ||
| 46 | expect(result).toEqual({ | |
| 47 | number: 42, | |
| 48 | url: 'https://github.com/stacksjs/bughq/pull/42', | |
| 49 | branch: 'bughq/fix-1', | |
| 50 | base: 'main', | |
| 51 | commitSha: 'commit-sha', | |
| 52 | }) | |
| 53 | expect(calls.filter(call => call.url.endsWith('/git/blobs'))).toHaveLength(2) | |
| 54 | expect(calls.at(-2)?.body).toEqual({ ref: 'refs/heads/bughq/fix-1', sha: 'commit-sha' }) | |
| 55 | expect(calls.at(-1)?.body).toMatchObject({ draft: true, head: 'bughq/fix-1', base: 'main' }) | |
| 56 | }) | |
| 57 | ||
| 58 | test('reads repository trees and base64 files', async () => { | |
| 59 | const responses = [ | |
| 60 | json({ sha: 'tree', truncated: false, tree: [{ path: 'src/a.ts', mode: '100644', type: 'blob', sha: 'a', size: 12 }] }), | |
| 61 | json({ type: 'file', encoding: 'base64', content: Buffer.from('hello\n').toString('base64'), size: 6 }), | |
| 62 | ] | |
| 63 | const fetcher = (async () => responses.shift()!) as typeof fetch | |
| 64 | const tree = await fetchRepositoryTree('stacksjs', 'bughq', 'main', { token: 'test', fetch: fetcher }) | |
| 65 | const file = await fetchRepositoryFile('stacksjs', 'bughq', 'src/a.ts', 'main', { token: 'test', fetch: fetcher }) | |
| 66 | expect(tree.entries[0]?.path).toBe('src/a.ts') | |
| 67 | expect(file).toBe('hello\n') | |
| 68 | }) | |
| 69 | ||
| 70 | test('rejects unsafe paths before making a request', async () => { | |
| 71 | await expect(createPullRequestWithFiles({ | |
| 72 | owner: 'stacksjs', repo: 'bughq', branch: 'fix', title: 'x', body: 'x', commitMessage: 'x', | |
| 73 | files: [{ path: '../secret', content: 'x' }], token: 'test', | |
| 74 | })).rejects.toThrow('Unsafe repository path') | |
| 75 | }) | |
| 76 | }) | |
| @@ -0,0 +1,74 @@ | ||
| 1 | # @stacksjs/image | |
| 2 | ||
| 3 | Native responsive image generation for Stacks, powered by `ts-images`. | |
| 4 | ||
| 5 | ## Generate responsive images | |
| 6 | ||
| 7 | ```ts | |
| 8 | import { image } from '@stacksjs/image' | |
| 9 | ||
| 10 | const manifest = await image('uploads/hero.jpg', { | |
| 11 | root: process.cwd(), | |
| 12 | outputDir: 'public/media/images', | |
| 13 | publicPath: '/media/images', | |
| 14 | }) | |
| 15 | .preset('hero') | |
| 16 | .formats(['avif', 'webp', 'jpeg']) | |
| 17 | .quality(82) | |
| 18 | .generate() | |
| 19 | ``` | |
| 20 | ||
| 21 | Presets cover `avatar`, `content`, `hero`, and `thumbnail`. Custom transforms can use `.widths()`, `.height()`, `.aspectRatio()`, `.fit()`, and `.position()`. Source-sized output is included where appropriate, and generation never upscales unless `upscale: true` is explicit. | |
| 22 | ||
| 23 | ## S3-compatible storage and CloudFront | |
| 24 | ||
| 25 | Pass any structural storage adapter. The adapter can wrap Stacks storage, S3, R2, B2, or another S3-compatible provider. | |
| 26 | ||
| 27 | ```ts | |
| 28 | const manifest = await image('uploads/avatar.png', { | |
| 29 | authorize: (_path, request) => request.user.can('view-media'), | |
| 30 | authorizationContext: request, | |
| 31 | }) | |
| 32 | .preset('avatar') | |
| 33 | .storage({ | |
| 34 | fileExists: key => disk.exists(key), | |
| 35 | write: async (key, contents) => { | |
| 36 | await disk.put(key, contents) | |
| 37 | return { size: contents.byteLength } | |
| 38 | }, | |
| 39 | stat: async key => ({ size: (await disk.stat(key)).size }), | |
| 40 | publicUrl: key => disk.url(key), | |
| 41 | }) | |
| 42 | .generate() | |
| 43 | ``` | |
| 44 | ||
| 45 | The returned filenames are content-addressed and safe for an immutable CloudFront behavior. Use a short-lived signed transform for private originals: | |
| 46 | ||
| 47 | ```ts | |
| 48 | import { signImageTransform, verifyImageTransform } from '@stacksjs/image' | |
| 49 | ||
| 50 | const expires = Math.floor(Date.now() / 1000) + 300 | |
| 51 | const signature = signImageTransform('/private/hero.jpg?w=1280&format=avif', expires, secret) | |
| 52 | ||
| 53 | if (!verifyImageTransform(requestPath, expires, signature, secret)) | |
| 54 | throw new Response('Forbidden', { status: 403 }) | |
| 55 | ``` | |
| 56 | ||
| 57 | ## STX component and art direction | |
| 58 | ||
| 59 | ```stx | |
| 60 | <Image | |
| 61 | src="" | |
| 62 | alt="A product on a workbench" | |
| 63 | width="" | |
| 64 | height="" | |
| 65 | :sources="[ | |
| 66 | { media: '(min-width: 960px)', type: 'image/avif', srcset: desktopAvif }, | |
| 67 | { media: '(max-width: 959px)', type: 'image/webp', srcset: mobileWebp }, | |
| 68 | ]" | |
| 69 | sizes="(max-width: 959px) 100vw, 50vw" | |
| 70 | priority | |
| 71 | /> | |
| 72 | ``` | |
| 73 | ||
| 74 | Use `decorative` instead of empty alt text for decorative images. The component renders responsive, layout-stable HTML without requiring client JavaScript. | |
| @@ -0,0 +1,4 @@ | ||
| 1 | import { frameworkExternal, intro, outro, transpilePackage } from '../build/src' | |
| 2 | const { startTime } = await intro({ dir: import.meta.dir }) | |
| 3 | await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() }) | |
| 4 | await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } }) | |
| @@ -1,34 +1,23 @@ | ||
| 1 | 1 | { |
| 2 | "name": "@stacksjs/registry", | |
| 2 | "name": "@stacksjs/image", | |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 6 | "description": "The Stacks Registry.", | |
| 5 | "version": "0.70.163", | |
| 6 | "description": "Native responsive image delivery for Stacks.", | |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | "contributors": [ | |
| 9 | "Chris Breuer <chris@stacksjs.com>" | |
| 10 | ], | |
| 11 | 8 | "license": "MIT", |
| 12 | 9 | "funding": "https://github.com/sponsors/chrisbbreuer", |
| 13 | "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme", | |
| 14 | 10 | "repository": { |
| 15 | 11 | "type": "git", |
| 16 | 12 | "url": "git+https://github.com/stacksjs/stacks.git", |
| 17 | "directory": "./storage/framework/core/registry" | |
| 18 | }, | |
| 19 | "bugs": { | |
| 20 | "url": "https://github.com/stacksjs/stacks/issues" | |
| 13 | "directory": "./storage/framework/core/image" | |
| 21 | 14 | }, |
| 22 | "keywords": [ | |
| 23 | "registry", | |
| 24 | "stacks" | |
| 25 | ], | |
| 26 | 15 | "exports": { |
| 27 | 16 | ".": { |
| 17 | "types": "./dist/index.d.ts", | |
| 28 | 18 | "development": "./src/index.ts", |
| 29 | 19 | "bun": "./dist/index.js", |
| 30 | "import": "./dist/index.js", | |
| 31 | "default": "./dist/index.js" | |
| 20 | "import": "./dist/index.js" | |
| 32 | 21 | } |
| 33 | 22 | }, |
| 34 | 23 | "module": "dist/index.js", |
| @@ -37,14 +26,14 @@ | ||
| 37 | 26 | "README.md", |
| 38 | 27 | "dist" |
| 39 | 28 | ], |
| 40 | "dependencies": { | |
| 41 | "@stacksjs/types": "workspace:*" | |
| 42 | }, | |
| 43 | 29 | "scripts": { |
| 44 | 30 | "build": "bun build.ts", |
| 45 | 31 | "typecheck": "bun tsc --noEmit", |
| 46 | 32 | "prepublishOnly": "bun run build" |
| 47 | 33 | }, |
| 34 | "dependencies": { | |
| 35 | "ts-images": "^0.2.1" | |
| 36 | }, | |
| 48 | 37 | "devDependencies": { |
| 49 | 38 | "better-dx": "catalog:" |
| 50 | 39 | } |
| @@ -0,0 +1,146 @@ | ||
| 1 | import type { ImageData, ResizeFit } from 'ts-images' | |
| 2 | import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto' | |
| 3 | import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises' | |
| 4 | import { basename, extname, isAbsolute, relative, resolve } from 'node:path' | |
| 5 | import { decode, encode, imageToSplatHash, resize } from 'ts-images' | |
| 6 | ||
| 7 | export type ImageFormat = 'avif' | 'webp' | 'jpeg' | 'png' | |
| 8 | export interface ImageVariant { width: number, height: number, bytes: number, format: ImageFormat, mimeType: string, path: string, url: string, cacheKey: string } | |
| 9 | export interface ImageManifest { source: { width: number, height: number, hash: string }, variants: ImageVariant[], placeholder: string } | |
| 10 | export type ImagePreset = 'avatar' | 'content' | 'hero' | 'thumbnail' | |
| 11 | export interface ImageStorageAdapter { fileExists: (_path: string) => Promise<boolean>, write: (_path: string, _contents: Uint8Array) => Promise<{ size: number }>, stat: (_path: string) => Promise<{ size: number }>, publicUrl: (_path: string) => Promise<string> } | |
| 12 | export interface ImageOptions { root?: string, outputDir?: string, publicPath?: string, concurrency?: number, upscale?: boolean, authorize?: (_source: string, _context?: unknown) => boolean | Promise<boolean>, authorizationContext?: unknown } | |
| 13 | ||
| 14 | const mime: Record<ImageFormat, string> = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', png: 'image/png' } | |
| 15 | function integer(name: string, value: number, min: number, max: number): void { | |
| 16 | if (!Number.isInteger(value) || value < min || value > max) throw new TypeError(`${name} must be between ${min} and ${max}`) | |
| 17 | } | |
| 18 | ||
| 19 | export function resolveImageSource(source: string, root: string = process.cwd()): string { | |
| 20 | const allowed = resolve(root) | |
| 21 | const candidate = isAbsolute(source) ? resolve(source) : resolve(allowed, source) | |
| 22 | const relation = relative(allowed, candidate) | |
| 23 | if (source.includes('\0') || relation === '..' || relation.startsWith('../') || isAbsolute(relation)) throw new Error('Image source must stay inside the configured root') | |
| 24 | return candidate | |
| 25 | } | |
| 26 | ||
| 27 | export class ImageBuilder { | |
| 28 | private targetWidths = [480, 768, 1280, 1920] | |
| 29 | private targetFormats: ImageFormat[] = ['avif', 'webp', 'jpeg'] | |
| 30 | private targetFit: ResizeFit = 'inside' | |
| 31 | private targetHeight?: number | |
| 32 | private targetAspectRatio?: number | |
| 33 | private targetPosition: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'center' | |
| 34 | private includeOriginal = true | |
| 35 | private targetStorage?: { adapter: ImageStorageAdapter, prefix: string } | |
| 36 | private targetQuality = 82 | |
| 37 | private options: ImageOptions & { root: string, outputDir: string, publicPath: string, concurrency: number, upscale: boolean } | |
| 38 | ||
| 39 | constructor(private readonly source: string, options: ImageOptions = {}) { | |
| 40 | this.options = { ...options, root: options.root ?? process.cwd(), outputDir: options.outputDir ?? resolve('public/media/images'), publicPath: options.publicPath ?? '/media/images', concurrency: options.concurrency ?? 4, upscale: options.upscale ?? false } | |
| 41 | } | |
| 42 | ||
| 43 | widths(widths: number[]): this { if (!widths.length) throw new TypeError('Image widths are required'); widths.forEach(value => integer('Image width', value, 1, 16_384)); this.targetWidths = [...new Set(widths)].sort((a, b) => a - b); return this } | |
| 44 | formats(formats: ImageFormat[]): this { if (!formats.length) throw new TypeError('Image formats are required'); this.targetFormats = [...new Set(formats)]; return this } | |
| 45 | fit(fit: ResizeFit): this { this.targetFit = fit; return this } | |
| 46 | height(value: number): this { integer('Image height', value, 1, 16_384); this.targetHeight = value; this.targetAspectRatio = undefined; return this } | |
| 47 | aspectRatio(value: number): this { if (!Number.isFinite(value) || value <= 0 || value > 100) throw new TypeError('Image aspect ratio must be between 0 and 100'); this.targetAspectRatio = value; this.targetHeight = undefined; return this } | |
| 48 | position(value: typeof this.targetPosition): this { this.targetPosition = value; return this } | |
| 49 | preset(value: ImagePreset): this { | |
| 50 | const presets = { avatar: { widths: [64, 128, 256, 512], ratio: 1, fit: 'cover' as const, original: false }, content: { widths: [320, 640, 960, 1280, 1920], fit: 'inside' as const, original: true }, hero: { widths: [640, 1280, 1920, 2560], ratio: 16 / 9, fit: 'cover' as const, original: true }, thumbnail: { widths: [160, 320, 640], ratio: 16 / 9, fit: 'cover' as const, original: false } } as const | |
| 51 | const preset = presets[value] | |
| 52 | this.targetWidths = [...preset.widths] | |
| 53 | this.targetFit = preset.fit | |
| 54 | this.targetAspectRatio = 'ratio' in preset ? preset.ratio : undefined | |
| 55 | this.targetHeight = undefined | |
| 56 | this.includeOriginal = preset.original | |
| 57 | return this | |
| 58 | } | |
| 59 | quality(value: number): this { integer('Image quality', value, 1, 100); this.targetQuality = value; return this } | |
| 60 | output(dir: string, publicPath = '/media/images'): this { this.options.outputDir = resolve(dir); this.options.publicPath = `/${publicPath.replace(/^\/+|\/+$/g, '')}`; return this } | |
| 61 | storage(adapter: ImageStorageAdapter, prefix = 'media/images'): this { this.targetStorage = { adapter, prefix: prefix.replace(/^\/+|\/+$/g, '') }; return this } | |
| 62 | ||
| 63 | async generate(): Promise<ImageManifest> { | |
| 64 | integer('Image concurrency', this.options.concurrency, 1, 32) | |
| 65 | const sourcePath = resolveImageSource(this.source, this.options.root) | |
| 66 | if (this.options.authorize && !await this.options.authorize(sourcePath, this.options.authorizationContext)) throw new Error('Image delivery is not authorized') | |
| 67 | const bytes = new Uint8Array(await readFile(sourcePath)) | |
| 68 | const hash = createHash('sha256').update(bytes).digest('hex') | |
| 69 | const decoded = await decode(bytes) | |
| 70 | const widths = this.targetWidths.filter(width => this.options.upscale || width <= decoded.width) | |
| 71 | if (this.includeOriginal && !widths.includes(decoded.width)) widths.push(decoded.width) | |
| 72 | const tasks = [...new Set(widths)].sort((a, b) => a - b).flatMap(width => this.targetFormats.map(format => ({ width, format }))) | |
| 73 | const variants: ImageVariant[] = [] | |
| 74 | let cursor = 0 | |
| 75 | await Promise.all(Array.from({ length: Math.min(tasks.length, this.options.concurrency) }, async () => { | |
| 76 | while (cursor < tasks.length) { | |
| 77 | const task = tasks[cursor++]! | |
| 78 | variants.push(await this.variant(decoded, hash, sourcePath, task.width, task.format)) | |
| 79 | } | |
| 80 | })) | |
| 81 | variants.sort((a, b) => a.width - b.width || this.targetFormats.indexOf(a.format) - this.targetFormats.indexOf(b.format)) | |
| 82 | return { source: { width: decoded.width, height: decoded.height, hash }, variants, placeholder: Buffer.from(imageToSplatHash(decoded)).toString('base64url') } | |
| 83 | } | |
| 84 | ||
| 85 | private async variant(source: ImageData, hash: string, sourcePath: string, width: number, format: ImageFormat): Promise<ImageVariant> { | |
| 86 | const height = this.targetHeight ?? (this.targetAspectRatio ? Math.max(1, Math.round(width / this.targetAspectRatio)) : undefined) | |
| 87 | const output = width === source.width && height === undefined ? source : resize(source, { width, height, fit: this.targetFit, position: this.targetPosition }) | |
| 88 | if (!this.options.upscale && (output.width > source.width || output.height > source.height)) throw new TypeError(`Image variant ${output.width}x${output.height} would upscale the source`) | |
| 89 | const cacheKey = createHash('sha256').update(`${hash}:${width}:${height ?? 'auto'}:${format}:${this.targetFit}:${this.targetPosition}:${this.targetQuality}`).digest('hex') | |
| 90 | const stem = basename(sourcePath, extname(sourcePath)).replace(/[^a-zA-Z0-9_-]/g, '-') || 'image' | |
| 91 | const filename = `${stem}-${output.width}x${output.height}-${cacheKey.slice(0, 16)}.${format === 'jpeg' ? 'jpg' : format}` | |
| 92 | const path = resolve(this.options.outputDir, filename) | |
| 93 | if (this.targetStorage) { | |
| 94 | const key = this.targetStorage.prefix ? `${this.targetStorage.prefix}/${filename}` : filename | |
| 95 | if (await this.targetStorage.adapter.fileExists(key)) { | |
| 96 | const existing = await this.targetStorage.adapter.stat(key) | |
| 97 | return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey } | |
| 98 | } | |
| 99 | const encoded = await encode(output, format, { quality: this.targetQuality, progressive: true }) | |
| 100 | const written = await this.targetStorage.adapter.write(key, encoded) | |
| 101 | return { width: output.width, height: output.height, bytes: written.size, format, mimeType: mime[format], path: key, url: await this.targetStorage.adapter.publicUrl(key), cacheKey } | |
| 102 | } | |
| 103 | const existing = await stat(path).catch(() => null) | |
| 104 | if (existing) return { width: output.width, height: output.height, bytes: existing.size, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey } | |
| 105 | const encoded = await encode(output, format, { quality: this.targetQuality, progressive: true }) | |
| 106 | await mkdir(this.options.outputDir, { recursive: true }) | |
| 107 | const temporary = `${path}.${process.pid}.${randomUUID()}.tmp` | |
| 108 | await writeFile(temporary, encoded) | |
| 109 | await rename(temporary, path) | |
| 110 | return { width: output.width, height: output.height, bytes: encoded.byteLength, format, mimeType: mime[format], path, url: `${this.options.publicPath}/${filename}`, cacheKey } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | export function image(source: string, options: ImageOptions = {}): ImageBuilder { return new ImageBuilder(source, options) } | |
| 115 | ||
| 116 | function accepted(accept: string, mimeType: string): number { | |
| 117 | const [type, subtype] = mimeType.split('/') | |
| 118 | let best = 0 | |
| 119 | for (const item of accept.split(',')) { | |
| 120 | const [range = '*/*', ...params] = item.trim().toLowerCase().split(';').map(value => value.trim()) | |
| 121 | const [acceptedType, acceptedSubtype] = range.split('/') | |
| 122 | if (acceptedType !== '*' && acceptedType !== type || acceptedSubtype !== '*' && acceptedSubtype !== subtype) continue | |
| 123 | const raw = params.find(param => param.startsWith('q=')) | |
| 124 | best = Math.max(best, raw ? Number.parseFloat(raw.slice(2)) || 0 : 1) | |
| 125 | } | |
| 126 | return best | |
| 127 | } | |
| 128 | ||
| 129 | export function negotiateImageVariant(variants: readonly ImageVariant[], accept = '*/*', width?: number): ImageVariant | undefined { | |
| 130 | const widths = [...new Set(variants.map(item => item.width))].sort((a, b) => a - b) | |
| 131 | const target = width === undefined ? widths.at(-1) : widths.find(value => value >= width) ?? widths.at(-1) | |
| 132 | return variants.filter(item => item.width === target).map((variant, index) => ({ variant, index, q: accepted(accept || '*/*', variant.mimeType) })).filter(item => item.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.variant | |
| 133 | } | |
| 134 | ||
| 135 | export function imageResponseHeaders(variant: ImageVariant): Record<string, string> { | |
| 136 | return { 'Content-Type': variant.mimeType, 'Content-Length': String(variant.bytes), 'Cache-Control': 'public, max-age=31536000, immutable', 'ETag': `"${variant.cacheKey}"`, 'Vary': 'Accept', 'X-Image-Width': String(variant.width), 'X-Image-Height': String(variant.height) } | |
| 137 | } | |
| 138 | ||
| 139 | export function signImageTransform(path: string, expires: number, secret: string): string { | |
| 140 | return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url') | |
| 141 | } | |
| 142 | export function verifyImageTransform(path: string, expires: number, signature: string, secret: string, now: number = Date.now()): boolean { | |
| 143 | if (!Number.isInteger(expires) || expires * 1000 <= now) return false | |
| 144 | const expected = Buffer.from(signImageTransform(path, expires, secret)); const actual = Buffer.from(signature) | |
| 145 | return expected.length === actual.length && timingSafeEqual(expected, actual) | |
| 146 | } | |
| @@ -0,0 +1,1 @@ | ||
| 1 | [2026-07-23T16:49:26.955Z] local.INFO: Building @stacksjs/image... | |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { afterEach, describe, expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { createImageData, encode } from 'ts-images' | |
| 6 | import { image, negotiateImageVariant, resolveImageSource, signImageTransform, verifyImageTransform } from '../src' | |
| 7 | const dirs: string[] = [] | |
| 8 | afterEach(async () => Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))) | |
| 9 | ||
| 10 | describe('@stacksjs/image', () => { | |
| 11 | test('generates content-addressed variants without upscaling', async () => { | |
| 12 | const root = await mkdtemp(join(tmpdir(), 'stacks-image-')); dirs.push(root) | |
| 13 | const input = createImageData(4, 2); input.data.fill(255); await writeFile(join(root, 'hero.png'), await encode(input, 'png')) | |
| 14 | const result = await image('hero.png', { root, outputDir: join(root, 'out') }).widths([2, 8]).formats(['webp', 'png']).generate() | |
| 15 | expect(result.variants.map(item => item.width)).toEqual([2, 2, 4, 4]) | |
| 16 | expect(result.placeholder.length).toBeGreaterThan(10) | |
| 17 | expect((await readFile(result.variants[0].path)).length).toBeGreaterThan(0) | |
| 18 | }) | |
| 19 | test('negotiates q-values, rejects traversal, and signs transforms', () => { | |
| 20 | const variants = [{ width: 800, height: 450, bytes: 10, format: 'avif' as const, mimeType: 'image/avif', path: '/a', url: '/a', cacheKey: 'a' }, { width: 800, height: 450, bytes: 12, format: 'jpeg' as const, mimeType: 'image/jpeg', path: '/b', url: '/b', cacheKey: 'b' }] | |
| 21 | expect(negotiateImageVariant(variants, 'image/jpeg;q=.5,image/avif;q=1')?.format).toBe('avif') | |
| 22 | expect(() => resolveImageSource('../private.jpg', '/srv/uploads')).toThrow(/inside/) | |
| 23 | const expires = Math.floor(Date.now() / 1000) + 60; const signature = signImageTransform('/hero', expires, 'secret') | |
| 24 | expect(verifyImageTransform('/hero', expires, signature, 'secret')).toBe(true) | |
| 25 | }) | |
| 26 | test('applies presets and authorizes before source reads', async () => { | |
| 27 | const root = await mkdtemp(join(tmpdir(), 'stacks-image-preset-')); dirs.push(root) | |
| 28 | const input = createImageData(8, 4); input.data.fill(200); await writeFile(join(root, 'avatar.png'), await encode(input, 'png')) | |
| 29 | const result = await image('avatar.png', { root, outputDir: join(root, 'out') }).preset('avatar').widths([2]).formats(['png']).generate() | |
| 30 | expect(result.variants).toHaveLength(1) | |
| 31 | expect(result.variants[0]).toMatchObject({ width: 2, height: 2 }) | |
| 32 | await expect(image('missing.png', { root, authorize: () => false }).generate()).rejects.toThrow('not authorized') | |
| 33 | }) | |
| 34 | test('publishes image variants through a storage adapter', async () => { | |
| 35 | const root = await mkdtemp(join(tmpdir(), 'stacks-image-storage-')); dirs.push(root) | |
| 36 | const input = createImageData(4, 4); input.data.fill(150); await writeFile(join(root, 'image.png'), await encode(input, 'png')) | |
| 37 | const objects = new Map<string, Uint8Array>() | |
| 38 | const adapter = { fileExists: async (path: string) => objects.has(path), write: async (path: string, bytes: Uint8Array) => { objects.set(path, bytes); return { size: bytes.byteLength } }, stat: async (path: string) => ({ size: objects.get(path)!.byteLength }), publicUrl: async (path: string) => `https://media.example/${path}` } | |
| 39 | const result = await image('image.png', { root }).preset('avatar').widths([2]).formats(['webp']).storage(adapter).generate() | |
| 40 | expect(result.variants[0].path).toStartWith('media/images/') | |
| 41 | expect(result.variants[0].url).toStartWith('https://media.example/') | |
| 42 | }) | |
| 43 | }) | |
| @@ -409,11 +409,17 @@ export const log: Log = { | ||
| 409 | 409 | |
| 410 | 410 | warn: async (message: string, context?: unknown) => { |
| 411 | 411 | const logger = await getLogger() |
| 412 | // No context → call with a single arg. Passing a nullish second arg makes | |
| 413 | // clarity stringify it and append a stray " undefined" / " null" to the | |
| 414 | // line (e.g. `log.warn('… All data will be lost.')`, or `log.warn(msg, | |
| 415 | // null)`). `== null` catches both. Matches `warning`. | |
| 416 | if (context == null) { | |
| 417 | await logger.warn(message) | |
| 418 | return | |
| 419 | } | |
| 412 | 420 | // Normalize so Errors in the context survive clarity's JSON.stringify |
| 413 | 421 | // (stacksjs/stacks#1956). |
| 414 | const normalized = context === undefined | |
| 415 | ? undefined | |
| 416 | : normalizeContextValue(context) as Record<string, unknown> | |
| 422 | const normalized = normalizeContextValue(context) as Record<string, unknown> | |
| 417 | 423 | await logger.warn(message, normalized) |
| 418 | 424 | }, |
| 419 | 425 | |
| @@ -1,35 +0,0 @@ | ||
| 1 | [2026-07-22T11:31:17.855Z] local.ERROR: action failed Error: kaboom | |
| 2 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/error-reporting.test.ts:85:38) {"requestId":"req-1"} | |
| 3 | [2026-07-22T11:31:17.862Z] local.ERROR: Error: bare | |
| 4 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/error-reporting.test.ts:90:32) | |
| 5 | [2026-07-22T11:31:17.882Z] local.INFO: test info message | |
| 6 | [2026-07-22T11:31:17.883Z] local.INFO: msg extra 123 | |
| 7 | [2026-07-22T11:31:17.883Z] local.ERROR: test error message | |
| 8 | [2026-07-22T11:31:17.883Z] local.ERROR: Error: test | |
| 9 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/logging.test.ts:47:34) | |
| 10 | [2026-07-22T11:31:17.883Z] local.ERROR: Error: {"code":"ERR_01"} | |
| 11 | [2026-07-22T11:31:17.884Z] local.WARNING: test warning undefined | |
| 12 | [2026-07-22T11:31:17.884Z] local.SUCCESS: operation complete | |
| 13 | [2026-07-22T11:31:17.890Z] local.INFO: db-query: 5.72ms | |
| 14 | [2026-07-22T11:31:17.890Z] local.INFO: api-call: 0.00ms {"endpoint":"/users"} | |
| 15 | [2026-07-22T11:31:17.891Z] local.INFO: ECHO: echo output | |
| 16 | [2026-07-22T11:31:17.893Z] local.INFO: integration test info | |
| 17 | [2026-07-22T11:31:17.893Z] local.WARNING: integration test warn undefined | |
| 18 | [2026-07-22T11:31:17.893Z] local.ERROR: integration test error | |
| 19 | [2026-07-22T11:31:17.893Z] local.ERROR: Error: test error object | |
| 20 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/integration.test.ts:21:32) | |
| 21 | [2026-07-22T11:31:17.894Z] local.SUCCESS: integration test success | |
| 22 | [2026-07-22T11:31:17.894Z] local.INFO: test-timer: 0.00ms | |
| 23 | [2026-07-22T11:31:17.895Z] local.INFO: duration-test: 0.44ms {"iterations":4999950000} | |
| 24 | [2026-07-22T11:31:17.895Z] local.INFO: ECHO: echo test | |
| 25 | [2026-07-22T11:31:17.898Z] local.WARNING: [router] CSRF cookie seeding failed { | |
| 26 | "error": { | |
| 27 | "name": "Error", | |
| 28 | "message": "seed failed", | |
| 29 | "stack": "Error: seed failed\n at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/context-normalization.test.ts:53:68)" | |
| 30 | } | |
| 31 | } | |
| 32 | [2026-07-22T11:31:17.898Z] local.INFO: request failed Error: bare | |
| 33 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/context-normalization.test.ts:58:49) | |
| 34 | [2026-07-22T11:31:17.898Z] local.ERROR: action failed Error: primary | |
| 35 | at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/context-normalization.test.ts:67:38) {"error":{"name":"Error","message":"ctx-err","stack":"Error: ctx-err\n at <anonymous> (/Users/glennmichaeltorregosa/Documents/Projects/stacks/storage/framework/core/logging/tests/context-normalization.test.ts:67:69)"}} | |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.393Z] local.INFO: Building @stacksjs/logging... | |
| 2 | [2026-07-23T13:13:14.415Z] local.INFO: Building @stacksjs/logging... | |
| 3 | [2026-07-23T13:13:36.572Z] local.INFO: Building @stacksjs/logging... | |
| 1 | [2026-07-23T16:49:27.716Z] local.INFO: Building @stacksjs/logging... | |
| 2 | [2026-07-23T16:49:39.051Z] local.INFO: Building @stacksjs/logging... | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/notifications", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks notifications integration.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.448Z] local.INFO: Building @stacksjs/orm... | |
| 2 | [2026-07-23T13:13:13.158Z] local.INFO: Building @stacksjs/orm... | |
| 3 | [2026-07-23T13:13:36.596Z] local.INFO: Building @stacksjs/orm... | |
| 1 | [2026-07-23T16:49:25.693Z] local.INFO: Building @stacksjs/orm... | |
| 2 | [2026-07-23T16:49:39.068Z] local.INFO: Building @stacksjs/orm... | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "stacks", |
| 3 | 3 | "type": "module", |
| 4 | "version": "0.70.162", | |
| 4 | "version": "0.70.163", | |
| 5 | 5 | "description": "The Stacks framework.", |
| 6 | 6 | "author": "Chris Breuer", |
| 7 | 7 | "contributors": [ |
| @@ -72,6 +72,10 @@ | ||
| 72 | 72 | "types": "./auth/dist/index.d.ts", |
| 73 | 73 | "import": "./auth/dist/index.js" |
| 74 | 74 | }, |
| 75 | "./audio": { | |
| 76 | "types": "./audio/dist/index.d.ts", | |
| 77 | "import": "./audio/dist/index.js" | |
| 78 | }, | |
| 75 | 79 | "./buddy": { |
| 76 | 80 | "types": "./buddy/dist/index.d.ts", |
| 77 | 81 | "import": "./buddy/dist/index.js" |
| @@ -172,6 +176,10 @@ | ||
| 172 | 176 | "types": "./health/dist/index.d.ts", |
| 173 | 177 | "import": "./health/dist/index.js" |
| 174 | 178 | }, |
| 179 | "./image": { | |
| 180 | "types": "./image/dist/index.d.ts", | |
| 181 | "import": "./image/dist/index.js" | |
| 182 | }, | |
| 175 | 183 | "./lint": { |
| 176 | 184 | "types": "./lint/dist/index.d.ts", |
| 177 | 185 | "import": "./lint/dist/index.js" |
| @@ -284,6 +292,10 @@ | ||
| 284 | 292 | "types": "./validation/dist/index.d.ts", |
| 285 | 293 | "import": "./validation/dist/index.js" |
| 286 | 294 | }, |
| 295 | "./video": { | |
| 296 | "types": "./video/dist/index.d.ts", | |
| 297 | "import": "./video/dist/index.js" | |
| 298 | }, | |
| 287 | 299 | "./skills": { |
| 288 | 300 | "types": "./skills/dist/index.d.ts", |
| 289 | 301 | "import": "./skills/dist/index.js" |
| @@ -302,84 +314,87 @@ | ||
| 302 | 314 | }, |
| 303 | 315 | "web-types": "./web-types.json", |
| 304 | 316 | "dependencies": { |
| 305 | "@stacksjs/actions": "^0.70.162", | |
| 306 | "@stacksjs/ai": "^0.70.162", | |
| 307 | "@stacksjs/alias": "^0.70.162", | |
| 308 | "@stacksjs/arrays": "^0.70.162", | |
| 309 | "@stacksjs/auth": "^0.70.162", | |
| 310 | "@stacksjs/buddy": "^0.70.162", | |
| 311 | "@stacksjs/browser-extension": "^0.70.162", | |
| 312 | "@stacksjs/build": "^0.70.162", | |
| 313 | "@stacksjs/cache": "^0.70.162", | |
| 314 | "@stacksjs/cli": "^0.70.162", | |
| 315 | "@stacksjs/cloud": "^0.70.162", | |
| 316 | "@stacksjs/collections": "^0.70.162", | |
| 317 | "@stacksjs/config": "^0.70.162", | |
| 318 | "@stacksjs/database": "^0.70.162", | |
| 319 | "@stacksjs/defaults": "^0.70.162", | |
| 320 | "@stacksjs/desktop": "^0.70.162", | |
| 317 | "@stacksjs/actions": "^0.70.163", | |
| 318 | "@stacksjs/ai": "^0.70.163", | |
| 319 | "@stacksjs/alias": "^0.70.163", | |
| 320 | "@stacksjs/audio": "^0.70.163", | |
| 321 | "@stacksjs/arrays": "^0.70.163", | |
| 322 | "@stacksjs/auth": "^0.70.163", | |
| 323 | "@stacksjs/buddy": "^0.70.163", | |
| 324 | "@stacksjs/browser-extension": "^0.70.163", | |
| 325 | "@stacksjs/build": "^0.70.163", | |
| 326 | "@stacksjs/cache": "^0.70.163", | |
| 327 | "@stacksjs/cli": "^0.70.163", | |
| 328 | "@stacksjs/cloud": "^0.70.163", | |
| 329 | "@stacksjs/collections": "^0.70.163", | |
| 330 | "@stacksjs/config": "^0.70.163", | |
| 331 | "@stacksjs/database": "^0.70.163", | |
| 332 | "@stacksjs/defaults": "^0.70.163", | |
| 333 | "@stacksjs/desktop": "^0.70.163", | |
| 321 | 334 | "better-dx": "catalog:", |
| 322 | "@stacksjs/dns": "^0.70.162", | |
| 323 | "@stacksjs/email": "^0.70.162", | |
| 324 | "@stacksjs/enums": "^0.70.162", | |
| 325 | "@stacksjs/env": "^0.70.162", | |
| 326 | "@stacksjs/error-handling": "^0.70.162", | |
| 327 | "@stacksjs/events": "^0.70.162", | |
| 328 | "@stacksjs/feature-flags": "^0.70.162", | |
| 329 | "@stacksjs/git": "^0.70.162", | |
| 330 | "@stacksjs/health": "^0.70.162", | |
| 331 | "@stacksjs/lint": "^0.70.162", | |
| 332 | "@stacksjs/logging": "^0.70.162", | |
| 333 | "@stacksjs/notifications": "^0.70.162", | |
| 334 | "@stacksjs/objects": "^0.70.162", | |
| 335 | "@stacksjs/orm": "^0.70.162", | |
| 336 | "@stacksjs/path": "^0.70.162", | |
| 337 | "@stacksjs/payments": "^0.70.162", | |
| 338 | "@stacksjs/realtime": "^0.70.162", | |
| 339 | "@stacksjs/router": "^0.70.162", | |
| 340 | "@stacksjs/search-engine": "^0.70.162", | |
| 341 | "@stacksjs/security": "^0.70.162", | |
| 342 | "@stacksjs/server": "^0.70.162", | |
| 343 | "@stacksjs/storage": "^0.70.162", | |
| 344 | "@stacksjs/strings": "^0.70.162", | |
| 335 | "@stacksjs/dns": "^0.70.163", | |
| 336 | "@stacksjs/email": "^0.70.163", | |
| 337 | "@stacksjs/enums": "^0.70.163", | |
| 338 | "@stacksjs/env": "^0.70.163", | |
| 339 | "@stacksjs/error-handling": "^0.70.163", | |
| 340 | "@stacksjs/events": "^0.70.163", | |
| 341 | "@stacksjs/feature-flags": "^0.70.163", | |
| 342 | "@stacksjs/git": "^0.70.163", | |
| 343 | "@stacksjs/health": "^0.70.163", | |
| 344 | "@stacksjs/image": "^0.70.163", | |
| 345 | "@stacksjs/lint": "^0.70.163", | |
| 346 | "@stacksjs/logging": "^0.70.163", | |
| 347 | "@stacksjs/notifications": "^0.70.163", | |
| 348 | "@stacksjs/objects": "^0.70.163", | |
| 349 | "@stacksjs/orm": "^0.70.163", | |
| 350 | "@stacksjs/path": "^0.70.163", | |
| 351 | "@stacksjs/payments": "^0.70.163", | |
| 352 | "@stacksjs/realtime": "^0.70.163", | |
| 353 | "@stacksjs/router": "^0.70.163", | |
| 354 | "@stacksjs/search-engine": "^0.70.163", | |
| 355 | "@stacksjs/security": "^0.70.163", | |
| 356 | "@stacksjs/server": "^0.70.163", | |
| 357 | "@stacksjs/storage": "^0.70.163", | |
| 358 | "@stacksjs/strings": "^0.70.163", | |
| 345 | 359 | "@stacksjs/tlsx": "^0.13.0", |
| 346 | 360 | "@stacksjs/ts-cloud": "catalog:", |
| 347 | "@stacksjs/tunnel": "^0.70.162", | |
| 348 | "@stacksjs/types": "^0.70.162", | |
| 349 | "@stacksjs/ui": "^0.70.162", | |
| 350 | "@stacksjs/utils": "^0.70.162", | |
| 351 | "@stacksjs/skills": "^0.70.162", | |
| 352 | "@stacksjs/validation": "^0.70.162", | |
| 353 | "@stacksjs/api": "^0.70.162", | |
| 354 | "@stacksjs/analytics": "^0.70.162", | |
| 355 | "@stacksjs/chat": "^0.70.162", | |
| 356 | "@stacksjs/cms": "^0.70.162", | |
| 357 | "@stacksjs/commerce": "^0.70.162", | |
| 358 | "@stacksjs/datetime": "^0.70.162", | |
| 359 | "@stacksjs/docs": "^0.70.162", | |
| 360 | "@stacksjs/faker": "^0.70.162", | |
| 361 | "@stacksjs/push": "^0.70.162", | |
| 362 | "@stacksjs/query-builder": "^0.70.162", | |
| 363 | "@stacksjs/queue": "^0.70.162", | |
| 364 | "@stacksjs/repl": "^0.70.162", | |
| 365 | "@stacksjs/scheduler": "^0.70.162", | |
| 366 | "@stacksjs/slug": "^0.70.162", | |
| 367 | "@stacksjs/sms": "^0.70.162", | |
| 368 | "@stacksjs/testing": "^0.70.162", | |
| 369 | "@stacksjs/tinker": "^0.70.162", | |
| 370 | "@stacksjs/composables": "^0.70.162", | |
| 371 | "@stacksjs/shell": "^0.70.162", | |
| 372 | "@stacksjs/charts": "^0.70.162", | |
| 373 | "@stacksjs/whois": "^0.70.162", | |
| 374 | "@stacksjs/browser": "^0.70.162", | |
| 375 | "@stacksjs/calendar-api": "^0.70.162", | |
| 376 | "@stacksjs/github": "^0.70.162", | |
| 377 | "@stacksjs/http": "^0.70.162", | |
| 378 | "@stacksjs/registry": "^0.70.162", | |
| 379 | "@stacksjs/socials": "^0.70.162", | |
| 380 | "@stacksjs/newsletter": "^0.70.162", | |
| 381 | "@stacksjs/i18n": "^0.70.162", | |
| 382 | "@stacksjs/cron": "^0.70.162", | |
| 361 | "@stacksjs/tunnel": "^0.70.163", | |
| 362 | "@stacksjs/types": "^0.70.163", | |
| 363 | "@stacksjs/ui": "^0.70.163", | |
| 364 | "@stacksjs/utils": "^0.70.163", | |
| 365 | "@stacksjs/skills": "^0.70.163", | |
| 366 | "@stacksjs/validation": "^0.70.163", | |
| 367 | "@stacksjs/video": "^0.70.163", | |
| 368 | "@stacksjs/api": "^0.70.163", | |
| 369 | "@stacksjs/analytics": "^0.70.163", | |
| 370 | "@stacksjs/chat": "^0.70.163", | |
| 371 | "@stacksjs/cms": "^0.70.163", | |
| 372 | "@stacksjs/commerce": "^0.70.163", | |
| 373 | "@stacksjs/datetime": "^0.70.163", | |
| 374 | "@stacksjs/docs": "^0.70.163", | |
| 375 | "@stacksjs/faker": "^0.70.163", | |
| 376 | "@stacksjs/push": "^0.70.163", | |
| 377 | "@stacksjs/query-builder": "^0.70.163", | |
| 378 | "@stacksjs/queue": "^0.70.163", | |
| 379 | "@stacksjs/repl": "^0.70.163", | |
| 380 | "@stacksjs/scheduler": "^0.70.163", | |
| 381 | "@stacksjs/slug": "^0.70.163", | |
| 382 | "@stacksjs/sms": "^0.70.163", | |
| 383 | "@stacksjs/testing": "^0.70.163", | |
| 384 | "@stacksjs/tinker": "^0.70.163", | |
| 385 | "@stacksjs/composables": "^0.70.163", | |
| 386 | "@stacksjs/shell": "^0.70.163", | |
| 387 | "@stacksjs/charts": "^0.70.163", | |
| 388 | "@stacksjs/whois": "^0.70.163", | |
| 389 | "@stacksjs/browser": "^0.70.163", | |
| 390 | "@stacksjs/calendar-api": "^0.70.163", | |
| 391 | "@stacksjs/github": "^0.70.163", | |
| 392 | "@stacksjs/http": "^0.70.163", | |
| 393 | "@stacksjs/registry": "^0.70.163", | |
| 394 | "@stacksjs/socials": "^0.70.163", | |
| 395 | "@stacksjs/newsletter": "^0.70.163", | |
| 396 | "@stacksjs/i18n": "^0.70.163", | |
| 397 | "@stacksjs/cron": "^0.70.163", | |
| 383 | 398 | "bun-query-builder": "catalog:" |
| 384 | 399 | } |
| 385 | 400 | } |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/payments", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks payments package. Currently supporting Stripe.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/query-builder", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks Query Builder integration", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/queue", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks Queue system powered by bun-queue.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -25,16 +25,30 @@ interface JobProgress { | ||
| 25 | 25 | updatedAt: number |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | /** | |
| 29 | * Clamp a reported progress value to the `percent` contract (0-100). | |
| 30 | * Non-finite input (NaN/Infinity) reports 0. | |
| 31 | * | |
| 32 | * `percent` is a percent, not a fraction: `50` is 50%, `12.5` is 12.5%. | |
| 33 | * The previous implementation branched on the value (`v <= 1 ? v*100 : v`) | |
| 34 | * to also accept fractions, which made `setJobProgress(id, 1)` — a caller | |
| 35 | * meaning 1% — jump to 100%. Progress is single-contract now | |
| 36 | * (stacksjs/stacks#1984). | |
| 37 | */ | |
| 38 | export function clampProgressPercent(percent: number): number { | |
| 39 | return Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0 | |
| 40 | } | |
| 41 | ||
| 28 | 42 | /** |
| 29 | 43 | * Update the progress of the running job. Workers call this from |
| 30 | 44 | * inside their handler; the UI polls `getJobProgress(id)` to render |
| 31 | * a progress bar. | |
| 45 | * a progress bar. `percent` is 0-100 (a percent, not a 0-1 fraction). | |
| 32 | 46 | * |
| 33 | 47 | * @example |
| 34 | 48 | * ```ts |
| 35 | * await job.progress(0.5, 'Halfway there') | |
| 49 | * await setJobProgress(job.id, 50, 'Halfway there') | |
| 36 | 50 | * // … later |
| 37 | * await job.progress(1.0, 'Done') | |
| 51 | * await setJobProgress(job.id, 100, 'Done') | |
| 38 | 52 | * ``` |
| 39 | 53 | */ |
| 40 | 54 | export async function setJobProgress( |
| @@ -43,10 +57,9 @@ export async function setJobProgress( | ||
| 43 | 57 | message?: string, |
| 44 | 58 | ): Promise<void> { |
| 45 | 59 | const { cache } = await import('@stacksjs/cache') |
| 46 | const clamped = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent * (percent <= 1 ? 100 : 1))) : 0 | |
| 47 | 60 | await cache.set( |
| 48 | 61 | PROGRESS_KEY(jobId), |
| 49 | { percent: clamped, message, updatedAt: Date.now() } as JobProgress, | |
| 62 | { percent: clampProgressPercent(percent), message, updatedAt: Date.now() } as JobProgress, | |
| 50 | 63 | TTL_SECONDS, |
| 51 | 64 | ) |
| 52 | 65 | } |
| @@ -0,0 +1,31 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { clampProgressPercent } from '../src/job-progress' | |
| 3 | ||
| 4 | // stacksjs/stacks#1984: setJobProgress used a value-branching heuristic | |
| 5 | // (`v <= 1 ? v*100 : v`) to accept both fractions and percents, so a caller | |
| 6 | // meaning 1% — `setJobProgress(id, 1)` — jumped to 100%. Progress is now a | |
| 7 | // single contract: percent, 0-100. | |
| 8 | describe('setJobProgress percent contract (stacksjs/stacks#1984)', () => { | |
| 9 | it('treats the value as a percent, not a fraction', () => { | |
| 10 | expect(clampProgressPercent(1)).toBe(1) // the regression: was 100 | |
| 11 | expect(clampProgressPercent(50)).toBe(50) | |
| 12 | expect(clampProgressPercent(12.5)).toBe(12.5) // sub-percent precision preserved | |
| 13 | expect(clampProgressPercent(100)).toBe(100) | |
| 14 | }) | |
| 15 | ||
| 16 | it('does not scale small values (no branching on the magnitude)', () => { | |
| 17 | expect(clampProgressPercent(0)).toBe(0) | |
| 18 | expect(clampProgressPercent(0.5)).toBe(0.5) // 0.5% under the percent contract, not 50% | |
| 19 | }) | |
| 20 | ||
| 21 | it('clamps out-of-range values into [0, 100]', () => { | |
| 22 | expect(clampProgressPercent(150)).toBe(100) | |
| 23 | expect(clampProgressPercent(-5)).toBe(0) | |
| 24 | }) | |
| 25 | ||
| 26 | it('reports 0 for non-finite input', () => { | |
| 27 | expect(clampProgressPercent(Number.NaN)).toBe(0) | |
| 28 | expect(clampProgressPercent(Number.POSITIVE_INFINITY)).toBe(0) | |
| 29 | expect(clampProgressPercent(Number.NEGATIVE_INFINITY)).toBe(0) | |
| 30 | }) | |
| 31 | }) | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/realtime", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks realtime integration. Built on top of ts-broadcasting.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -1,1 +0,0 @@ | ||
| 1 | [2026-07-14T12:57:53.865Z] local.INFO: Building @stacksjs/router... | |
| @@ -1,4 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.164Z] local.INFO: Building @stacksjs/router... | |
| 2 | [2026-07-23T13:12:51.163Z] local.INFO: Building @stacksjs/router... | |
| 3 | [2026-07-23T13:13:12.586Z] local.INFO: Building @stacksjs/router... | |
| 4 | [2026-07-23T13:13:36.501Z] local.INFO: Building @stacksjs/router... | |
| 1 | [2026-07-23T16:49:27.148Z] local.INFO: Building @stacksjs/router... | |
| 2 | [2026-07-23T16:49:39.024Z] local.INFO: Building @stacksjs/router... | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/search-engine", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks search engine integrations.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/shell", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "A better Stacks shell experience. Currently only fully supporting Oh My Zsh. Stay tuned for more. Or request one!", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/skills", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Agent skills for the Stacks framework, following the agentskills.io standard.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/sms", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks SMS integration. Painlessly create & manage your inboxes, templates, and send sms.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/storage", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "The Stacks file system.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -52,15 +52,8 @@ | ||
| 52 | 52 | "prepublishOnly": "bun run build" |
| 53 | 53 | }, |
| 54 | 54 | "dependencies": { |
| 55 | "@stacksjs/ts-cloud": "catalog:" | |
| 56 | }, | |
| 57 | "peerDependencies": { | |
| 58 | "sharp": "^0.35.3" | |
| 59 | }, | |
| 60 | "peerDependenciesMeta": { | |
| 61 | "sharp": { | |
| 62 | "optional": true | |
| 63 | } | |
| 55 | "@stacksjs/ts-cloud": "catalog:", | |
| 56 | "ts-images": "^0.1.11" | |
| 64 | 57 | }, |
| 65 | 58 | "devDependencies": { |
| 66 | 59 | "@stacksjs/arrays": "workspace:*", |
| @@ -68,7 +61,6 @@ | ||
| 68 | 61 | "@stacksjs/error-handling": "workspace:*", |
| 69 | 62 | "@stacksjs/path": "workspace:*", |
| 70 | 63 | "@stacksjs/strings": "workspace:*", |
| 71 | "@stacksjs/types": "workspace:*", | |
| 72 | "sharp": "^0.35.3" | |
| 64 | "@stacksjs/types": "workspace:*" | |
| 73 | 65 | } |
| 74 | 66 | } |
| @@ -92,6 +92,40 @@ async function isSettled(p: Promise<unknown>): Promise<boolean> { | ||
| 92 | 92 | return result !== sentinel |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | /** | |
| 96 | * Options for the ts-cloud S3 client. Mirrors ts-cloud's internal | |
| 97 | * `S3ClientOptions` (declared but not re-exported from its package root); | |
| 98 | * structural typing lets this satisfy the `S3Client` constructor's 3rd argument. | |
| 99 | */ | |
| 100 | interface S3ClientOptions { | |
| 101 | endpoint?: string | |
| 102 | forcePathStyle?: boolean | |
| 103 | credentials?: { accessKeyId: string, secretAccessKey: string, sessionToken?: string } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Build ts-cloud `S3ClientOptions` from adapter config, or `undefined` for a | |
| 108 | * plain AWS S3 client. The config endpoint may carry a scheme | |
| 109 | * (`https://s3.filebase.com`); ts-cloud wants a scheme-less host, so it's | |
| 110 | * stripped here. This is what routes the adapter to S3-compatible providers - | |
| 111 | * Filebase, Backblaze B2, Cloudflare R2, Hetzner Object Storage | |
| 112 | * (stacksjs/stacks#938, #1897, #1896). | |
| 113 | */ | |
| 114 | export function resolveS3ClientOptions(config: { | |
| 115 | endpoint?: string | |
| 116 | usePathStyleEndpoint?: boolean | |
| 117 | credentials?: { accessKeyId: string, secretAccessKey: string, sessionToken?: string } | |
| 118 | }): S3ClientOptions | undefined { | |
| 119 | const options: S3ClientOptions = {} | |
| 120 | if (config.endpoint) | |
| 121 | options.endpoint = config.endpoint.replace(/^https?:\/\//i, '').replace(/\/+$/, '') | |
| 122 | if (config.usePathStyleEndpoint) | |
| 123 | options.forcePathStyle = true | |
| 124 | if (config.credentials) | |
| 125 | options.credentials = config.credentials | |
| 126 | return Object.keys(options).length > 0 ? options : undefined | |
| 127 | } | |
| 128 | ||
| 95 | 129 | /** |
| 96 | 130 | * AWS S3 storage adapter using ts-cloud S3Client |
| 97 | 131 | */ |
| @@ -101,6 +135,8 @@ export class S3StorageAdapter implements StorageAdapter { | ||
| 101 | 135 | private bucket: string |
| 102 | 136 | private prefix: string |
| 103 | 137 | private region: string |
| 138 | private endpoint?: string | |
| 139 | private usePathStyleEndpoint?: boolean | |
| 104 | 140 | /** |
| 105 | 141 | * Caller-supplied credentials, kept so we can issue presigned POST |
| 106 | 142 | * policies (which need access to the signing key — ts-cloud's |
| @@ -120,6 +156,8 @@ export class S3StorageAdapter implements StorageAdapter { | ||
| 120 | 156 | this.prefix = config.prefix || '' |
| 121 | 157 | this.region = config.region || 'us-east-1' |
| 122 | 158 | this.credentials = config.credentials |
| 159 | this.endpoint = config.endpoint | |
| 160 | this.usePathStyleEndpoint = config.usePathStyleEndpoint | |
| 123 | 161 | |
| 124 | 162 | if (!this.bucket) { |
| 125 | 163 | throw new Error('S3 bucket name is required') |
| @@ -131,7 +169,11 @@ export class S3StorageAdapter implements StorageAdapter { | ||
| 131 | 169 | return this._client |
| 132 | 170 | if (!this._clientPromise) { |
| 133 | 171 | this._clientPromise = import('@stacksjs/ts-cloud').then((cloud) => { |
| 134 | this._client = new cloud.S3Client(this.region) | |
| 172 | this._client = new cloud.S3Client( | |
| 173 | this.region, | |
| 174 | undefined, | |
| 175 | resolveS3ClientOptions({ endpoint: this.endpoint, usePathStyleEndpoint: this.usePathStyleEndpoint, credentials: this.credentials }), | |
| 176 | ) | |
| 135 | 177 | return this._client |
| 136 | 178 | }) |
| 137 | 179 | } |
| @@ -176,6 +176,14 @@ class StorageManager { | ||
| 176 | 176 | bucket: config.bucket, |
| 177 | 177 | region: config.region, |
| 178 | 178 | prefix: config.prefix, |
| 179 | endpoint: config.endpoint, | |
| 180 | usePathStyleEndpoint: config.usePathStyleEndpoint, | |
| 181 | // S3DiskConfig uses { key, secret }; the adapter/ts-cloud client use | |
| 182 | // { accessKeyId, secretAccessKey }. Map so S3-compatible providers whose | |
| 183 | // keys aren't in the AWS env chain still authenticate (stacksjs/stacks#1897). | |
| 184 | credentials: config.credentials | |
| 185 | ? { accessKeyId: config.credentials.key, secretAccessKey: config.credentials.secret } | |
| 186 | : undefined, | |
| 179 | 187 | }) |
| 180 | 188 | } |
| 181 | 189 | |
| @@ -1,142 +1,90 @@ | ||
| 1 | /** | |
| 2 | * Image-processing pipeline for `Storage.put(file, { transform })` | |
| 3 | * (stacksjs/stacks#1856 Stage 5). | |
| 4 | * | |
| 5 | * `sharp` is a native module with a non-trivial install footprint | |
| 6 | * (libvips, platform-specific binaries), so this lives in its own | |
| 7 | * submodule (`@stacksjs/storage/image`) and is lazy-imported. Projects | |
| 8 | * that never upload images don't pay the install cost; projects that | |
| 9 | * do get a one-line addition to their upload action: | |
| 10 | * | |
| 11 | * @example | |
| 12 | * ```ts | |
| 13 | * import { Storage } from '@stacksjs/storage' | |
| 14 | * import { transform } from '@stacksjs/storage/image' | |
| 15 | * | |
| 16 | * await Storage.put(file, { | |
| 17 | * disk: 'public', | |
| 18 | * dir: 'avatars', | |
| 19 | * transform: transform(img => img.resize(512, 512, { fit: 'cover' }).webp({ quality: 85 })), | |
| 20 | * }) | |
| 21 | * ``` | |
| 22 | * | |
| 23 | * The transformer receives a `sharp.Sharp` instance pre-loaded with the | |
| 24 | * uploaded bytes; whatever it returns is awaited and persisted. Sharp | |
| 25 | * pipelines are chainable — `resize`, `webp`, `jpeg`, `png`, `rotate`, | |
| 26 | * `blur`, `withMetadata`, etc. See https://sharp.pixelplumbing.com. | |
| 27 | */ | |
| 1 | import type { ImageData, ResizeFit } from 'ts-images' | |
| 2 | import { blur as applyBlur, decode, encode, resize as applyResize, rotate as applyRotate } from 'ts-images' | |
| 28 | 3 | |
| 29 | /** | |
| 30 | * Loose type for the sharp module — declared here so we don't pull the | |
| 31 | * sharp dts into every storage consumer's typecheck graph. Real | |
| 32 | * tooling lookups happen via the lazy import below. | |
| 33 | */ | |
| 34 | interface SharpFactory { | |
| 35 | (input?: Buffer | Uint8Array): SharpInstance | |
| 36 | } | |
| 4 | export type NativeImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | |
| 5 | export interface NativeEncodeOptions { quality?: number, progressive?: boolean, lossless?: boolean, effort?: number } | |
| 6 | export interface NativeResizeOptions { fit?: ResizeFit, withoutEnlargement?: boolean } | |
| 37 | 7 | |
| 38 | interface SharpInstance { | |
| 39 | toBuffer(): Promise<Buffer> | |
| 40 | resize: (...args: unknown[]) => SharpInstance | |
| 41 | jpeg: (...args: unknown[]) => SharpInstance | |
| 42 | png: (...args: unknown[]) => SharpInstance | |
| 43 | webp: (...args: unknown[]) => SharpInstance | |
| 44 | avif: (...args: unknown[]) => SharpInstance | |
| 45 | gif: (...args: unknown[]) => SharpInstance | |
| 46 | rotate: (...args: unknown[]) => SharpInstance | |
| 47 | blur: (...args: unknown[]) => SharpInstance | |
| 48 | withMetadata: (...args: unknown[]) => SharpInstance | |
| 49 | [k: string]: unknown | |
| 8 | export interface NativeImagePipeline { | |
| 9 | resize: (width?: number, height?: number, options?: NativeResizeOptions) => NativeImagePipeline | |
| 10 | jpeg: (options?: NativeEncodeOptions) => NativeImagePipeline | |
| 11 | png: (options?: NativeEncodeOptions) => NativeImagePipeline | |
| 12 | webp: (options?: NativeEncodeOptions) => NativeImagePipeline | |
| 13 | avif: (options?: NativeEncodeOptions) => NativeImagePipeline | |
| 14 | rotate: (degrees?: number) => NativeImagePipeline | |
| 15 | blur: (sigma?: number) => NativeImagePipeline | |
| 16 | withMetadata: () => NativeImagePipeline | |
| 17 | toBuffer: () => Promise<Buffer> | |
| 50 | 18 | } |
| 51 | 19 | |
| 52 | /** | |
| 53 | * Resolve the sharp factory at first use. Stays cached for the rest of | |
| 54 | * the process lifetime so a long-lived server doesn't re-import on | |
| 55 | * every upload. Throws a clear `Error` with install instructions if | |
| 56 | * sharp isn't on disk — projects that don't need image transforms | |
| 57 | * shouldn't have to install it. | |
| 58 | */ | |
| 59 | let cachedSharp: SharpFactory | null = null | |
| 20 | class Pipeline implements NativeImagePipeline { | |
| 21 | private operations: Array<(image: ImageData) => ImageData> = [] | |
| 22 | private format: NativeImageFormat = 'webp' | |
| 23 | private options: NativeEncodeOptions = { quality: 80 } | |
| 24 | constructor(private readonly input: Uint8Array) {} | |
| 25 | ||
| 26 | resize(width?: number, height?: number, options: NativeResizeOptions = {}): NativeImagePipeline { | |
| 27 | if (width !== undefined && (!Number.isInteger(width) || width <= 0)) throw new TypeError('Image width must be positive') | |
| 28 | if (height !== undefined && (!Number.isInteger(height) || height <= 0)) throw new TypeError('Image height must be positive') | |
| 29 | if (width === undefined && height === undefined) throw new TypeError('Image resize requires a width or height') | |
| 30 | this.operations.push((image) => { | |
| 31 | if (options.withoutEnlargement !== false && (width === undefined || width >= image.width) && (height === undefined || height >= image.height)) return image | |
| 32 | return applyResize(image, { width, height, fit: options.fit ?? 'inside' }) | |
| 33 | }) | |
| 34 | return this | |
| 35 | } | |
| 36 | ||
| 37 | jpeg(options: NativeEncodeOptions = {}): NativeImagePipeline { return this.output('jpeg', options) } | |
| 38 | png(options: NativeEncodeOptions = {}): NativeImagePipeline { return this.output('png', options) } | |
| 39 | webp(options: NativeEncodeOptions = {}): NativeImagePipeline { return this.output('webp', options) } | |
| 40 | avif(options: NativeEncodeOptions = {}): NativeImagePipeline { return this.output('avif', options) } | |
| 41 | ||
| 42 | rotate(degrees = 90): NativeImagePipeline { | |
| 43 | if (!Number.isFinite(degrees)) throw new TypeError('Image rotation must be finite') | |
| 44 | this.operations.push(image => applyRotate(image, degrees)) | |
| 45 | return this | |
| 46 | } | |
| 60 | 47 | |
| 61 | async function loadSharp(): Promise<SharpFactory> { | |
| 62 | if (cachedSharp) return cachedSharp | |
| 63 | try { | |
| 64 | const mod = await import('sharp') | |
| 65 | cachedSharp = (mod as unknown as { default?: SharpFactory }).default ?? (mod as unknown as SharpFactory) | |
| 66 | return cachedSharp! | |
| 48 | blur(sigma = 1): NativeImagePipeline { | |
| 49 | if (!Number.isFinite(sigma) || sigma <= 0) throw new TypeError('Image blur sigma must be positive') | |
| 50 | this.operations.push(image => applyBlur(image, sigma)) | |
| 51 | return this | |
| 67 | 52 | } |
| 68 | catch (err) { | |
| 69 | throw new Error( | |
| 70 | '[storage/image] `sharp` is not installed. Image transforms require it as a peer dependency.\n' | |
| 71 | + ' Install with: `bun add sharp`\n' | |
| 72 | + ` Original load error: ${err instanceof Error ? err.message : String(err)}`, | |
| 73 | ) | |
| 53 | ||
| 54 | withMetadata(): NativeImagePipeline { | |
| 55 | throw new Error('Native image transforms strip metadata for privacy') | |
| 56 | } | |
| 57 | ||
| 58 | async toBuffer(): Promise<Buffer> { | |
| 59 | let image = await decode(this.input) | |
| 60 | for (const operation of this.operations) image = operation(image) | |
| 61 | return Buffer.from(await encode(image, this.format, this.options)) | |
| 62 | } | |
| 63 | ||
| 64 | private output(format: NativeImageFormat, options: NativeEncodeOptions): NativeImagePipeline { | |
| 65 | const quality = options.quality ?? 80 | |
| 66 | if (!Number.isFinite(quality) || quality < 1 || quality > 100) throw new TypeError('Image quality must be between 1 and 100') | |
| 67 | this.format = format | |
| 68 | this.options = { ...options, quality } | |
| 69 | return this | |
| 74 | 70 | } |
| 75 | 71 | } |
| 76 | 72 | |
| 77 | /** | |
| 78 | * Wrap a Sharp pipeline so it composes with | |
| 79 | * `Storage.put(file, { transform })`. The pipeline function receives a | |
| 80 | * fresh Sharp instance preloaded with the file's bytes; its return | |
| 81 | * value is the final pipeline chain (sharp's methods are chainable so | |
| 82 | * "return img.resize(...)" is the normal shape). | |
| 83 | * | |
| 84 | * The wrapper handles the buffer normalisation + final `.toBuffer()` | |
| 85 | * call so callers don't have to. | |
| 86 | */ | |
| 87 | export function transform( | |
| 88 | pipeline: (img: SharpInstance) => SharpInstance | Promise<SharpInstance>, | |
| 89 | ): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 73 | export function transform(pipeline: (image: NativeImagePipeline) => NativeImagePipeline | Promise<NativeImagePipeline>): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 90 | 74 | return async (input) => { |
| 91 | const sharp = await loadSharp() | |
| 92 | const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : input | |
| 93 | const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes) | |
| 94 | const img = sharp(buf) | |
| 95 | const piped = await pipeline(img) | |
| 96 | return piped.toBuffer() | |
| 75 | const bytes = input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength) | |
| 76 | return (await pipeline(new Pipeline(bytes))).toBuffer() | |
| 97 | 77 | } |
| 98 | 78 | } |
| 99 | 79 | |
| 100 | /** | |
| 101 | * Common preset for square avatars — resizes + crops to fit, encodes | |
| 102 | * as WebP at quality 85. Covers the by-far most common | |
| 103 | * `Storage.put(file, { transform: ... })` callsite. | |
| 104 | * | |
| 105 | * @example | |
| 106 | * ```ts | |
| 107 | * import { avatar } from '@stacksjs/storage/image' | |
| 108 | * | |
| 109 | * await Storage.put(file, { | |
| 110 | * dir: 'avatars', | |
| 111 | * transform: avatar(512), | |
| 112 | * }) | |
| 113 | * ``` | |
| 114 | */ | |
| 115 | 80 | export function avatar(size = 512, quality = 85): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { |
| 116 | return transform(img => img.resize(size, size, { fit: 'cover' }).webp({ quality })) | |
| 81 | return transform(image => image.resize(size, size, { fit: 'cover' }).webp({ quality })) | |
| 117 | 82 | } |
| 118 | 83 | |
| 119 | /** | |
| 120 | * Generic resize preset. `fit` defaults to `'inside'` (preserve aspect | |
| 121 | * ratio, don't crop) which is the most common non-avatar case. | |
| 122 | */ | |
| 123 | export function resize( | |
| 124 | width: number, | |
| 125 | height: number, | |
| 126 | fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' = 'inside', | |
| 127 | ): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 128 | return transform(img => img.resize(width, height, { fit })) | |
| 84 | export function resize(width: number, height: number, fit: ResizeFit = 'inside'): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 85 | return transform(image => image.resize(width, height, { fit }).webp()) | |
| 129 | 86 | } |
| 130 | 87 | |
| 131 | /** | |
| 132 | * Strip EXIF + colour profile + other embedded metadata. Useful when | |
| 133 | * accepting user uploads where you don't want to leak GPS coordinates | |
| 134 | * embedded in phone photos. Pairs well with resize/format presets via | |
| 135 | * the explicit `transform()` form when needed. | |
| 136 | * | |
| 137 | * sharp strips metadata by default unless `withMetadata()` is called; | |
| 138 | * this preset just makes the intent explicit. | |
| 139 | */ | |
| 140 | export function stripMetadata(): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 141 | return transform(img => img) | |
| 88 | export function stripMetadata(format: NativeImageFormat = 'webp'): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> { | |
| 89 | return transform(image => image[format]()) | |
| 142 | 90 | } |
| @@ -25,7 +25,7 @@ export type { FilenameStrategy, PutFileOptions, UploadedFileLike } from './put-f | ||
| 25 | 25 | export { UploadedFile, uploadedFile, uploadedFiles } from './uploaded-file' |
| 26 | 26 | |
| 27 | 27 | // Filesystem type helpers |
| 28 | export { configFromEnv, localDisk, s3Disk } from './types/filesystem' | |
| 28 | export { backblazeDisk, configFromEnv, filebaseDisk, hetznerDisk, localDisk, r2Disk, s3Disk } from './types/filesystem' | |
| 29 | 29 | // Disk-name autocomplete: userland augments `KnownDisks` to get |
| 30 | 30 | // completion on `Storage.disk('…')` (stacksjs/stacks#1924). |
| 31 | 31 | export type { DiskName, KnownDisks } from './types/filesystem' |
| @@ -80,10 +80,8 @@ export interface PutFileOptions { | ||
| 80 | 80 | /** |
| 81 | 81 | * Optional image-processing pipeline applied before write |
| 82 | 82 | * (stacksjs/stacks#1856 Stage 5). Receives the raw bytes + a Sharp |
| 83 | * instance loaded lazily from the `@stacksjs/storage/image` submodule; | |
| 84 | * returns the processed bytes that get persisted. Throws a clear | |
| 85 | * error if `sharp` isn't installed — projects that don't need image | |
| 86 | * transforms don't pay the native-module install cost. | |
| 83 | * pipeline from the `@stacksjs/storage/image` submodule; returns the | |
| 84 | * processed bytes that get persisted. | |
| 87 | 85 | * |
| 88 | 86 | * If the transform changes the output format (e.g. JPEG → WebP), the |
| 89 | 87 | * caller is responsible for updating `mimetype` / `filename` to match |
| @@ -334,6 +334,15 @@ export interface StorageAdapterConfig { | ||
| 334 | 334 | region?: string |
| 335 | 335 | /** S3 key prefix */ |
| 336 | 336 | prefix?: string |
| 337 | /** | |
| 338 | * Endpoint URL for S3-compatible providers (Filebase, Backblaze B2, | |
| 339 | * Cloudflare R2, Hetzner Object Storage, MinIO, ...). May include the | |
| 340 | * scheme (`https://...`); the adapter passes the host on to the S3 client. | |
| 341 | * Omit for AWS S3. | |
| 342 | */ | |
| 343 | endpoint?: string | |
| 344 | /** Force path-style addressing instead of virtual-hosted-style. */ | |
| 345 | usePathStyleEndpoint?: boolean | |
| 337 | 346 | /** |
| 338 | 347 | * AWS credentials. When omitted the adapter falls back to the |
| 339 | 348 | * standard env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, |
| @@ -169,6 +169,84 @@ export function s3Disk(bucket: string, options?: Partial<Omit<S3DiskConfig, 'dri | ||
| 169 | 169 | } |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | /** | |
| 173 | * Helper to create a Filebase disk config (stacksjs/stacks#938). | |
| 174 | * | |
| 175 | * Filebase (https://filebase.com) is an S3-compatible, IPFS-backed object | |
| 176 | * store, so it reuses the `s3` adapter with the endpoint pinned to | |
| 177 | * `https://s3.filebase.com` and the region to `us-east-1` (Filebase's single | |
| 178 | * S3 region). Pass a Filebase bucket; supply Filebase credentials via | |
| 179 | * `options.credentials` or the standard `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` | |
| 180 | * env vars. Any field (prefix, visibility, credentials, ...) can be overridden. | |
| 181 | */ | |
| 182 | export function filebaseDisk(bucket: string, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig { | |
| 183 | return { | |
| 184 | driver: 's3', | |
| 185 | bucket, | |
| 186 | region: 'us-east-1', | |
| 187 | endpoint: 'https://s3.filebase.com', | |
| 188 | visibility: 'private', | |
| 189 | ...options, | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Helper to create a Backblaze B2 disk config (stacksjs/stacks#1897). | |
| 195 | * | |
| 196 | * Backblaze B2 exposes an S3-compatible API, so it reuses the `s3` adapter. | |
| 197 | * `region` is the B2 region embedded in the endpoint (e.g. `us-west-004`, | |
| 198 | * `eu-central-003`); the endpoint resolves to `https://s3.<region>.backblazeb2.com`. | |
| 199 | * Supply B2 application key credentials via `options.credentials` or the AWS_* env vars. | |
| 200 | */ | |
| 201 | export function backblazeDisk(bucket: string, region: string, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig { | |
| 202 | return { | |
| 203 | driver: 's3', | |
| 204 | bucket, | |
| 205 | region, | |
| 206 | endpoint: `https://s3.${region}.backblazeb2.com`, | |
| 207 | visibility: 'private', | |
| 208 | ...options, | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Helper to create a Cloudflare R2 disk config (stacksjs/stacks#1896). | |
| 214 | * | |
| 215 | * R2 exposes an S3-compatible API, so it reuses the `s3` adapter. R2 has a | |
| 216 | * single logical region (`auto`) and a per-account endpoint | |
| 217 | * `https://<accountId>.r2.cloudflarestorage.com`. Supply R2 token credentials | |
| 218 | * via `options.credentials` or the AWS_* env vars. | |
| 219 | */ | |
| 220 | export function r2Disk(bucket: string, accountId: string, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig { | |
| 221 | return { | |
| 222 | driver: 's3', | |
| 223 | bucket, | |
| 224 | region: 'auto', | |
| 225 | endpoint: `https://${accountId}.r2.cloudflarestorage.com`, | |
| 226 | visibility: 'private', | |
| 227 | ...options, | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * Helper to create a Hetzner Object Storage disk config (stacksjs/stacks#1897). | |
| 233 | * | |
| 234 | * Hetzner Object Storage exposes an S3-compatible API, so it reuses the `s3` | |
| 235 | * adapter. `location` is the datacenter (e.g. `fsn1`, `nbg1`, `hel1`), used as | |
| 236 | * both the region and the endpoint host `https://<location>.your-objectstorage.com`. | |
| 237 | * Supply Hetzner S3 credentials via `options.credentials` or the AWS_* env vars. | |
| 238 | */ | |
| 239 | export function hetznerDisk(bucket: string, location: string, options?: Partial<Omit<S3DiskConfig, 'driver' | 'bucket'>>): S3DiskConfig { | |
| 240 | return { | |
| 241 | driver: 's3', | |
| 242 | bucket, | |
| 243 | region: location, | |
| 244 | endpoint: `https://${location}.your-objectstorage.com`, | |
| 245 | visibility: 'private', | |
| 246 | ...options, | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 172 | 250 | /** |
| 173 | 251 | * Create filesystem config from environment variables |
| 174 | 252 | */ |
| @@ -0,0 +1,35 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { filebaseDisk } from '../src/types/filesystem' | |
| 3 | ||
| 4 | // Filebase is S3-compatible (IPFS-backed), so it reuses the s3 adapter with the | |
| 5 | // endpoint pinned. filebaseDisk makes it a first-class preset (stacksjs/stacks#938). | |
| 6 | describe('filebaseDisk', () => { | |
| 7 | it('presets an s3 disk pointed at the Filebase endpoint', () => { | |
| 8 | expect(filebaseDisk('my-bucket')).toEqual({ | |
| 9 | driver: 's3', | |
| 10 | bucket: 'my-bucket', | |
| 11 | region: 'us-east-1', | |
| 12 | endpoint: 'https://s3.filebase.com', | |
| 13 | visibility: 'private', | |
| 14 | }) | |
| 15 | }) | |
| 16 | ||
| 17 | it('lets callers supply credentials and other overrides', () => { | |
| 18 | const disk = filebaseDisk('assets', { | |
| 19 | credentials: { key: 'FILEBASE_KEY', secret: 'FILEBASE_SECRET' }, | |
| 20 | prefix: 'uploads/', | |
| 21 | visibility: 'public', | |
| 22 | }) | |
| 23 | expect(disk.credentials).toEqual({ key: 'FILEBASE_KEY', secret: 'FILEBASE_SECRET' }) | |
| 24 | expect(disk.prefix).toBe('uploads/') | |
| 25 | expect(disk.visibility).toBe('public') | |
| 26 | // Endpoint stays pinned to Filebase unless explicitly overridden. | |
| 27 | expect(disk.endpoint).toBe('https://s3.filebase.com') | |
| 28 | expect(disk.driver).toBe('s3') | |
| 29 | }) | |
| 30 | ||
| 31 | it('allows overriding the endpoint (e.g. a regional/gateway mirror)', () => { | |
| 32 | expect(filebaseDisk('b', { endpoint: 'https://custom.filebase.example' }).endpoint) | |
| 33 | .toBe('https://custom.filebase.example') | |
| 34 | }) | |
| 35 | }) | |
| @@ -1,52 +1,32 @@ | ||
| 1 | /** | |
| 2 | * Tests for `@stacksjs/storage/image` (stacksjs/stacks#1856 Stage 5). | |
| 3 | * | |
| 4 | * sharp is a peer dependency — these tests don't require it. They | |
| 5 | * verify the shape of the wrapper module (`transform`, `avatar`, | |
| 6 | * `resize`, `stripMetadata`) and the clear error users see when sharp | |
| 7 | * isn't installed. | |
| 8 | * | |
| 9 | * The actual sharp pipeline (resize/webp/etc.) is sharp's own test | |
| 10 | * surface — we cover the integration shim, not the image library. | |
| 11 | */ | |
| 12 | ||
| 13 | 1 | import { describe, expect, test } from 'bun:test' |
| 2 | import { createImageData, decode, encode } from 'ts-images' | |
| 14 | 3 | import { avatar, resize, stripMetadata, transform } from '../src/image' |
| 15 | 4 | |
| 16 | describe('@stacksjs/storage/image — exports', () => { | |
| 17 | test('factory functions are present', () => { | |
| 18 | expect(typeof transform).toBe('function') | |
| 19 | expect(typeof avatar).toBe('function') | |
| 20 | expect(typeof resize).toBe('function') | |
| 21 | expect(typeof stripMetadata).toBe('function') | |
| 22 | }) | |
| 5 | async function fixture(): Promise<Uint8Array> { | |
| 6 | const image = createImageData(4, 2) | |
| 7 | image.data.fill(255) | |
| 8 | return encode(image, 'png') | |
| 9 | } | |
| 23 | 10 | |
| 24 | test('each preset returns a function suitable for `Storage.put({ transform })`', () => { | |
| 11 | describe('@stacksjs/storage/image', () => { | |
| 12 | test('provides native Storage.put transforms', () => { | |
| 25 | 13 | expect(typeof avatar()).toBe('function') |
| 26 | expect(typeof resize(100, 100)).toBe('function') | |
| 14 | expect(typeof resize(2, 2)).toBe('function') | |
| 27 | 15 | expect(typeof stripMetadata()).toBe('function') |
| 28 | expect(typeof transform(img => img)).toBe('function') | |
| 16 | expect(typeof transform(image => image)).toBe('function') | |
| 29 | 17 | }) |
| 30 | }) | |
| 31 | 18 | |
| 32 | describe('@stacksjs/storage/image — lazy sharp load error', () => { | |
| 33 | // sharp is not a dependency in the framework's own test environment. | |
| 34 | // The presets should throw a clear install message rather than a | |
| 35 | // bare `Cannot find module` when invoked. | |
| 36 | test('`avatar()` throws a friendly error when sharp is missing', async () => { | |
| 37 | const fn = avatar() | |
| 38 | await expect(fn(new Uint8Array([1, 2, 3]))).rejects.toThrow(/sharp.*not installed/i) | |
| 19 | test('resizes without upscaling and creates square avatars', async () => { | |
| 20 | const source = await fixture() | |
| 21 | const resized = await decode(await resize(2, 2)(source)) | |
| 22 | const square = await decode(await avatar(2)(source)) | |
| 23 | expect([resized.width, resized.height]).toEqual([2, 1]) | |
| 24 | expect([square.width, square.height]).toEqual([2, 2]) | |
| 39 | 25 | }) |
| 40 | 26 | |
| 41 | test('the error message includes the install command', async () => { | |
| 42 | const fn = transform(img => img) | |
| 43 | try { | |
| 44 | await fn(new Uint8Array([1, 2, 3])) | |
| 45 | throw new Error('expected sharp-missing error') | |
| 46 | } | |
| 47 | catch (e) { | |
| 48 | const message = e instanceof Error ? e.message : String(e) | |
| 49 | expect(message).toContain('bun add sharp') | |
| 50 | } | |
| 27 | test('validates transforms before encoding', async () => { | |
| 28 | const source = await fixture() | |
| 29 | await expect(transform(image => image.resize(0, 2))(source)).rejects.toThrow(/width/) | |
| 30 | await expect(transform(image => image.webp({ quality: 101 }))(source)).rejects.toThrow(/quality/) | |
| 51 | 31 | }) |
| 52 | 32 | }) |
| @@ -0,0 +1,49 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { backblazeDisk, hetznerDisk, r2Disk } from '../src/types/filesystem' | |
| 3 | ||
| 4 | // Backblaze B2, Cloudflare R2 and Hetzner Object Storage are all S3-compatible, | |
| 5 | // so they reuse the s3 adapter with the endpoint pinned to the service | |
| 6 | // (stacksjs/stacks#1897, #1896). These presets make each a first-class disk. | |
| 7 | describe('S3-compatible storage presets', () => { | |
| 8 | it('backblazeDisk pins the region into the endpoint', () => { | |
| 9 | expect(backblazeDisk('bucket', 'us-west-004')).toEqual({ | |
| 10 | driver: 's3', | |
| 11 | bucket: 'bucket', | |
| 12 | region: 'us-west-004', | |
| 13 | endpoint: 'https://s3.us-west-004.backblazeb2.com', | |
| 14 | visibility: 'private', | |
| 15 | }) | |
| 16 | }) | |
| 17 | ||
| 18 | it('r2Disk uses the auto region and the per-account endpoint', () => { | |
| 19 | expect(r2Disk('bucket', 'abc123account')).toEqual({ | |
| 20 | driver: 's3', | |
| 21 | bucket: 'bucket', | |
| 22 | region: 'auto', | |
| 23 | endpoint: 'https://abc123account.r2.cloudflarestorage.com', | |
| 24 | visibility: 'private', | |
| 25 | }) | |
| 26 | }) | |
| 27 | ||
| 28 | it('hetznerDisk uses the location as both region and endpoint host', () => { | |
| 29 | expect(hetznerDisk('bucket', 'fsn1')).toEqual({ | |
| 30 | driver: 's3', | |
| 31 | bucket: 'bucket', | |
| 32 | region: 'fsn1', | |
| 33 | endpoint: 'https://fsn1.your-objectstorage.com', | |
| 34 | visibility: 'private', | |
| 35 | }) | |
| 36 | }) | |
| 37 | ||
| 38 | it('each accepts credentials and overrides', () => { | |
| 39 | const disk = r2Disk('assets', 'acct', { | |
| 40 | credentials: { key: 'R2_KEY', secret: 'R2_SECRET' }, | |
| 41 | prefix: 'uploads/', | |
| 42 | visibility: 'public', | |
| 43 | }) | |
| 44 | expect(disk.credentials).toEqual({ key: 'R2_KEY', secret: 'R2_SECRET' }) | |
| 45 | expect(disk.prefix).toBe('uploads/') | |
| 46 | expect(disk.visibility).toBe('public') | |
| 47 | expect(disk.endpoint).toBe('https://acct.r2.cloudflarestorage.com') | |
| 48 | }) | |
| 49 | }) | |
| @@ -0,0 +1,38 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { resolveS3ClientOptions } from '../src/adapters/s3' | |
| 3 | ||
| 4 | // This helper is what actually routes the S3 adapter to S3-compatible providers | |
| 5 | // (Filebase/B2/R2/Hetzner). Without it the endpoint from the disk presets was | |
| 6 | // dropped and every request hit AWS (stacksjs/stacks#938, #1897, #1896). | |
| 7 | describe('resolveS3ClientOptions', () => { | |
| 8 | it('returns undefined for a plain AWS config (no endpoint/creds)', () => { | |
| 9 | expect(resolveS3ClientOptions({})).toBeUndefined() | |
| 10 | }) | |
| 11 | ||
| 12 | it('strips the scheme and trailing slash (ts-cloud wants a bare host)', () => { | |
| 13 | expect(resolveS3ClientOptions({ endpoint: 'https://s3.filebase.com' })).toEqual({ endpoint: 's3.filebase.com' }) | |
| 14 | expect(resolveS3ClientOptions({ endpoint: 'https://s3.us-west-004.backblazeb2.com/' })) | |
| 15 | .toEqual({ endpoint: 's3.us-west-004.backblazeb2.com' }) | |
| 16 | expect(resolveS3ClientOptions({ endpoint: 'http://localhost:9000' })).toEqual({ endpoint: 'localhost:9000' }) | |
| 17 | }) | |
| 18 | ||
| 19 | it('passes forcePathStyle when requested', () => { | |
| 20 | expect(resolveS3ClientOptions({ endpoint: 'https://x.example', usePathStyleEndpoint: true })) | |
| 21 | .toEqual({ endpoint: 'x.example', forcePathStyle: true }) | |
| 22 | }) | |
| 23 | ||
| 24 | it('passes credentials through unchanged', () => { | |
| 25 | expect(resolveS3ClientOptions({ credentials: { accessKeyId: 'A', secretAccessKey: 'S' } })) | |
| 26 | .toEqual({ credentials: { accessKeyId: 'A', secretAccessKey: 'S' } }) | |
| 27 | }) | |
| 28 | ||
| 29 | it('combines endpoint + credentials (the real R2 shape)', () => { | |
| 30 | expect(resolveS3ClientOptions({ | |
| 31 | endpoint: 'https://acct.r2.cloudflarestorage.com', | |
| 32 | credentials: { accessKeyId: 'A', secretAccessKey: 'S' }, | |
| 33 | })).toEqual({ | |
| 34 | endpoint: 'acct.r2.cloudflarestorage.com', | |
| 35 | credentials: { accessKeyId: 'A', secretAccessKey: 'S' }, | |
| 36 | }) | |
| 37 | }) | |
| 38 | }) | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@stacksjs/tinker", |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "Interactive REPL with Stacks framework preloaded.", |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | 8 | "contributors": [ |
| @@ -33,9 +33,29 @@ type AiModel = | ||
| 33 | 33 | * have any questions, feel free to reach out via Discord or GitHub Discussions. |
| 34 | 34 | */ |
| 35 | 35 | export interface AiOptions { |
| 36 | default: AiModel | |
| 36 | default: 'anthropic' | 'openai' | 'ollama' | AiModel | |
| 37 | 37 | models: AiModel[] |
| 38 | 38 | deploy: boolean |
| 39 | drivers?: { | |
| 40 | anthropic?: { | |
| 41 | apiKey?: string | |
| 42 | model?: string | |
| 43 | maxTokens?: number | |
| 44 | anthropicVersion?: string | |
| 45 | } | |
| 46 | openai?: { | |
| 47 | apiKey?: string | |
| 48 | model?: string | |
| 49 | maxTokens?: number | |
| 50 | baseUrl?: string | |
| 51 | embeddingModel?: string | |
| 52 | } | |
| 53 | ollama?: { | |
| 54 | host?: string | |
| 55 | model?: string | |
| 56 | embeddingModel?: string | |
| 57 | } | |
| 58 | } | |
| 39 | 59 | } |
| 40 | 60 | |
| 41 | 61 | export type AiConfig = Partial<AiOptions> |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.471Z] local.INFO: Building @stacksjs/types... | |
| 2 | [2026-07-23T13:13:13.645Z] local.INFO: Building @stacksjs/types... | |
| 3 | [2026-07-23T13:13:36.589Z] local.INFO: Building @stacksjs/types... | |
| 1 | [2026-07-23T16:49:27.435Z] local.INFO: Building @stacksjs/types... | |
| 2 | [2026-07-23T16:49:39.082Z] local.INFO: Building @stacksjs/types... | |
| @@ -1,3 +1,2 @@ | ||
| 1 | [2026-07-23T13:04:26.419Z] local.INFO: Building @stacksjs/validation... | |
| 2 | [2026-07-23T13:13:14.058Z] local.INFO: Building @stacksjs/validation... | |
| 3 | [2026-07-23T13:13:36.624Z] local.INFO: Building @stacksjs/validation... | |
| 1 | [2026-07-23T16:49:29.558Z] local.INFO: Building @stacksjs/validation... | |
| 2 | [2026-07-23T16:49:39.080Z] local.INFO: Building @stacksjs/validation... | |
| @@ -0,0 +1,82 @@ | ||
| 1 | # @stacksjs/video | |
| 2 | ||
| 3 | Deterministic, capability-aware video planning for Stacks. Unsupported codec operations fail early instead of being mislabeled as remuxing. | |
| 4 | ||
| 5 | ## Inspect, plan, and process | |
| 6 | ||
| 7 | ```ts | |
| 8 | import { detectVideoRuntimeCapabilities } from 'ts-videos' | |
| 9 | import { video } from '@stacksjs/video' | |
| 10 | ||
| 11 | const delivery = await video('uploads/demo.mp4') | |
| 12 | .profile({ | |
| 13 | width: 1920, | |
| 14 | height: 1080, | |
| 15 | duration: 92, | |
| 16 | frameRate: 30, | |
| 17 | container: 'mp4', | |
| 18 | videoCodec: 'h264', | |
| 19 | audioCodec: 'aac', | |
| 20 | }) | |
| 21 | .ladder('auto') | |
| 22 | .output(['mp4', 'webm']) | |
| 23 | .streaming(['hls', 'dash']) | |
| 24 | .runtime(await detectVideoRuntimeCapabilities()) | |
| 25 | .process({ signal: request.signal }) | |
| 26 | ``` | |
| 27 | ||
| 28 | `.generate()` returns the deterministic plan without processing. `.process()` executes its container and rendition matrix through native WebCodecs and the built-in MP4 or WebM muxers. The same call returns progressive files, CMAF segments, HLS and DASH manifests, poster art, preview sprites, and preview WebVTT when the native preview runtime is available. Compatible packets are copied, real codec changes are encoded, scaling never upscales, metadata is preserved, and work runs in bounded batches. | |
| 29 | ||
| 30 | ```ts | |
| 31 | await storage.putMany(delivery.files) | |
| 32 | ``` | |
| 33 | ||
| 34 | ## HLS, DASH, and protected delivery | |
| 35 | ||
| 36 | ```ts | |
| 37 | import { createAdaptiveDeliveryBundle } from 'ts-videos/protected-delivery' | |
| 38 | ||
| 39 | const bundle = await createAdaptiveDeliveryBundle(plan, segmentedRenditions, { | |
| 40 | hlsAes128: { | |
| 41 | key, | |
| 42 | keyUri: '/media/keys/demo', | |
| 43 | }, | |
| 44 | drm: { | |
| 45 | descriptors: [{ system: 'widevine', keyId, licenseUrl }], | |
| 46 | dashSegmentsEncrypted: true, | |
| 47 | }, | |
| 48 | }) | |
| 49 | ``` | |
| 50 | ||
| 51 | HLS AES-128 is performed with Web Crypto. Widevine, PlayReady, FairPlay, and ClearKey descriptors require already encrypted CENC or SAMPLE-AES media. Clear segments cannot be labeled as proprietary DRM content. | |
| 52 | ||
| 53 | For hosted delivery, publish immutable segments to S3 and use the media helpers from `ts-cloud`: | |
| 54 | ||
| 55 | ```ts | |
| 56 | import { buildMediaCdnPlan, signCloudFrontCookies } from 'ts-cloud' | |
| 57 | ||
| 58 | const cdn = buildMediaCdnPlan({ | |
| 59 | bucket: 'example-media', | |
| 60 | region: 'us-west-2', | |
| 61 | domain: 'media.example.com', | |
| 62 | protected: true, | |
| 63 | }) | |
| 64 | const cookies = signCloudFrontCookies({ resource: 'https://media.example.com/demo/*', expires }, signer) | |
| 65 | ``` | |
| 66 | ||
| 67 | Manifests remain short-lived or private, while content-addressed segments use immutable caching. Key routes must enforce the same application authorization or signed audience boundary. | |
| 68 | ||
| 69 | ## Default STX player | |
| 70 | ||
| 71 | ```stx | |
| 72 | <Video | |
| 73 | src="/media/demo/manifest.mpd" | |
| 74 | title="Product demonstration" | |
| 75 | poster="/media/demo/poster.avif" | |
| 76 | :sources="progressiveFallbacks" | |
| 77 | :tracks="captionAndChapterTracks" | |
| 78 | :drm="drm" | |
| 79 | /> | |
| 80 | ``` | |
| 81 | ||
| 82 | The default UI includes play, seek, live edge, time, mute, volume, speed, quality, captions, audio tracks, AirPlay, Remote Playback, Picture in Picture, fullscreen, and accessible errors. Native video controls remain as the no-JavaScript fallback. | |
| @@ -0,0 +1,4 @@ | ||
| 1 | import { frameworkExternal, intro, outro, transpilePackage } from '../build/src' | |
| 2 | const { startTime } = await intro({ dir: import.meta.dir }) | |
| 3 | await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() }) | |
| 4 | await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } }) | |
| @@ -1,34 +1,23 @@ | ||
| 1 | 1 | { |
| 2 | "name": "@stacksjs/registry", | |
| 2 | "name": "@stacksjs/video", | |
| 3 | 3 | "type": "module", |
| 4 | 4 | "sideEffects": false, |
| 5 | "version": "0.70.162", | |
| 6 | "description": "The Stacks Registry.", | |
| 5 | "version": "0.70.163", | |
| 6 | "description": "Native video delivery planning for Stacks.", | |
| 7 | 7 | "author": "Chris Breuer", |
| 8 | "contributors": [ | |
| 9 | "Chris Breuer <chris@stacksjs.com>" | |
| 10 | ], | |
| 11 | 8 | "license": "MIT", |
| 12 | 9 | "funding": "https://github.com/sponsors/chrisbbreuer", |
| 13 | "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme", | |
| 14 | 10 | "repository": { |
| 15 | 11 | "type": "git", |
| 16 | 12 | "url": "git+https://github.com/stacksjs/stacks.git", |
| 17 | "directory": "./storage/framework/core/registry" | |
| 18 | }, | |
| 19 | "bugs": { | |
| 20 | "url": "https://github.com/stacksjs/stacks/issues" | |
| 13 | "directory": "./storage/framework/core/video" | |
| 21 | 14 | }, |
| 22 | "keywords": [ | |
| 23 | "registry", | |
| 24 | "stacks" | |
| 25 | ], | |
| 26 | 15 | "exports": { |
| 27 | 16 | ".": { |
| 17 | "types": "./dist/index.d.ts", | |
| 28 | 18 | "development": "./src/index.ts", |
| 29 | 19 | "bun": "./dist/index.js", |
| 30 | "import": "./dist/index.js", | |
| 31 | "default": "./dist/index.js" | |
| 20 | "import": "./dist/index.js" | |
| 32 | 21 | } |
| 33 | 22 | }, |
| 34 | 23 | "module": "dist/index.js", |
| @@ -37,14 +26,15 @@ | ||
| 37 | 26 | "README.md", |
| 38 | 27 | "dist" |
| 39 | 28 | ], |
| 40 | "dependencies": { | |
| 41 | "@stacksjs/types": "workspace:*" | |
| 42 | }, | |
| 43 | 29 | "scripts": { |
| 44 | 30 | "build": "bun build.ts", |
| 45 | 31 | "typecheck": "bun tsc --noEmit", |
| 46 | 32 | "prepublishOnly": "bun run build" |
| 47 | 33 | }, |
| 34 | "dependencies": { | |
| 35 | "ts-video-player": "^0.1.0", | |
| 36 | "ts-videos": "^0.1.1" | |
| 37 | }, | |
| 48 | 38 | "devDependencies": { |
| 49 | 39 | "better-dx": "catalog:" |
| 50 | 40 | } |
| @@ -0,0 +1,139 @@ | ||
| 1 | import { createHmac, timingSafeEqual } from 'node:crypto' | |
| 2 | ||
| 3 | export type VideoContainer = 'mp4' | 'webm' | |
| 4 | export type StreamingFormat = 'hls' | 'dash' | |
| 5 | export type VideoCodec = 'h264' | 'h265' | 'vp8' | 'vp9' | 'av1' | 'mpeg1' | 'mpeg2' | 'mpeg4' | 'theora' | 'mjpeg' | 'prores' | 'dnxhd' | 'unknown' | |
| 6 | export type VideoAudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'alac' | 'ac3' | 'eac3' | 'dts' | 'pcm_s16le' | 'pcm_s16be' | 'pcm_s24le' | 'pcm_s24be' | 'pcm_s32le' | 'pcm_s32be' | 'pcm_f32le' | 'pcm_f32be' | 'pcm_f64le' | 'pcm_f64be' | 'pcm_mulaw' | 'pcm_alaw' | 'unknown' | |
| 7 | export interface VideoProfile { width: number, height: number, duration: number, frameRate: number, container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, videoBitrate?: number, hasAudio?: boolean, hdr?: boolean } | |
| 8 | export interface VideoRendition { name: string, width: number, height: number, frameRate: number, videoBitrate: number, audioBitrate: number } | |
| 9 | export interface VideoCapabilities { videoEncoder: boolean, audioEncoder: boolean, videoCodecs: string[], audioCodecs: string[] } | |
| 10 | export interface VideoOutput { container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, action: 'copy' | 'transcode', available: boolean, reason?: string } | |
| 11 | export interface VideoPlan { source: string, profile: VideoProfile, renditions: VideoRendition[], outputs: VideoOutput[], streaming: StreamingFormat[], segmentDuration: number, keyframeInterval: number } | |
| 12 | export type VideoProcessOptions = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineOptions | |
| 13 | export type ProcessedVideoDelivery = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineResult | |
| 14 | export interface PreviewCue { startTime: number, endTime: number, uri: string, x?: number, y?: number, width?: number, height?: number } | |
| 15 | const edges = [240, 360, 480, 540, 720, 1080, 1440, 2160] | |
| 16 | const even = (value: number): number => Math.max(2, Math.round(value / 2) * 2) | |
| 17 | function validate(profile: VideoProfile): void { | |
| 18 | for (const [name, value] of Object.entries({ width: profile.width, height: profile.height, duration: profile.duration, frameRate: profile.frameRate })) { | |
| 19 | if (!Number.isFinite(value) || value <= 0) throw new TypeError(`Video ${name} must be positive`) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | export function deriveVideoLadder(profile: VideoProfile, maximum?: number): VideoRendition[] { | |
| 24 | validate(profile) | |
| 25 | const short = Math.min(profile.width, profile.height); const limit = Math.min(short, maximum ?? short) | |
| 26 | const targets = edges.filter(edge => edge <= limit); if (!targets.includes(limit)) targets.push(limit) | |
| 27 | return [...new Set(targets)].sort((a, b) => a - b).map((edge) => { | |
| 28 | const scale = edge / short | |
| 29 | const width = even(profile.width * scale) | |
| 30 | const height = even(profile.height * scale) | |
| 31 | const rate = Math.round(width * height * Math.min(60, profile.frameRate) * 0.075 * (profile.frameRate > 30 ? Math.min(2, profile.frameRate / 30) : 1) * (profile.hdr ? 1.25 : 1) / 1000) * 1000 | |
| 32 | return { name: `${edge}p`, width, height, frameRate: profile.frameRate, videoBitrate: Math.max(250_000, Math.min(profile.videoBitrate ?? Infinity, rate)), audioBitrate: profile.hasAudio === false ? 0 : width >= 1280 ? 192_000 : 128_000 } | |
| 33 | }).filter((item, index, all) => item.width <= profile.width && item.height <= profile.height && all.findIndex(value => value.width === item.width && value.height === item.height) === index) | |
| 34 | } | |
| 35 | ||
| 36 | export class VideoBuilder { | |
| 37 | private inspected?: VideoProfile | |
| 38 | private formats: VideoContainer[] = ['mp4', 'webm'] | |
| 39 | private streams: StreamingFormat[] = ['hls', 'dash'] | |
| 40 | private maximum?: number | |
| 41 | private capabilities: VideoCapabilities = { videoEncoder: false, audioEncoder: false, videoCodecs: [], audioCodecs: [] } | |
| 42 | constructor(private readonly source: string) {} | |
| 43 | profile(value: VideoProfile): this { validate(value); this.inspected = value; return this } | |
| 44 | ladder(value: 'auto' | number): this { if (value !== 'auto' && (!Number.isInteger(value) || value <= 0)) throw new TypeError('Video ladder height must be positive'); this.maximum = value === 'auto' ? undefined : value; return this } | |
| 45 | output(formats: VideoContainer[]): this { if (!formats.length) throw new TypeError('Video formats are required'); this.formats = [...new Set(formats)]; return this } | |
| 46 | streaming(formats: StreamingFormat[]): this { this.streams = [...new Set(formats)]; return this } | |
| 47 | runtime(value: VideoCapabilities): this { this.capabilities = value; return this } | |
| 48 | generate(): VideoPlan { | |
| 49 | if (!this.inspected) throw new Error('Video inspection is required; pass its profile with .profile()') | |
| 50 | const profile = this.inspected; const renditions = deriveVideoLadder(profile, this.maximum) | |
| 51 | const outputs = this.formats.map((container): VideoOutput => { | |
| 52 | const videoCodec = container === 'mp4' ? 'h264' : 'vp9'; const audioCodec = profile.hasAudio === false ? undefined : container === 'mp4' ? 'aac' : 'opus' | |
| 53 | const original = renditions.length === 1 && renditions[0]!.width === profile.width && renditions[0]!.height === profile.height | |
| 54 | const copy = original && profile.container === container && profile.videoCodec === videoCodec && (!audioCodec || profile.audioCodec === audioCodec) | |
| 55 | const available = copy || this.capabilities.videoEncoder && this.capabilities.videoCodecs.includes(videoCodec) && (!audioCodec || this.capabilities.audioEncoder && this.capabilities.audioCodecs.includes(audioCodec)) | |
| 56 | return { container, videoCodec, audioCodec, action: copy ? 'copy' : 'transcode', available, reason: available ? undefined : `Native ${videoCodec}${audioCodec ? `/${audioCodec}` : ''} encoding is unavailable` } | |
| 57 | }) | |
| 58 | const segmentDuration = profile.duration <= 30 ? 2 : profile.duration <= 600 ? 4 : 6 | |
| 59 | return { source: this.source, profile, renditions, outputs, streaming: this.streams, segmentDuration, keyframeInterval: Math.max(1, Math.round(profile.frameRate * segmentDuration)) } | |
| 60 | } | |
| 61 | async process(options: VideoProcessOptions = {}): Promise<ProcessedVideoDelivery> { return processVideoPlan(this.generate(), options) } | |
| 62 | } | |
| 63 | export function video(source: string): VideoBuilder { return new VideoBuilder(source) } | |
| 64 | export async function processVideoPlan(plan: VideoPlan, options: VideoProcessOptions = {}): Promise<ProcessedVideoDelivery> { | |
| 65 | assertVideoPlanExecutable(plan) | |
| 66 | const { createVideoDeliveryPipeline } = await import('ts-videos/delivery-pipeline') | |
| 67 | return createVideoDeliveryPipeline(plan.source, { | |
| 68 | source: plan.profile, | |
| 69 | renditions: plan.renditions, | |
| 70 | outputs: plan.outputs, | |
| 71 | streaming: plan.streaming, | |
| 72 | segmentDuration: plan.segmentDuration, | |
| 73 | keyframeInterval: plan.keyframeInterval, | |
| 74 | }, options) | |
| 75 | } | |
| 76 | export function assertVideoPlanExecutable(plan: VideoPlan): void { | |
| 77 | const missing = plan.outputs.filter(value => !value.available) | |
| 78 | if (missing.length) throw new Error(missing.map(value => `${value.container}: ${value.reason}`).join('; ')) | |
| 79 | } | |
| 80 | export function createHlsMaster(plan: VideoPlan, uri: (rendition: VideoRendition) => string): string { | |
| 81 | const lines = ['#EXTM3U', '#EXT-X-VERSION:7', '#EXT-X-INDEPENDENT-SEGMENTS']; const output = plan.outputs.find(value => value.container === 'mp4') | |
| 82 | for (const item of plan.renditions) { lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${item.videoBitrate + item.audioBitrate},AVERAGE-BANDWIDTH=${Math.round((item.videoBitrate + item.audioBitrate) * .9)},RESOLUTION=${item.width}x${item.height},FRAME-RATE=${item.frameRate.toFixed(3)},CODECS="${output?.videoCodec ?? plan.profile.videoCodec}${output?.audioCodec ? `,${output.audioCodec}` : ''}"`); lines.push(uri(item)) } | |
| 83 | return `${lines.join('\n')}\n` | |
| 84 | } | |
| 85 | function time(seconds: number): string { | |
| 86 | const ms = Math.round(seconds * 1000) | |
| 87 | return `${String(Math.floor(ms / 3600000)).padStart(2, '0')}:${String(Math.floor(ms % 3600000 / 60000)).padStart(2, '0')}:${String(Math.floor(ms % 60000 / 1000)).padStart(2, '0')}.${String(ms % 1000).padStart(3, '0')}` | |
| 88 | } | |
| 89 | export function createPreviewVtt(cues: readonly PreviewCue[]): string { | |
| 90 | let end = 0 | |
| 91 | const lines = ['WEBVTT', ''] | |
| 92 | cues.forEach((cue, index) => { | |
| 93 | if (cue.startTime < end || cue.endTime <= cue.startTime) throw new TypeError(`Invalid preview cue ${index}`) | |
| 94 | const sprite = [cue.x, cue.y, cue.width, cue.height].every(value => value !== undefined) | |
| 95 | lines.push(`${time(cue.startTime)} --> ${time(cue.endTime)}`, sprite ? `${cue.uri}#xywh=${cue.x},${cue.y},${cue.width},${cue.height}` : cue.uri, '') | |
| 96 | end = cue.endTime | |
| 97 | }) | |
| 98 | return lines.join('\n') | |
| 99 | } | |
| 100 | export function videoResponseHeaders(bytes: number, etag: string, contentType: string): Record<string, string> { | |
| 101 | return { 'Accept-Ranges': 'bytes', 'Content-Length': String(bytes), 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', 'ETag': `"${etag}"` } | |
| 102 | } | |
| 103 | export function signVideoAsset(path: string, expires: number, secret: string): string { | |
| 104 | return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url') | |
| 105 | } | |
| 106 | export function verifyVideoAsset(path: string, expires: number, signature: string, secret: string, now: number = Date.now()): boolean { if (!Number.isInteger(expires) || expires * 1000 <= now) return false; const expected = Buffer.from(signVideoAsset(path, expires, secret)); const actual = Buffer.from(signature); return expected.length === actual.length && timingSafeEqual(expected, actual) } | |
| 107 | ||
| 108 | export interface VideoSegment { uri: string, duration: number, data: Uint8Array } | |
| 109 | export interface VideoHlsProtection { key: Uint8Array, keyUri: string, iv?: (_index: number) => Uint8Array } | |
| 110 | export interface ProtectedVideoPlaylist { playlist: string, files: Record<string, Uint8Array>, encrypted: boolean } | |
| 111 | ||
| 112 | function sequenceIv(index: number): Uint8Array { const value = new Uint8Array(16); new DataView(value.buffer).setBigUint64(8, BigInt(index)); return value } | |
| 113 | function hex(value: Uint8Array): string { return [...value].map(byte => byte.toString(16).padStart(2, '0')).join('') } | |
| 114 | async function encryptAes(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { | |
| 115 | if (key.byteLength !== 16 || iv.byteLength !== 16) throw new TypeError('HLS AES-128 keys and IVs must contain 16 bytes') | |
| 116 | const keyBytes = Uint8Array.from(key); const ivBytes = Uint8Array.from(iv); const dataBytes = Uint8Array.from(data) | |
| 117 | const cryptoKey = await crypto.subtle.importKey('raw', keyBytes.buffer, { name: 'AES-CBC' }, false, ['encrypt']) | |
| 118 | return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivBytes.buffer }, cryptoKey, dataBytes.buffer)) | |
| 119 | } | |
| 120 | export async function createProtectedVideoPlaylist(segments: VideoSegment[], protection?: VideoHlsProtection): Promise<ProtectedVideoPlaylist> { | |
| 121 | if (!segments.length) throw new TypeError('Video playlist requires segments') | |
| 122 | if (protection && /[\r\n"]/.test(protection.keyUri)) throw new TypeError('Invalid video key URI') | |
| 123 | const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map(item => item.duration)))}`, '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-INDEPENDENT-SEGMENTS']; const files: Record<string, Uint8Array> = {} | |
| 124 | for (const [index, segment] of segments.entries()) { | |
| 125 | if (!Number.isFinite(segment.duration) || segment.duration <= 0 || /[\r\n"]/.test(segment.uri)) throw new TypeError(`Invalid video segment ${index}`) | |
| 126 | let data = segment.data | |
| 127 | if (protection) { const iv = protection.iv?.(index) ?? sequenceIv(index); data = await encryptAes(data, protection.key, iv); lines.push(`#EXT-X-KEY:METHOD=AES-128,URI="${protection.keyUri}",IV=0x${hex(iv)}`) } | |
| 128 | lines.push(`#EXTINF:${segment.duration.toFixed(6)},`, segment.uri); files[segment.uri] = data | |
| 129 | } | |
| 130 | lines.push('#EXT-X-ENDLIST', '') | |
| 131 | return { playlist: lines.join('\n'), files, encrypted: !!protection } | |
| 132 | } | |
| 133 | export function videoAssetHeaders(path: string, bytes?: number, etag?: string, protectedMedia = false): Record<string, string> { | |
| 134 | const manifest = /\.(?:m3u8|mpd|vtt)$/i.test(path) | |
| 135 | const headers: Record<string, string> = { 'Accept-Ranges': 'bytes', 'Cache-Control': protectedMedia && manifest ? 'private, no-store' : manifest ? 'public, max-age=5, s-maxage=30' : 'public, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff' } | |
| 136 | if (bytes !== undefined) headers['Content-Length'] = String(bytes) | |
| 137 | if (etag) headers.ETag = `"${etag}"` | |
| 138 | return headers | |
| 139 | } | |
| @@ -0,0 +1,1 @@ | ||
| 1 | [2026-07-23T16:49:27.964Z] local.INFO: Building @stacksjs/video... | |
| @@ -0,0 +1,25 @@ | ||
| 1 | import { expect, mock, test } from 'bun:test' | |
| 2 | ||
| 3 | mock.module('ts-videos/delivery-pipeline', () => ({ | |
| 4 | createVideoDeliveryPipeline: async (source: string, plan: { source: { width: number }, renditions: unknown[] }): Promise<unknown> => { | |
| 5 | expect(source).toBe('movie.mp4') | |
| 6 | expect(plan.source.width).toBe(1920) | |
| 7 | expect(plan.renditions.length).toBeGreaterThan(1) | |
| 8 | return { | |
| 9 | derivatives: [{ output: { container: 'mp4' }, rendition: plan.renditions[0], bytes: new Uint8Array([1, 2, 3]) }], | |
| 10 | files: { 'video/1080p.mp4': new Uint8Array([1, 2, 3]) }, | |
| 11 | } | |
| 12 | }, | |
| 13 | })) | |
| 14 | ||
| 15 | const { video } = await import('../src') | |
| 16 | ||
| 17 | test('processes a generated video plan through ts-videos', async () => { | |
| 18 | const result = await video('movie.mp4') | |
| 19 | .profile({ width: 1920, height: 1080, duration: 60, frameRate: 30, container: 'mp4', videoCodec: 'h264', audioCodec: 'aac' }) | |
| 20 | .output(['mp4']) | |
| 21 | .runtime({ videoEncoder: true, audioEncoder: true, videoCodecs: ['h264'], audioCodecs: ['aac'] }) | |
| 22 | .process() | |
| 23 | expect(result.derivatives[0]?.bytes).toEqual(new Uint8Array([1, 2, 3])) | |
| 24 | expect(result.files['video/1080p.mp4']).toEqual(new Uint8Array([1, 2, 3])) | |
| 25 | }) | |
| @@ -0,0 +1,9 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { assertVideoPlanExecutable, createHlsMaster, createPreviewVtt, createProtectedVideoPlaylist, deriveVideoLadder, video, videoAssetHeaders } from '../src' | |
| 3 | const profile = { width: 1920, height: 1080, duration: 120, frameRate: 30, container: 'mp4' as const, videoCodec: 'h264', audioCodec: 'aac', hasAudio: true } | |
| 4 | describe('@stacksjs/video', () => { | |
| 5 | test('creates distinct ladders without upscaling', () => expect(deriveVideoLadder(profile).map(value => value.height)).toEqual([240, 360, 480, 540, 720, 1080])) | |
| 6 | test('fails early when encoders are unavailable', () => { const plan = video('demo.mp4').profile(profile).generate(); expect(plan.outputs[1]).toMatchObject({ action: 'transcode', available: false }); expect(() => assertVideoPlanExecutable(plan)).toThrow(/unavailable/) }) | |
| 7 | test('emits HLS metadata and preview VTT', () => { const plan = video('demo.mp4').profile(profile).output(['mp4']).runtime({ videoEncoder: true, audioEncoder: true, videoCodecs: ['h264'], audioCodecs: ['aac'] }).generate(); expect(createHlsMaster(plan, item => `${item.name}.m3u8`)).toContain('RESOLUTION=1920x1080'); expect(createPreviewVtt([{ startTime: 0, endTime: 10, uri: 'sprite.jpg', x: 0, y: 0, width: 160, height: 90 }])).toContain('#xywh=0,0,160,90') }) | |
| 8 | test('encrypts HLS segments and protects manifest caching', async () => { const source = new Uint8Array([1, 2, 3]); const result = await createProtectedVideoPlaylist([{ uri: 'one.m4s', duration: 4, data: source }], { key: new Uint8Array(16).fill(4), keyUri: '/keys/video' }); expect(result.files['one.m4s']).not.toEqual(source); expect(result.playlist).toContain('METHOD=AES-128'); expect(videoAssetHeaders('master.m3u8', undefined, undefined, true)['Cache-Control']).toBe('private, no-store') }) | |
| 9 | }) | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "publisher": "Stacks", |
| 3 | 3 | "name": "vscode-stacks", |
| 4 | 4 | "displayName": "Stacks", |
| 5 | "version": "0.70.162", | |
| 5 | "version": "0.70.163", | |
| 6 | 6 | "description": "A modern Stacks development environment.", |
| 7 | 7 | "license": "MIT", |
| 8 | 8 | "funding": "https://github.com/sponsors/chrisbbreuer", |
| @@ -0,0 +1,92 @@ | ||
| 1 | <script server> | |
| 2 | interface AudioSource { src: string, type?: string } | |
| 3 | interface AudioDrm { | |
| 4 | widevine?: { url: string, headers?: Record<string, string> } | |
| 5 | playready?: { url: string, headers?: Record<string, string> } | |
| 6 | fairplay?: { url: string, certificateUrl: string, headers?: Record<string, string> } | |
| 7 | } | |
| 8 | interface AudioTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean } | |
| 9 | interface AudioProps { | |
| 10 | src: string | |
| 11 | sources?: AudioSource[] | |
| 12 | drm?: AudioDrm | |
| 13 | tracks?: AudioTrack[] | |
| 14 | title?: string | |
| 15 | artist?: string | |
| 16 | artwork?: string | |
| 17 | waveform?: string | |
| 18 | controls?: boolean | |
| 19 | autoplay?: boolean | |
| 20 | loop?: boolean | |
| 21 | muted?: boolean | |
| 22 | preload?: 'none' | 'metadata' | 'auto' | |
| 23 | crossorigin?: 'anonymous' | 'use-credentials' | |
| 24 | class?: string | |
| 25 | } | |
| 26 | const props = defineProps<AudioProps>() | |
| 27 | const src = props.src || '' | |
| 28 | const sources = props.sources || [] | |
| 29 | const tracks = props.tracks || [] | |
| 30 | const title = props.title || 'Audio player' | |
| 31 | const artist = props.artist || '' | |
| 32 | const artwork = props.artwork || '' | |
| 33 | const waveform = props.waveform || '' | |
| 34 | const controls = props.controls !== false | |
| 35 | const preload = props.preload || 'metadata' | |
| 36 | const crossorigin = props.crossorigin || '' | |
| 37 | const className = props.class || '' | |
| 38 | const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source) | |
| 39 | const playerTracks = tracks.map(track => ({ ...track, language: track.srclang })) | |
| 40 | const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026') | |
| 41 | </script> | |
| 42 | ||
| 43 | <script client> | |
| 44 | import { registerElements } from 'ts-video-player/elements' | |
| 45 | registerElements() | |
| 46 | </script> | |
| 47 | ||
| 48 | <figure class="m-0 overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200 dark:bg-stone-950 dark:ring-stone-800 "> | |
| 49 | <div class="flex gap-4 items-center p-4 sm:p-5"> | |
| 50 | @if(artwork) | |
| 51 | <img src="" alt="" width="72" height="72" loading="lazy" decoding="async" class="object-cover shrink-0 h-18 w-18 ring-1 ring-black/5 rounded-xl dark:ring-white/10"> | |
| 52 | @else | |
| 53 | <div class="flex items-center justify-center shrink-0 h-18 w-18 text-stone-500 dark:text-stone-400 bg-stone-100 dark:bg-stone-900 rounded-xl" aria-hidden="true"><span class="h-7 w-7 i-hugeicons-music-note-01"></span></div> | |
| 54 | @endif | |
| 55 | <div class="flex-1 min-w-0"> | |
| 56 | <figcaption class="font-semibold text-sm text-stone-950 truncate dark:text-white">{{ title }}</figcaption> | |
| 57 | @if(artist)<p class="mt-0.5 text-sm text-stone-500 truncate dark:text-stone-400">{{ artist }}</p>@endif | |
| 58 | @if(waveform)<img src="" alt="" loading="lazy" decoding="async" class="object-cover mt-3 h-10 w-full opacity-70 dark:opacity-80">@endif | |
| 59 | </div> | |
| 60 | </div> | |
| 61 | <div class="p-3 bg-stone-950 border-stone-100 border-t dark:border-stone-800"> | |
| 62 | <video-player src="" title="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" data-media-config="" @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif preload="" @if(crossorigin) crossorigin="" keep-alive aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="block overflow-hidden relative h-16 w-full text-white rounded-xl focus-within:ring-2 focus-within:ring-sky-500"> | |
| 63 | <media-error-display></media-error-display> | |
| 64 | <audio data-native-fallback src="" @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif preload="" @if(crossorigin) crossorigin="" @endif aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="block h-16 w-full accent-sky-500"> | |
| 65 | @foreach (sources as source)<source src="" @if(source.type) type="" @endif>@endforeach | |
| 66 | @foreach (tracks as track)<track src="" kind="" @if(track.srclang) srclang="" @endif @if(track.label) label="" @endif @if(track.default) default @endif>@endforeach | |
| 67 | Your browser does not support audio playback. | |
| 68 | </audio> | |
| 69 | @if(controls) | |
| 70 | <video-skin> | |
| 71 | <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button> | |
| 72 | <media-play-button slot="left" aria-label="Play or pause"></media-play-button> | |
| 73 | <media-seek-button slot="left" seconds="10" aria-label="Forward 10 seconds"></media-seek-button> | |
| 74 | <media-time-group slot="center" class="font-mono tabular-nums text-xs"></media-time-group> | |
| 75 | <media-progress-bar slot="center" @if(waveform) waveform="" @endif aria-label="Audio progress"></media-progress-bar> | |
| 76 | <media-mute-button slot="right" aria-label="Mute or unmute"></media-mute-button> | |
| 77 | <media-volume-slider slot="right" aria-label="Volume"></media-volume-slider> | |
| 78 | <media-playback-rate-button slot="right" rates="0.5,0.75,1,1.25,1.5,2" aria-label="Playback speed"></media-playback-rate-button> | |
| 79 | <media-settings-menu slot="right" aria-label="Audio settings"></media-settings-menu> | |
| 80 | <media-airplay-button slot="right" aria-label="Play on AirPlay"></media-airplay-button> | |
| 81 | <media-remote-playback-button slot="right" aria-label="Play on another device"></media-remote-playback-button> | |
| 82 | </video-skin> | |
| 83 | @endif | |
| 84 | </video-player> | |
| 85 | </div> | |
| 86 | </figure> | |
| 87 | ||
| 88 | <style> | |
| 89 | video-player:not(:defined) video-skin { display: none; } | |
| 90 | video-player:defined > [data-native-fallback] { display: none; } | |
| 91 | @media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } } | |
| 92 | </style> | |
| @@ -0,0 +1,59 @@ | ||
| 1 | <script server> | |
| 2 | interface ImageSource { type: string, srcset: string, media?: string } | |
| 3 | interface ImageProps { | |
| 4 | src: string | |
| 5 | alt: string | |
| 6 | width?: number | |
| 7 | height?: number | |
| 8 | aspectRatio?: string | |
| 9 | srcset?: string | |
| 10 | sizes?: string | |
| 11 | sources?: ImageSource[] | |
| 12 | loading?: 'lazy' | 'eager' | |
| 13 | decoding?: 'async' | 'sync' | 'auto' | |
| 14 | fetchpriority?: 'high' | 'low' | 'auto' | |
| 15 | priority?: boolean | |
| 16 | decorative?: boolean | |
| 17 | fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' | |
| 18 | position?: string | |
| 19 | placeholder?: string | |
| 20 | class?: string | |
| 21 | } | |
| 22 | const props = defineProps<ImageProps>() | |
| 23 | if (!props.decorative && !props.alt?.trim()) throw new TypeError('Image alt text is required unless decorative is true') | |
| 24 | const src = props.src || '' | |
| 25 | const alt = props.decorative ? '' : props.alt || '' | |
| 26 | const width = props.width || '' | |
| 27 | const height = props.height || '' | |
| 28 | const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '') | |
| 29 | const srcset = props.srcset || '' | |
| 30 | const sizes = props.sizes || (srcset ? '100vw' : '') | |
| 31 | const sources = props.sources || [] | |
| 32 | const loading = props.priority ? 'eager' : props.loading || 'lazy' | |
| 33 | const decoding = props.decoding || 'async' | |
| 34 | const priority = props.priority ? 'high' : props.fetchpriority || 'auto' | |
| 35 | const fit = props.fit || 'cover' | |
| 36 | const position = props.position || 'center' | |
| 37 | const placeholder = props.placeholder || '' | |
| 38 | const className = props.class || '' | |
| 39 | </script> | |
| 40 | ||
| 41 | <picture class="relative block overflow-hidden bg-stone-100 dark:bg-stone-900 " @if(ratio) style="aspect-ratio: ;" @endif> | |
| 42 | @foreach (sources as source) | |
| 43 | <source type="" srcset="" @if(source.media) media="" @endif @if(sizes) sizes="" @endif> | |
| 44 | @endforeach | |
| 45 | <img | |
| 46 | src="" | |
| 47 | alt="" | |
| 48 | @if(width) width="" @endif | |
| 49 | @if(height) height="" @endif | |
| 50 | @if(srcset) srcset="" @endif | |
| 51 | @if(sizes) sizes="" @endif | |
| 52 | loading="" | |
| 53 | decoding="" | |
| 54 | fetchpriority="" | |
| 55 | @if(props.decorative) aria-hidden="true" @endif | |
| 56 | class="h-full w-full" | |
| 57 | style="object-fit: ; object-position: ; " | |
| 58 | > | |
| 59 | </picture> | |
| @@ -0,0 +1,108 @@ | ||
| 1 | <script server> | |
| 2 | interface VideoSource { src: string, type?: string } | |
| 3 | interface VideoDrm { | |
| 4 | widevine?: { url: string, headers?: Record<string, string> } | |
| 5 | playready?: { url: string, headers?: Record<string, string> } | |
| 6 | fairplay?: { url: string, certificateUrl: string, headers?: Record<string, string> } | |
| 7 | } | |
| 8 | interface VideoTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean } | |
| 9 | interface VideoProps { | |
| 10 | src: string | |
| 11 | sources?: VideoSource[] | |
| 12 | drm?: VideoDrm | |
| 13 | tracks?: VideoTrack[] | |
| 14 | poster?: string | |
| 15 | title?: string | |
| 16 | width?: number | |
| 17 | height?: number | |
| 18 | aspectRatio?: string | |
| 19 | controls?: boolean | |
| 20 | autoplay?: boolean | |
| 21 | loop?: boolean | |
| 22 | muted?: boolean | |
| 23 | playsinline?: boolean | |
| 24 | preload?: 'none' | 'metadata' | 'auto' | |
| 25 | crossorigin?: 'anonymous' | 'use-credentials' | |
| 26 | controlslist?: string | |
| 27 | disablePictureInPicture?: boolean | |
| 28 | class?: string | |
| 29 | } | |
| 30 | const props = defineProps<VideoProps>() | |
| 31 | const src = props.src || '' | |
| 32 | const sources = props.sources || [] | |
| 33 | const tracks = props.tracks || [] | |
| 34 | const title = props.title || 'Video player' | |
| 35 | const poster = props.poster || '' | |
| 36 | const width = props.width || '' | |
| 37 | const height = props.height || '' | |
| 38 | const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '16 / 9') | |
| 39 | const controls = props.controls !== false | |
| 40 | const inline = props.playsinline !== false | |
| 41 | const preload = props.preload || 'metadata' | |
| 42 | const crossorigin = props.crossorigin || '' | |
| 43 | const controlslist = props.controlslist || 'nodownload' | |
| 44 | const className = props.class || '' | |
| 45 | const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source) | |
| 46 | const playerTracks = tracks.map(track => ({ ...track, language: track.srclang })) | |
| 47 | const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026') | |
| 48 | </script> | |
| 49 | ||
| 50 | <script client> | |
| 51 | import { registerElements } from 'ts-video-player/elements' | |
| 52 | registerElements() | |
| 53 | </script> | |
| 54 | ||
| 55 | <figure class="m-0 "> | |
| 56 | <video-player | |
| 57 | src="" | |
| 58 | data-media-config="" | |
| 59 | @if(poster) poster="" @endif | |
| 60 | @if(controls) controls @endif | |
| 61 | @if(props.autoplay) autoplay @endif | |
| 62 | @if(props.loop) loop @endif | |
| 63 | @if(props.muted) muted @endif | |
| 64 | @if(inline) playsinline @endif | |
| 65 | preload="" | |
| 66 | @if(crossorigin) crossorigin="" @endif | |
| 67 | controlslist="" | |
| 68 | @if(props.disablePictureInPicture) disable-picture-in-picture @endif | |
| 69 | keep-alive | |
| 70 | aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" | |
| 71 | class="block overflow-hidden relative w-full bg-black ring-1 ring-black/10 rounded-2xl dark:ring-white/10 focus-within:ring-2 focus-within:ring-sky-500 shadow-sm group" | |
| 72 | style="aspect-ratio: ;" | |
| 73 | > | |
| 74 | <media-error-display></media-error-display> | |
| 75 | <video data-native-fallback src="" @if(poster) poster="" @endif @if(width) width="" @endif @if(height) height="" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="" @if(crossorigin) crossorigin="" @endif controlslist="" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="chore(deps): upgrade vendored Stacks framework to 0.70.163 by glennmichael123 · stacks/postline #29" class="object-contain h-full w-full"> | |
| 76 | @foreach (sources as source) | |
| 77 | <source src="" @if(source.type) type="" @endif> | |
| 78 | @endforeach | |
| 79 | @foreach (tracks as track) | |
| 80 | <track src="" kind="" @if(track.srclang) srclang="" @endif @if(track.label) label="" @endif @if(track.default) default @endif> | |
| 81 | @endforeach | |
| 82 | Your browser does not support video playback. | |
| 83 | </video> | |
| 84 | <video-skin> | |
| 85 | <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button> | |
| 86 | <media-play-button slot="left" aria-label="Play or pause"></media-play-button> | |
| 87 | <media-seek-button slot="left" seconds="10" aria-label="Forward 10 seconds"></media-seek-button> | |
| 88 | <media-live-button slot="left" aria-label="Go to live edge"></media-live-button> | |
| 89 | <media-time-group slot="center" class="font-mono tabular-nums text-xs"></media-time-group> | |
| 90 | <media-progress-bar slot="center" previews="metadata" aria-label="Video progress"></media-progress-bar> | |
| 91 | <media-mute-button slot="right" aria-label="Mute or unmute"></media-mute-button> | |
| 92 | <media-volume-slider slot="right" aria-label="Volume"></media-volume-slider> | |
| 93 | <media-playback-rate-button slot="right" rates="0.5,0.75,1,1.25,1.5,2" aria-label="Playback speed"></media-playback-rate-button> | |
| 94 | <media-settings-menu slot="right" aria-label="Video settings"></media-settings-menu> | |
| 95 | <media-airplay-button slot="right" aria-label="Play on AirPlay"></media-airplay-button> | |
| 96 | <media-remote-playback-button slot="right" aria-label="Play on another device"></media-remote-playback-button> | |
| 97 | <media-pip-button slot="right" aria-label="Picture in Picture"></media-pip-button> | |
| 98 | <media-fullscreen-button slot="right" aria-label="Fullscreen"></media-fullscreen-button> | |
| 99 | </video-skin> | |
| 100 | </video-player> | |
| 101 | <figcaption class="sr-only">{{ title }}</figcaption> | |
| 102 | </figure> | |
| 103 | ||
| 104 | <style> | |
| 105 | video-player:not(:defined) video-skin { display: none; } | |
| 106 | video-player:defined > [data-native-fallback] { display: none; } | |
| 107 | @media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } } | |
| 108 | </style> | |
| @@ -11,3 +11,6 @@ export { default as SidebarModern } from './Dashboard/SidebarModern.stx' | ||
| 11 | 11 | |
| 12 | 12 | // Dashboard and UI components |
| 13 | 13 | export * from './Dashboard' |
| 14 | export { default as Audio } from './Audio.stx' | |
| 15 | export { default as Image } from './Image.stx' | |
| 16 | export { default as Video } from './Video.stx' | |