ReviewOS

also looking at this

stacks/postline

chore(deps): upgrade vendored Stacks framework to 0.70.163

#29
Merged glennmichael123 wants to merge chore/upgrade-0.70.163 into main
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.

bun.lockmodified+226-228
Changes to bun.lock
@@ -22,92 +22,95 @@
2222 },
2323 "storage/framework/core": {
2424 "name": "stacks",
25 "version": "0.70.162",
25 "version": "0.70.163",
2626 "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",
9698 "@stacksjs/tlsx": "^0.13.0",
9799 "@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",
104107 "better-dx": "catalog:",
105108 "bun-query-builder": "catalog:",
106109 },
107110 },
108111 "storage/framework/core/actions": {
109112 "name": "@stacksjs/actions",
110 "version": "0.70.162",
113 "version": "0.70.163",
111114 "dependencies": {
112115 "@stacksjs/bumpx": "^0.2.6",
113116 "@stacksjs/bunpress": "^0.1.18",
@@ -145,7 +148,7 @@
145148 },
146149 "storage/framework/core/ai": {
147150 "name": "@stacksjs/ai",
148 "version": "0.70.162",
151 "version": "0.70.163",
149152 "dependencies": {
150153 "@stacksjs/ts-cloud": "catalog:",
151154 },
@@ -162,7 +165,7 @@
162165 },
163166 "storage/framework/core/alias": {
164167 "name": "@stacksjs/alias",
165 "version": "0.70.162",
168 "version": "0.70.163",
166169 "devDependencies": {
167170 "@stacksjs/path": "workspace:*",
168171 "better-dx": "catalog:",
@@ -170,14 +173,14 @@
170173 },
171174 "storage/framework/core/analytics": {
172175 "name": "@stacksjs/analytics",
173 "version": "0.70.162",
176 "version": "0.70.163",
174177 "devDependencies": {
175178 "better-dx": "catalog:",
176179 },
177180 },
178181 "storage/framework/core/api": {
179182 "name": "@stacksjs/api",
180 "version": "0.70.162",
183 "version": "0.70.163",
181184 "devDependencies": {
182185 "@stacksjs/utils": "workspace:*",
183186 "better-dx": "catalog:",
@@ -185,15 +188,26 @@
185188 },
186189 "storage/framework/core/arrays": {
187190 "name": "@stacksjs/arrays",
188 "version": "0.70.162",
191 "version": "0.70.163",
189192 "devDependencies": {
190193 "@stacksjs/utils": "workspace:*",
191194 "better-dx": "catalog:",
192195 },
193196 },
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 },
194208 "storage/framework/core/auth": {
195209 "name": "@stacksjs/auth",
196 "version": "0.70.162",
210 "version": "0.70.163",
197211 "dependencies": {
198212 "@stacksjs/bun-router": "^0.0.20",
199213 "@stacksjs/ts-auth": "^0.4.3",
@@ -206,7 +220,7 @@
206220 },
207221 "storage/framework/core/browser": {
208222 "name": "@stacksjs/browser",
209 "version": "0.70.162",
223 "version": "0.70.163",
210224 "dependencies": {
211225 "@stacksjs/composables": "workspace:*",
212226 "@stacksjs/stx": "catalog:",
@@ -227,7 +241,7 @@
227241 },
228242 "storage/framework/core/browser-extension": {
229243 "name": "@stacksjs/browser-extension",
230 "version": "0.70.162",
244 "version": "0.70.163",
231245 "dependencies": {
232246 "web-ext": "catalog:",
233247 },
@@ -237,10 +251,9 @@
237251 },
238252 "storage/framework/core/buddy": {
239253 "name": "@stacksjs/buddy",
240 "version": "0.70.162",
254 "version": "0.70.163",
241255 "bin": {
242256 "stacks": "dist/cli.js",
243 "stx": "dist/cli.js",
244257 "buddy": "dist/cli.js",
245258 "bud": "dist/cli.js",
246259 },
@@ -298,7 +311,7 @@
298311 },
299312 "storage/framework/core/build": {
300313 "name": "@stacksjs/build",
301 "version": "0.70.162",
314 "version": "0.70.163",
302315 "devDependencies": {
303316 "@stacksjs/cli": "workspace:*",
304317 "@stacksjs/path": "workspace:*",
@@ -343,7 +356,7 @@
343356 },
344357 "storage/framework/core/cache": {
345358 "name": "@stacksjs/cache",
346 "version": "0.70.162",
359 "version": "0.70.163",
347360 "devDependencies": {
348361 "@stacksjs/config": "workspace:*",
349362 "@stacksjs/ts-cache": "^0.1.5",
@@ -351,7 +364,7 @@
351364 },
352365 "storage/framework/core/calendar-api": {
353366 "name": "@stacksjs/calendar-api",
354 "version": "0.70.162",
367 "version": "0.70.163",
355368 "dependencies": {
356369 "@stacksjs/stx": "catalog:",
357370 },
@@ -363,7 +376,7 @@
363376 },
364377 "storage/framework/core/charts": {
365378 "name": "@stacksjs/charts",
366 "version": "0.70.162",
379 "version": "0.70.163",
367380 "dependencies": {
368381 "@ts-charts/format": "^0.2.0",
369382 "@ts-charts/scale": "^0.2.0",
@@ -375,7 +388,7 @@
375388 },
376389 "storage/framework/core/chat": {
377390 "name": "@stacksjs/chat",
378 "version": "0.70.162",
391 "version": "0.70.163",
379392 "devDependencies": {
380393 "@stacksjs/cli": "workspace:*",
381394 "@stacksjs/config": "workspace:*",
@@ -386,7 +399,7 @@
386399 },
387400 "storage/framework/core/cli": {
388401 "name": "@stacksjs/cli",
389 "version": "0.70.162",
402 "version": "0.70.163",
390403 "dependencies": {
391404 "@stacksjs/clapp": "catalog:",
392405 },
@@ -404,7 +417,7 @@
404417 },
405418 "storage/framework/core/cloud": {
406419 "name": "@stacksjs/cloud",
407 "version": "0.70.162",
420 "version": "0.70.163",
408421 "dependencies": {
409422 "@stacksjs/ts-cloud": "catalog:",
410423 "@stacksjs/ts-xml": "^0.1.0",
@@ -425,7 +438,7 @@
425438 },
426439 "storage/framework/core/cms": {
427440 "name": "@stacksjs/cms",
428 "version": "0.70.162",
441 "version": "0.70.163",
429442 "dependencies": {
430443 "ts-slug": "^0.1.0",
431444 "ts-spreadsheets": "^0.2.0",
@@ -436,7 +449,7 @@
436449 },
437450 "storage/framework/core/collections": {
438451 "name": "@stacksjs/collections",
439 "version": "0.70.162",
452 "version": "0.70.163",
440453 "dependencies": {
441454 "ts-collect": "^0.4.3",
442455 },
@@ -446,7 +459,7 @@
446459 },
447460 "storage/framework/core/commerce": {
448461 "name": "@stacksjs/commerce",
449 "version": "0.70.162",
462 "version": "0.70.163",
450463 "dependencies": {
451464 "ts-spreadsheets": "^0.2.0",
452465 },
@@ -456,7 +469,7 @@
456469 },
457470 "storage/framework/core/composables": {
458471 "name": "@stacksjs/composables",
459 "version": "0.70.162",
472 "version": "0.70.163",
460473 "dependencies": {
461474 "@stacksjs/datetime": "workspace:*",
462475 "@stacksjs/stx": "catalog:",
@@ -467,7 +480,7 @@
467480 },
468481 "storage/framework/core/config": {
469482 "name": "@stacksjs/config",
470 "version": "0.70.162",
483 "version": "0.70.163",
471484 "dependencies": {
472485 "ts-pantry": "^0.10.34",
473486 },
@@ -480,14 +493,14 @@
480493 },
481494 "storage/framework/core/cron": {
482495 "name": "@stacksjs/cron",
483 "version": "0.70.162",
496 "version": "0.70.163",
484497 "devDependencies": {
485498 "better-dx": "catalog:",
486499 },
487500 },
488501 "storage/framework/core/database": {
489502 "name": "@stacksjs/database",
490 "version": "0.70.162",
503 "version": "0.70.163",
491504 "dependencies": {
492505 "@stacksjs/ts-validation": "^0.5.0",
493506 "bun-query-builder": "catalog:",
@@ -508,7 +521,7 @@
508521 },
509522 "storage/framework/core/datetime": {
510523 "name": "@stacksjs/datetime",
511 "version": "0.70.162",
524 "version": "0.70.163",
512525 "devDependencies": {
513526 "@stacksjs/utils": "workspace:*",
514527 "better-dx": "catalog:",
@@ -516,21 +529,21 @@
516529 },
517530 "storage/framework/core/defaults": {
518531 "name": "@stacksjs/defaults",
519 "version": "0.70.162",
532 "version": "0.70.163",
520533 "dependencies": {
521534 "@iconify-json/f7": "^1.2.2",
522535 },
523536 },
524537 "storage/framework/core/deploy": {
525538 "name": "@stacksjs/deploy",
526 "version": "0.70.162",
539 "version": "0.70.163",
527540 "dependencies": {
528541 "@stacksjs/ts-cloud": "catalog:",
529542 },
530543 },
531544 "storage/framework/core/desktop": {
532545 "name": "@stacksjs/desktop",
533 "version": "0.70.162",
546 "version": "0.70.163",
534547 "devDependencies": {
535548 "@stacksjs/build": "workspace:*",
536549 "@stacksjs/server": "workspace:*",
@@ -539,11 +552,11 @@
539552 },
540553 "storage/framework/core/development": {
541554 "name": "@stacksjs/development",
542 "version": "0.70.162",
555 "version": "0.70.163",
543556 },
544557 "storage/framework/core/dns": {
545558 "name": "@stacksjs/dns",
546 "version": "0.70.162",
559 "version": "0.70.163",
547560 "dependencies": {
548561 "@stacksjs/dnsx": "^0.2.3",
549562 "@stacksjs/ts-cloud": "catalog:",
@@ -560,7 +573,7 @@
560573 },
561574 "storage/framework/core/docs": {
562575 "name": "@stacksjs/docs",
563 "version": "0.70.162",
576 "version": "0.70.163",
564577 "dependencies": {
565578 "@stacksjs/bunpress": "^0.1.18",
566579 },
@@ -574,7 +587,7 @@
574587 },
575588 "storage/framework/core/email": {
576589 "name": "@stacksjs/email",
577 "version": "0.70.162",
590 "version": "0.70.163",
578591 "dependencies": {
579592 "@stacksjs/ts-cloud": "catalog:",
580593 },
@@ -588,14 +601,14 @@
588601 },
589602 "storage/framework/core/enums": {
590603 "name": "@stacksjs/enums",
591 "version": "0.70.162",
604 "version": "0.70.163",
592605 "devDependencies": {
593606 "better-dx": "catalog:",
594607 },
595608 },
596609 "storage/framework/core/env": {
597610 "name": "@stacksjs/env",
598 "version": "0.70.162",
611 "version": "0.70.163",
599612 "devDependencies": {
600613 "@stacksjs/path": "workspace:*",
601614 "@stacksjs/validation": "workspace:*",
@@ -604,7 +617,7 @@
604617 },
605618 "storage/framework/core/error-handling": {
606619 "name": "@stacksjs/error-handling",
607 "version": "0.70.162",
620 "version": "0.70.163",
608621 "dependencies": {
609622 "@stacksjs/stx": "catalog:",
610623 "ts-error-handling": "^0.1.1",
@@ -621,14 +634,14 @@
621634 },
622635 "storage/framework/core/events": {
623636 "name": "@stacksjs/events",
624 "version": "0.70.162",
637 "version": "0.70.163",
625638 "devDependencies": {
626639 "better-dx": "catalog:",
627640 },
628641 },
629642 "storage/framework/core/faker": {
630643 "name": "@stacksjs/faker",
631 "version": "0.70.162",
644 "version": "0.70.163",
632645 "devDependencies": {
633646 "@stacksjs/cli": "workspace:*",
634647 "@stacksjs/config": "workspace:*",
@@ -638,7 +651,7 @@
638651 },
639652 "storage/framework/core/feature-flags": {
640653 "name": "@stacksjs/feature-flags",
641 "version": "0.70.162",
654 "version": "0.70.163",
642655 "devDependencies": {
643656 "@stacksjs/config": "workspace:*",
644657 "@stacksjs/database": "workspace:*",
@@ -648,7 +661,7 @@
648661 },
649662 "storage/framework/core/git": {
650663 "name": "@stacksjs/git",
651 "version": "0.70.162",
664 "version": "0.70.163",
652665 "dependencies": {
653666 "cz-git": "^1.13.1",
654667 },
@@ -660,14 +673,14 @@
660673 },
661674 "storage/framework/core/github": {
662675 "name": "@stacksjs/github",
663 "version": "0.70.162",
676 "version": "0.70.163",
664677 "devDependencies": {
665678 "better-dx": "catalog:",
666679 },
667680 },
668681 "storage/framework/core/health": {
669682 "name": "@stacksjs/health",
670 "version": "0.70.162",
683 "version": "0.70.163",
671684 "devDependencies": {
672685 "@stacksjs/notifications": "workspace:*",
673686 "better-dx": "catalog:",
@@ -675,7 +688,7 @@
675688 },
676689 "storage/framework/core/http": {
677690 "name": "@stacksjs/http",
678 "version": "0.70.162",
691 "version": "0.70.163",
679692 "dependencies": {
680693 "@stacksjs/httx": "^0.1.10",
681694 },
@@ -685,7 +698,7 @@
685698 },
686699 "storage/framework/core/i18n": {
687700 "name": "@stacksjs/i18n",
688 "version": "0.70.162",
701 "version": "0.70.163",
689702 "dependencies": {
690703 "@stacksjs/ts-i18n": "^0.1.9",
691704 },
@@ -693,9 +706,19 @@
693706 "@stacksjs/development": "workspace:*",
694707 },
695708 },
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 },
696719 "storage/framework/core/lint": {
697720 "name": "@stacksjs/lint",
698 "version": "0.70.162",
721 "version": "0.70.163",
699722 "devDependencies": {
700723 "@stacksjs/gitlint": "catalog:",
701724 "better-dx": "catalog:",
@@ -704,7 +727,7 @@
704727 },
705728 "storage/framework/core/logging": {
706729 "name": "@stacksjs/logging",
707 "version": "0.70.162",
730 "version": "0.70.163",
708731 "devDependencies": {
709732 "@stacksjs/clarity": "^0.3.31",
710733 "@stacksjs/cli": "workspace:*",
@@ -718,7 +741,7 @@
718741 },
719742 "storage/framework/core/newsletter": {
720743 "name": "@stacksjs/newsletter",
721 "version": "0.70.162",
744 "version": "0.70.163",
722745 "devDependencies": {
723746 "@stacksjs/cli": "workspace:*",
724747 "@stacksjs/config": "workspace:*",
@@ -729,7 +752,7 @@
729752 },
730753 "storage/framework/core/notifications": {
731754 "name": "@stacksjs/notifications",
732 "version": "0.70.162",
755 "version": "0.70.163",
733756 "devDependencies": {
734757 "@stacksjs/chat": "workspace:*",
735758 "@stacksjs/cli": "workspace:*",
@@ -744,7 +767,7 @@
744767 },
745768 "storage/framework/core/objects": {
746769 "name": "@stacksjs/objects",
747 "version": "0.70.162",
770 "version": "0.70.163",
748771 "devDependencies": {
749772 "@stacksjs/collections": "workspace:*",
750773 "@stacksjs/types": "workspace:*",
@@ -755,7 +778,7 @@
755778 },
756779 "storage/framework/core/orm": {
757780 "name": "@stacksjs/orm",
758 "version": "0.70.162",
781 "version": "0.70.163",
759782 "dependencies": {
760783 "@stacksjs/ts-validation": "^0.5.0",
761784 "bun-query-builder": "catalog:",
@@ -769,14 +792,14 @@
769792 },
770793 "storage/framework/core/path": {
771794 "name": "@stacksjs/path",
772 "version": "0.70.162",
795 "version": "0.70.163",
773796 "devDependencies": {
774797 "better-dx": "catalog:",
775798 },
776799 },
777800 "storage/framework/core/payments": {
778801 "name": "@stacksjs/payments",
779 "version": "0.70.162",
802 "version": "0.70.163",
780803 "devDependencies": {
781804 "@stacksjs/config": "workspace:*",
782805 "@stacksjs/utils": "workspace:*",
@@ -795,7 +818,7 @@
795818 },
796819 "storage/framework/core/push": {
797820 "name": "@stacksjs/push",
798 "version": "0.70.162",
821 "version": "0.70.163",
799822 "devDependencies": {
800823 "@stacksjs/cli": "workspace:*",
801824 "@stacksjs/config": "workspace:*",
@@ -805,7 +828,7 @@
805828 },
806829 "storage/framework/core/query-builder": {
807830 "name": "@stacksjs/query-builder",
808 "version": "0.70.162",
831 "version": "0.70.163",
809832 "dependencies": {
810833 "bun-query-builder": "catalog:",
811834 },
@@ -817,7 +840,7 @@
817840 },
818841 "storage/framework/core/queue": {
819842 "name": "@stacksjs/queue",
820 "version": "0.70.162",
843 "version": "0.70.163",
821844 "dependencies": {
822845 "@stacksjs/bun-queue": "^0.1.7",
823846 },
@@ -834,7 +857,7 @@
834857 },
835858 "storage/framework/core/realtime": {
836859 "name": "@stacksjs/realtime",
837 "version": "0.70.162",
860 "version": "0.70.163",
838861 "dependencies": {
839862 "ts-broadcasting": "^0.0.8",
840863 },
@@ -844,7 +867,7 @@
844867 },
845868 "storage/framework/core/registry": {
846869 "name": "@stacksjs/registry",
847 "version": "0.70.162",
870 "version": "0.70.163",
848871 "dependencies": {
849872 "@stacksjs/types": "workspace:*",
850873 },
@@ -854,7 +877,7 @@
854877 },
855878 "storage/framework/core/repl": {
856879 "name": "@stacksjs/repl",
857 "version": "0.70.162",
880 "version": "0.70.163",
858881 "devDependencies": {
859882 "@stacksjs/tinker": "workspace:*",
860883 "better-dx": "catalog:",
@@ -862,7 +885,7 @@
862885 },
863886 "storage/framework/core/router": {
864887 "name": "@stacksjs/router",
865 "version": "0.70.162",
888 "version": "0.70.163",
866889 "dependencies": {
867890 "@stacksjs/bun-router": "^0.0.20",
868891 "ts-rate-limiter": "^0.4.2",
@@ -882,7 +905,7 @@
882905 },
883906 "storage/framework/core/scheduler": {
884907 "name": "@stacksjs/scheduler",
885 "version": "0.70.162",
908 "version": "0.70.163",
886909 "devDependencies": {
887910 "@stacksjs/actions": "workspace:*",
888911 "@stacksjs/cron": "workspace:*",
@@ -891,7 +914,7 @@
891914 },
892915 "storage/framework/core/search-engine": {
893916 "name": "@stacksjs/search-engine",
894 "version": "0.70.162",
917 "version": "0.70.163",
895918 "devDependencies": {
896919 "@stacksjs/config": "workspace:*",
897920 "better-dx": "catalog:",
@@ -906,7 +929,7 @@
906929 },
907930 "storage/framework/core/security": {
908931 "name": "@stacksjs/security",
909 "version": "0.70.162",
932 "version": "0.70.163",
910933 "dependencies": {
911934 "ts-security-crypto": "0.0.2",
912935 },
@@ -920,7 +943,7 @@
920943 },
921944 "storage/framework/core/server": {
922945 "name": "@stacksjs/server",
923 "version": "0.70.162",
946 "version": "0.70.163",
924947 "dependencies": {
925948 "bun-plugin-auto-imports": "^0.4.0",
926949 },
@@ -934,14 +957,14 @@
934957 },
935958 "storage/framework/core/shell": {
936959 "name": "@stacksjs/shell",
937 "version": "0.70.162",
960 "version": "0.70.163",
938961 "devDependencies": {
939962 "better-dx": "catalog:",
940963 },
941964 },
942965 "storage/framework/core/skills": {
943966 "name": "@stacksjs/skills",
944 "version": "0.70.162",
967 "version": "0.70.163",
945968 "dependencies": {
946969 "@stacksjs/path": "workspace:*",
947970 "@stacksjs/storage": "workspace:*",
@@ -953,7 +976,7 @@
953976 },
954977 "storage/framework/core/slug": {
955978 "name": "@stacksjs/slug",
956 "version": "0.70.162",
979 "version": "0.70.163",
957980 "dependencies": {
958981 "ts-slug": "^0.1.0",
959982 },
@@ -975,7 +998,7 @@
975998 },
976999 "storage/framework/core/sms": {
9771000 "name": "@stacksjs/sms",
978 "version": "0.70.162",
1001 "version": "0.70.163",
9791002 "devDependencies": {
9801003 "@stacksjs/cli": "workspace:*",
9811004 "@stacksjs/config": "workspace:*",
@@ -986,7 +1009,7 @@
9861009 },
9871010 "storage/framework/core/socials": {
9881011 "name": "@stacksjs/socials",
989 "version": "0.70.162",
1012 "version": "0.70.163",
9901013 "devDependencies": {
9911014 "@stacksjs/error-handling": "workspace:*",
9921015 "@stacksjs/router": "workspace:*",
@@ -995,9 +1018,10 @@
9951018 },
9961019 "storage/framework/core/storage": {
9971020 "name": "@stacksjs/storage",
998 "version": "0.70.162",
1021 "version": "0.70.163",
9991022 "dependencies": {
10001023 "@stacksjs/ts-cloud": "catalog:",
1024 "ts-images": "^0.1.11",
10011025 },
10021026 "devDependencies": {
10031027 "@stacksjs/arrays": "workspace:*",
@@ -1006,18 +1030,11 @@
10061030 "@stacksjs/strings": "workspace:*",
10071031 "@stacksjs/types": "workspace:*",
10081032 "better-dx": "catalog:",
1009 "sharp": "^0.35.3",
10101033 },
1011 "peerDependencies": {
1012 "sharp": "^0.35.3",
1013 },
1014 "optionalPeers": [
1015 "sharp",
1016 ],
10171034 },
10181035 "storage/framework/core/strings": {
10191036 "name": "@stacksjs/strings",
1020 "version": "0.70.162",
1037 "version": "0.70.163",
10211038 "devDependencies": {
10221039 "@stacksjs/alias": "workspace:*",
10231040 "@stacksjs/types": "workspace:*",
@@ -1026,7 +1043,7 @@
10261043 },
10271044 "storage/framework/core/testing": {
10281045 "name": "@stacksjs/testing",
1029 "version": "0.70.162",
1046 "version": "0.70.163",
10301047 "dependencies": {
10311048 "@stacksjs/ts-cloud": "catalog:",
10321049 },
@@ -1038,14 +1055,14 @@
10381055 },
10391056 "storage/framework/core/tinker": {
10401057 "name": "@stacksjs/tinker",
1041 "version": "0.70.162",
1058 "version": "0.70.163",
10421059 "devDependencies": {
10431060 "better-dx": "catalog:",
10441061 },
10451062 },
10461063 "storage/framework/core/tunnel": {
10471064 "name": "@stacksjs/tunnel",
1048 "version": "0.70.162",
1065 "version": "0.70.163",
10491066 "dependencies": {
10501067 "localtunnels": "^0.2.7",
10511068 },
@@ -1055,7 +1072,7 @@
10551072 },
10561073 "storage/framework/core/types": {
10571074 "name": "@stacksjs/types",
1058 "version": "0.70.162",
1075 "version": "0.70.163",
10591076 "dependencies": {
10601077 "@stacksjs/bunpress": "^0.1.18",
10611078 "@stacksjs/clapp": "catalog:",
@@ -1078,7 +1095,7 @@
10781095 },
10791096 "storage/framework/core/ui": {
10801097 "name": "@stacksjs/ui",
1081 "version": "0.70.162",
1098 "version": "0.70.163",
10821099 "dependencies": {
10831100 "@cwcss/crosswind": "^0.2.6",
10841101 "@stacksjs/components": "catalog:",
@@ -1092,7 +1109,7 @@
10921109 },
10931110 "storage/framework/core/utils": {
10941111 "name": "@stacksjs/utils",
1095 "version": "0.70.162",
1112 "version": "0.70.163",
10961113 "dependencies": {
10971114 "@stacksjs/browser": "workspace:*",
10981115 },
@@ -1113,7 +1130,7 @@
11131130 },
11141131 "storage/framework/core/validation": {
11151132 "name": "@stacksjs/validation",
1116 "version": "0.70.162",
1133 "version": "0.70.163",
11171134 "dependencies": {
11181135 "@stacksjs/ts-validation": "^0.5.0",
11191136 },
@@ -1123,9 +1140,20 @@
11231140 "better-dx": "catalog:",
11241141 },
11251142 },
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 },
11261154 "storage/framework/core/whois": {
11271155 "name": "@stacksjs/whois",
1128 "version": "0.70.162",
1156 "version": "0.70.163",
11291157 "devDependencies": {
11301158 "better-dx": "catalog:",
11311159 },
@@ -1196,8 +1224,6 @@
11961224
11971225 "@devicefarmer/adbkit-monkey": ["@devicefarmer/adbkit-monkey@1.2.1", "", {}, "sha512-ZzZY/b66W2Jd6NHbAhLyDWOEIBWC11VizGFk7Wx7M61JZRz7HR9Cq5P+65RKWUU7u6wgsE8Lmh9nE4Mz+U2eTg=="],
11981226
1199 "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
1200
12011227 "@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=="],
12021228
12031229 "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
@@ -1238,60 +1264,6 @@
12381264
12391265 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
12401266
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
12951267 "@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=="],
12961268
12971269 "@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 @@
13421314
13431315 "@stacksjs/arrays": ["@stacksjs/arrays@workspace:storage/framework/core/arrays"],
13441316
1317 "@stacksjs/audio": ["@stacksjs/audio@workspace:storage/framework/core/audio"],
1318
13451319 "@stacksjs/auth": ["@stacksjs/auth@workspace:storage/framework/core/auth"],
13461320
13471321 "@stacksjs/browser": ["@stacksjs/browser@workspace:storage/framework/core/browser"],
@@ -1440,6 +1414,8 @@
14401414
14411415 "@stacksjs/i18n": ["@stacksjs/i18n@workspace:storage/framework/core/i18n"],
14421416
1417 "@stacksjs/image": ["@stacksjs/image@workspace:storage/framework/core/image"],
1418
14431419 "@stacksjs/lint": ["@stacksjs/lint@workspace:storage/framework/core/lint"],
14441420
14451421 "@stacksjs/logging": ["@stacksjs/logging@workspace:storage/framework/core/logging"],
@@ -1550,10 +1526,24 @@
15501526
15511527 "@stacksjs/validation": ["@stacksjs/validation@workspace:storage/framework/core/validation"],
15521528
1529 "@stacksjs/video": ["@stacksjs/video@workspace:storage/framework/core/video"],
1530
15531531 "@stacksjs/whois": ["@stacksjs/whois@workspace:storage/framework/core/whois"],
15541532
15551533 "@stripe/stripe-js": ["@stripe/stripe-js@9.12.0", "", {}, "sha512-wCUYZNYo7E/6B3Rv2i/g/Rmj2mTiOX6HEwWYUWUuNUKwEhzTo/SXuQ1IoNo3mknWIHUQyqdakv1Nl2SiYPrCrw=="],
15561534
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
15571547 "@ts-charts/array": ["@ts-charts/array@0.2.0", "", { "dependencies": { "@ts-charts/internmap": "0.2.0" } }, "sha512-rcYZWzYx8pjqpcJOLyNWcxeCwEGFte1HANtMxTiiXswQ990VkWYK1iVY13W4EwUwJU0TGXdg1e3DGjVXtrwc5w=="],
15581548
15591549 "@ts-charts/color": ["@ts-charts/color@0.2.0", "", {}, "sha512-Oe/gbCmTUhlkqQk1xFg+MSObDm5qEAlvBxmGiCDRsPZaPNrTlGlAiuiynPUfb9nl5ByZ39y3aPJFQjmq/RG+WA=="],
@@ -1578,6 +1568,10 @@
15781568
15791569 "@ts-cloud/core": ["@ts-cloud/core@0.7.56", "", { "dependencies": { "@ts-cloud/aws-types": "0.7.56" } }, "sha512-DG4DFeIePeKJFd1BUg9bbImLf439KD1o9kmtmNDLpf8jjNk10qLQQH4pyv8nx7S1IhWXuBImpsWtlnVkklYYmA=="],
15801570
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
15811575 "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
15821576
15831577 "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -1826,8 +1820,6 @@
18261820
18271821 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
18281822
1829 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
1830
18311823 "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=="],
18321824
18331825 "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -2296,8 +2288,6 @@
22962288
22972289 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
22982290
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
23012291 "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
23022292
23032293 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -2396,7 +2386,7 @@
23962386
23972387 "ts-gif": ["ts-gif@0.1.2", "", { "dependencies": { "data-uri-to-buffer": "^6.0.2" } }, "sha512-jbraplO1qqJtrtkFeepNPUGPad7w8FQHWIQFsn1OJbHeWKUbdqdkrX18XzNXI5Fw7+s6A3nbNTdj+Ukn4d3f8g=="],
23982388
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=="],
24002390
24012391 "ts-jpeg": ["ts-jpeg@0.3.1", "", {}, "sha512-KIGTJj4UIlkGoCFEofx8jHHQEkJfSLFJY0MHz60Mu4Scjrcj7K/0UJGCbL9QvfIaFGAOGdf0xNFpYSuzUpimwA=="],
24022392
@@ -2412,9 +2402,11 @@
24122402
24132403 "ts-syntax-highlighter": ["ts-syntax-highlighter@0.2.1", "", { "bin": { "ts-syntax-highlighter": "dist/bin/cli.js" } }, "sha512-HEfxaVKifwvnX0NaOE8nfs4VFdZo6WdREj1KRehelfYsO1N+m9vOIj5exhlOqFpxOPWd4BlalU8y0WWS7yPWjw=="],
24142404
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=="],
24162406
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=="],
24182410
24192411 "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
24202412
@@ -2540,6 +2532,8 @@
25402532
25412533 "@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=="],
25422534
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
25432537 "@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=="],
25442538
25452539 "@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 @@
26102604
26112605 "@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=="],
26122606
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
26132609 "@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=="],
26142610
26152611 "@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 @@
26622658
26632659 "@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=="],
26642660
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
26652663 "@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=="],
26662664
26672665 "@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.99", "", { "peerDependencies": { "craft": "*" }, "optionalPeers": ["craft"] }, "sha512-CQwp77Y50Qbz3ih2KBGfIqUhmUm3U5wkPHDGtQM6hIlBYexbyxtdZMBp/uZkvD4wYrtojMtqnzb/TxBvxwQ9uA=="],
26682666
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
26692669 "@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=="],
26702670
26712671 "@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 @@
26882688
26892689 "@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=="],
26902690
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
26912693 "@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=="],
26922694
26932695 "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 @@
27862788
27872789 "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
27882790
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
27912791 "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=="],
27922792
27932793 "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -2874,8 +2874,6 @@
28742874
28752875 "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
28762876
2877 "stacks/@stacksjs/tlsx/@stacksjs/clapp": ["@stacksjs/clapp@0.2.10", "", {}, "sha512-l/2F0u5ihpae8R4kJ3Pdh6tV7aEpSxu6hCyafhw38Fl9+x2YKxZfUK+fF/G5AVwSzJar3dkKuMxbMcQ0PXKGRg=="],
2878
28792877 "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
28802878
28812879 "widest-line/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
storage/framework/auto-imports/functions.tsmodified+2-0
Changes to storage/framework/auto-imports/functions.ts
@@ -2,6 +2,8 @@
22
33export { increment, decrement, reset, count } from '../../../resources/functions/counter'
44export { toggleDark, isDark, preferredDark } from '../../../resources/functions/dark'
5export { fetchBlogPosts, fetchBlogPost, saveBlogPost, deleteBlogPost } from '../defaults/functions/blog'
6export type { BlogPost, BlogPostDraft, BlogListResponse } from '../defaults/functions/blog'
57export { useDirectUpload } from '../defaults/functions/uploads'
68export type { PresignResponse, DirectUploadResult, UseDirectUploadOptions, UseDirectUploadHandle } from '../defaults/functions/uploads'
79export { useAnalytics } from '../defaults/functions/analytics'
storage/framework/auto-imports/jobs.tsmodified+2-0
Changes to storage/framework/auto-imports/jobs.ts
@@ -1,3 +1,5 @@
11// Generated by bun-plugin-auto-imports
2export { default as PublishScheduledPosts } from '../../../app/Jobs/PublishScheduledPosts'
3export { default as SyncEngagementMetrics } from '../../../app/Jobs/SyncEngagementMetrics'
24export { default as SendWelcomeEmail } from '../../../app/Jobs/SendWelcomeEmail'
35export { default as Inspire } from '../../../app/Jobs/Inspire'
storage/framework/auto-imports/models.tsmodified+1-0
Changes to storage/framework/auto-imports/models.ts
@@ -4,6 +4,7 @@ export { default as PostTarget } from '../../../app/Models/PostTarget'
44export { default as SocialIdentity } from '../../../app/Models/SocialIdentity'
55export { default as MediaAsset } from '../../../app/Models/MediaAsset'
66export { default as TimelineItem } from '../../../app/Models/TimelineItem'
7export { default as BlogPost } from '../../../app/Models/BlogPost'
78export { default as SocialDriver } from '../../../app/Models/SocialDriver'
89export { default as Post } from '../../../app/Models/Post'
910export { default as Tag } from '../defaults/app/Models/Tag'
storage/framework/core/actions/package.jsonmodified+1-1
Changes to storage/framework/core/actions/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/actions",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks actions.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/actions/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/actions/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/ai/README.mdmodified+30-0
Changes to storage/framework/core/ai/README.md
@@ -35,6 +35,36 @@ const client = useAI()
3535// client...
3636```
3737
38## Compact project context
39
40Stacks conventions reduce the amount of application-owned code a coding model
41must generate. A model can describe domain shape once, use known Model-View-Action
42locations, and rely on framework traits and generators for migrations, validation,
43routes, API artifacts, and types where those paths are supported. Installed
44dependencies still exist, but their `node_modules` implementation is package-manager
45state, not application context an LLM should ingest or reproduce.
46
47Generate a deterministic context payload for any coding model or agent:
48
49```bash
50buddy ai:context
51buddy ai:context --json
52buddy ai:context --json --output .stacks/ai-context.json
53buddy ai:context --max-chars 4000 --model claude-sonnet-4
54```
55
56The versioned JSON contract identifies canonical source roles, instruction files,
57available application surfaces, safe package metadata, and representative source
58paths. It excludes dependency, build, cache, lock, environment, credential, key,
59and secret paths by default. Existing Buddy AI calls consume the same compact text
60representation.
61
62Output metrics include the enforced character budget and a heuristic token estimate.
63They also compare the payload with the previous unstructured context shape. This is
64evidence about prompt size only. It is not evidence that a particular model will
65write correct code, and exact billing tokens require the selected provider's
66tokenizer.
67
3868Learn more in the docs.
3969
4070## 🧪 Testing
storage/framework/core/ai/package.jsonmodified+1-1
Changes to storage/framework/core/ai/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/ai",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks Artificial Intelligence.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/ai/src/buddy.tsmodified+3-24
Changes to storage/framework/core/ai/src/buddy.ts
@@ -16,11 +16,12 @@ import type {
1616 RepoState,
1717 StreamingResult,
1818} from './types'
19import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2020import { homedir } from 'node:os'
2121import { dirname, join } from 'node:path'
2222import { claudeAgent } from './agents'
2323import { createAnthropicDriver, createClaudeAgentSDKDriver, createOllamaDriver, createOpenAIDriver } from './drivers'
24import { buildProjectContext } from './context'
2425
2526// =============================================================================
2627// Configuration
@@ -250,29 +251,7 @@ export function getAvailableDrivers(): string[] {
250251 * Get repository structure for context
251252 */
252253export 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 `
270Repository 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
276255}
277256
278257/**
storage/framework/core/ai/src/client.tsadded+202-0
Changes to storage/framework/core/ai/src/client.ts
@@ -0,0 +1,202 @@
1import type {
2 AIMessage,
3 AIMessageContent,
4 AIProvider,
5 AIResult,
6 ChatCompletionOptions,
7 ConfiguredAIClient,
8 ConfiguredAIOptions,
9 GenerateObjectOptions,
10} from './types'
11import * as anthropic from './drivers/anthropic'
12import * as ollama from './drivers/ollama'
13import * as openai from './drivers/openai'
14
15function 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
21function 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
34function 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
49function 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
57function 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
108function 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 */
160export 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
202export { parseObject as parseAIObject, validateSchema as validateAISchema }
storage/framework/core/ai/src/context.tsadded+300-0
Changes to storage/framework/core/ai/src/context.ts
@@ -0,0 +1,300 @@
1import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'
2import { basename, relative, resolve, sep } from 'node:path'
3import { estimateTokens } from './utils/tokens'
4
5export const STACKS_PROJECT_CONTEXT_SCHEMA = 'https://stacksjs.org/schemas/ai-project-context/v1'
6
7const DEFAULT_MAX_CHARS = 4000
8const DEFAULT_MAX_FILES = 30
9
10const 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
26const sensitiveNames = /^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|credentials(?:\..*)?|id_(?:rsa|ed25519)(?:\.pub)?|secrets?(?:\..*)?)$/i
27const lockNames = /(?:^|\.)(?:bun|package-lock|pnpm-lock|yarn|pantry)\.?(?:lock|yaml)?$/i
28
29export interface ProjectContextOptions {
30 maxChars?: number
31 maxFiles?: number
32 model?: string
33}
34
35export interface ProjectContextSurface {
36 id: string
37 purpose: string
38 paths: string[]
39}
40
41export 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
63export 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
78export interface ProjectContextResult {
79 context: StacksProjectContext
80 text: string
81 metrics: ProjectContextMetrics
82}
83
84interface SurfaceDefinition {
85 id: string
86 purpose: string
87 prefixes: string[]
88}
89
90const 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
101function normalizePath(path: string): string {
102 return path.split(sep).join('/')
103}
104
105function 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
114function 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
134function 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
157function 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
167function 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
174function 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
180function 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
210function 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
223function 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
236export 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}
storage/framework/core/ai/src/drivers/ollama/index.tsmodified+8-0
Changes to storage/framework/core/ai/src/drivers/ollama/index.ts
@@ -159,8 +159,15 @@ export async function chat(
159159 temperature,
160160 topP,
161161 stop,
162 responseFormat,
162163 } = options
163164
165 const format = responseFormat?.type === 'json_schema'
166 ? responseFormat.json_schema.schema
167 : responseFormat?.type === 'json_object'
168 ? 'json'
169 : undefined
170
164171 const response = await fetch(`${config.host}/api/chat`, {
165172 method: 'POST',
166173 headers: { 'Content-Type': 'application/json' },
@@ -168,6 +175,7 @@ export async function chat(
168175 model,
169176 messages,
170177 stream: false,
178 format,
171179 options: {
172180 temperature,
173181 top_p: topP,
storage/framework/core/ai/src/drivers/openai/index.tsmodified+33-8
Changes to storage/framework/core/ai/src/drivers/openai/index.ts
@@ -217,6 +217,9 @@ export async function chat(
217217 temperature,
218218 topP,
219219 stop,
220 tools,
221 toolChoice,
222 responseFormat,
220223 } = options
221224
222225 // Normalize content arrays into OpenAI's wire format
@@ -228,20 +231,42 @@ export async function chat(
228231 // Track wall-clock duration for usage reporters (#1878 A-6).
229232 const startedAt = Date.now()
230233
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
231263 const response = await fetchWithRetry(`${config.baseUrl || DEFAULT_BASE_URL}/chat/completions`, {
232264 method: 'POST',
233265 headers: {
234266 'Content-Type': 'application/json',
235267 'Authorization': `Bearer ${config.apiKey}`,
236268 },
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),
245270 })
246271
247272 if (!response.ok) {
storage/framework/core/ai/src/index.tsmodified+6-0
Changes to storage/framework/core/ai/src/index.ts
@@ -4,12 +4,18 @@ export * from './types'
44// Drivers
55export * from './drivers'
66
7// Provider-neutral, config-driven client
8export * from './client'
9
710// Agents
811export * from './agents'
912
1013// Buddy - Voice AI Code Assistant
1114export * from './buddy'
1215
16// Deterministic, budgeted project context for coding models and agents
17export * from './context'
18
1319// Text utilities
1420export * from './text'
1521
storage/framework/core/ai/src/types.tsmodified+28-1
Changes to storage/framework/core/ai/src/types.ts
@@ -216,7 +216,7 @@ export interface MCPConfig {
216216
217217// AI module config (used by @stacksjs/config)
218218export interface AIConfig {
219 default?: string
219 default?: AIProvider | string
220220 models?: string[]
221221 drivers?: {
222222 anthropic?: AIDriverConfig & { anthropicVersion?: string }
@@ -227,3 +227,30 @@ export interface AIConfig {
227227 search?: SearchConfig
228228 mcp?: MCPConfig
229229}
230
231export type AIProvider = 'anthropic' | 'openai' | 'ollama'
232
233export 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
241export 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
248export 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}
storage/framework/core/ai/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/ai/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.074Z] local.INFO: Building @stacksjs/ai...
1[2026-07-23T16:49:26.968Z] local.INFO: Building @stacksjs/ai...
storage/framework/core/ai/tests/client.test.tsadded+79-0
Changes to storage/framework/core/ai/tests/client.test.ts
@@ -0,0 +1,79 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { createAIClient, parseAIObject, validateAISchema } from '../src/client'
3
4const originalFetch = globalThis.fetch
5
6afterEach(() => {
7 globalThis.fetch = originalFetch
8})
9
10describe('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})
storage/framework/core/ai/tests/context.test.tsadded+78-0
Changes to storage/framework/core/ai/tests/context.test.ts
@@ -0,0 +1,78 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildProjectContext } from '../src/context'
6import { getRepoContext } from '../src/buddy'
7
8const temporaryRoots: string[] = []
9
10function 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
35afterEach(() => {
36 for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
37})
38
39describe('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})
storage/framework/core/alias/package.jsonmodified+1-1
Changes to storage/framework/core/alias/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/alias",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks aliases.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/alias/src/index.tsmodified+6-0
Changes to storage/framework/core/alias/src/index.ts
@@ -24,6 +24,8 @@ export const alias: Record<string, string> = {
2424 'stacks/arrays': p.arraysPath('src/index.ts'),
2525 '@stacksjs/arrays/*': p.arraysPath('src/*'),
2626 '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'),
2729 '@stacksjs/auth': p.authPath('src/index.ts'),
2830 'stacks/auth': p.authPath('src/index.ts'),
2931 '@stacksjs/api/*': p.coreApiPath('src/*'),
@@ -123,6 +125,8 @@ export const alias: Record<string, string> = {
123125 '@stacksjs/git/*': p.gitPath('src/*'),
124126 'stacks/git/*': p.gitPath('src/*'),
125127 '@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'),
126130 'stacks/lint': p.lintPath('src/index.ts'),
127131 '@stacksjs/lint/*': p.lintPath('src/*'),
128132 'stacks/lint/*': p.lintPath('src/*'),
@@ -226,6 +230,8 @@ export const alias: Record<string, string> = {
226230 'stacks/validation': p.validationPath('src/index.ts'),
227231 '@stacksjs/validation/*': p.validationPath('src/*'),
228232 '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'),
229235
230236 'framework/*': p.frameworkPath('core/*'),
231237 '~/app/*': p.appPath('*'),
storage/framework/core/alias/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/alias/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.498Z] local.INFO: Building @stacksjs/alias...
1[2026-07-23T16:49:26.728Z] local.INFO: Building @stacksjs/alias...
storage/framework/core/analytics/package.jsonmodified+1-1
Changes to storage/framework/core/analytics/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/analytics",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks Analytics. Privacy-friendly.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/analytics/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/analytics/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.799Z] local.INFO: Building @stacksjs/analytics...
1[2026-07-23T16:49:26.667Z] local.INFO: Building @stacksjs/analytics...
storage/framework/core/api/package.jsonmodified+1-1
Changes to storage/framework/core/api/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/api",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks array utilities.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/api/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/api/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.479Z] local.INFO: Building @stacksjs/api...
1[2026-07-23T16:49:29.887Z] local.INFO: Building @stacksjs/api...
storage/framework/core/arrays/package.jsonmodified+1-1
Changes to storage/framework/core/arrays/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/arrays",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks array utilities.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/arrays/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/arrays/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.054Z] local.INFO: Building @stacksjs/arrays...
1[2026-07-23T16:49:25.996Z] local.INFO: Building @stacksjs/arrays...
storage/framework/core/audio/README.mdadded+66-0
Changes to storage/framework/core/audio/README.md
@@ -0,0 +1,66 @@
1# @stacksjs/audio
2
3Capability-aware audio planning, format negotiation, waveforms, and transcript normalization for Stacks.
4
5## Process music, podcasts, and voice
6
7```ts
8import { detectAudioRuntimeCapabilities } from '@ts-audio/core'
9import { audio } from '@stacksjs/audio'
10
11const 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
26Use `.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
31import { createWaveform, negotiateAudioOutput, normalizeTranscript } from '@stacksjs/audio'
32
33const selected = negotiateAudioOutput(plan.outputs, request.headers.get('accept') ?? '*/*')
34const waveform = createWaveform(decodedChannels, sampleRate, 1200)
35const transcript = normalizeTranscript('en', providerSegments)
36```
37
38Transcription 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
43import { createProtectedAudioPlaylist } from '@stacksjs/audio'
44
45const delivery = await createProtectedAudioPlaylist(segments, {
46 key,
47 keyUri: '/media/keys/episode',
48})
49```
50
51Native 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
66The 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.
storage/framework/core/audio/build.tsadded+4-0
Changes to storage/framework/core/audio/build.ts
@@ -0,0 +1,4 @@
1import { frameworkExternal, intro, outro, transpilePackage } from '../build/src'
2const { startTime } = await intro({ dir: import.meta.dir })
3await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() })
4await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } })
storage/framework/core/registry/package.json storage/framework/core/audio/package.jsoncopied+10-20
Changes to storage/framework/core/audio/package.json
@@ -1,34 +1,23 @@
11{
2 "name": "@stacksjs/registry",
2 "name": "@stacksjs/audio",
33 "type": "module",
44 "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.",
77 "author": "Chris Breuer",
8 "contributors": [
9 "Chris Breuer <chris@stacksjs.com>"
10 ],
118 "license": "MIT",
129 "funding": "https://github.com/sponsors/chrisbbreuer",
13 "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme",
1410 "repository": {
1511 "type": "git",
1612 "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"
2114 },
22 "keywords": [
23 "registry",
24 "stacks"
25 ],
2615 "exports": {
2716 ".": {
17 "types": "./dist/index.d.ts",
2818 "development": "./src/index.ts",
2919 "bun": "./dist/index.js",
30 "import": "./dist/index.js",
31 "default": "./dist/index.js"
20 "import": "./dist/index.js"
3221 }
3322 },
3423 "module": "dist/index.js",
@@ -37,14 +26,15 @@
3726 "README.md",
3827 "dist"
3928 ],
40 "dependencies": {
41 "@stacksjs/types": "workspace:*"
42 },
4329 "scripts": {
4430 "build": "bun build.ts",
4531 "typecheck": "bun tsc --noEmit",
4632 "prepublishOnly": "bun run build"
4733 },
34 "dependencies": {
35 "@ts-audio/core": "^0.1.1",
36 "ts-video-player": "^0.1.0"
37 },
4838 "devDependencies": {
4939 "better-dx": "catalog:"
5040 }
storage/framework/core/audio/src/index.tsadded+122-0
Changes to storage/framework/core/audio/src/index.ts
@@ -0,0 +1,122 @@
1import { createHmac, timingSafeEqual } from 'node:crypto'
2
3export type AudioFormat = 'opus' | 'aac' | 'mp3'
4export type AudioContent = 'speech' | 'music' | 'general'
5export type AudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'alac' | 'pcm_s16le' | 'pcm_s24le' | 'pcm_s32le' | 'pcm_f32le' | 'pcm_f64le'
6export interface AudioProfile { codec: AudioCodec, container: string, duration: number, sampleRate: number, channels: number, bitrate?: number }
7export interface AudioCapabilities { encoder: boolean, codecs: AudioFormat[] }
8export 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 }
9export interface AudioPlan { source: string, profile: AudioProfile, outputs: AudioOutput[], loudness: number }
10export interface AudioProcessOptions { batchSize?: number, signal?: AbortSignal }
11export interface ProcessedAudioDerivative { output: AudioOutput, bytes: Uint8Array }
12export interface Waveform { version: 1, channels: number, sampleRate: number, duration: number, samplesPerPeak: number, peaks: number[][] }
13export interface TranscriptSegment { startTime: number, endTime: number, text: string, confidence?: number, speaker?: string }
14export interface Transcript { language: string, segments: Array<TranscriptSegment & { id: number }>, vtt: string }
15const 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' } }
16function 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
22export 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}
27export 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}
42export function audio(source: string): AudioBuilder { return new AudioBuilder(source) }
43export 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}
48export 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}
52function 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}
65export 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}
68export 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}
74function 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}
78export 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}
91export 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}
94export function signAudioAsset(path: string, expires: number, secret: string): string {
95 return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url')
96}
97export 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
99export interface AudioSegment { uri: string, duration: number, data: Uint8Array }
100export interface AudioHlsProtection { key: Uint8Array, keyUri: string }
101export 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}
storage/framework/core/audio/storage/logs/stacks-2026-07-23.logadded+1-0
Changes to storage/framework/core/audio/storage/logs/stacks-2026-07-23.log
@@ -0,0 +1,1 @@
1[2026-07-23T16:49:25.484Z] local.INFO: Building @stacksjs/audio...
storage/framework/core/audio/tests/audio.test.tsadded+9-0
Changes to storage/framework/core/audio/tests/audio.test.ts
@@ -0,0 +1,9 @@
1import { describe, expect, test } from 'bun:test'
2import { assertAudioPlanExecutable, audio, createProtectedAudioPlaylist, createWaveform, negotiateAudioOutput, normalizeTranscript } from '../src'
3const profile = { codec: 'flac', container: 'flac', duration: 60, sampleRate: 48000, channels: 2, bitrate: 800000 }
4describe('@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})
storage/framework/core/audio/tests/process.test.tsadded+20-0
Changes to storage/framework/core/audio/tests/process.test.ts
@@ -0,0 +1,20 @@
1import { expect, mock, test } from 'bun:test'
2
3mock.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
12const { audio } = await import('../src')
13
14test('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})
storage/framework/core/auth/package.jsonmodified+1-1
Changes to storage/framework/core/auth/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/auth",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "A more simplistic way to authenticate.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/auth/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/auth/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/browser-extension/package.jsonmodified+1-1
Changes to storage/framework/core/browser-extension/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/browser-extension",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/browser-extension/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/browser-extension/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.300Z] local.INFO: Building @stacksjs/browser-extension...
1[2026-07-23T16:49:27.660Z] local.INFO: Building @stacksjs/browser-extension...
storage/framework/core/browser/package.jsonmodified+1-1
Changes to storage/framework/core/browser/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/browser",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks core frontend/browser functionalities.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/browser/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/browser/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:16.462Z] local.INFO: Building @stacksjs/browser...
1[2026-07-23T16:49:35.866Z] local.INFO: Building @stacksjs/browser...
storage/framework/core/buddy/package.jsonmodified+41-42
Changes to storage/framework/core/buddy/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/buddy",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Meet Buddy. The Stacks runtime.",
77 "author": "Chris Breuer",
88 "contributors": [
@@ -64,7 +64,6 @@
6464 "types": "dist/index.d.ts",
6565 "bin": {
6666 "stacks": "dist/cli.js",
67 "stx": "dist/cli.js",
6867 "buddy": "dist/cli.js",
6968 "bud": "dist/cli.js"
7069 },
@@ -92,51 +91,51 @@
9291 "prepublishOnly": "bun run build"
9392 },
9493 "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",
103102 "@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",
115114 "@stacksjs/gitit": "^0.2.5",
116 "@stacksjs/health": "^0.70.162",
115 "@stacksjs/health": "^0.70.163",
117116 "@stacksjs/dnsx": "^0.2.3",
118117 "@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",
128127 "@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",
140139 "@stacksjs/ts-cloud": "catalog:"
141140 },
142141 "devDependencies": {
storage/framework/core/buddy/src/commands/ai-context.tsadded+54-0
Changes to storage/framework/core/buddy/src/commands/ai-context.ts
@@ -0,0 +1,54 @@
1import type { CLI, CliOptions } from '@stacksjs/types'
2import { mkdirSync, writeFileSync } from 'node:fs'
3import { dirname, resolve } from 'node:path'
4import process from 'node:process'
5import { buildProjectContext } from '@stacksjs/ai'
6import { onUnknownSubcommand } from '@stacksjs/cli'
7
8export interface AiContextCommandOptions extends CliOptions {
9 json?: boolean
10 output?: string
11 maxChars?: string | number
12 model?: string
13}
14
15export interface AiContextCommandResult {
16 output: string
17 outputPath: string | null
18}
19
20export 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
37export 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}
storage/framework/core/buddy/src/commands/deploy.tsmodified+21-29
Changes to storage/framework/core/buddy/src/commands/deploy.ts
@@ -569,15 +569,16 @@ function fmtDuration(secs: number): string {
569569}
570570
571571/**
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).
575576 */
576577async function pollUntil(opts: {
577578 label: string
578579 timeoutSecs: number
579580 intervalMs?: number
580 check: () => void
581 check: () => unknown | Promise<unknown>
581582 timeoutMessage: (elapsedSecs: number) => string
582583}): Promise<void> {
583584 log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`)
@@ -586,7 +587,7 @@ async function pollUntil(opts: {
586587 let lastHeartbeat = 0
587588 for (;;) {
588589 try {
589 opts.check()
590 await opts.check()
590591 return
591592 }
592593 catch {
@@ -613,33 +614,24 @@ async function pollUntil(opts: {
613614 * TS_CLOUD_SSH_WAIT_SECS (default 480 = 8m) — SSH reachability
614615 * TS_CLOUD_BOOT_WAIT_SECS (default 720 = 12m) — cloud-init + bun on PATH
615616 */
616async 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 })
617async 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 })
636628
637629 // 1) Wait for SSH to accept connections (server may still be booting).
638630 const sshWaitSecs = readWaitSecs('TS_CLOUD_SSH_WAIT_SECS', 8 * 60)
639631 await pollUntil({
640632 label: 'Waiting for SSH to come up',
641633 timeoutSecs: sshWaitSecs,
642 check: () => { run('true') },
634 check: () => run('true'),
643635 timeoutMessage: elapsed =>
644636 `SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). `
645637 + `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> {
649641 // 2) Block on cloud-init, then confirm bun landed on PATH.
650642 log.info('Waiting for cloud-init (installing bun + caddy)...')
651643 try {
652 run('cloud-init status --wait || true')
644 await run('cloud-init status --wait || true')
653645 }
654646 catch (err) {
655647 log.debug('cloud-init status --wait returned non-zero (continuing):', err)
@@ -659,7 +651,7 @@ async function waitForRemoteReady(ip: string, verbose: boolean): Promise<void> {
659651 await pollUntil({
660652 label: 'Waiting for the bun runtime',
661653 timeoutSecs: bootWaitSecs,
662 check: () => { run('test -x /usr/local/bin/bun') },
654 check: () => run('test -x /usr/local/bin/bun'),
663655 timeoutMessage: elapsed =>
664656 `bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). `
665657 + `cloud-init may have failed — SSH in and check /var/log/cloud-init-output.log; `
@@ -1168,7 +1160,7 @@ async function runHetznerDeploy(args: {
11681160 process.exit(ExitCode.FatalError)
11691161 }
11701162
1171 await waitForRemoteReady(ip, verbose)
1163 await waitForRemoteReady(ip)
11721164
11731165 // Package each site as source-only: dependencies are NOT shipped. They are
11741166 // installed on the server from the committed lockfile via the site's
storage/framework/core/buddy/src/commands/migrate.tsmodified+18-4
Changes to storage/framework/core/buddy/src/commands/migrate.ts
@@ -386,7 +386,8 @@ export function migrate(buddy: CLI): void {
386386 }
387387 }
388388 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)
390391 }
391392 await outro('Diff complete — no changes applied.', { startTime: perf, useSeconds: true })
392393 process.exit(ExitCode.Success)
@@ -404,6 +405,9 @@ export function migrate(buddy: CLI): void {
404405 }
405406 else {
406407 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()
407411 const proceed = await confirm({
408412 message: `Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,
409413 initial: true,
@@ -421,7 +425,10 @@ export function migrate(buddy: CLI): void {
421425 // the migration table by both inserting the same row name.
422426 const lock = acquireMigrationLock()
423427 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.')
425432 process.exit(ExitCode.FatalError)
426433 }
427434
@@ -610,7 +617,9 @@ export function migrate(buddy: CLI): void {
610617
611618 // Hard kill-switch — the command refuses to run at all.
612619 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(
614623 `\`buddy migrate:fresh\` is disabled by your migration safety guards (it DROPS every table).\n`
615624 + ` Target: ${APP_ENV} database "${dbLabel}"\n`
616625 + ` To allow it, set database.safety.migrateFresh to 'allow' in config/database.ts,\n`
@@ -628,12 +637,17 @@ export function migrate(buddy: CLI): void {
628637 const hint = guards.migrateFresh === 'confirm'
629638 ? 'Guard is "confirm": migrate:fresh must be run interactively.'
630639 : '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}`)
632641 await outro('migrate:fresh cancelled.', { startTime: perf, useSeconds: true })
633642 process.exit(ExitCode.FatalError)
634643 }
635644
636645 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()
637651 const typed = await text({ message: `Type the database name "${dbLabel}" to confirm (blank to cancel):` })
638652 if (typed.trim() !== dbLabel) {
639653 await outro('migrate:fresh cancelled — confirmation did not match.', { startTime: perf, useSeconds: true })
storage/framework/core/buddy/src/lazy-commands.tsmodified+1-0
Changes to storage/framework/core/buddy/src/lazy-commands.ts
@@ -16,6 +16,7 @@ interface CommandLoader {
1616const commandRegistry: Record<string, CommandLoader> = {
1717 'about': { path: './commands/about.ts', exportName: 'about' },
1818 'add': { path: './commands/add.ts', exportName: 'add' },
19 'ai:context': { path: './commands/ai-context.ts', exportName: 'aiContext' },
1920 'auth': { path: './commands/auth.ts', exportName: 'auth' },
2021 'build': { path: './commands/build.ts', exportName: 'build' },
2122 'cd': { path: './commands/cd.ts', exportName: 'cd' },
storage/framework/core/buddy/src/project-setup.tsmodified+1-0
Changes to storage/framework/core/buddy/src/project-setup.ts
@@ -12,6 +12,7 @@ const APP_KEY_OPTIONAL_COMMANDS = [
1212 'clean',
1313 'fresh',
1414 'about',
15 'ai:context',
1516 'doctor',
1617 'list',
1718 'setup',
storage/framework/core/buddy/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/buddy/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:17.015Z] local.INFO: Building @stacksjs/buddy...
1[2026-07-23T16:49:36.452Z] local.INFO: Building @stacksjs/buddy...
storage/framework/core/buddy/tests/ai-context.test.tsadded+53-0
Changes to storage/framework/core/buddy/tests/ai-context.test.ts
@@ -0,0 +1,53 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { cli } from '@stacksjs/cli'
6import { aiContext, generateAiContextOutput } from '../src/commands/ai-context'
7import { commandInventoryEntry } from '../src/commands/list'
8import { shouldSkipAppKeyCheck } from '../src/project-setup'
9
10const temporaryRoots: string[] = []
11
12function 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
21afterEach(() => {
22 for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
23})
24
25describe('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})
storage/framework/core/build/package.jsonmodified+1-1
Changes to storage/framework/core/build/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/build",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks framework build tools and configurations.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/build/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/build/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.482Z] local.INFO: Building @stacksjs/build...
1[2026-07-23T16:49:26.774Z] local.INFO: Building @stacksjs/build...
storage/framework/core/cache/package.jsonmodified+1-1
Changes to storage/framework/core/cache/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/cache",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Caching the Stacks way.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/cache/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/cache/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.482Z] local.INFO: Building @stacksjs/cache...
1[2026-07-23T16:49:30.553Z] local.INFO: Building @stacksjs/cache...
storage/framework/core/calendar-api/package.jsonmodified+1-1
Changes to storage/framework/core/calendar-api/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/calendar-api",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Easily interact with calendar APIs.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/calendar-api/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/calendar-api/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.163Z] local.INFO: Building @stacksjs/calendar-api...
1[2026-07-23T16:49:32.242Z] local.INFO: Building @stacksjs/calendar-api...
storage/framework/core/charts/package.jsonmodified+1-1
Changes to storage/framework/core/charts/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/charts",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Chart rendering for Stacks. Built on ts-charts (D3 in TypeScript).",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/charts/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/charts/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.659Z] local.INFO: Building @stacksjs/charts...
1[2026-07-23T16:49:31.060Z] local.INFO: Building @stacksjs/charts...
storage/framework/core/chat/package.jsonmodified+1-1
Changes to storage/framework/core/chat/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/chat",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Easily interact with chat APIs.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/chat/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/chat/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.739Z] local.INFO: Building @stacksjs/chat...
1[2026-07-23T16:49:28.418Z] local.INFO: Building @stacksjs/chat...
storage/framework/core/cli/package.jsonmodified+1-1
Changes to storage/framework/core/cli/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/cli",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "TypeScript framework for CLI artisans. Build beautiful console apps with ease.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/cli/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/cli/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.671Z] local.INFO: Building @stacksjs/cli...
1[2026-07-23T16:49:29.744Z] local.INFO: Building @stacksjs/cli...
storage/framework/core/cloud/package.jsonmodified+1-1
Changes to storage/framework/core/cloud/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/cloud",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks cloud/serverless integration & implementation.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/cloud/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/cloud/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.079Z] local.INFO: Building @stacksjs/cloud...
1[2026-07-23T16:49:30.070Z] local.INFO: Building @stacksjs/cloud...
storage/framework/core/cms/package.jsonmodified+1-1
Changes to storage/framework/core/cms/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/cms",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks cms utilities.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/cms/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/cms/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.267Z] local.INFO: Building @stacksjs/cms...
1[2026-07-23T16:49:29.938Z] local.INFO: Building @stacksjs/cms...
storage/framework/core/collections/package.jsonmodified+1-1
Changes to storage/framework/core/collections/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/collections",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks collections.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/collections/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/collections/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.473Z] local.INFO: Building @stacksjs/collections...
1[2026-07-23T16:49:25.125Z] local.INFO: Building @stacksjs/collections...
storage/framework/core/commerce/package.jsonmodified+1-1
Changes to storage/framework/core/commerce/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/commerce",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks commerce utilities.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/commerce/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/commerce/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.647Z] local.INFO: Building @stacksjs/commerce...
1[2026-07-23T16:49:28.390Z] local.INFO: Building @stacksjs/commerce...
storage/framework/core/composables/package.jsonmodified+1-1
Changes to storage/framework/core/composables/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/composables",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks composables powered by stx reactivity.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/composables/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/composables/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:16.017Z] local.INFO: Building @stacksjs/composables...
1[2026-07-23T16:49:35.341Z] local.INFO: Building @stacksjs/composables...
storage/framework/core/config/package.jsonmodified+1-1
Changes to storage/framework/core/config/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/config",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks config helper methods.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/config/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/config/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.561Z] local.INFO: Building @stacksjs/config...
1[2026-07-23T16:49:24.963Z] local.INFO: Building @stacksjs/config...
storage/framework/core/cron/package.jsonmodified+1-1
Changes to storage/framework/core/cron/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/cron",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks cron.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/cron/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/cron/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:15.060Z] local.INFO: Building @stacksjs/cron...
1[2026-07-23T16:49:27.706Z] local.INFO: Building @stacksjs/cron...
storage/framework/core/database/package.jsonmodified+1-1
Changes to storage/framework/core/database/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/database",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks database integration.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/database/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/database/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/datetime/package.jsonmodified+1-1
Changes to storage/framework/core/datetime/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/datetime",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks datetime helpers.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/datetime/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/datetime/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.852Z] local.INFO: Building @stacksjs/datetime...
1[2026-07-23T16:49:26.949Z] local.INFO: Building @stacksjs/datetime...
storage/framework/core/defaults/package.jsonmodified+1-1
Changes to storage/framework/core/defaults/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/defaults",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "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.",
77 "author": "Chris Breuer",
88 "license": "MIT",
storage/framework/core/defaults/resources/components/Audio.stxadded+92-0
Changes to storage/framework/core/defaults/resources/components/Audio.stx
@@ -0,0 +1,92 @@
1<script server>
2interface AudioSource { src: string, type?: string }
3interface 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}
8interface AudioTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean }
9interface 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}
26const props = defineProps<AudioProps>()
27const src = props.src || ''
28const sources = props.sources || []
29const tracks = props.tracks || []
30const title = props.title || 'Audio player'
31const artist = props.artist || ''
32const artwork = props.artwork || ''
33const waveform = props.waveform || ''
34const controls = props.controls !== false
35const preload = props.preload || 'metadata'
36const crossorigin = props.crossorigin || ''
37const className = props.class || ''
38const 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)
39const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
40const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
41</script>
42
43<script client>
44import { registerElements } from 'ts-video-player/elements'
45registerElements()
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>
89video-player:not(:defined) video-skin { display: none; }
90video-player:defined > [data-native-fallback] { display: none; }
91@media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } }
92</style>
storage/framework/core/defaults/resources/components/Image.stxadded+59-0
Changes to storage/framework/core/defaults/resources/components/Image.stx
@@ -0,0 +1,59 @@
1<script server>
2interface ImageSource { type: string, srcset: string, media?: string }
3interface 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}
22const props = defineProps<ImageProps>()
23if (!props.decorative && !props.alt?.trim()) throw new TypeError('Image alt text is required unless decorative is true')
24const src = props.src || ''
25const alt = props.decorative ? '' : props.alt || ''
26const width = props.width || ''
27const height = props.height || ''
28const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '')
29const srcset = props.srcset || ''
30const sizes = props.sizes || (srcset ? '100vw' : '')
31const sources = props.sources || []
32const loading = props.priority ? 'eager' : props.loading || 'lazy'
33const decoding = props.decoding || 'async'
34const priority = props.priority ? 'high' : props.fetchpriority || 'auto'
35const fit = props.fit || 'cover'
36const position = props.position || 'center'
37const placeholder = props.placeholder || ''
38const 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>
storage/framework/core/defaults/resources/components/Video.stxadded+108-0
Changes to storage/framework/core/defaults/resources/components/Video.stx
@@ -0,0 +1,108 @@
1<script server>
2interface VideoSource { src: string, type?: string }
3interface 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}
8interface VideoTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean }
9interface 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}
30const props = defineProps<VideoProps>()
31const src = props.src || ''
32const sources = props.sources || []
33const tracks = props.tracks || []
34const title = props.title || 'Video player'
35const poster = props.poster || ''
36const width = props.width || ''
37const height = props.height || ''
38const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '16 / 9')
39const controls = props.controls !== false
40const inline = props.playsinline !== false
41const preload = props.preload || 'metadata'
42const crossorigin = props.crossorigin || ''
43const controlslist = props.controlslist || 'nodownload'
44const className = props.class || ''
45const 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)
46const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
47const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
48</script>
49
50<script client>
51import { registerElements } from 'ts-video-player/elements'
52registerElements()
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>
105video-player:not(:defined) video-skin { display: none; }
106video-player:defined > [data-native-fallback] { display: none; }
107@media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } }
108</style>
storage/framework/core/defaults/resources/components/index.tsmodified+3-0
Changes to storage/framework/core/defaults/resources/components/index.ts
@@ -11,3 +11,6 @@ export { default as SidebarModern } from './Dashboard/SidebarModern.stx'
1111
1212// Dashboard and UI components
1313export * from './Dashboard'
14export { default as Audio } from './Audio.stx'
15export { default as Image } from './Image.stx'
16export { default as Video } from './Video.stx'
storage/framework/core/deploy/package.jsonmodified+1-1
Changes to storage/framework/core/deploy/package.json
@@ -1,6 +1,6 @@
11{
22 "name": "@stacksjs/deploy",
3 "version": "0.70.162",
3 "version": "0.70.163",
44 "type": "module",
55 "sideEffects": false,
66 "description": "Deployment module for Stacks using ts-cloud",
storage/framework/core/desktop/package.jsonmodified+1-1
Changes to storage/framework/core/desktop/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/desktop",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Desktop engine.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/desktop/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/desktop/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.345Z] local.INFO: Building @stacksjs/desktop...
1[2026-07-23T16:49:26.988Z] local.INFO: Building @stacksjs/desktop...
storage/framework/core/development/package.jsonmodified+1-1
Changes to storage/framework/core/development/package.json
@@ -1,6 +1,6 @@
11{
22 "name": "@stacksjs/development",
3 "version": "0.70.162",
3 "version": "0.70.163",
44 "sideEffects": false,
55 "private": true,
66 "main": "index.ts"
storage/framework/core/dns/package.jsonmodified+1-1
Changes to storage/framework/core/dns/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/dns",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Easily manage your DNS.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/dns/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/dns/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.891Z] local.INFO: Building @stacksjs/dns...
1[2026-07-23T16:49:27.212Z] local.INFO: Building @stacksjs/dns...
storage/framework/core/docs/package.jsonmodified+1-1
Changes to storage/framework/core/docs/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/docs",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks way to document.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/docs/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/docs/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.319Z] local.INFO: Building @stacksjs/docs...
1[2026-07-23T16:49:26.331Z] local.INFO: Building @stacksjs/docs...
storage/framework/core/email/package.jsonmodified+1-1
Changes to storage/framework/core/email/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/email",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Email integration. Painlessly create & manage your inboxes, templates, and send emails.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/email/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/email/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/enums/package.jsonmodified+1-1
Changes to storage/framework/core/enums/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/enums",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Stacks enums.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/enums/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/enums/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.114Z] local.INFO: Building @stacksjs/enums...
1[2026-07-23T16:49:27.897Z] local.INFO: Building @stacksjs/enums...
storage/framework/core/env/package.jsonmodified+1-1
Changes to storage/framework/core/env/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/env",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks env helper methods.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/env/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/env/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/error-handling/package.jsonmodified+1-1
Changes to storage/framework/core/error-handling/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/error-handling",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Type safe error handling.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/error-handling/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/error-handling/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.141Z] local.INFO: Building @stacksjs/error-handling...
1[2026-07-23T16:49:26.198Z] local.INFO: Building @stacksjs/error-handling...
storage/framework/core/events/package.jsonmodified+1-1
Changes to storage/framework/core/events/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/events",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Functional event emitting.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/events/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/events/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.462Z] local.INFO: Building @stacksjs/events...
1[2026-07-23T16:49:26.302Z] local.INFO: Building @stacksjs/events...
storage/framework/core/faker/package.jsonmodified+1-1
Changes to storage/framework/core/faker/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/faker",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "Faker functions.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/faker/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/faker/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.500Z] local.INFO: Building @stacksjs/faker...
1[2026-07-23T16:49:29.636Z] local.INFO: Building @stacksjs/faker...
storage/framework/core/feature-flags/package.jsonmodified+1-1
Changes to storage/framework/core/feature-flags/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/feature-flags",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Typed, scoped feature flags for Stacks applications.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/feature-flags/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/feature-flags/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.253Z] local.INFO: Building @stacksjs/feature-flags...
1[2026-07-23T16:49:30.759Z] local.INFO: Building @stacksjs/feature-flags...
storage/framework/core/git/package.jsonmodified+1-1
Changes to storage/framework/core/git/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/git",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks git utilities & conventions.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/git/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/git/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.021Z] local.INFO: Building @stacksjs/git...
1[2026-07-23T16:49:29.828Z] local.INFO: Building @stacksjs/git...
storage/framework/core/github/package.jsonmodified+1-1
Changes to storage/framework/core/github/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/github",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "GitHub API client used by Stacks framework features (dashboard CI surface, notifications, runner alerts).",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/github/src/client.tsmodified+76-0
Changes to storage/framework/core/github/src/client.ts
@@ -1,5 +1,11 @@
11export const GITHUB_API = 'https://api.github.com'
22
3export interface GitHubClientOptions {
4 token?: string
5 apiUrl?: string
6 fetch?: typeof globalThis.fetch
7}
8
39function getToken(): string {
410 const token = process.env.GITHUB_TOKEN
511 if (!token)
@@ -7,6 +13,12 @@ function getToken(): string {
713 return token
814}
915
16function resolveToken(token?: string): string {
17 if (token)
18 return token
19 return getToken()
20}
21
1022export function ghHeaders(): Record<string, string> {
1123 return {
1224 'Authorization': `Bearer ${getToken()}`,
@@ -15,6 +27,70 @@ export function ghHeaders(): Record<string, string> {
1527 }
1628}
1729
30export 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
38function 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. */
53export 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
74export 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
1894/**
1995 * `fetch` against the GitHub API that retries on secondary rate limits.
2096 *
storage/framework/core/github/src/index.tsmodified+10-1
Changes to storage/framework/core/github/src/index.ts
@@ -9,7 +9,8 @@
99 */
1010
1111export { fetchBotPRCounts } from './bots'
12export { ghFetch, ghHeaders, GITHUB_API, mapWithConcurrency } from './client'
12export { ghFetch, ghHeaders, githubHeaders, githubJson, githubRequest, GITHUB_API, mapWithConcurrency } from './client'
13export type { GitHubClientOptions } from './client'
1314export { clearDashboardCache, getDashboardData } from './dashboard'
1415export { detectNewlyFailedRuns } from './failure-detector'
1516export type { DetectOptions, FailedTransition, PreviousRunState } from './failure-detector'
@@ -25,6 +26,14 @@ export type {
2526 RunnerSample,
2627} from './runner-pressure-detector'
2728export { fetchFailedJobs, fetchRepoStatus } from './runs'
29export { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from './pull-requests'
30export type {
31 CreatedPullRequest,
32 CreatePullRequestWithFilesOptions,
33 RepositoryFileChange,
34 RepositoryTree,
35 RepositoryTreeEntry,
36} from './pull-requests'
2837export type {
2938 DashboardData,
3039 DashboardOptions,
storage/framework/core/github/src/pull-requests.tsadded+169-0
Changes to storage/framework/core/github/src/pull-requests.ts
@@ -0,0 +1,169 @@
1import type { GitHubClientOptions } from './client'
2import { githubJson } from './client'
3
4export interface RepositoryFileChange {
5 path: string
6 content: string
7}
8
9export 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
21export interface CreatedPullRequest {
22 number: number
23 url: string
24 branch: string
25 base: string
26 commitSha: string
27}
28
29export interface RepositoryTreeEntry {
30 path: string
31 mode: string
32 type: 'blob' | 'tree' | 'commit'
33 sha: string
34 size?: number
35}
36
37export interface RepositoryTree {
38 sha: string
39 truncated: boolean
40 entries: RepositoryTreeEntry[]
41}
42
43function 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
49function 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
54function 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
73export 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
87export 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 */
116export 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}
storage/framework/core/github/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/github/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.655Z] local.INFO: Building @stacksjs/github...
1[2026-07-23T16:49:29.490Z] local.INFO: Building @stacksjs/github...
storage/framework/core/github/tests/pull-requests.test.tsadded+76-0
Changes to storage/framework/core/github/tests/pull-requests.test.ts
@@ -0,0 +1,76 @@
1import { describe, expect, test } from 'bun:test'
2import { createPullRequestWithFiles, fetchRepositoryFile, fetchRepositoryTree } from '../src/pull-requests'
3
4function json(value: unknown, status = 200): Response {
5 return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } })
6}
7
8describe('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})
storage/framework/core/health/package.jsonmodified+1-1
Changes to storage/framework/core/health/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/health",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Health services.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/health/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/health/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.569Z] local.INFO: Building @stacksjs/health...
1[2026-07-23T16:49:26.978Z] local.INFO: Building @stacksjs/health...
storage/framework/core/http/package.jsonmodified+1-1
Changes to storage/framework/core/http/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/http",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Stacks HTTP methods.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/http/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/http/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.986Z] local.INFO: Building @stacksjs/http...
1[2026-07-23T16:49:26.562Z] local.INFO: Building @stacksjs/http...
storage/framework/core/i18n/package.jsonmodified+1-1
Changes to storage/framework/core/i18n/package.json
@@ -1,6 +1,6 @@
11{
22 "name": "@stacksjs/i18n",
3 "version": "0.70.162",
3 "version": "0.70.163",
44 "description": "Internationalization system for Stacks.js applications",
55 "type": "module",
66 "sideEffects": false,
storage/framework/core/i18n/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/i18n/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.092Z] local.INFO: Building @stacksjs/i18n...
1[2026-07-23T16:49:25.555Z] local.INFO: Building @stacksjs/i18n...
storage/framework/core/image/README.mdadded+74-0
Changes to storage/framework/core/image/README.md
@@ -0,0 +1,74 @@
1# @stacksjs/image
2
3Native responsive image generation for Stacks, powered by `ts-images`.
4
5## Generate responsive images
6
7```ts
8import { image } from '@stacksjs/image'
9
10const 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
21Presets 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
25Pass any structural storage adapter. The adapter can wrap Stacks storage, S3, R2, B2, or another S3-compatible provider.
26
27```ts
28const 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
45The returned filenames are content-addressed and safe for an immutable CloudFront behavior. Use a short-lived signed transform for private originals:
46
47```ts
48import { signImageTransform, verifyImageTransform } from '@stacksjs/image'
49
50const expires = Math.floor(Date.now() / 1000) + 300
51const signature = signImageTransform('/private/hero.jpg?w=1280&format=avif', expires, secret)
52
53if (!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
74Use `decorative` instead of empty alt text for decorative images. The component renders responsive, layout-stable HTML without requiring client JavaScript.
storage/framework/core/image/build.tsadded+4-0
Changes to storage/framework/core/image/build.ts
@@ -0,0 +1,4 @@
1import { frameworkExternal, intro, outro, transpilePackage } from '../build/src'
2const { startTime } = await intro({ dir: import.meta.dir })
3await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() })
4await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } })
storage/framework/core/registry/package.json storage/framework/core/image/package.jsoncopied+9-20
Changes to storage/framework/core/image/package.json
@@ -1,34 +1,23 @@
11{
2 "name": "@stacksjs/registry",
2 "name": "@stacksjs/image",
33 "type": "module",
44 "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.",
77 "author": "Chris Breuer",
8 "contributors": [
9 "Chris Breuer <chris@stacksjs.com>"
10 ],
118 "license": "MIT",
129 "funding": "https://github.com/sponsors/chrisbbreuer",
13 "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme",
1410 "repository": {
1511 "type": "git",
1612 "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"
2114 },
22 "keywords": [
23 "registry",
24 "stacks"
25 ],
2615 "exports": {
2716 ".": {
17 "types": "./dist/index.d.ts",
2818 "development": "./src/index.ts",
2919 "bun": "./dist/index.js",
30 "import": "./dist/index.js",
31 "default": "./dist/index.js"
20 "import": "./dist/index.js"
3221 }
3322 },
3423 "module": "dist/index.js",
@@ -37,14 +26,14 @@
3726 "README.md",
3827 "dist"
3928 ],
40 "dependencies": {
41 "@stacksjs/types": "workspace:*"
42 },
4329 "scripts": {
4430 "build": "bun build.ts",
4531 "typecheck": "bun tsc --noEmit",
4632 "prepublishOnly": "bun run build"
4733 },
34 "dependencies": {
35 "ts-images": "^0.2.1"
36 },
4837 "devDependencies": {
4938 "better-dx": "catalog:"
5039 }
storage/framework/core/image/src/index.tsadded+146-0
Changes to storage/framework/core/image/src/index.ts
@@ -0,0 +1,146 @@
1import type { ImageData, ResizeFit } from 'ts-images'
2import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
3import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
4import { basename, extname, isAbsolute, relative, resolve } from 'node:path'
5import { decode, encode, imageToSplatHash, resize } from 'ts-images'
6
7export type ImageFormat = 'avif' | 'webp' | 'jpeg' | 'png'
8export interface ImageVariant { width: number, height: number, bytes: number, format: ImageFormat, mimeType: string, path: string, url: string, cacheKey: string }
9export interface ImageManifest { source: { width: number, height: number, hash: string }, variants: ImageVariant[], placeholder: string }
10export type ImagePreset = 'avatar' | 'content' | 'hero' | 'thumbnail'
11export 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> }
12export interface ImageOptions { root?: string, outputDir?: string, publicPath?: string, concurrency?: number, upscale?: boolean, authorize?: (_source: string, _context?: unknown) => boolean | Promise<boolean>, authorizationContext?: unknown }
13
14const mime: Record<ImageFormat, string> = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', png: 'image/png' }
15function 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
19export 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
27export 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
114export function image(source: string, options: ImageOptions = {}): ImageBuilder { return new ImageBuilder(source, options) }
115
116function 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
129export 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
135export 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
139export function signImageTransform(path: string, expires: number, secret: string): string {
140 return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url')
141}
142export 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}
storage/framework/core/image/storage/logs/stacks-2026-07-23.logadded+1-0
Changes to storage/framework/core/image/storage/logs/stacks-2026-07-23.log
@@ -0,0 +1,1 @@
1[2026-07-23T16:49:26.955Z] local.INFO: Building @stacksjs/image...
storage/framework/core/image/tests/image.test.tsadded+43-0
Changes to storage/framework/core/image/tests/image.test.ts
@@ -0,0 +1,43 @@
1import { afterEach, describe, expect, test } from 'bun:test'
2import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { createImageData, encode } from 'ts-images'
6import { image, negotiateImageVariant, resolveImageSource, signImageTransform, verifyImageTransform } from '../src'
7const dirs: string[] = []
8afterEach(async () => Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))))
9
10describe('@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})
storage/framework/core/lint/package.jsonmodified+1-1
Changes to storage/framework/core/lint/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/lint",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks way to lint.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/logging/package.jsonmodified+1-1
Changes to storage/framework/core/logging/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/logging",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks logging system.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/logging/src/index.tsmodified+9-3
Changes to storage/framework/core/logging/src/index.ts
@@ -409,11 +409,17 @@ export const log: Log = {
409409
410410 warn: async (message: string, context?: unknown) => {
411411 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 }
412420 // Normalize so Errors in the context survive clarity's JSON.stringify
413421 // (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>
417423 await logger.warn(message, normalized)
418424 },
419425
storage/framework/core/logging/storage/logs/stacks-2026-07-22.logdeleted+0-35
Changes to storage/framework/core/logging/storage/logs/stacks-2026-07-22.log
@@ -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)"}}
storage/framework/core/logging/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/logging/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/newsletter/package.jsonmodified+1-1
Changes to storage/framework/core/newsletter/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/newsletter",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Promotional email & newsletter primitives for Stacks. Lists, campaigns, subscribers, and one-click unsubscribe — sent through @stacksjs/email.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/newsletter/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/newsletter/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.008Z] local.INFO: Building @stacksjs/newsletter...
1[2026-07-23T16:49:31.166Z] local.INFO: Building @stacksjs/newsletter...
storage/framework/core/notifications/package.jsonmodified+1-1
Changes to storage/framework/core/notifications/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/notifications",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks notifications integration.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/notifications/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/notifications/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.726Z] local.INFO: Building @stacksjs/notifications...
1[2026-07-23T16:49:26.989Z] local.INFO: Building @stacksjs/notifications...
storage/framework/core/objects/package.jsonmodified+1-1
Changes to storage/framework/core/objects/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/objects",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks objects.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/objects/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/objects/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.015Z] local.INFO: Building @stacksjs/objects...
1[2026-07-23T16:49:27.800Z] local.INFO: Building @stacksjs/objects...
storage/framework/core/orm/package.jsonmodified+1-1
Changes to storage/framework/core/orm/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/orm",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks ORM integration",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/orm/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/orm/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/package.jsonmodified+91-76
Changes to storage/framework/core/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "stacks",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks framework.",
66 "author": "Chris Breuer",
77 "contributors": [
@@ -72,6 +72,10 @@
7272 "types": "./auth/dist/index.d.ts",
7373 "import": "./auth/dist/index.js"
7474 },
75 "./audio": {
76 "types": "./audio/dist/index.d.ts",
77 "import": "./audio/dist/index.js"
78 },
7579 "./buddy": {
7680 "types": "./buddy/dist/index.d.ts",
7781 "import": "./buddy/dist/index.js"
@@ -172,6 +176,10 @@
172176 "types": "./health/dist/index.d.ts",
173177 "import": "./health/dist/index.js"
174178 },
179 "./image": {
180 "types": "./image/dist/index.d.ts",
181 "import": "./image/dist/index.js"
182 },
175183 "./lint": {
176184 "types": "./lint/dist/index.d.ts",
177185 "import": "./lint/dist/index.js"
@@ -284,6 +292,10 @@
284292 "types": "./validation/dist/index.d.ts",
285293 "import": "./validation/dist/index.js"
286294 },
295 "./video": {
296 "types": "./video/dist/index.d.ts",
297 "import": "./video/dist/index.js"
298 },
287299 "./skills": {
288300 "types": "./skills/dist/index.d.ts",
289301 "import": "./skills/dist/index.js"
@@ -302,84 +314,87 @@
302314 },
303315 "web-types": "./web-types.json",
304316 "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",
321334 "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",
345359 "@stacksjs/tlsx": "^0.13.0",
346360 "@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",
383398 "bun-query-builder": "catalog:"
384399 }
385400}
storage/framework/core/path/package.jsonmodified+1-1
Changes to storage/framework/core/path/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/path",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks path.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/path/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/path/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.102Z] local.INFO: Building @stacksjs/path...
1[2026-07-23T16:49:26.216Z] local.INFO: Building @stacksjs/path...
storage/framework/core/payments/package.jsonmodified+1-1
Changes to storage/framework/core/payments/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/payments",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks payments package. Currently supporting Stripe.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/payments/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/payments/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.522Z] local.INFO: Building @stacksjs/payments...
1[2026-07-23T16:49:26.460Z] local.INFO: Building @stacksjs/payments...
storage/framework/core/push/package.jsonmodified+1-1
Changes to storage/framework/core/push/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/push",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Push integration",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/push/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/push/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.642Z] local.INFO: Building @stacksjs/push...
1[2026-07-23T16:49:27.618Z] local.INFO: Building @stacksjs/push...
storage/framework/core/query-builder/package.jsonmodified+1-1
Changes to storage/framework/core/query-builder/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/query-builder",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Query Builder integration",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/query-builder/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/query-builder/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.603Z] local.INFO: Building @stacksjs/query-builder...
1[2026-07-23T16:49:26.373Z] local.INFO: Building @stacksjs/query-builder...
storage/framework/core/queue/package.jsonmodified+1-1
Changes to storage/framework/core/queue/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/queue",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Queue system powered by bun-queue.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/queue/src/job-progress.tsmodified+18-5
Changes to storage/framework/core/queue/src/job-progress.ts
@@ -25,16 +25,30 @@ interface JobProgress {
2525 updatedAt: number
2626}
2727
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 */
38export function clampProgressPercent(percent: number): number {
39 return Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0
40}
41
2842/**
2943 * Update the progress of the running job. Workers call this from
3044 * 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).
3246 *
3347 * @example
3448 * ```ts
35 * await job.progress(0.5, 'Halfway there')
49 * await setJobProgress(job.id, 50, 'Halfway there')
3650 * // … later
37 * await job.progress(1.0, 'Done')
51 * await setJobProgress(job.id, 100, 'Done')
3852 * ```
3953 */
4054export async function setJobProgress(
@@ -43,10 +57,9 @@ export async function setJobProgress(
4357 message?: string,
4458): Promise<void> {
4559 const { cache } = await import('@stacksjs/cache')
46 const clamped = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent * (percent <= 1 ? 100 : 1))) : 0
4760 await cache.set(
4861 PROGRESS_KEY(jobId),
49 { percent: clamped, message, updatedAt: Date.now() } as JobProgress,
62 { percent: clampProgressPercent(percent), message, updatedAt: Date.now() } as JobProgress,
5063 TTL_SECONDS,
5164 )
5265}
storage/framework/core/queue/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/queue/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.921Z] local.INFO: Building @stacksjs/queue...
1[2026-07-23T16:49:27.750Z] local.INFO: Building @stacksjs/queue...
storage/framework/core/queue/tests/job-progress.test.tsadded+31-0
Changes to storage/framework/core/queue/tests/job-progress.test.ts
@@ -0,0 +1,31 @@
1import { describe, expect, it } from 'bun:test'
2import { 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.
8describe('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})
storage/framework/core/realtime/package.jsonmodified+1-1
Changes to storage/framework/core/realtime/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/realtime",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks realtime integration. Built on top of ts-broadcasting.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/realtime/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/realtime/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.405Z] local.INFO: Building @stacksjs/realtime...
1[2026-07-23T16:49:25.492Z] local.INFO: Building @stacksjs/realtime...
storage/framework/core/registry/package.jsonmodified+1-1
Changes to storage/framework/core/registry/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/registry",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Registry.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/registry/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/registry/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:16.145Z] local.INFO: Building @stacksjs/registry...
1[2026-07-23T16:49:35.401Z] local.INFO: Building @stacksjs/registry...
storage/framework/core/repl/package.jsonmodified+1-1
Changes to storage/framework/core/repl/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/repl",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "A Stacks optimized TypeScript REPL.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/repl/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/repl/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.114Z] local.INFO: Building @stacksjs/repl...
1[2026-07-23T16:49:27.865Z] local.INFO: Building @stacksjs/repl...
storage/framework/core/router/package.jsonmodified+1-1
Changes to storage/framework/core/router/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/router",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks framework router.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/router/storage/logs/stacks-2026-07-14.logdeleted+0-1
Changes to storage/framework/core/router/storage/logs/stacks-2026-07-14.log
@@ -1,1 +0,0 @@
1[2026-07-14T12:57:53.865Z] local.INFO: Building @stacksjs/router...
storage/framework/core/router/storage/logs/stacks-2026-07-23.logmodified+2-4
Changes to storage/framework/core/router/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/scheduler/package.jsonmodified+1-1
Changes to storage/framework/core/scheduler/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/scheduler",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks scheduler.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/scheduler/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/scheduler/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.290Z] local.INFO: Building @stacksjs/scheduler...
1[2026-07-23T16:49:29.144Z] local.INFO: Building @stacksjs/scheduler...
storage/framework/core/search-engine/package.jsonmodified+1-1
Changes to storage/framework/core/search-engine/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/search-engine",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks search engine integrations.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/search-engine/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/search-engine/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.038Z] local.INFO: Building @stacksjs/search-engine...
1[2026-07-23T16:49:31.069Z] local.INFO: Building @stacksjs/search-engine...
storage/framework/core/security/package.jsonmodified+1-1
Changes to storage/framework/core/security/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/security",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks framework security.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/security/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/security/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.692Z] local.INFO: Building @stacksjs/security...
1[2026-07-23T16:49:28.325Z] local.INFO: Building @stacksjs/security...
storage/framework/core/server/package.jsonmodified+1-1
Changes to storage/framework/core/server/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/server",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Local development and production-ready.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/server/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/server/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.559Z] local.INFO: Building @stacksjs/server...
1[2026-07-23T16:49:31.269Z] local.INFO: Building @stacksjs/server...
storage/framework/core/shell/package.jsonmodified+1-1
Changes to storage/framework/core/shell/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/shell",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "A better Stacks shell experience. Currently only fully supporting Oh My Zsh. Stay tuned for more. Or request one!",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/shell/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/shell/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.818Z] local.INFO: Building @stacksjs/shell...
1[2026-07-23T16:49:29.608Z] local.INFO: Building @stacksjs/shell...
storage/framework/core/skills/package.jsonmodified+1-1
Changes to storage/framework/core/skills/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/skills",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Agent skills for the Stacks framework, following the agentskills.io standard.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/skills/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/skills/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:16.109Z] local.INFO: Building @stacksjs/skills...
1[2026-07-23T16:49:35.355Z] local.INFO: Building @stacksjs/skills...
storage/framework/core/slug/package.jsonmodified+1-1
Changes to storage/framework/core/slug/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/slug",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks slug functionality.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/slug/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/slug/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.858Z] local.INFO: Building @stacksjs/slug...
1[2026-07-23T16:49:25.599Z] local.INFO: Building @stacksjs/slug...
storage/framework/core/slugs/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/slugs/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.282Z] local.INFO: Building @stacksjs/slugs...
1[2026-07-23T16:49:29.621Z] local.INFO: Building @stacksjs/slugs...
storage/framework/core/sms/package.jsonmodified+1-1
Changes to storage/framework/core/sms/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/sms",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks SMS integration. Painlessly create & manage your inboxes, templates, and send sms.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/sms/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/sms/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.831Z] local.INFO: Building @stacksjs/sms...
1[2026-07-23T16:49:27.816Z] local.INFO: Building @stacksjs/sms...
storage/framework/core/socials/package.jsonmodified+1-1
Changes to storage/framework/core/socials/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/socials",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "A simple and elegant social authentication package for Stacks.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/socials/storage/logs/stacks-2026-07-22.logdeleted+0-2
Changes to storage/framework/core/socials/storage/logs/stacks-2026-07-22.log
@@ -1,2 +0,0 @@
1[2026-07-22T11:29:42.407Z] local.INFO: Building @stacksjs/socials...
2[2026-07-22T11:35:19.188Z] local.INFO: Building @stacksjs/socials...
storage/framework/core/socials/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/socials/storage/logs/stacks-2026-07-23.log
@@ -1,3 +1,2 @@
1[2026-07-23T13:04:26.473Z] local.INFO: Building @stacksjs/socials...
2[2026-07-23T13:13:13.577Z] local.INFO: Building @stacksjs/socials...
3[2026-07-23T13:13:36.560Z] local.INFO: Building @stacksjs/socials...
1[2026-07-23T16:49:26.825Z] local.INFO: Building @stacksjs/socials...
2[2026-07-23T16:49:39.001Z] local.INFO: Building @stacksjs/socials...
storage/framework/core/storage/package.jsonmodified+4-12
Changes to storage/framework/core/storage/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/storage",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks file system.",
77 "author": "Chris Breuer",
88 "contributors": [
@@ -52,15 +52,8 @@
5252 "prepublishOnly": "bun run build"
5353 },
5454 "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"
6457 },
6558 "devDependencies": {
6659 "@stacksjs/arrays": "workspace:*",
@@ -68,7 +61,6 @@
6861 "@stacksjs/error-handling": "workspace:*",
6962 "@stacksjs/path": "workspace:*",
7063 "@stacksjs/strings": "workspace:*",
71 "@stacksjs/types": "workspace:*",
72 "sharp": "^0.35.3"
64 "@stacksjs/types": "workspace:*"
7365 }
7466}
storage/framework/core/storage/src/adapters/s3.tsmodified+43-1
Changes to storage/framework/core/storage/src/adapters/s3.ts
@@ -92,6 +92,40 @@ async function isSettled(p: Promise<unknown>): Promise<boolean> {
9292 return result !== sentinel
9393}
9494
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 */
100interface 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 */
114export 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
95129/**
96130 * AWS S3 storage adapter using ts-cloud S3Client
97131 */
@@ -101,6 +135,8 @@ export class S3StorageAdapter implements StorageAdapter {
101135 private bucket: string
102136 private prefix: string
103137 private region: string
138 private endpoint?: string
139 private usePathStyleEndpoint?: boolean
104140 /**
105141 * Caller-supplied credentials, kept so we can issue presigned POST
106142 * policies (which need access to the signing key — ts-cloud's
@@ -120,6 +156,8 @@ export class S3StorageAdapter implements StorageAdapter {
120156 this.prefix = config.prefix || ''
121157 this.region = config.region || 'us-east-1'
122158 this.credentials = config.credentials
159 this.endpoint = config.endpoint
160 this.usePathStyleEndpoint = config.usePathStyleEndpoint
123161
124162 if (!this.bucket) {
125163 throw new Error('S3 bucket name is required')
@@ -131,7 +169,11 @@ export class S3StorageAdapter implements StorageAdapter {
131169 return this._client
132170 if (!this._clientPromise) {
133171 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 )
135177 return this._client
136178 })
137179 }
storage/framework/core/storage/src/facade.tsmodified+8-0
Changes to storage/framework/core/storage/src/facade.ts
@@ -176,6 +176,14 @@ class StorageManager {
176176 bucket: config.bucket,
177177 region: config.region,
178178 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,
179187 })
180188 }
181189
storage/framework/core/storage/src/image.tsmodified+71-123
Changes to storage/framework/core/storage/src/image.ts
@@ -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 */
1import type { ImageData, ResizeFit } from 'ts-images'
2import { blur as applyBlur, decode, encode, resize as applyResize, rotate as applyRotate } from 'ts-images'
283
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 */
34interface SharpFactory {
35 (input?: Buffer | Uint8Array): SharpInstance
36}
4export type NativeImageFormat = 'jpeg' | 'png' | 'webp' | 'avif'
5export interface NativeEncodeOptions { quality?: number, progressive?: boolean, lossless?: boolean, effort?: number }
6export interface NativeResizeOptions { fit?: ResizeFit, withoutEnlargement?: boolean }
377
38interface 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
8export 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>
5018}
5119
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 */
59let cachedSharp: SharpFactory | null = null
20class 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 }
6047
61async 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
6752 }
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
7470 }
7571}
7672
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 */
87export function transform(
88 pipeline: (img: SharpInstance) => SharpInstance | Promise<SharpInstance>,
89): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> {
73export function transform(pipeline: (image: NativeImagePipeline) => NativeImagePipeline | Promise<NativeImagePipeline>): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> {
9074 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()
9777 }
9878}
9979
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 */
11580export 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 }))
11782}
11883
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 */
123export 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 }))
84export 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())
12986}
13087
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 */
140export function stripMetadata(): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> {
141 return transform(img => img)
88export function stripMetadata(format: NativeImageFormat = 'webp'): (input: Uint8Array | Buffer | ArrayBuffer) => Promise<Buffer> {
89 return transform(image => image[format]())
14290}
storage/framework/core/storage/src/index.tsmodified+1-1
Changes to storage/framework/core/storage/src/index.ts
@@ -25,7 +25,7 @@ export type { FilenameStrategy, PutFileOptions, UploadedFileLike } from './put-f
2525export { UploadedFile, uploadedFile, uploadedFiles } from './uploaded-file'
2626
2727// Filesystem type helpers
28export { configFromEnv, localDisk, s3Disk } from './types/filesystem'
28export { backblazeDisk, configFromEnv, filebaseDisk, hetznerDisk, localDisk, r2Disk, s3Disk } from './types/filesystem'
2929// Disk-name autocomplete: userland augments `KnownDisks` to get
3030// completion on `Storage.disk('…')` (stacksjs/stacks#1924).
3131export type { DiskName, KnownDisks } from './types/filesystem'
storage/framework/core/storage/src/put-file.tsmodified+2-4
Changes to storage/framework/core/storage/src/put-file.ts
@@ -80,10 +80,8 @@ export interface PutFileOptions {
8080 /**
8181 * Optional image-processing pipeline applied before write
8282 * (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.
8785 *
8886 * If the transform changes the output format (e.g. JPEG → WebP), the
8987 * caller is responsible for updating `mimetype` / `filename` to match
storage/framework/core/storage/src/types.tsmodified+9-0
Changes to storage/framework/core/storage/src/types.ts
@@ -334,6 +334,15 @@ export interface StorageAdapterConfig {
334334 region?: string
335335 /** S3 key prefix */
336336 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
337346 /**
338347 * AWS credentials. When omitted the adapter falls back to the
339348 * standard env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
storage/framework/core/storage/src/types/filesystem.tsmodified+78-0
Changes to storage/framework/core/storage/src/types/filesystem.ts
@@ -169,6 +169,84 @@ export function s3Disk(bucket: string, options?: Partial<Omit<S3DiskConfig, 'dri
169169 }
170170}
171171
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 */
182export 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 */
201export 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 */
220export 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 */
239export 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
172250/**
173251 * Create filesystem config from environment variables
174252 */
storage/framework/core/storage/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/storage/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.028Z] local.INFO: Building @stacksjs/storage...
1[2026-07-23T16:49:27.021Z] local.INFO: Building @stacksjs/storage...
storage/framework/core/storage/tests/filebase-disk.test.tsadded+35-0
Changes to storage/framework/core/storage/tests/filebase-disk.test.ts
@@ -0,0 +1,35 @@
1import { describe, expect, it } from 'bun:test'
2import { 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).
6describe('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})
storage/framework/core/storage/tests/image.test.tsmodified+20-40
Changes to storage/framework/core/storage/tests/image.test.ts
@@ -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
131import { describe, expect, test } from 'bun:test'
2import { createImageData, decode, encode } from 'ts-images'
143import { avatar, resize, stripMetadata, transform } from '../src/image'
154
16describe('@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 })
5async function fixture(): Promise<Uint8Array> {
6 const image = createImageData(4, 2)
7 image.data.fill(255)
8 return encode(image, 'png')
9}
2310
24 test('each preset returns a function suitable for `Storage.put({ transform })`', () => {
11describe('@stacksjs/storage/image', () => {
12 test('provides native Storage.put transforms', () => {
2513 expect(typeof avatar()).toBe('function')
26 expect(typeof resize(100, 100)).toBe('function')
14 expect(typeof resize(2, 2)).toBe('function')
2715 expect(typeof stripMetadata()).toBe('function')
28 expect(typeof transform(img => img)).toBe('function')
16 expect(typeof transform(image => image)).toBe('function')
2917 })
30})
3118
32describe('@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])
3925 })
4026
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/)
5131 })
5232})
storage/framework/core/storage/tests/s3-compatible-disks.test.tsadded+49-0
Changes to storage/framework/core/storage/tests/s3-compatible-disks.test.ts
@@ -0,0 +1,49 @@
1import { describe, expect, it } from 'bun:test'
2import { 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.
7describe('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})
storage/framework/core/storage/tests/s3-endpoint-options.test.tsadded+38-0
Changes to storage/framework/core/storage/tests/s3-endpoint-options.test.ts
@@ -0,0 +1,38 @@
1import { describe, expect, it } from 'bun:test'
2import { 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).
7describe('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})
storage/framework/core/strings/package.jsonmodified+1-1
Changes to storage/framework/core/strings/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/strings",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks string utilities.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/strings/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/strings/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:12.974Z] local.INFO: Building @stacksjs/strings...
1[2026-07-23T16:49:24.774Z] local.INFO: Building @stacksjs/strings...
storage/framework/core/testing/package.jsonmodified+1-1
Changes to storage/framework/core/testing/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/testing",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks way of testing.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/testing/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/testing/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.851Z] local.INFO: Building @stacksjs/testing...
1[2026-07-23T16:49:27.249Z] local.INFO: Building @stacksjs/testing...
storage/framework/core/tinker/package.jsonmodified+1-1
Changes to storage/framework/core/tinker/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/tinker",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Interactive REPL with Stacks framework preloaded.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/tinker/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/tinker/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:14.245Z] local.INFO: Building @stacksjs/tinker...
1[2026-07-23T16:49:30.857Z] local.INFO: Building @stacksjs/tinker...
storage/framework/core/tunnel/package.jsonmodified+1-1
Changes to storage/framework/core/tunnel/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/tunnel",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Local development tunnel.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/tunnel/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/tunnel/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.690Z] local.INFO: Building @stacksjs/tunnel...
1[2026-07-23T16:49:29.635Z] local.INFO: Building @stacksjs/tunnel...
storage/framework/core/types/package.jsonmodified+1-1
Changes to storage/framework/core/types/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/types",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks framework types.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/types/src/ai.tsmodified+21-1
Changes to storage/framework/core/types/src/ai.ts
@@ -33,9 +33,29 @@ type AiModel =
3333 * have any questions, feel free to reach out via Discord or GitHub Discussions.
3434 */
3535export interface AiOptions {
36 default: AiModel
36 default: 'anthropic' | 'openai' | 'ollama' | AiModel
3737 models: AiModel[]
3838 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 }
3959}
4060
4161export type AiConfig = Partial<AiOptions>
storage/framework/core/types/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/types/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/ui/package.jsonmodified+1-1
Changes to storage/framework/core/ui/package.json
@@ -1,7 +1,7 @@
11{
22 "name": "@stacksjs/ui",
33 "type": "module",
4 "version": "0.70.162",
4 "version": "0.70.163",
55 "description": "The Stacks UI engine.",
66 "author": "Chris Breuer",
77 "contributors": [
storage/framework/core/ui/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/ui/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:13.781Z] local.INFO: Building @stacksjs/ui...
1[2026-07-23T16:49:27.862Z] local.INFO: Building @stacksjs/ui...
storage/framework/core/utils/package.jsonmodified+1-1
Changes to storage/framework/core/utils/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/utils",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks helper functions.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/utils/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/utils/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:16.753Z] local.INFO: Building @stacksjs/utils...
1[2026-07-23T16:49:36.183Z] local.INFO: Building @stacksjs/utils...
storage/framework/core/validation/package.jsonmodified+1-1
Changes to storage/framework/core/validation/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/validation",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "The Stacks Validation ways.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/validation/storage/logs/stacks-2026-07-23.logmodified+2-3
Changes to storage/framework/core/validation/storage/logs/stacks-2026-07-23.log
@@ -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...
storage/framework/core/video/README.mdadded+82-0
Changes to storage/framework/core/video/README.md
@@ -0,0 +1,82 @@
1# @stacksjs/video
2
3Deterministic, 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
8import { detectVideoRuntimeCapabilities } from 'ts-videos'
9import { video } from '@stacksjs/video'
10
11const 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
31await storage.putMany(delivery.files)
32```
33
34## HLS, DASH, and protected delivery
35
36```ts
37import { createAdaptiveDeliveryBundle } from 'ts-videos/protected-delivery'
38
39const 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
51HLS 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
53For hosted delivery, publish immutable segments to S3 and use the media helpers from `ts-cloud`:
54
55```ts
56import { buildMediaCdnPlan, signCloudFrontCookies } from 'ts-cloud'
57
58const cdn = buildMediaCdnPlan({
59 bucket: 'example-media',
60 region: 'us-west-2',
61 domain: 'media.example.com',
62 protected: true,
63})
64const cookies = signCloudFrontCookies({ resource: 'https://media.example.com/demo/*', expires }, signer)
65```
66
67Manifests 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
82The 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.
storage/framework/core/video/build.tsadded+4-0
Changes to storage/framework/core/video/build.ts
@@ -0,0 +1,4 @@
1import { frameworkExternal, intro, outro, transpilePackage } from '../build/src'
2const { startTime } = await intro({ dir: import.meta.dir })
3await transpilePackage({ dir: import.meta.dir, external: frameworkExternal() })
4await outro({ dir: import.meta.dir, startTime, result: { errors: [], warnings: [] } })
storage/framework/core/registry/package.json storage/framework/core/video/package.jsoncopied+10-20
Changes to storage/framework/core/video/package.json
@@ -1,34 +1,23 @@
11{
2 "name": "@stacksjs/registry",
2 "name": "@stacksjs/video",
33 "type": "module",
44 "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.",
77 "author": "Chris Breuer",
8 "contributors": [
9 "Chris Breuer <chris@stacksjs.com>"
10 ],
118 "license": "MIT",
129 "funding": "https://github.com/sponsors/chrisbbreuer",
13 "homepage": "https://github.com/stacksjs/stacks/tree/main/registry/framework/core/registry#readme",
1410 "repository": {
1511 "type": "git",
1612 "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"
2114 },
22 "keywords": [
23 "registry",
24 "stacks"
25 ],
2615 "exports": {
2716 ".": {
17 "types": "./dist/index.d.ts",
2818 "development": "./src/index.ts",
2919 "bun": "./dist/index.js",
30 "import": "./dist/index.js",
31 "default": "./dist/index.js"
20 "import": "./dist/index.js"
3221 }
3322 },
3423 "module": "dist/index.js",
@@ -37,14 +26,15 @@
3726 "README.md",
3827 "dist"
3928 ],
40 "dependencies": {
41 "@stacksjs/types": "workspace:*"
42 },
4329 "scripts": {
4430 "build": "bun build.ts",
4531 "typecheck": "bun tsc --noEmit",
4632 "prepublishOnly": "bun run build"
4733 },
34 "dependencies": {
35 "ts-video-player": "^0.1.0",
36 "ts-videos": "^0.1.1"
37 },
4838 "devDependencies": {
4939 "better-dx": "catalog:"
5040 }
storage/framework/core/video/src/index.tsadded+139-0
Changes to storage/framework/core/video/src/index.ts
@@ -0,0 +1,139 @@
1import { createHmac, timingSafeEqual } from 'node:crypto'
2
3export type VideoContainer = 'mp4' | 'webm'
4export type StreamingFormat = 'hls' | 'dash'
5export type VideoCodec = 'h264' | 'h265' | 'vp8' | 'vp9' | 'av1' | 'mpeg1' | 'mpeg2' | 'mpeg4' | 'theora' | 'mjpeg' | 'prores' | 'dnxhd' | 'unknown'
6export 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'
7export interface VideoProfile { width: number, height: number, duration: number, frameRate: number, container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, videoBitrate?: number, hasAudio?: boolean, hdr?: boolean }
8export interface VideoRendition { name: string, width: number, height: number, frameRate: number, videoBitrate: number, audioBitrate: number }
9export interface VideoCapabilities { videoEncoder: boolean, audioEncoder: boolean, videoCodecs: string[], audioCodecs: string[] }
10export interface VideoOutput { container: VideoContainer, videoCodec: VideoCodec, audioCodec?: VideoAudioCodec, action: 'copy' | 'transcode', available: boolean, reason?: string }
11export interface VideoPlan { source: string, profile: VideoProfile, renditions: VideoRendition[], outputs: VideoOutput[], streaming: StreamingFormat[], segmentDuration: number, keyframeInterval: number }
12export type VideoProcessOptions = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineOptions
13export type ProcessedVideoDelivery = import('ts-videos/delivery-pipeline').VideoDeliveryPipelineResult
14export interface PreviewCue { startTime: number, endTime: number, uri: string, x?: number, y?: number, width?: number, height?: number }
15const edges = [240, 360, 480, 540, 720, 1080, 1440, 2160]
16const even = (value: number): number => Math.max(2, Math.round(value / 2) * 2)
17function 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
23export 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
36export 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}
63export function video(source: string): VideoBuilder { return new VideoBuilder(source) }
64export 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}
76export 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}
80export 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}
85function 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}
89export 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}
100export 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}
103export function signVideoAsset(path: string, expires: number, secret: string): string {
104 return createHmac('sha256', secret).update(`${path}\n${expires}`).digest('base64url')
105}
106export 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
108export interface VideoSegment { uri: string, duration: number, data: Uint8Array }
109export interface VideoHlsProtection { key: Uint8Array, keyUri: string, iv?: (_index: number) => Uint8Array }
110export interface ProtectedVideoPlaylist { playlist: string, files: Record<string, Uint8Array>, encrypted: boolean }
111
112function sequenceIv(index: number): Uint8Array { const value = new Uint8Array(16); new DataView(value.buffer).setBigUint64(8, BigInt(index)); return value }
113function hex(value: Uint8Array): string { return [...value].map(byte => byte.toString(16).padStart(2, '0')).join('') }
114async 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}
120export 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}
133export 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}
storage/framework/core/video/storage/logs/stacks-2026-07-23.logadded+1-0
Changes to storage/framework/core/video/storage/logs/stacks-2026-07-23.log
@@ -0,0 +1,1 @@
1[2026-07-23T16:49:27.964Z] local.INFO: Building @stacksjs/video...
storage/framework/core/video/tests/process.test.tsadded+25-0
Changes to storage/framework/core/video/tests/process.test.ts
@@ -0,0 +1,25 @@
1import { expect, mock, test } from 'bun:test'
2
3mock.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
15const { video } = await import('../src')
16
17test('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})
storage/framework/core/video/tests/video.test.tsadded+9-0
Changes to storage/framework/core/video/tests/video.test.ts
@@ -0,0 +1,9 @@
1import { describe, expect, test } from 'bun:test'
2import { assertVideoPlanExecutable, createHlsMaster, createPreviewVtt, createProtectedVideoPlaylist, deriveVideoLadder, video, videoAssetHeaders } from '../src'
3const profile = { width: 1920, height: 1080, duration: 120, frameRate: 30, container: 'mp4' as const, videoCodec: 'h264', audioCodec: 'aac', hasAudio: true }
4describe('@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})
storage/framework/core/whois/package.jsonmodified+1-1
Changes to storage/framework/core/whois/package.json
@@ -2,7 +2,7 @@
22 "name": "@stacksjs/whois",
33 "type": "module",
44 "sideEffects": false,
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "Easily get whois info.",
77 "author": "Chris Breuer",
88 "contributors": [
storage/framework/core/whois/storage/logs/stacks-2026-07-23.logmodified+1-1
Changes to storage/framework/core/whois/storage/logs/stacks-2026-07-23.log
@@ -1,1 +1,1 @@
1[2026-07-23T13:13:15.134Z] local.INFO: Building @stacksjs/whois...
1[2026-07-23T16:49:26.600Z] local.INFO: Building @stacksjs/whois...
storage/framework/defaults/ide/vscode/package.jsonmodified+1-1
Changes to storage/framework/defaults/ide/vscode/package.json
@@ -2,7 +2,7 @@
22 "publisher": "Stacks",
33 "name": "vscode-stacks",
44 "displayName": "Stacks",
5 "version": "0.70.162",
5 "version": "0.70.163",
66 "description": "A modern Stacks development environment.",
77 "license": "MIT",
88 "funding": "https://github.com/sponsors/chrisbbreuer",
storage/framework/defaults/resources/components/Audio.stxadded+92-0
Changes to storage/framework/defaults/resources/components/Audio.stx
@@ -0,0 +1,92 @@
1<script server>
2interface AudioSource { src: string, type?: string }
3interface 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}
8interface AudioTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean }
9interface 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}
26const props = defineProps<AudioProps>()
27const src = props.src || ''
28const sources = props.sources || []
29const tracks = props.tracks || []
30const title = props.title || 'Audio player'
31const artist = props.artist || ''
32const artwork = props.artwork || ''
33const waveform = props.waveform || ''
34const controls = props.controls !== false
35const preload = props.preload || 'metadata'
36const crossorigin = props.crossorigin || ''
37const className = props.class || ''
38const 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)
39const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
40const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
41</script>
42
43<script client>
44import { registerElements } from 'ts-video-player/elements'
45registerElements()
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>
89video-player:not(:defined) video-skin { display: none; }
90video-player:defined > [data-native-fallback] { display: none; }
91@media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } }
92</style>
storage/framework/defaults/resources/components/Image.stxadded+59-0
Changes to storage/framework/defaults/resources/components/Image.stx
@@ -0,0 +1,59 @@
1<script server>
2interface ImageSource { type: string, srcset: string, media?: string }
3interface 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}
22const props = defineProps<ImageProps>()
23if (!props.decorative && !props.alt?.trim()) throw new TypeError('Image alt text is required unless decorative is true')
24const src = props.src || ''
25const alt = props.decorative ? '' : props.alt || ''
26const width = props.width || ''
27const height = props.height || ''
28const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '')
29const srcset = props.srcset || ''
30const sizes = props.sizes || (srcset ? '100vw' : '')
31const sources = props.sources || []
32const loading = props.priority ? 'eager' : props.loading || 'lazy'
33const decoding = props.decoding || 'async'
34const priority = props.priority ? 'high' : props.fetchpriority || 'auto'
35const fit = props.fit || 'cover'
36const position = props.position || 'center'
37const placeholder = props.placeholder || ''
38const 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>
storage/framework/defaults/resources/components/Video.stxadded+108-0
Changes to storage/framework/defaults/resources/components/Video.stx
@@ -0,0 +1,108 @@
1<script server>
2interface VideoSource { src: string, type?: string }
3interface 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}
8interface VideoTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean }
9interface 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}
30const props = defineProps<VideoProps>()
31const src = props.src || ''
32const sources = props.sources || []
33const tracks = props.tracks || []
34const title = props.title || 'Video player'
35const poster = props.poster || ''
36const width = props.width || ''
37const height = props.height || ''
38const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '16 / 9')
39const controls = props.controls !== false
40const inline = props.playsinline !== false
41const preload = props.preload || 'metadata'
42const crossorigin = props.crossorigin || ''
43const controlslist = props.controlslist || 'nodownload'
44const className = props.class || ''
45const 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)
46const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
47const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
48</script>
49
50<script client>
51import { registerElements } from 'ts-video-player/elements'
52registerElements()
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>
105video-player:not(:defined) video-skin { display: none; }
106video-player:defined > [data-native-fallback] { display: none; }
107@media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } }
108</style>
storage/framework/defaults/resources/components/index.tsmodified+3-0
Changes to storage/framework/defaults/resources/components/index.ts
@@ -11,3 +11,6 @@ export { default as SidebarModern } from './Dashboard/SidebarModern.stx'
1111
1212// Dashboard and UI components
1313export * from './Dashboard'
14export { default as Audio } from './Audio.stx'
15export { default as Image } from './Image.stx'
16export { default as Video } from './Video.stx'