@stacksjs/stx 0.2.153 → 0.2.155

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 (75) hide show
  1. package/dist/animation.js +6 -6
  2. package/dist/app-shell.js +1 -27
  3. package/dist/build-message.d.ts +10 -0
  4. package/dist/build-message.js +26 -0
  5. package/dist/builtins/attrs.js +6 -1
  6. package/dist/cli.js +1481 -1231
  7. package/dist/client-script-bundler.js +2 -4
  8. package/dist/client-script.d.ts +29 -0
  9. package/dist/client-script.js +140 -95
  10. package/dist/color-mode-boot.d.ts +31 -0
  11. package/dist/color-mode-boot.js +41 -5
  12. package/dist/component-renderer.js +10 -21
  13. package/dist/composables/index.d.ts +2 -0
  14. package/dist/composables/use-clipboard.d.ts +1 -5
  15. package/dist/composables/use-clipboard.js +57 -46
  16. package/dist/composables/use-color-mode.js +13 -5
  17. package/dist/composables/use-router.d.ts +28 -11
  18. package/dist/composables/use-router.js +63 -9
  19. package/dist/config.js +22 -7
  20. package/dist/craft.js +1029 -789
  21. package/dist/dev-server/crosswind.js +8 -6
  22. package/dist/dev-server/serve-app.js +4 -2
  23. package/dist/dev-server/serve-markdown.js +4 -1
  24. package/dist/dev-server/serve-multi.js +32 -20
  25. package/dist/document-shell.d.ts +65 -0
  26. package/dist/document-shell.js +59 -12
  27. package/dist/dynamic-components.js +123 -4
  28. package/dist/events.d.ts +26 -49
  29. package/dist/events.js +23 -9
  30. package/dist/expressions.d.ts +1 -1
  31. package/dist/expressions.js +3 -3
  32. package/dist/framework-composables.d.ts +41 -0
  33. package/dist/framework-composables.js +115 -0
  34. package/dist/hot-reload.js +8 -2
  35. package/dist/includes.js +2 -2
  36. package/dist/isolated-build.d.ts +12 -0
  37. package/dist/isolated-build.js +18 -0
  38. package/dist/plugin-system.d.ts +1 -1
  39. package/dist/process.js +40 -10
  40. package/dist/production-server.js +4 -1
  41. package/dist/prop-sentinels.d.ts +31 -0
  42. package/dist/prop-sentinels.js +7 -0
  43. package/dist/pwa.js +966 -726
  44. package/dist/reactive.js +1 -1
  45. package/dist/reactivity.d.ts +0 -15
  46. package/dist/reactivity.js +15 -6
  47. package/dist/runtime-globals.d.ts +40 -2
  48. package/dist/runtime-globals.js +90 -0
  49. package/dist/runtime-injection.d.ts +51 -0
  50. package/dist/runtime-injection.js +13 -0
  51. package/dist/script-emit.d.ts +20 -0
  52. package/dist/script-emit.js +11 -0
  53. package/dist/script-validation.js +12 -7
  54. package/dist/signal-processing.js +25 -40
  55. package/dist/signals-api.d.ts +30 -0
  56. package/dist/signals-api.js +18 -0
  57. package/dist/signals.js +258 -47
  58. package/dist/site-builder/build.js +21 -7
  59. package/dist/site-builder/seo.js +19 -10
  60. package/dist/site-builder/theme.js +5 -4
  61. package/dist/site-builder/types.d.ts +1 -0
  62. package/dist/ssg.js +986 -746
  63. package/dist/state-management.js +4 -1
  64. package/dist/store-loader.d.ts +0 -11
  65. package/dist/store-loader.js +27 -3
  66. package/dist/strict-lint.d.ts +9 -6
  67. package/dist/strict-lint.js +2 -21
  68. package/dist/stx.d.ts +111 -16
  69. package/dist/unresolved-identifiers.d.ts +50 -0
  70. package/dist/unresolved-identifiers.js +146 -0
  71. package/dist/utils.js +3 -3
  72. package/dist/variable-extractor.js +17 -1
  73. package/dist/x-element.js +1 -1
  74. package/package.json +5 -5
  75. package/stx.d.ts +111 -16
@@ -329,21 +329,23 @@ export async function generateCrosswindCSS(htmlContent, appDir) {
329
329
  const safelist = [
330
330
  ...baseConfig.safelist || [],
331
331
  ...userConfig.safelist || []
332
- ], crosswindConfig = {
332
+ ], preflights = [
333
+ ...baseConfig.preflights || [],
334
+ ...userConfig.preflights || []
335
+ ], includePreflight = userConfig.preflight !== !1, minify = userConfig.minify === !0, crosswindConfig = {
333
336
  ...baseConfig,
334
337
  ...userConfig,
335
338
  content: [],
336
339
  output: "",
337
- preflight: !0,
338
- minify: !1,
339
340
  theme,
340
- safelist
341
+ safelist,
342
+ ...preflights.length > 0 && { preflights }
341
343
  }, generator = new hw.CSSGenerator(crosswindConfig);
342
344
  for (const cls of safelist)
343
345
  generator.generate(cls);
344
346
  for (const className of classes)
345
347
  generator.generate(className);
346
- let css = generator.toCSS(!0, !1);
348
+ let css = generator.toCSS(includePreflight, minify);
347
349
  const shortcuts = crosswindConfig.shortcuts || userConfig.shortcuts || {};
348
350
  for (const [name, classStr] of Object.entries(shortcuts)) {
349
351
  if (!classes.has(name) && !safelist.includes(name))
@@ -352,7 +354,7 @@ export async function generateCrosswindCSS(htmlContent, appDir) {
352
354
  for (const p of parts)
353
355
  generator.generate(p);
354
356
  }
355
- css = generator.toCSS(!0, !1);
357
+ css = generator.toCSS(includePreflight, minify);
356
358
  const cssLines = css.split(`
357
359
  `);
358
360
  for (const [name, classStr] of Object.entries(shortcuts)) {
@@ -11,6 +11,7 @@ import {
11
11
  stopHmrServer
12
12
  } from "../hot-reload.js";
13
13
  import { partialsCache } from "../includes.js";
14
+ import { pageShipsSignalsRuntime } from "../runtime-injection.js";
14
15
  import { BUILD_ID_HEADER, getBuildId } from "../build-id.js";
15
16
  import { stateDir } from "../state-dir.js";
16
17
  import { plugin as stxPlugin } from "../plugin.js";
@@ -257,7 +258,7 @@ export async function serveApp(appDir = ".", options = {}) {
257
258
  } else
258
259
  output = await processDirectives(templateContent, context, route.filePath, merged, dependencies);
259
260
  if (!shell)
260
- output = await injectRouterScript(output);
261
+ output = await injectRouterScript(output, { router: merged.router });
261
262
  {
262
263
  const { interpolateScriptExpressions } = await import("../expressions.js");
263
264
  output = output.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/g, (full, attrs, body) => {
@@ -436,7 +437,7 @@ export async function serveApp(appDir = ".", options = {}) {
436
437
  }
437
438
  let content = builtPage.content;
438
439
  if (shell && isSpaNavigation(request)) {
439
- const layoutMetadata = extractLayoutMetadata(content), titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i), pageTitle = titleMatch ? titleMatch[1].trim() : "";
440
+ const layoutMetadata = extractLayoutMetadata(content), shipsRuntime = pageShipsSignalsRuntime(content), titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i), pageTitle = titleMatch ? titleMatch[1].trim() : "";
440
441
  if (Object.keys(routeMatch.params).length > 0)
441
442
  content = injectRouteParams(content, routeMatch.params);
442
443
  content = await injectCrosswindCSS(content, absoluteAppDir);
@@ -449,6 +450,7 @@ export async function serveApp(appDir = ".", options = {}) {
449
450
  "X-STX-Fragment": "true",
450
451
  "X-STX-Layout": layoutMetadata.layout,
451
452
  "X-STX-Layout-Group": layoutMetadata.group,
453
+ "X-STX-Runtime": shipsRuntime ? "true" : "false",
452
454
  ...pageTitle && { "X-STX-Title": encodeURIComponent(pageTitle) },
453
455
  [BUILD_ID_HEADER]: getBuildId(),
454
456
  "Cache-Control": "no-store, no-cache, must-revalidate"
@@ -44,7 +44,7 @@ export async function serveMarkdownFile(filePath, options = {}) {
44
44
  <head>
45
45
  <meta charset="UTF-8">
46
46
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
47
- <title>${data.title || path.basename(absolutePath)}</title>
47
+ <title>${escapeTitle(data.title || path.basename(absolutePath))}</title>
48
48
  <!-- Syntax highlighting styles -->
49
49
  <style id="syntax-theme">
50
50
  :root {
@@ -279,3 +279,6 @@ Press ${colors.cyan}h${colors.reset} + ${colors.cyan}Enter${colors.reset} to sho
279
279
  }
280
280
  return !0;
281
281
  }
282
+ function escapeTitle(value) {
283
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
284
+ }
@@ -8,6 +8,7 @@ import { partialsCache } from "../includes.js";
8
8
  import { stateDir } from "../state-dir.js";
9
9
  import { plugin as stxPlugin } from "../plugin.js";
10
10
  import { clearComponentCache } from "../utils.js";
11
+ import { formatBuildFailure } from "../build-message.js";
11
12
  import { bracketPathToRegex } from "stx-router";
12
13
  import {
13
14
  colors,
@@ -73,7 +74,7 @@ export async function serveMultipleStxFiles(filePaths, options = {}) {
73
74
  <head>
74
75
  <meta charset="UTF-8">
75
76
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
76
- <title>${data.title || path.basename(absolutePath)}</title>
77
+ <title>${escapeTitle(data.title || path.basename(absolutePath))}</title>
77
78
  <!-- Syntax highlighting styles -->
78
79
  <style id="syntax-theme">
79
80
  :root {
@@ -253,25 +254,33 @@ export async function serveMultipleStxFiles(filePaths, options = {}) {
253
254
  };
254
255
  });
255
256
  }
256
- }, result = await Bun.build({
257
- entrypoints: [absolutePath],
258
- outdir: outputDir,
259
- plugins: [publicAssetsPlugin, stxPlugin],
260
- define: {
261
- "process.env.NODE_ENV": '"development"'
262
- },
263
- ...options.stxOptions
264
- });
265
- if (!result.success) {
266
- console.error(`${colors.red}Build failed for ${colors.bright}${filePath}${colors.reset}:`, result.logs);
267
- continue;
268
- }
269
- const htmlOutput = result.outputs.find((o) => o.path.endsWith(".html"));
270
- if (!htmlOutput) {
271
- console.error(`${colors.red}No HTML output found for ${colors.bright}${filePath}${colors.reset}`);
257
+ };
258
+ let htmlContent;
259
+ try {
260
+ const result = await Bun.build({
261
+ entrypoints: [absolutePath],
262
+ outdir: outputDir,
263
+ plugins: [publicAssetsPlugin, stxPlugin],
264
+ define: {
265
+ "process.env.NODE_ENV": '"development"'
266
+ },
267
+ ...options.stxOptions
268
+ });
269
+ if (!result.success) {
270
+ console.error(`${colors.red}Build failed for ${colors.bright}${filePath}${colors.reset}:`, formatBuildFailure(result.logs, absolutePath));
271
+ continue;
272
+ }
273
+ const htmlOutput = result.outputs.find((o) => o.path.endsWith(".html"));
274
+ if (!htmlOutput) {
275
+ console.error(`${colors.red}No HTML output found for ${colors.bright}${filePath}${colors.reset}`);
276
+ continue;
277
+ }
278
+ htmlContent = await Bun.file(htmlOutput.path).text();
279
+ } catch (error) {
280
+ console.error(`${colors.red}Build failed for ${colors.bright}${filePath}${colors.reset}:
281
+ ${formatBuildFailure(error, absolutePath)}`);
272
282
  continue;
273
283
  }
274
- const htmlContent = await Bun.file(htmlOutput.path).text();
275
284
  let routePath = `/${path.relative(commonDir, absolutePath).replace(/\.stx$/, "")}`;
276
285
  if (routePath.endsWith("/index"))
277
286
  routePath = routePath.slice(0, -6) || "/";
@@ -303,7 +312,7 @@ export async function serveMultipleStxFiles(filePaths, options = {}) {
303
312
  }
304
313
  return !0;
305
314
  } catch (error) {
306
- console.error(`${colors.red}Error processing files:${colors.reset}`, error);
315
+ console.error(`${colors.red}Error processing files:${colors.reset}`, formatBuildFailure(error));
307
316
  return !1;
308
317
  }
309
318
  };
@@ -494,7 +503,7 @@ export async function serveMultipleStxFiles(filePaths, options = {}) {
494
503
  if (isRouterRequest) {
495
504
  const mainMatch = content.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
496
505
  if (mainMatch) {
497
- const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i), partialHtml = `<!DOCTYPE html><html><head><title>${titleMatch ? titleMatch[1] : ""}</title></head><body><main>${mainMatch[1]}</main></body></html>`;
506
+ const titleMatch = content.match(/<title[^>]*>([\s\S]*?)<\/title>/i), title = titleMatch ? titleMatch[1] : "", partialHtml = `<!DOCTYPE html><html><head><title>${escapeTitle(title)}</title></head><body><main>${mainMatch[1]}</main></body></html>`;
498
507
  return new Response(partialHtml, {
499
508
  headers: {
500
509
  "Content-Type": "text/html",
@@ -579,3 +588,6 @@ Press ${colors.cyan}h${colors.reset} + ${colors.cyan}Enter${colors.reset} to sho
579
588
  }
580
589
  return !0;
581
590
  }
591
+ function escapeTitle(value) {
592
+ return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
593
+ }
@@ -1,5 +1,19 @@
1
1
  import type { AppHeadConfig } from './types/config-types';
2
2
  import type { ColorModeBootConfig } from './color-mode-boot';
3
+ /** Emit `k="v"` pairs with every value attribute-escaped. */
4
+ /**
5
+ * The identity of a `<meta>` tag, for deduplication.
6
+ *
7
+ * Shared by both places that merge config head tags into a page, because they
8
+ * disagreed: injectConfigHeadTags deduped and generateDocumentShell just
9
+ * concatenated, so the same `app.head.meta` produced one `<meta charset>` on a
10
+ * page that owned its document and two on a page built through the shell
11
+ * (stacksjs/stx#1840). One key, one answer.
12
+ *
13
+ * Returns '' for a tag with no identifying attribute, which the callers treat
14
+ * as "cannot dedup, keep it".
15
+ */
16
+ export declare function metaDedupKey(m: Record<string, string>): string;
3
17
  /**
4
18
  * Build the attribute string for the shell's `<html>` open tag.
5
19
  *
@@ -72,6 +86,19 @@ export declare function hoistLeadingHeadTags(content: string): { body: string, h
72
86
  export declare function generateDocumentShell(content: string, headConfig?: AppHeadConfig, options?: {
73
87
  /** Page-specific title (overrides config) */
74
88
  title?: string
89
+ /**
90
+ * The title is already HTML and must NOT be escaped again.
91
+ *
92
+ * Set when the title was lifted out of author-written markup — an
93
+ * `@head <title>` block or `@section('title')` — where entities are already
94
+ * in their final form and any interpolation has already been escaped by
95
+ * `{{ }}`. Escaping it a second time renders `Caf&eacute; &amp; Bar` as
96
+ * `Caf&amp;eacute; &amp;amp; Bar`.
97
+ *
98
+ * A title from `useHead({ title })` is a plain JS string and must stay
99
+ * escaped — that is the XSS path (#1792 item 4).
100
+ */
101
+ titleIsHtml?: boolean
75
102
  /** Extra styles to inject in <head> (e.g. Crosswind CSS, scoped styles) */
76
103
  styles?: string[]
77
104
  /** Extra scripts to inject before </body> (e.g. signals runtime, router) */
@@ -104,6 +131,44 @@ export declare function generateDocumentShell(content: string, headConfig?: AppH
104
131
  * which made the check spuriously fail and auto-shell wrap the user's
105
132
  * complete HTML doc as if it were just body content.
106
133
  */
134
+ /**
135
+ * Does this string OPEN a full HTML document?
136
+ *
137
+ * Anchored, but tolerant of what stx's own pipeline prepends: whitespace, HTML
138
+ * comments, and `<script>` blocks (the signals runtime, scoped setup scripts,
139
+ * theme guards) all routinely land ahead of a layout's markup.
140
+ *
141
+ * The check used to be `/<!DOCTYPE\b/i` — unanchored — so the literal text
142
+ * `<!DOCTYPE` ANYWHERE made a fragment look like a document and sent it through
143
+ * wrapper-stripping. Easy to hit on any page that documents HTML: a doctype in
144
+ * a comment, in a `<pre><code>` sample, or in a JS string (stacksjs#1787). The
145
+ * `<html>` half was already anchored, so the asymmetry was clearly accidental.
146
+ *
147
+ * Note this is the opposite bias from `hasDocumentShell` in document-shell.ts,
148
+ * which stays permissive on purpose: there, a false negative double-wraps a
149
+ * complete document, so leaning towards "yes, it's a document" is the safe
150
+ * direction. Here a false positive shreds a fragment, so it leans the other way.
151
+ */
152
+ export declare function startsDocument(html: string): boolean;
153
+ /**
154
+ * Does this output already carry a document wrapper?
155
+ *
156
+ * ANCHORED, and it has to be. The test used to be an unanchored
157
+ * `/<html[\s>]/i.test(html)`, so a DOCTYPE or an html tag mentioned ANYWHERE —
158
+ * including inside an HTML comment — reported a shell that was not there
159
+ * (stacksjs/stx#1792, part one item 3).
160
+ *
161
+ * The cascade was silent and severe: no shell means no `<head>`, so
162
+ * `injectCloakStyle` bails and every element stx deliberately cloaked renders
163
+ * VISIBLE; no `<meta name="stx-layout">`; and with neither that meta nor
164
+ * `data-stx-content` the router's document sniff fails, so every navigation
165
+ * hard-reloads. One comment mentioning the root element was enough, and the
166
+ * reporter ended up writing a house rule telling authors not to name the tag in
167
+ * prose.
168
+ *
169
+ * Shares `startsDocument` with the app-shell path, which was anchored for the
170
+ * same reason in #1787 and had been the outlier's correct twin ever since.
171
+ */
107
172
  export declare function hasDocumentShell(html: string): boolean;
108
173
  /**
109
174
  * Inject the config `app.head` tags — script / meta / link / headRaw, but NOT
@@ -4,6 +4,23 @@ const SAFE_ATTR_NAME = /^[A-Za-z_:][\w:.-]*$/;
4
4
  function escapeAttr(value) {
5
5
  return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6
6
  }
7
+ function escapeText(value) {
8
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
9
+ }
10
+ export function metaDedupKey(m) {
11
+ if (m.name)
12
+ return `name="${m.name}"`;
13
+ if (m.property)
14
+ return `property="${m.property}"`;
15
+ if (m.charset != null)
16
+ return "charset=";
17
+ if (m["http-equiv"])
18
+ return `http-equiv="${m["http-equiv"]}"`;
19
+ return "";
20
+ }
21
+ function attrPairs(entries, skip) {
22
+ return Object.entries(entries).filter(([k]) => !skip?.(k)).map(([k, v]) => `${k}="${escapeAttr(String(v))}"`).join(" ");
23
+ }
7
24
  export function buildHtmlAttrs(htmlAttrs = {}, lang = "en") {
8
25
  const rest = [], managed = [];
9
26
  let className = "", resolvedLang = lang;
@@ -188,26 +205,29 @@ export function generateDocumentShell(content, headConfig = {}, options = {}) {
188
205
  htmlAttrs = {},
189
206
  bodyClass = "",
190
207
  bodyAttrs = {}
191
- } = headConfig, pageTitle = options.title || configTitle, metaTags = [...[
208
+ } = headConfig, pageTitle = options.title || configTitle, defaultMeta = [
192
209
  { charset: "UTF-8" },
193
210
  { name: "viewport", content: "width=device-width, initial-scale=1.0" }
194
- ], ...meta].map((m) => {
195
- return ` <meta ${Object.entries(m).map(([k, v]) => `${k}="${v}"`).join(" ")}>`;
211
+ ], configuredMetaKeys = new Set(meta.map(metaDedupKey).filter(Boolean)), metaTags = [
212
+ ...defaultMeta.filter((d) => !configuredMetaKeys.has(metaDedupKey(d))),
213
+ ...meta
214
+ ].map((m) => {
215
+ return ` <meta ${attrPairs(m)}>`;
196
216
  }).join(`
197
217
  `), linkTags = link.map((l) => {
198
- return ` <link ${Object.entries(l).map(([k, v]) => `${k}="${v}"`).join(" ")}>`;
218
+ return ` <link ${attrPairs(l)}>`;
199
219
  }).join(`
200
220
  `), configHeadScripts = headScriptTags.map((s) => {
201
221
  if (s.src)
202
- return ` <script ${Object.entries(s).filter(([k]) => k !== "content").map(([k, v]) => `${k}="${v}"`).join(" ")}></script>`;
222
+ return ` <script ${attrPairs(s, (k) => k === "content")}></script>`;
203
223
  if (s.content)
204
224
  return ` <script>${s.content}</script>`;
205
225
  return "";
206
226
  }).filter(Boolean).join(`
207
- `), htmlAttrStr = buildHtmlAttrs(htmlAttrs, lang), bodyAttrStr = Object.entries(bodyAttrs).map(([k, v]) => ` ${k}="${v}"`).join(""), bodyClassStr = bodyClass ? ` class="${bodyClass}"` : "", { body: pageBody, hoisted: pageHeadTags } = hoistLeadingHeadTags(content), headParts = [
227
+ `), htmlAttrStr = buildHtmlAttrs(htmlAttrs, lang), bodyAttrStr = Object.entries(bodyAttrs).map(([k, v]) => ` ${k}="${escapeAttr(String(v))}"`).join(""), bodyClassStr = bodyClass ? ` class="${escapeAttr(bodyClass)}"` : "", { body: pageBody, hoisted: pageHeadTags } = hoistLeadingHeadTags(content), headParts = [
208
228
  metaTags,
209
229
  options.colorMode ? ` ${generateColorModeBootScript(options.colorMode)}` : "",
210
- ` <title>${pageTitle}</title>`,
230
+ ` <title>${options.titleIsHtml ? pageTitle : escapeText(pageTitle)}</title>`,
211
231
  ` ${CLOAK_STYLE}`,
212
232
  linkTags,
213
233
  configHeadScripts,
@@ -233,8 +253,35 @@ ${bodyParts.join(`
233
253
  </body>
234
254
  </html>`;
235
255
  }
256
+ export function startsDocument(html) {
257
+ let i = 0;
258
+ while (i < html.length) {
259
+ const rest = html.slice(i), ws = rest.match(/^\s+/);
260
+ if (ws) {
261
+ i += ws[0].length;
262
+ continue;
263
+ }
264
+ if (rest.startsWith("<!--")) {
265
+ const end = html.indexOf("-->", i);
266
+ if (end === -1)
267
+ return !1;
268
+ i = end + 3;
269
+ continue;
270
+ }
271
+ const preamble = rest.match(/^<(script|style)\b[^>]*>/i);
272
+ if (preamble) {
273
+ const closeTag = `</${preamble[1].toLowerCase()}>`, end = html.toLowerCase().indexOf(closeTag, i + preamble[0].length);
274
+ if (end === -1)
275
+ return !1;
276
+ i = end + closeTag.length;
277
+ continue;
278
+ }
279
+ return /^<!DOCTYPE\b/i.test(rest) || /^<html[\s>]/i.test(rest);
280
+ }
281
+ return !1;
282
+ }
236
283
  export function hasDocumentShell(html) {
237
- return /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html);
284
+ return startsDocument(html);
238
285
  }
239
286
  export function injectConfigHeadTags(html, headConfig = {}) {
240
287
  const headCloseIdx = html.lastIndexOf("</head>");
@@ -242,21 +289,21 @@ export function injectConfigHeadTags(html, headConfig = {}) {
242
289
  return html;
243
290
  const { meta = [], link = [], script = [], headRaw = "" } = headConfig, head = html.slice(0, headCloseIdx), parts = [];
244
291
  for (const m of meta) {
245
- const dedup = m.name ? `name="${m.name}"` : m.property ? `property="${m.property}"` : m.charset != null ? "charset=" : m["http-equiv"] ? `http-equiv="${m["http-equiv"]}"` : "";
292
+ const dedup = metaDedupKey(m);
246
293
  if (dedup && head.includes(dedup))
247
294
  continue;
248
- parts.push(` <meta ${Object.entries(m).map(([k, v]) => `${k}="${v}"`).join(" ")}>`);
295
+ parts.push(` <meta ${attrPairs(m)}>`);
249
296
  }
250
297
  for (const l of link) {
251
298
  if (l.href && head.includes(`href="${l.href}"`))
252
299
  continue;
253
- parts.push(` <link ${Object.entries(l).map(([k, v]) => `${k}="${v}"`).join(" ")}>`);
300
+ parts.push(` <link ${attrPairs(l)}>`);
254
301
  }
255
302
  for (const s of script)
256
303
  if (s.src) {
257
304
  if (head.includes(`src="${s.src}"`))
258
305
  continue;
259
- parts.push(` <script ${Object.entries(s).filter(([k]) => k !== "content").map(([k, v]) => `${k}="${v}"`).join(" ")}></script>`);
306
+ parts.push(` <script ${attrPairs(s, (k) => k === "content")}></script>`);
260
307
  } else if (s.content) {
261
308
  if (head.includes(s.content))
262
309
  continue;
@@ -3,6 +3,115 @@ import { safeEvaluate } from "./safe-evaluator.js";
3
3
  function escapeHtmlComment(value) {
4
4
  return value.replace(/--/g, "- -").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5
5
  }
6
+ const HTML_TAGS = new Set([
7
+ "a",
8
+ "abbr",
9
+ "address",
10
+ "article",
11
+ "aside",
12
+ "audio",
13
+ "b",
14
+ "bdi",
15
+ "bdo",
16
+ "blockquote",
17
+ "button",
18
+ "canvas",
19
+ "caption",
20
+ "cite",
21
+ "code",
22
+ "col",
23
+ "colgroup",
24
+ "data",
25
+ "datalist",
26
+ "dd",
27
+ "del",
28
+ "details",
29
+ "dfn",
30
+ "dialog",
31
+ "div",
32
+ "dl",
33
+ "dt",
34
+ "em",
35
+ "fieldset",
36
+ "figcaption",
37
+ "figure",
38
+ "footer",
39
+ "form",
40
+ "h1",
41
+ "h2",
42
+ "h3",
43
+ "h4",
44
+ "h5",
45
+ "h6",
46
+ "header",
47
+ "hgroup",
48
+ "hr",
49
+ "i",
50
+ "iframe",
51
+ "img",
52
+ "input",
53
+ "ins",
54
+ "kbd",
55
+ "label",
56
+ "legend",
57
+ "li",
58
+ "main",
59
+ "mark",
60
+ "menu",
61
+ "meter",
62
+ "nav",
63
+ "ol",
64
+ "optgroup",
65
+ "option",
66
+ "output",
67
+ "p",
68
+ "picture",
69
+ "pre",
70
+ "progress",
71
+ "q",
72
+ "rp",
73
+ "rt",
74
+ "ruby",
75
+ "s",
76
+ "samp",
77
+ "section",
78
+ "select",
79
+ "small",
80
+ "span",
81
+ "strong",
82
+ "sub",
83
+ "summary",
84
+ "sup",
85
+ "table",
86
+ "tbody",
87
+ "td",
88
+ "textarea",
89
+ "tfoot",
90
+ "th",
91
+ "thead",
92
+ "time",
93
+ "tr",
94
+ "u",
95
+ "ul",
96
+ "video"
97
+ ]), VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
98
+ function resolveIsName(expr, context) {
99
+ const mustache = /^\s*\{\{\s*([\s\S]+?)\s*\}\}\s*$/.exec(expr), source = mustache ? mustache[1] : expr, evaluated = safeEvaluate(source, context);
100
+ if (evaluated && typeof evaluated === "string")
101
+ return evaluated;
102
+ const literal = source.trim();
103
+ return HTML_TAGS.has(literal) ? literal : null;
104
+ }
105
+ function displayExpression(expr) {
106
+ const mustache = /^\s*\{\{\s*([\s\S]+?)\s*\}\}\s*$/.exec(expr);
107
+ return mustache ? mustache[1] : expr;
108
+ }
109
+ function renderAsElement(name, attrs, slotContent) {
110
+ const cleanedAttrs = attrs.replace(/\s*(?::is|v-bind:is)\s*=\s*"[^"]*"/gi, "").trim(), open = cleanedAttrs ? `<${name} ${cleanedAttrs}>` : `<${name}>`;
111
+ if (VOID_TAGS.has(name))
112
+ return cleanedAttrs ? `<${name} ${cleanedAttrs}>` : `<${name}>`;
113
+ return `${open}${slotContent}</${name}>`;
114
+ }
6
115
  export async function processDynamicComponents(template, context, filePath, options, dependencies) {
7
116
  let result = template;
8
117
  const componentPattern = /<component\s+(?::is|v-bind:is)\s*=\s*"([^"]*)"([^>]*)>([\s\S]*?)<\/component>/gi, matches = [];
@@ -18,12 +127,17 @@ export async function processDynamicComponents(template, context, filePath, opti
18
127
  for (let i = matches.length - 1;i >= 0; i--) {
19
128
  const m = matches[i];
20
129
  try {
21
- const componentName = safeEvaluate(m.expr, context);
130
+ const componentName = resolveIsName(m.expr, context);
22
131
  if (!componentName || typeof componentName !== "string") {
23
- const replacement = `<!-- dynamic component: could not resolve "${escapeHtmlComment(m.expr)}" -->`;
132
+ const replacement = `<!-- dynamic component: could not resolve "${escapeHtmlComment(displayExpression(m.expr))}" -->`;
24
133
  result = result.slice(0, m.index) + replacement + result.slice(m.index + m.full.length);
25
134
  continue;
26
135
  }
136
+ if (HTML_TAGS.has(componentName)) {
137
+ const rendered = renderAsElement(componentName, m.attrs, m.slotContent);
138
+ result = result.slice(0, m.index) + rendered + result.slice(m.index + m.full.length);
139
+ continue;
140
+ }
27
141
  const propsStr = m.attrs.trim(), props = {}, propsPattern = /(?::([a-zA-Z_][a-zA-Z0-9_-]*)|([a-zA-Z_][a-zA-Z0-9_-]*))\s*=\s*"([^"]*)"/g;
28
142
  let propMatch;
29
143
  while ((propMatch = propsPattern.exec(propsStr)) !== null) {
@@ -61,12 +175,17 @@ async function processSelfClosingDynamicComponents(template, context, filePath,
61
175
  for (let i = matches.length - 1;i >= 0; i--) {
62
176
  const m = matches[i];
63
177
  try {
64
- const componentName = safeEvaluate(m.expr, context);
178
+ const componentName = resolveIsName(m.expr, context);
65
179
  if (!componentName || typeof componentName !== "string") {
66
- const replacement = `<!-- dynamic component: could not resolve "${escapeHtmlComment(m.expr)}" -->`;
180
+ const replacement = `<!-- dynamic component: could not resolve "${escapeHtmlComment(displayExpression(m.expr))}" -->`;
67
181
  result = result.slice(0, m.index) + replacement + result.slice(m.index + m.full.length);
68
182
  continue;
69
183
  }
184
+ if (HTML_TAGS.has(componentName)) {
185
+ const rendered = renderAsElement(componentName, m.attrs, "");
186
+ result = result.slice(0, m.index) + rendered + result.slice(m.index + m.full.length);
187
+ continue;
188
+ }
70
189
  const componentsDir = options.componentsDir || "components", rendered = await renderComponentWithSlot(componentName, {}, "", componentsDir, context, filePath, options, new Set, dependencies ?? new Set);
71
190
  result = result.slice(0, m.index) + rendered + result.slice(m.index + m.full.length);
72
191
  } catch (error) {
package/dist/events.d.ts CHANGED
@@ -1,3 +1,29 @@
1
+ /**
2
+ * `@click="doThing"` names a function, so it must be INVOKED — not evaluated.
3
+ *
4
+ * The declarative runtime learned this in #1695: without it the expression falls
5
+ * through to a generic `new Function` and runs as a discarded identifier
6
+ * statement, so the click silently does nothing. Every IMPERATIVE binding path
7
+ * still evaluated it, in three separate places — the inline `else` branch
8
+ * emitted by client-script.ts, the `__stx_runHandler` fallback below, and
9
+ * `__stx_execute` in the x-data bridge — so the declarative path disagreed with
10
+ * all three (#1824).
11
+ *
12
+ * Normalising here, at emit time, rather than inside each of those runtimes:
13
+ * the handler text is already known when it is emitted, so one transform covers
14
+ * every path, and the rule stays out of the generated template literals.
15
+ *
16
+ * Restricted to a plain identifier, matching the runtime's own check exactly —
17
+ * a dotted path would require `obj` to exist before `typeof obj.fn` could be
18
+ * asked. The `_isSignal` exclusion is the runtime's too, and it matters because
19
+ * signals ARE functions: without it `@click="count"` would read the signal
20
+ * instead of doing nothing.
21
+ */
22
+ export declare function asInvocableStatement(handler: string): string;
23
+ /**
24
+ * Generate the complete runtime script
25
+ */
26
+ export declare function generateRuntimeScript(events: ParsedEvent[]): string;
1
27
  /**
2
28
  * Extract and process event attributes from a template without generating
3
29
  * a standalone script. Returns the modified template (with @event attributes
@@ -33,55 +59,6 @@ export declare function processEventDirectives(template: string, context: Record
33
59
  * Check if a template contains event directives
34
60
  */
35
61
  export declare function hasEventDirectives(template: string): boolean;
36
- /* eslint-disable prefer-const, style/max-statements-per-line, no-super-linear-backtracking, regexp/no-unused-capturing-group */
37
- /**
38
- * Event Directives Module
39
- *
40
- * Provides Alpine.js-style event handling for STX templates.
41
- * Transforms `@click`, `@keydown.enter`, etc. into client-side event listeners.
42
- *
43
- * ## Supported Syntax
44
- *
45
- * ### Basic Events
46
- * ```html
47
- * <button @click="count++">Increment</button>
48
- * <button @click="handleClick()">Click me</button>
49
- * <form @submit="handleSubmit()">...</form>
50
- * ```
51
- *
52
- * ### Event Modifiers
53
- * ```html
54
- * <form @submit.prevent="handleSubmit()">...</form>
55
- * <a @click.prevent.stop="navigate()">Link</a>
56
- * <button @click.once="initialize()">Init</button>
57
- * <div @click.self="onlyThis()">...</div>
58
- * <input @keydown.capture="log()">
59
- * ```
60
- *
61
- * ### Key Modifiers
62
- * ```html
63
- * <input @keydown.enter="submit()">
64
- * <input @keyup.escape="cancel()">
65
- * <input @keydown.space="toggle()">
66
- * <input @keydown.ctrl.s="save()">
67
- * <input @keydown.meta.enter="send()">
68
- * ```
69
- *
70
- * ### Mouse Modifiers
71
- * ```html
72
- * <div @click.left="handleLeft()">
73
- * <div @click.right="handleRight()">
74
- * <div @click.middle="handleMiddle()">
75
- * ```
76
- *
77
- * ### Special Variables
78
- * ```html
79
- * <input @input="value = $event.target.value">
80
- * <button @click="handleClick($el)">Uses element</button>
81
- * ```
82
- *
83
- * @module events
84
- */
85
62
  // =============================================================================
86
63
  // Types
87
64
  // =============================================================================