@stacksjs/stx 0.2.156 → 0.2.158

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Locate the real `<body>` open tag.
3
+ *
4
+ * Every consumer of this used to do `html.replace(/<body([^>]*)>/, ...)`, which
5
+ * takes the FIRST match anywhere in the document — including inside a
6
+ * `<style>` block, a `<script>`, or an HTML comment. Any of those can contain
7
+ * the characters `<body` in prose without meaning a tag.
8
+ *
9
+ * When that happened the consequences were silent and total. The setup-wiring
10
+ * step in process.ts stamps `data-stx="__stx_setup_…"` onto the body open tag,
11
+ * and the runtime invokes a page's setup function by reading that attribute.
12
+ * Stamp it into a CSS comment instead and the real `<body>` never gets it, so
13
+ * the setup function ships, parses, and is never called: every `:if`,
14
+ * `:hidden`, `@click` and `{{ signal() }}` on the page stays inert, with no
15
+ * error anywhere. A single sentence in a CSS comment mentioning the body
16
+ * element was enough to disable a whole site's interactivity.
17
+ *
18
+ * `runtime-injection.ts` has the same shape and a similar failure: it would
19
+ * inject the signals runtime `<script>` INSIDE a `<style>` block, where it
20
+ * never executes.
21
+ *
22
+ * This is the same class of bug as the unanchored `hasDocumentShell` check
23
+ * fixed in stacksjs/stx#1792, whose reporter ended up writing a house rule
24
+ * telling authors not to name the tag in prose. Authors should not need that
25
+ * rule; scanning should skip regions that cannot contain markup.
26
+ */
27
+ export interface BodyTagMatch {
28
+ /** Index of the `<` that opens the tag. */
29
+ index: number;
30
+ /** The full open tag, e.g. `<body class="x">`. */
31
+ tag: string;
32
+ /** The attribute text between `<body` and `>`, e.g. ` class="x"`. */
33
+ attrs: string;
34
+ }
35
+ /**
36
+ * Find the `<body>` open tag that is genuinely markup, skipping comments,
37
+ * scripts and style blocks.
38
+ *
39
+ * @returns the match, or null when the document has no real body tag.
40
+ */
41
+ export declare function findBodyOpenTag(html: string): BodyTagMatch | null;
42
+ /** Whether the document carries a real `<body>` tag, ignoring text regions. */
43
+ export declare function hasBodyOpenTag(html: string): boolean;
44
+ /**
45
+ * Replace the real `<body>` open tag, leaving text regions alone.
46
+ *
47
+ * @param replacer - receives the full tag and its attribute text
48
+ * @returns the html unchanged when there is no real body tag
49
+ */
50
+ export declare function replaceBodyOpenTag(html: string, replacer: (tag: string, attrs: string) => string): string;
@@ -0,0 +1,45 @@
1
+ const SKIP_REGIONS = [
2
+ { open: /<!--/, close: "-->" },
3
+ { open: /<script\b/i, close: "</script>" },
4
+ { open: /<style\b/i, close: "</style>" }
5
+ ];
6
+ export function findBodyOpenTag(html) {
7
+ let cursor = 0;
8
+ while (cursor < html.length) {
9
+ const rest = html.slice(cursor), bodyMatch = /<body\b([^>]*)>/i.exec(rest);
10
+ if (!bodyMatch)
11
+ 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);
16
+ if (!found)
17
+ continue;
18
+ const at = cursor + found.index;
19
+ if (at < bodyAt && (nearestSkipAt === -1 || at < nearestSkipAt)) {
20
+ nearestSkipAt = at;
21
+ nearestClose = region.close;
22
+ }
23
+ }
24
+ if (nearestSkipAt === -1)
25
+ return {
26
+ index: bodyAt,
27
+ tag: bodyMatch[0],
28
+ attrs: bodyMatch[1] ?? ""
29
+ };
30
+ const closeAt = html.toLowerCase().indexOf(nearestClose, nearestSkipAt);
31
+ if (closeAt === -1)
32
+ return null;
33
+ cursor = closeAt + nearestClose.length;
34
+ }
35
+ return null;
36
+ }
37
+ export function hasBodyOpenTag(html) {
38
+ return findBodyOpenTag(html) !== null;
39
+ }
40
+ export function replaceBodyOpenTag(html, replacer) {
41
+ const match = findBodyOpenTag(html);
42
+ if (!match)
43
+ return html;
44
+ return html.slice(0, match.index) + replacer(match.tag, match.attrs) + html.slice(match.index + match.tag.length);
45
+ }
package/dist/process.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { injectCrosswindCSS } from "./dev-server/crosswind.js";
4
+ import { findBodyOpenTag, replaceBodyOpenTag } from "./find-body-tag.js";
4
5
  import { matchHtmlComment, matchStyleElement, maskAtElementPosition, stashScriptElements } from "./html-masking.js";
5
6
  import { processA11yDirectives } from "./a11y.js";
6
7
  import { generateLifecycleRuntime } from "./composables.js";
@@ -300,9 +301,9 @@ export async function processDirectives(template, context, filePath, options, de
300
301
  <meta name="stx-layout" content="${escapeHtmlAttribute(layoutName)}">
301
302
  <meta name="stx-layout-group" content="${escapeHtmlAttribute(layoutGroup)}">`);
302
303
  }
303
- const setupMatch = result.match(/function (__stx_setup_\d+_\d+)\(/);
304
- if (setupMatch && result.includes("<body") && !result.match(/<body[^>]*data-stx=/)) {
305
- result = result.replace(/<body([^>]*)>/, `<body$1 data-stx="${setupMatch[1]}">`);
304
+ const setupMatch = result.match(/function (__stx_setup_\d+_\d+)\(/), bodyTag = setupMatch ? findBodyOpenTag(result) : null;
305
+ if (setupMatch && bodyTag && !/\bdata-stx=/.test(bodyTag.tag)) {
306
+ result = replaceBodyOpenTag(result, (_tag, attrs) => `<body${attrs} data-stx="${setupMatch[1]}">`);
306
307
  const wrongAttrRegex = new RegExp(`(<(?!body)[a-zA-Z][^>]*) data-stx="${setupMatch[1]}"`, "i");
307
308
  result = result.replace(wrongAttrRegex, "$1");
308
309
  }