@stacksjs/stx 0.2.152 → 0.2.154

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 (88) hide show
  1. package/dist/animation.js +6 -6
  2. package/dist/app-shell.js +2 -1
  3. package/dist/browser-composables.d.ts +8 -0
  4. package/dist/build-id.d.ts +49 -0
  5. package/dist/build-id.js +20 -0
  6. package/dist/build-message.d.ts +10 -0
  7. package/dist/build-message.js +26 -0
  8. package/dist/builtins/attrs.js +6 -1
  9. package/dist/cli.js +1743 -1255
  10. package/dist/client-script-bundler.js +2 -4
  11. package/dist/client-script.d.ts +29 -0
  12. package/dist/client-script.js +142 -97
  13. package/dist/color-mode-boot.d.ts +46 -10
  14. package/dist/color-mode-boot.js +53 -13
  15. package/dist/component-client-factories.js +3 -2
  16. package/dist/component-renderer.js +10 -21
  17. package/dist/composables/index.d.ts +8 -1
  18. package/dist/composables/use-clipboard.d.ts +1 -5
  19. package/dist/composables/use-clipboard.js +57 -46
  20. package/dist/composables/use-color-mode.d.ts +19 -4
  21. package/dist/composables/use-color-mode.js +49 -28
  22. package/dist/composables/use-router.d.ts +28 -11
  23. package/dist/composables/use-router.js +63 -9
  24. package/dist/composables/use-storage.d.ts +14 -15
  25. package/dist/composables/use-storage.js +63 -2
  26. package/dist/config.js +22 -7
  27. package/dist/craft.js +1319 -841
  28. package/dist/dev-server/crosswind.js +8 -6
  29. package/dist/dev-server/serve-app.js +13 -7
  30. package/dist/dev-server/serve-markdown.js +4 -1
  31. package/dist/dev-server/serve-multi.js +32 -20
  32. package/dist/dev-server/types.d.ts +8 -1
  33. package/dist/document-shell.d.ts +120 -0
  34. package/dist/document-shell.js +161 -14
  35. package/dist/dynamic-components.js +123 -4
  36. package/dist/events.d.ts +26 -49
  37. package/dist/events.js +23 -9
  38. package/dist/expressions.d.ts +1 -1
  39. package/dist/expressions.js +3 -3
  40. package/dist/framework-composables.d.ts +41 -0
  41. package/dist/framework-composables.js +115 -0
  42. package/dist/head.js +5 -1
  43. package/dist/hot-reload.js +8 -2
  44. package/dist/includes.js +4 -4
  45. package/dist/index.d.ts +17 -0
  46. package/dist/index.js +9 -0
  47. package/dist/isolated-build.d.ts +12 -0
  48. package/dist/isolated-build.js +18 -0
  49. package/dist/plugin-system.d.ts +1 -1
  50. package/dist/process.js +46 -12
  51. package/dist/production-builder.js +3 -1
  52. package/dist/production-server.js +4 -1
  53. package/dist/prop-sentinels.d.ts +31 -0
  54. package/dist/prop-sentinels.js +7 -0
  55. package/dist/pwa.js +1258 -780
  56. package/dist/reactive.js +1 -1
  57. package/dist/reactivity.d.ts +0 -15
  58. package/dist/reactivity.js +15 -6
  59. package/dist/runtime-globals.d.ts +40 -2
  60. package/dist/runtime-globals.js +90 -0
  61. package/dist/runtime-injection.d.ts +51 -0
  62. package/dist/runtime-injection.js +13 -0
  63. package/dist/script-emit.d.ts +20 -0
  64. package/dist/script-emit.js +11 -0
  65. package/dist/script-validation.js +12 -7
  66. package/dist/signal-processing.js +25 -40
  67. package/dist/signals-api.d.ts +30 -0
  68. package/dist/signals-api.js +18 -0
  69. package/dist/signals.js +532 -85
  70. package/dist/site-builder/build.js +21 -7
  71. package/dist/site-builder/seo.js +19 -10
  72. package/dist/site-builder/theme.js +5 -4
  73. package/dist/site-builder/types.d.ts +1 -0
  74. package/dist/ssg.js +1255 -777
  75. package/dist/state-management.js +4 -1
  76. package/dist/store-loader.d.ts +0 -11
  77. package/dist/store-loader.js +27 -3
  78. package/dist/strict-lint.d.ts +9 -6
  79. package/dist/strict-lint.js +2 -21
  80. package/dist/stx.d.ts +125 -22
  81. package/dist/types/config-types.d.ts +1 -0
  82. package/dist/unresolved-identifiers.d.ts +50 -0
  83. package/dist/unresolved-identifiers.js +146 -0
  84. package/dist/utils.js +3 -3
  85. package/dist/variable-extractor.js +17 -1
  86. package/dist/x-element.js +1 -1
  87. package/package.json +5 -5
  88. package/stx.d.ts +125 -22
@@ -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,8 @@ import {
11
11
  stopHmrServer
12
12
  } from "../hot-reload.js";
13
13
  import { partialsCache } from "../includes.js";
14
+ import { pageShipsSignalsRuntime } from "../runtime-injection.js";
15
+ import { BUILD_ID_HEADER, getBuildId } from "../build-id.js";
14
16
  import { stateDir } from "../state-dir.js";
15
17
  import { plugin as stxPlugin } from "../plugin.js";
16
18
  import { createRouter, matchRoute, formatRoutes, findErrorPage } from "../router.js";
@@ -74,14 +76,16 @@ function serveStaticFile(filePath) {
74
76
  export async function serveApp(appDir = ".", options = {}) {
75
77
  const port = options.port || 3000, watch = options.watch !== !1, hotReload = options.hotReload !== !1 && watch;
76
78
  let hmrServer = null, actualHmrPort = options.hmrPort || port + 1;
77
- const absoluteAppDir = path.resolve(appDir), { loadStxConfig } = await import("../index.js"), projectConfig = await loadStxConfig(absoluteAppDir), stxRoot = projectConfig.root && projectConfig.root !== "." ? path.join(absoluteAppDir, projectConfig.root) : absoluteAppDir, pagesDirName = projectConfig.pagesDir || "pages", pagesDir = path.join(stxRoot, pagesDirName);
79
+ const absoluteAppDir = path.resolve(appDir), { loadStxConfig } = await import("../index.js"), projectConfig = await loadStxConfig(absoluteAppDir), stxRoot = projectConfig.root && projectConfig.root !== "." ? path.join(absoluteAppDir, projectConfig.root) : absoluteAppDir, pagesDirName = options.pagesDir || projectConfig.pagesDir || "pages", pagesDir = path.join(stxRoot, pagesDirName);
78
80
  if (!fs.existsSync(pagesDir)) {
79
- console.error(`${colors.red}Error: No '${pagesDirName}' directory found in ${colors.bright}${stxRoot}${colors.reset}`);
80
- console.log(`${colors.dim}Create a ${pagesDirName}/ directory with .stx files to define your routes.${colors.reset}`);
81
- console.log(`${colors.dim}Example: ${pagesDirName}/index.stx for the homepage.${colors.reset}`);
81
+ console.error(`${colors.red}Error: No '${pagesDirName}' directory found${colors.reset}`);
82
+ console.log(`${colors.dim}Looked in: ${colors.bright}${pagesDir}${colors.reset}`);
83
+ console.log(`${colors.dim}Run stx dev from the app directory, pass it as an argument (stx dev <dir>),${colors.reset}`);
84
+ console.log(`${colors.dim}point at another directory with --pages <dir>, or set pagesDir in stx.config.ts.${colors.reset}`);
85
+ console.log(`${colors.dim}Starting fresh? Create ${pagesDirName}/index.stx for the homepage.${colors.reset}`);
82
86
  return !1;
83
87
  }
84
- const routes = createRouter(stxRoot);
88
+ const routes = createRouter(stxRoot, { pagesDir: pagesDirName });
85
89
  if (routes.length === 0) {
86
90
  console.error(`${colors.red}Error: No page files found in ${colors.bright}${pagesDir}${colors.reset}`);
87
91
  return !1;
@@ -254,7 +258,7 @@ export async function serveApp(appDir = ".", options = {}) {
254
258
  } else
255
259
  output = await processDirectives(templateContent, context, route.filePath, merged, dependencies);
256
260
  if (!shell)
257
- output = await injectRouterScript(output);
261
+ output = await injectRouterScript(output, { router: merged.router });
258
262
  {
259
263
  const { interpolateScriptExpressions } = await import("../expressions.js");
260
264
  output = output.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/g, (full, attrs, body) => {
@@ -433,7 +437,7 @@ export async function serveApp(appDir = ".", options = {}) {
433
437
  }
434
438
  let content = builtPage.content;
435
439
  if (shell && isSpaNavigation(request)) {
436
- 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() : "";
437
441
  if (Object.keys(routeMatch.params).length > 0)
438
442
  content = injectRouteParams(content, routeMatch.params);
439
443
  content = await injectCrosswindCSS(content, absoluteAppDir);
@@ -446,7 +450,9 @@ export async function serveApp(appDir = ".", options = {}) {
446
450
  "X-STX-Fragment": "true",
447
451
  "X-STX-Layout": layoutMetadata.layout,
448
452
  "X-STX-Layout-Group": layoutMetadata.group,
453
+ "X-STX-Runtime": shipsRuntime ? "true" : "false",
449
454
  ...pageTitle && { "X-STX-Title": encodeURIComponent(pageTitle) },
455
+ [BUILD_ID_HEADER]: getBuildId(),
450
456
  "Cache-Control": "no-store, no-cache, must-revalidate"
451
457
  }
452
458
  });
@@ -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
+ }
@@ -37,8 +37,14 @@ export declare const MIME_TYPES: {
37
37
  };
38
38
  /**
39
39
  * Default dev server options
40
+ *
41
+ * `pagesDir` is excluded rather than defaulted: it has no static default. Its
42
+ * absence is meaningful — it's what lets `stx.config.ts` `pagesDir` apply.
43
+ * Baking `'pages'` in here would override a configured `views/` for anyone who
44
+ * spread these defaults (#1781). serveApp resolves it as
45
+ * option → config → `'pages'`.
40
46
  */
41
- export declare const DEFAULT_DEV_OPTIONS: Required<Omit<DevServerOptions, 'stxOptions' | 'markdown'>>;
47
+ export declare const DEFAULT_DEV_OPTIONS: Required<Omit<DevServerOptions, 'stxOptions' | 'markdown' | 'pagesDir'>>;
42
48
  /**
43
49
  * Markdown rendering options
44
50
  */
@@ -59,6 +65,7 @@ export declare interface DevServerOptions {
59
65
  watch?: boolean
60
66
  native?: boolean
61
67
  titlebarHidden?: boolean
68
+ pagesDir?: string
62
69
  stxOptions?: StxOptions
63
70
  markdown?: MarkdownOptions
64
71
  cache?: boolean
@@ -1,5 +1,56 @@
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;
17
+ /**
18
+ * Build the attribute string for the shell's `<html>` open tag.
19
+ *
20
+ * `htmlAttrs.lang` wins over the `lang` option so `useHead({ htmlAttrs: { lang } })`
21
+ * is not emitted twice (two `lang=` attributes on one element is a parse error
22
+ * that browsers resolve by keeping the first — i.e. silently ignoring the
23
+ * more specific one).
24
+ *
25
+ * @param htmlAttrs - Attributes from config `app.head.htmlAttrs` / `useHead()`
26
+ * @param lang - The `lang` option, used unless `htmlAttrs.lang` overrides it
27
+ * @returns Leading-space-prefixed attribute string, e.g. ` lang="en" class="marketing"`
28
+ */
29
+ export declare function buildHtmlAttrs(htmlAttrs?: Record<string, string>, lang?: string): string;
30
+ /**
31
+ * Merge a page's `htmlAttrs` over the config's.
32
+ *
33
+ * Per-key override, except `class`, which unions: a global
34
+ * `app.head.htmlAttrs.class` (`h-full`, a font class, …) shouldn't silently
35
+ * vanish the moment one page adds a class of its own. Same rule as
36
+ * `applyHtmlAttrs`, so "class unions, everything else overrides" holds at every
37
+ * point htmlAttrs are combined.
38
+ */
39
+ export declare function mergeHtmlAttrs(base?: Record<string, string>, override?: Record<string, string>): Record<string, string>;
40
+ /**
41
+ * Merge `htmlAttrs` into a document that rendered its OWN `<html>` tag.
42
+ *
43
+ * `generateDocumentShell` only runs for pages without a shell, so a page or
44
+ * layout that writes `<html>` itself — the common case, and what SSG emits —
45
+ * would otherwise drop `useHead({ htmlAttrs })` entirely. Whether the attribute
46
+ * lands should not depend on who wrote the document element.
47
+ *
48
+ * `class` unions with what the template already put there (the template's own
49
+ * classes are never dropped); every other attribute is overwritten. Only the
50
+ * classes stx contributed are recorded in the marker, so an SPA navigation
51
+ * removes those and leaves the hand-written ones in place.
52
+ */
53
+ export declare function applyHtmlAttrs(html: string, htmlAttrs?: Record<string, string>): string;
3
54
  /**
4
55
  * Insert the inline x-cloak <style> before </head> if a head exists and the
5
56
  * style isn't already present. Idempotent (guards on the data-stx-cloak
@@ -35,6 +86,19 @@ export declare function hoistLeadingHeadTags(content: string): { body: string, h
35
86
  export declare function generateDocumentShell(content: string, headConfig?: AppHeadConfig, options?: {
36
87
  /** Page-specific title (overrides config) */
37
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
38
102
  /** Extra styles to inject in <head> (e.g. Crosswind CSS, scoped styles) */
39
103
  styles?: string[]
40
104
  /** Extra scripts to inject before </body> (e.g. signals runtime, router) */
@@ -67,6 +131,44 @@ export declare function generateDocumentShell(content: string, headConfig?: AppH
67
131
  * which made the check spuriously fail and auto-shell wrap the user's
68
132
  * complete HTML doc as if it were just body content.
69
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
+ */
70
172
  export declare function hasDocumentShell(html: string): boolean;
71
173
  /**
72
174
  * Inject the config `app.head` tags — script / meta / link / headRaw, but NOT
@@ -106,3 +208,21 @@ export declare function ensureDocumentShell(html: string, headConfig?: AppHeadCo
106
208
  * emission.
107
209
  */
108
210
  export declare const CLOAK_STYLE: '<style data-stx-cloak>[x-cloak]{display:none !important}</style>';
211
+ /**
212
+ * Markers recording which `<html>` attributes stx itself wrote from `htmlAttrs`
213
+ * (stacksjs/stx#1798).
214
+ *
215
+ * The router needs them to reconcile the root element across an SPA layout
216
+ * change. It cannot just diff `<html>` against the incoming document: by then
217
+ * the color-mode boot script has added its own `dark` class / `data-theme`, and
218
+ * `animation.ts` has added `data-reduced-motion`, none of which exist in the
219
+ * server response — a blind diff would strip them on every navigation and
220
+ * flash the wrong theme. These say exactly what stx put there, so the router
221
+ * can remove precisely that much and leave runtime state alone.
222
+ *
223
+ * `class` is tracked separately because it is the one attribute that gets
224
+ * *merged* into at runtime, so it has to be reconciled token-by-token rather
225
+ * than replaced wholesale.
226
+ */
227
+ export declare const HTML_ATTRS_MARKER: 'data-stx-html-attrs';
228
+ export declare const HTML_CLASS_MARKER: 'data-stx-html-class';
@@ -1,5 +1,121 @@
1
1
  import { generateColorModeBootScript } from "./color-mode-boot.js";
2
- export const CLOAK_STYLE = "<style data-stx-cloak>[x-cloak]{display:none !important}</style>";
2
+ export const CLOAK_STYLE = "<style data-stx-cloak>[x-cloak]{display:none !important}</style>", HTML_ATTRS_MARKER = "data-stx-html-attrs", HTML_CLASS_MARKER = "data-stx-html-class";
3
+ const SAFE_ATTR_NAME = /^[A-Za-z_:][\w:.-]*$/;
4
+ function escapeAttr(value) {
5
+ return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }
24
+ export function buildHtmlAttrs(htmlAttrs = {}, lang = "en") {
25
+ const rest = [], managed = [];
26
+ let className = "", resolvedLang = lang;
27
+ for (const [rawName, value] of Object.entries(htmlAttrs || {})) {
28
+ if (value == null)
29
+ continue;
30
+ const name = rawName.trim();
31
+ if (!SAFE_ATTR_NAME.test(name))
32
+ continue;
33
+ const lower = name.toLowerCase();
34
+ if (lower === "lang") {
35
+ resolvedLang = String(value);
36
+ continue;
37
+ }
38
+ if (lower === "class") {
39
+ className = String(value).trim();
40
+ continue;
41
+ }
42
+ rest.push(` ${name}="${escapeAttr(String(value))}"`);
43
+ managed.push(name);
44
+ }
45
+ let out = ` lang="${escapeAttr(resolvedLang)}"`;
46
+ if (className)
47
+ out += ` class="${escapeAttr(className)}"`;
48
+ out += rest.join("");
49
+ if (className)
50
+ out += ` ${HTML_CLASS_MARKER}="${escapeAttr(className)}"`;
51
+ if (managed.length)
52
+ out += ` ${HTML_ATTRS_MARKER}="${escapeAttr(managed.join(" "))}"`;
53
+ return out;
54
+ }
55
+ export function mergeHtmlAttrs(base = {}, override = {}) {
56
+ const merged = { ...base, ...override };
57
+ if (base.class && override.class) {
58
+ const tokens = base.class.split(/\s+/).filter(Boolean);
59
+ for (const token of override.class.split(/\s+/).filter(Boolean))
60
+ if (!tokens.includes(token))
61
+ tokens.push(token);
62
+ merged.class = tokens.join(" ");
63
+ }
64
+ return merged;
65
+ }
66
+ export function applyHtmlAttrs(html, htmlAttrs = {}) {
67
+ if (!htmlAttrs || Object.keys(htmlAttrs).length === 0)
68
+ return html;
69
+ const openTag = html.match(/<html\b([^>]*)>/i);
70
+ if (!openTag)
71
+ return html;
72
+ const existing = {}, order = [], attrRe = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
73
+ let m;
74
+ while ((m = attrRe.exec(openTag[1])) !== null) {
75
+ const name = m[1];
76
+ if (!(name in existing))
77
+ order.push(name);
78
+ existing[name] = m[2] ?? m[3] ?? m[4] ?? "";
79
+ }
80
+ const addedClasses = [], managed = [];
81
+ for (const [rawName, value] of Object.entries(htmlAttrs)) {
82
+ if (value == null)
83
+ continue;
84
+ const name = rawName.trim();
85
+ if (!SAFE_ATTR_NAME.test(name))
86
+ continue;
87
+ const lower = name.toLowerCase();
88
+ if (lower === "class") {
89
+ const present = (existing.class || "").split(/\s+/).filter(Boolean);
90
+ for (const token of String(value).split(/\s+/).filter(Boolean))
91
+ if (!present.includes(token)) {
92
+ present.push(token);
93
+ addedClasses.push(token);
94
+ }
95
+ if (!("class" in existing))
96
+ order.push("class");
97
+ existing.class = present.join(" ");
98
+ continue;
99
+ }
100
+ if (!(name in existing))
101
+ order.push(name);
102
+ existing[name] = String(value);
103
+ if (lower !== "lang")
104
+ managed.push(name);
105
+ }
106
+ if (addedClasses.length) {
107
+ if (!(HTML_CLASS_MARKER in existing))
108
+ order.push(HTML_CLASS_MARKER);
109
+ existing[HTML_CLASS_MARKER] = addedClasses.join(" ");
110
+ }
111
+ if (managed.length) {
112
+ if (!(HTML_ATTRS_MARKER in existing))
113
+ order.push(HTML_ATTRS_MARKER);
114
+ existing[HTML_ATTRS_MARKER] = managed.join(" ");
115
+ }
116
+ const rebuilt = order.map((name) => ` ${name}="${escapeAttr(existing[name])}"`).join("");
117
+ return html.replace(openTag[0], () => `<html${rebuilt}>`);
118
+ }
3
119
  export function injectCloakStyle(html) {
4
120
  if (html.includes("data-stx-cloak"))
5
121
  return html;
@@ -86,28 +202,32 @@ export function generateDocumentShell(content, headConfig = {}, options = {}) {
86
202
  link = [],
87
203
  script: headScriptTags = [],
88
204
  headRaw = "",
205
+ htmlAttrs = {},
89
206
  bodyClass = "",
90
207
  bodyAttrs = {}
91
- } = headConfig, pageTitle = options.title || configTitle, metaTags = [...[
208
+ } = headConfig, pageTitle = options.title || configTitle, defaultMeta = [
92
209
  { charset: "UTF-8" },
93
210
  { name: "viewport", content: "width=device-width, initial-scale=1.0" }
94
- ], ...meta].map((m) => {
95
- 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)}>`;
96
216
  }).join(`
97
217
  `), linkTags = link.map((l) => {
98
- return ` <link ${Object.entries(l).map(([k, v]) => `${k}="${v}"`).join(" ")}>`;
218
+ return ` <link ${attrPairs(l)}>`;
99
219
  }).join(`
100
220
  `), configHeadScripts = headScriptTags.map((s) => {
101
221
  if (s.src)
102
- 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>`;
103
223
  if (s.content)
104
224
  return ` <script>${s.content}</script>`;
105
225
  return "";
106
226
  }).filter(Boolean).join(`
107
- `), 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 = [
108
228
  metaTags,
109
229
  options.colorMode ? ` ${generateColorModeBootScript(options.colorMode)}` : "",
110
- ` <title>${pageTitle}</title>`,
230
+ ` <title>${options.titleIsHtml ? pageTitle : escapeText(pageTitle)}</title>`,
111
231
  ` ${CLOAK_STYLE}`,
112
232
  linkTags,
113
233
  configHeadScripts,
@@ -122,7 +242,7 @@ export function generateDocumentShell(content, headConfig = {}, options = {}) {
122
242
  options.bodyStack || ""
123
243
  ].filter(Boolean);
124
244
  return `<!DOCTYPE html>
125
- <html lang="${lang}">
245
+ <html${htmlAttrStr}>
126
246
  <head>
127
247
  ${headParts.join(`
128
248
  `)}
@@ -133,8 +253,35 @@ ${bodyParts.join(`
133
253
  </body>
134
254
  </html>`;
135
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
+ }
136
283
  export function hasDocumentShell(html) {
137
- return /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html);
284
+ return startsDocument(html);
138
285
  }
139
286
  export function injectConfigHeadTags(html, headConfig = {}) {
140
287
  const headCloseIdx = html.lastIndexOf("</head>");
@@ -142,21 +289,21 @@ export function injectConfigHeadTags(html, headConfig = {}) {
142
289
  return html;
143
290
  const { meta = [], link = [], script = [], headRaw = "" } = headConfig, head = html.slice(0, headCloseIdx), parts = [];
144
291
  for (const m of meta) {
145
- 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);
146
293
  if (dedup && head.includes(dedup))
147
294
  continue;
148
- parts.push(` <meta ${Object.entries(m).map(([k, v]) => `${k}="${v}"`).join(" ")}>`);
295
+ parts.push(` <meta ${attrPairs(m)}>`);
149
296
  }
150
297
  for (const l of link) {
151
298
  if (l.href && head.includes(`href="${l.href}"`))
152
299
  continue;
153
- parts.push(` <link ${Object.entries(l).map(([k, v]) => `${k}="${v}"`).join(" ")}>`);
300
+ parts.push(` <link ${attrPairs(l)}>`);
154
301
  }
155
302
  for (const s of script)
156
303
  if (s.src) {
157
304
  if (head.includes(`src="${s.src}"`))
158
305
  continue;
159
- 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>`);
160
307
  } else if (s.content) {
161
308
  if (head.includes(s.content))
162
309
  continue;