@stacksjs/stx 0.2.170 → 0.2.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli.js +1263 -1168
  2. package/dist/client-script-bundler.js +2 -1
  3. package/dist/client-script.d.ts +1 -12
  4. package/dist/client-script.js +2 -149
  5. package/dist/codemod.d.ts +1 -24
  6. package/dist/codemod.js +61 -9
  7. package/dist/compression.d.ts +11 -4
  8. package/dist/compression.js +52 -13
  9. package/dist/craft.js +489 -415
  10. package/dist/expressions.d.ts +0 -21
  11. package/dist/expressions.js +12 -1
  12. package/dist/find-body-tag.js +29 -17
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +2 -0
  15. package/dist/page-action.d.ts +83 -0
  16. package/dist/page-action.js +64 -0
  17. package/dist/production-builder.js +1 -0
  18. package/dist/production-server.js +10 -3
  19. package/dist/pwa.js +398 -324
  20. package/dist/reactive-tracking.d.ts +81 -0
  21. package/dist/reactive-tracking.js +49 -0
  22. package/dist/reactivity.js +29 -23
  23. package/dist/render.js +5 -18
  24. package/dist/runtime-injection.js +25 -39
  25. package/dist/script-validation.d.ts +0 -7
  26. package/dist/script-validation.js +6 -4
  27. package/dist/signals-api.d.ts +18 -0
  28. package/dist/signals-api.js +26 -41
  29. package/dist/signals.js +93 -15
  30. package/dist/ssg.d.ts +8 -0
  31. package/dist/ssg.js +515 -441
  32. package/dist/state-management.js +2 -2
  33. package/dist/strip-literals.d.ts +40 -0
  34. package/dist/strip-literals.js +149 -0
  35. package/dist/stx-virtual-ts.d.ts +103 -1
  36. package/dist/stx-virtual-ts.js +124 -7
  37. package/dist/template-compiler.js +4 -2
  38. package/dist/template-hydrator.d.ts +1 -1
  39. package/dist/template-hydrator.js +13 -1
  40. package/dist/typecheck.d.ts +2 -14
  41. package/dist/typecheck.js +133 -11
  42. package/dist/types/config-types.d.ts +10 -3
  43. package/dist/variable-extractor.js +1 -1
  44. package/package.json +5 -5
@@ -84,27 +84,6 @@ export declare function escapeHtml(unsafe: string): string;
84
84
  * or has reactive attributes (@if, @for) with function call expressions
85
85
  */
86
86
  export declare function usesSignalsInScript(template: string): boolean;
87
- /**
88
- * Interpolate server-side `{{ expr }}` / `{!! expr !!}` expressions inside a
89
- * `<script>` body, using the same evaluator as the main template.
90
- *
91
- * This makes server data available to client scripts without manual data-island
92
- * plumbing — write `const PRICE = {{ car.price }}` and get `const PRICE = 42`.
93
- *
94
- * JavaScript-aware rules (different from HTML `{{ }}`):
95
- * - `{{ expr }}` → `JSON.stringify(value)`. Strings become valid JS string
96
- * literals, numbers/booleans pass through, objects/arrays become JSON
97
- * literals. Safe to drop into any JS expression position.
98
- * - `{!! expr !!}` → raw `String(value)`. Use when you want the value spliced
99
- * verbatim (e.g. you've already serialized it yourself).
100
- * - Expressions that fail to evaluate (reference a client-only signal, a
101
- * loop variable, or anything not in context) are left **untouched** so the
102
- * client runtime can process them later. This mirrors the "preserve for
103
- * client" semantics of the main expression processor when signals are in use.
104
- *
105
- * Only the content strictly between `<script>` and `</script>` is touched —
106
- * attributes and surrounding HTML are left alone.
107
- */
108
87
  export declare function interpolateScriptExpressions(scriptBody: string, context: Record<string, any>): string;
109
88
  /**
110
89
  * Interpolate expressions in a `<script>` tag's ATTRIBUTES.
@@ -1,3 +1,4 @@
1
+ import { stripCommentsAndLiterals } from "./strip-literals.js";
1
2
  import { createSafeFunction, isExpressionSafe, safeEvaluate } from "./safe-evaluator.js";
2
3
  import { createDetailedErrorMessage } from "./utils.js";
3
4
  import { createPlaceholder } from "./placeholder.js";
@@ -298,6 +299,16 @@ function expressionUsesOnlyContextVars(expr, context) {
298
299
  }
299
300
  return !0;
300
301
  }
302
+ function expressionReadsServerBinding(expr, context) {
303
+ const declared = context.__stx_serverDeclared;
304
+ if (!declared || declared.size === 0)
305
+ return !1;
306
+ const searchable = stripCommentsAndLiterals(expr);
307
+ for (const name of declared)
308
+ if (new RegExp(`(?<![.#\\w$])${name}(?![\\w$])`).test(searchable))
309
+ return !0;
310
+ return !1;
311
+ }
301
312
  export function interpolateScriptExpressions(scriptBody, context) {
302
313
  let output = scriptBody;
303
314
  output = output.replace(/\{!!([\s\S]*?)!!\}/g, (match, expr) => {
@@ -426,7 +437,7 @@ export function processExpressions(template, context, filePath) {
426
437
  return match;
427
438
  if (hasSignals && !expressionUsesOnlyContextVars(trimmedExpr, context))
428
439
  return match;
429
- if (value === void 0 && context.__stx_buildMode === "compile" && !hasSignals)
440
+ if (context.__stx_buildMode === "compile" && !hasSignals && (value === void 0 || expressionReadsServerBinding(trimmedExpr, context)))
430
441
  return createPlaceholder("expr", trimmedExpr);
431
442
  return value !== void 0 && value !== null ? escapeHtml(String(value)) : "";
432
443
  } catch (error) {
@@ -1,36 +1,48 @@
1
1
  const SKIP_REGIONS = [
2
- { open: /<!--/, close: "-->" },
3
- { open: /<script\b/i, close: "</script>" },
4
- { open: /<style\b/i, close: "</style>" }
5
- ];
2
+ { open: /<!--/g, close: /-->/g },
3
+ { open: /<script\b/gi, close: /<\/script\s*>/gi },
4
+ { open: /<style\b/gi, close: /<\/style\s*>/gi }
5
+ ], BODY_OPEN = /<body\b([^>]*)>/gi;
6
6
  export function findBodyOpenTag(html) {
7
- let cursor = 0;
7
+ let cursor = 0, bodyMatch;
8
+ const opens = SKIP_REGIONS.map(() => {
9
+ return;
10
+ });
8
11
  while (cursor < html.length) {
9
- const rest = html.slice(cursor), bodyMatch = /<body\b([^>]*)>/i.exec(rest);
12
+ if (bodyMatch === void 0 || bodyMatch !== null && bodyMatch.index < cursor) {
13
+ BODY_OPEN.lastIndex = cursor;
14
+ bodyMatch = BODY_OPEN.exec(html);
15
+ }
10
16
  if (!bodyMatch)
11
17
  return null;
12
- const bodyAt = cursor + bodyMatch.index;
13
- let nearestSkipAt = -1, nearestClose = "";
14
- for (const region of SKIP_REGIONS) {
15
- const found = region.open.exec(rest);
18
+ const bodyAt = bodyMatch.index;
19
+ let nearestSkipAt = -1, nearestClose = null;
20
+ for (let i = 0;i < SKIP_REGIONS.length; i++) {
21
+ const region = SKIP_REGIONS[i];
22
+ let found = opens[i];
23
+ if (found === void 0 || found !== null && found.index < cursor) {
24
+ region.open.lastIndex = cursor;
25
+ found = region.open.exec(html);
26
+ opens[i] = found;
27
+ }
16
28
  if (!found)
17
29
  continue;
18
- const at = cursor + found.index;
19
- if (at < bodyAt && (nearestSkipAt === -1 || at < nearestSkipAt)) {
20
- nearestSkipAt = at;
30
+ if (found.index < bodyAt && (nearestSkipAt === -1 || found.index < nearestSkipAt)) {
31
+ nearestSkipAt = found.index;
21
32
  nearestClose = region.close;
22
33
  }
23
34
  }
24
- if (nearestSkipAt === -1)
35
+ if (nearestSkipAt === -1 || !nearestClose)
25
36
  return {
26
37
  index: bodyAt,
27
38
  tag: bodyMatch[0],
28
39
  attrs: bodyMatch[1] ?? ""
29
40
  };
30
- const closeAt = html.toLowerCase().indexOf(nearestClose, nearestSkipAt);
31
- if (closeAt === -1)
41
+ nearestClose.lastIndex = nearestSkipAt;
42
+ const closed = nearestClose.exec(html);
43
+ if (!closed)
32
44
  return null;
33
- cursor = closeAt + nearestClose.length;
45
+ cursor = closed.index + closed[0].length;
34
46
  }
35
47
  return null;
36
48
  }
package/dist/index.d.ts CHANGED
@@ -85,6 +85,12 @@ export { type AnalysisResult } from './analyzer';
85
85
  export { type AnalyzeOptions, type ChunkInfo } from './production-build';
86
86
  // generateReport moved to stx/visual-testing
87
87
  export { fileExists } from './utils';
88
+ // `CompressionOptions` is declared twice — the response compressor's options
89
+ // (`./compression`, what a server tunes) and the production build's
90
+ // (`./production-build`). A wildcard collision exports NEITHER, so the
91
+ // user-facing one is named explicitly here; the build's is reachable from the
92
+ // `stx/production-build` subpath.
93
+ export { type CompressionOptions } from './compression';
88
94
  export { type StreamingConfig } from './types';
89
95
  export { type Middleware } from './ssr';
90
96
  // Re-export the Icon-collection preloader so dev servers (e.g.
@@ -160,6 +166,7 @@ export * from './keep-alive';
160
166
  export * from './loops';
161
167
  export * from './markdown';
162
168
  export * from './middleware';
169
+ export * from './page-action';
163
170
  export * from './parser/index';
164
171
  export * from './manifest';
165
172
  export * from './placeholder';
@@ -196,6 +203,7 @@ export * from './loading-indicator';
196
203
  export * from './reactive-bindings';
197
204
  export * from './client-helpers';
198
205
  export * from './client-script';
206
+ export * from './strip-literals';
199
207
  export * from './component-hmr';
200
208
  export * from './hydration';
201
209
  // ssg moved to stx/ssg
package/dist/index.js CHANGED
@@ -79,6 +79,7 @@ export * from "./keep-alive.js";
79
79
  export * from "./loops.js";
80
80
  export * from "./markdown.js";
81
81
  export * from "./middleware.js";
82
+ export * from "./page-action.js";
82
83
  export * from "./parser/index.js";
83
84
  export * from "./manifest.js";
84
85
  export * from "./placeholder.js";
@@ -111,6 +112,7 @@ export * from "./loading-indicator.js";
111
112
  export * from "./reactive-bindings.js";
112
113
  export * from "./client-helpers.js";
113
114
  export * from "./client-script.js";
115
+ export * from "./strip-literals.js";
114
116
  export * from "./component-hmr.js";
115
117
  export * from "./hydration.js";
116
118
  export * from "./ssr.js";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Parse a form submission body into a plain object.
3
+ *
4
+ * Handles both encodings a browser form can send: `multipart/form-data` and
5
+ * `application/x-www-form-urlencoded`. A repeated field name collapses to an
6
+ * array, so `<input name="tag">` twice reads as `['a', 'b']` rather than
7
+ * silently keeping only the last one — checkbox groups are the common case.
8
+ *
9
+ * File parts are skipped: the value would be a `File`, and a page action that
10
+ * wants uploads should read `request.formData()` itself rather than have this
11
+ * hand it something that cannot round-trip into a re-rendered form field.
12
+ */
13
+ export declare function parseFormBody(request: Request): Promise<Record<string, string | string[]>>;
14
+ /**
15
+ * Whether this request should run a page action at all.
16
+ *
17
+ * GET and HEAD never do — a page action is for submissions, and running one on
18
+ * a GET would make every page load a mutation.
19
+ */
20
+ export declare function isActionableMethod(method: string | undefined): boolean;
21
+ /**
22
+ * Run a page's own `action`, if it declared one, and merge what it returned.
23
+ *
24
+ * The action is picked up off the render context rather than parsed out of the
25
+ * source: the server block has already run by this point, so a declared
26
+ * `action` is simply there — and it can close over anything else the block
27
+ * defined, which a regex-extracted function could not.
28
+ *
29
+ * Inert on GET and for any page without an action, so a page that does not opt
30
+ * in pays one `typeof` check.
31
+ */
32
+ export declare function runPageAction(context: Record<string, any>, options?: RunPageActionOptions): Promise<PageActionResult>;
33
+ /**
34
+ * The response a redirecting action should produce.
35
+ *
36
+ * 303 rather than 302 deliberately: it is the status that makes a browser
37
+ * follow up with a GET, so a reload or a Back does not resubmit the form.
38
+ */
39
+ export declare function actionRedirectResponse(to: string): Response;
40
+ /**
41
+ * A page handling its own form submission (stacksjs/stx#1847).
42
+ *
43
+ * A page that declares `action` in its `<script server>` block receives its own
44
+ * non-GET request. Whatever the action returns is merged into the render
45
+ * context, so `errors` and `values` repopulate the very same template that drew
46
+ * the form — one handler for the no-JS submit and the enhanced one, which is
47
+ * the half of the Laravel/Remix/SvelteKit contract stx was missing.
48
+ *
49
+ * ## Why this is a module rather than two implementations
50
+ *
51
+ * It shipped in `bun-plugin/src/serve.ts` only, so a form worked under
52
+ * `buddy dev` and silently did nothing in production — the request fell through
53
+ * to the ordinary render and answered 200 with the pre-submit markup, which is
54
+ * indistinguishable from the action having run and found nothing wrong. That is
55
+ * the same dev-only-correctness shape as `stx typecheck` being stricter than
56
+ * the editor, and this repo has been bitten repeatedly by two hand-maintained
57
+ * copies of one rule drifting apart. One module, both servers.
58
+ */
59
+ /**
60
+ * Everything an action is given about the request.
61
+ *
62
+ * A plain object rather than the `Request` alone, so an action is a pure
63
+ * function of its inputs and unit-tests without a server around it.
64
+ */
65
+ export declare interface PageActionContext {
66
+ request: Request
67
+ form: Record<string, string | string[]>
68
+ params: Record<string, string>
69
+ query: Record<string, unknown>
70
+ cookies: Record<string, string>
71
+ }
72
+ /** What running an action produced. */
73
+ export declare interface PageActionResult {
74
+ redirect?: string
75
+ ran: boolean
76
+ }
77
+ /** Request data a caller has already parsed, if it has. */
78
+ export declare interface RunPageActionOptions {
79
+ request?: Request
80
+ method?: string
81
+ params?: Record<string, string>
82
+ cookies?: Record<string, string>
83
+ }
@@ -0,0 +1,64 @@
1
+ export async function parseFormBody(request) {
2
+ const type = request.headers.get("content-type") || "", form = {}, put = (key, value) => {
3
+ const existing = form[key];
4
+ if (existing === void 0)
5
+ form[key] = value;
6
+ else if (Array.isArray(existing))
7
+ existing.push(value);
8
+ else
9
+ form[key] = [existing, value];
10
+ };
11
+ if (type.includes("multipart/form-data")) {
12
+ const data = await request.formData();
13
+ for (const [key, value] of data.entries())
14
+ if (typeof value === "string")
15
+ put(key, value);
16
+ return form;
17
+ }
18
+ if (type.includes("application/x-www-form-urlencoded")) {
19
+ for (const [key, value] of new URLSearchParams(await request.text()).entries())
20
+ put(key, value);
21
+ return form;
22
+ }
23
+ return form;
24
+ }
25
+ export function isActionableMethod(method) {
26
+ if (!method)
27
+ return !1;
28
+ const verb = method.toUpperCase();
29
+ return verb !== "GET" && verb !== "HEAD";
30
+ }
31
+ export async function runPageAction(context, options = {}) {
32
+ const request = options.request, method = options.method ?? request?.method;
33
+ if (!request || !isActionableMethod(method))
34
+ return { ran: !1 };
35
+ if (typeof context.action !== "function")
36
+ return { ran: !1 };
37
+ let form = {};
38
+ try {
39
+ form = await parseFormBody(request);
40
+ } catch {}
41
+ const result = await context.action({
42
+ request,
43
+ form,
44
+ params: options.params ?? {},
45
+ query: context.query ?? {},
46
+ cookies: options.cookies ?? {}
47
+ });
48
+ if (result && typeof result === "object") {
49
+ const to = result.redirect;
50
+ if (typeof to === "string" && to)
51
+ return { redirect: to, ran: !0 };
52
+ Object.assign(context, result);
53
+ }
54
+ return { ran: !0 };
55
+ }
56
+ export function actionRedirectResponse(to) {
57
+ return new Response(null, {
58
+ status: 303,
59
+ headers: {
60
+ Location: to,
61
+ "Cache-Control": "no-store"
62
+ }
63
+ });
64
+ }
@@ -21,6 +21,7 @@ function copyPublicAssets(srcDir, destDir) {
21
21
  let copied = 0, bytes = 0;
22
22
  if (!fs.existsSync(srcDir))
23
23
  return { copied, bytes };
24
+ fs.mkdirSync(destDir, { recursive: !0 });
24
25
  function walk(currentSrc, currentDest) {
25
26
  const entries = fs.readdirSync(currentSrc, { withFileTypes: !0 });
26
27
  for (const entry of entries) {
@@ -7,6 +7,7 @@ import { extractLayoutMetadata } from "./app-shell.js";
7
7
  import { pageShipsSignalsRuntime } from "./runtime-injection.js";
8
8
  import { patternToRegex } from "stx-router";
9
9
  import { compressResponse } from "./compression.js";
10
+ import { actionRedirectResponse, isActionableMethod } from "./page-action.js";
10
11
  const MIME_TYPES = {
11
12
  ".js": "application/javascript",
12
13
  ".mjs": "application/javascript",
@@ -143,7 +144,7 @@ export async function startProductionServer(options = {}) {
143
144
  });
144
145
  }
145
146
  }
146
- const compiled = compiledTemplates.get(matchedRoute.pattern);
147
+ const isMutating = isActionableMethod(request.method), compiled = compiledTemplates.get(matchedRoute.pattern);
147
148
  if (!compiled) {
148
149
  const errorPage = exactRoutes.get("/500") || null;
149
150
  if (errorPage) {
@@ -156,7 +157,7 @@ export async function startProductionServer(options = {}) {
156
157
  }
157
158
  return new Response("Internal Server Error", { status: 500 });
158
159
  }
159
- if (!compiled.hasServerScripts && Object.keys(compiled.placeholders).length === 0)
160
+ if (!isMutating && !compiled.hasServerScripts && Object.keys(compiled.placeholders).length === 0)
160
161
  return new Response(compiled.html, {
161
162
  headers: {
162
163
  "Content-Type": "text/html",
@@ -164,7 +165,13 @@ export async function startProductionServer(options = {}) {
164
165
  }
165
166
  });
166
167
  try {
167
- const { html, boundaries } = await hydrateTemplateStream(compiled, { params, request });
168
+ const { html, boundaries, redirect } = await hydrateTemplateStream(compiled, {
169
+ params,
170
+ request,
171
+ method: request.method
172
+ });
173
+ if (redirect)
174
+ return actionRedirectResponse(redirect);
168
175
  if (boundaries && boundaries.length > 0) {
169
176
  const { renderStreamingPage, streamToResponse } = await import("./streaming.js");
170
177
  return streamToResponse(renderStreamingPage(html, boundaries, { timeoutMs: 30000 }), {