@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.
- package/dist/animation.js +6 -6
- package/dist/app-shell.js +2 -1
- package/dist/browser-composables.d.ts +8 -0
- package/dist/build-id.d.ts +49 -0
- package/dist/build-id.js +20 -0
- package/dist/build-message.d.ts +10 -0
- package/dist/build-message.js +26 -0
- package/dist/builtins/attrs.js +6 -1
- package/dist/cli.js +1743 -1255
- package/dist/client-script-bundler.js +2 -4
- package/dist/client-script.d.ts +29 -0
- package/dist/client-script.js +142 -97
- package/dist/color-mode-boot.d.ts +46 -10
- package/dist/color-mode-boot.js +53 -13
- package/dist/component-client-factories.js +3 -2
- package/dist/component-renderer.js +10 -21
- package/dist/composables/index.d.ts +8 -1
- package/dist/composables/use-clipboard.d.ts +1 -5
- package/dist/composables/use-clipboard.js +57 -46
- package/dist/composables/use-color-mode.d.ts +19 -4
- package/dist/composables/use-color-mode.js +49 -28
- package/dist/composables/use-router.d.ts +28 -11
- package/dist/composables/use-router.js +63 -9
- package/dist/composables/use-storage.d.ts +14 -15
- package/dist/composables/use-storage.js +63 -2
- package/dist/config.js +22 -7
- package/dist/craft.js +1319 -841
- package/dist/dev-server/crosswind.js +8 -6
- package/dist/dev-server/serve-app.js +13 -7
- package/dist/dev-server/serve-markdown.js +4 -1
- package/dist/dev-server/serve-multi.js +32 -20
- package/dist/dev-server/types.d.ts +8 -1
- package/dist/document-shell.d.ts +120 -0
- package/dist/document-shell.js +161 -14
- package/dist/dynamic-components.js +123 -4
- package/dist/events.d.ts +26 -49
- package/dist/events.js +23 -9
- package/dist/expressions.d.ts +1 -1
- package/dist/expressions.js +3 -3
- package/dist/framework-composables.d.ts +41 -0
- package/dist/framework-composables.js +115 -0
- package/dist/head.js +5 -1
- package/dist/hot-reload.js +8 -2
- package/dist/includes.js +4 -4
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/isolated-build.d.ts +12 -0
- package/dist/isolated-build.js +18 -0
- package/dist/plugin-system.d.ts +1 -1
- package/dist/process.js +46 -12
- package/dist/production-builder.js +3 -1
- package/dist/production-server.js +4 -1
- package/dist/prop-sentinels.d.ts +31 -0
- package/dist/prop-sentinels.js +7 -0
- package/dist/pwa.js +1258 -780
- package/dist/reactive.js +1 -1
- package/dist/reactivity.d.ts +0 -15
- package/dist/reactivity.js +15 -6
- package/dist/runtime-globals.d.ts +40 -2
- package/dist/runtime-globals.js +90 -0
- package/dist/runtime-injection.d.ts +51 -0
- package/dist/runtime-injection.js +13 -0
- package/dist/script-emit.d.ts +20 -0
- package/dist/script-emit.js +11 -0
- package/dist/script-validation.js +12 -7
- package/dist/signal-processing.js +25 -40
- package/dist/signals-api.d.ts +30 -0
- package/dist/signals-api.js +18 -0
- package/dist/signals.js +532 -85
- package/dist/site-builder/build.js +21 -7
- package/dist/site-builder/seo.js +19 -10
- package/dist/site-builder/theme.js +5 -4
- package/dist/site-builder/types.d.ts +1 -0
- package/dist/ssg.js +1255 -777
- package/dist/state-management.js +4 -1
- package/dist/store-loader.d.ts +0 -11
- package/dist/store-loader.js +27 -3
- package/dist/strict-lint.d.ts +9 -6
- package/dist/strict-lint.js +2 -21
- package/dist/stx.d.ts +125 -22
- package/dist/types/config-types.d.ts +1 -0
- package/dist/unresolved-identifiers.d.ts +50 -0
- package/dist/unresolved-identifiers.js +146 -0
- package/dist/utils.js +3 -3
- package/dist/variable-extractor.js +17 -1
- package/dist/x-element.js +1 -1
- package/package.json +5 -5
- 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
|
-
],
|
|
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(
|
|
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(
|
|
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
|
|
80
|
-
console.log(`${colors.dim}
|
|
81
|
-
console.log(`${colors.dim}
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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>${
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
package/dist/document-shell.d.ts
CHANGED
|
@@ -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é & Bar` as
|
|
96
|
+
* `Caf&eacute; &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';
|
package/dist/document-shell.js
CHANGED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
6
|
+
}
|
|
7
|
+
function escapeText(value) {
|
|
8
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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,
|
|
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
|
-
],
|
|
95
|
-
|
|
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 ${
|
|
218
|
+
return ` <link ${attrPairs(l)}>`;
|
|
99
219
|
}).join(`
|
|
100
220
|
`), configHeadScripts = headScriptTags.map((s) => {
|
|
101
221
|
if (s.src)
|
|
102
|
-
return ` <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
|
|
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
|
|
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
|
|
292
|
+
const dedup = metaDedupKey(m);
|
|
146
293
|
if (dedup && head.includes(dedup))
|
|
147
294
|
continue;
|
|
148
|
-
parts.push(` <meta ${
|
|
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 ${
|
|
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 ${
|
|
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;
|