bosia 0.4.4 → 0.5.0

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/src/core/html.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { getDeclaredEnvKeys } from "./env.ts";
3
+ import { nonceAttr } from "./csp.ts";
3
4
 
4
5
  // ─── Dist Manifest ───────────────────────────────────────
5
6
  // Maps hashed filenames → script/link tags.
@@ -36,6 +37,22 @@ export function safeJsonStringify(data: unknown): string {
36
37
  return json.replace(/[<>&\u2028\u2029]/g, (c) => map[c]);
37
38
  }
38
39
 
40
+ const SCRIPT_HAZARD_RE = /<(\/script|!--)/gi;
41
+
42
+ /** Escapes JSON for safe embedding inside <script type="application/json"> blocks.
43
+ * Blocks premature </script> and <!-- (HTML script-data escape state) without
44
+ * the JS-context overhead of safeJsonStringify. */
45
+ export function safeJsonForScript(data: unknown): string {
46
+ let json: string;
47
+ try {
48
+ json = JSON.stringify(data);
49
+ } catch {
50
+ console.error("safeJsonForScript: failed to serialize data (circular reference?)");
51
+ json = "null";
52
+ }
53
+ return json.replace(SCRIPT_HAZARD_RE, "\\u003c$1");
54
+ }
55
+
39
56
  // ─── Public Env Injection ─────────────────────────────────
40
57
 
41
58
  /**
@@ -76,6 +93,9 @@ export function buildHtml(
76
93
  formData: any = null,
77
94
  lang?: string,
78
95
  ssr = true,
96
+ nonce?: string,
97
+ pageDeps: any = null,
98
+ layoutDeps: any[] | null = null,
79
99
  ): string {
80
100
  const cssLinks = (distManifest.css ?? [])
81
101
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
@@ -83,20 +103,35 @@ export function buildHtml(
83
103
 
84
104
  const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
85
105
 
106
+ const n = nonceAttr(nonce);
86
107
  const publicEnv = getPublicDynamicEnv();
87
108
  const envScript =
88
109
  Object.keys(publicEnv).length > 0
89
- ? `\n <script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
110
+ ? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
90
111
  : "";
91
112
 
92
- const formScript =
93
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
94
113
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
95
114
 
115
+ const depsScript =
116
+ pageDeps !== null || layoutDeps !== null
117
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
118
+ : "";
119
+
120
+ const sysScript =
121
+ ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
122
+
123
+ const dataIslands = csr
124
+ ? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
125
+ `\n <script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>` +
126
+ (formData != null
127
+ ? `\n <script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`
128
+ : "")
129
+ : "";
130
+
96
131
  const scripts = csr
97
- ? `${envScript}\n <script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formScript}</script>\n <script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
132
+ ? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
98
133
  : isDev
99
- ? `\n <script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
134
+ ? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
100
135
  : "";
101
136
 
102
137
  return `<!DOCTYPE html>
@@ -109,7 +144,7 @@ export function buildHtml(
109
144
  ${head}
110
145
  ${cssLinks}
111
146
  <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">
112
- <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
147
+ <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>
113
148
  </head>
114
149
  <body>
115
150
  <div id="app">${body}</div>${scripts}
@@ -121,27 +156,23 @@ export function buildHtml(
121
156
 
122
157
  import type { Metadata } from "./hooks.ts";
123
158
 
124
- const _shellOpenCache = new Map<string, string>();
125
-
126
159
  /** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
127
- export function buildHtmlShellOpen(lang?: string): string {
160
+ export function buildHtmlShellOpen(lang?: string, nonce?: string): string {
128
161
  const key = safeLang(lang);
129
- const cached = _shellOpenCache.get(key);
130
- if (cached) return cached;
162
+ const n = nonceAttr(nonce);
131
163
  const cssLinks = (distManifest.css ?? [])
132
164
  .map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
133
165
  .join("\n ");
134
- const result =
166
+ return (
135
167
  `<!DOCTYPE html>\n<html lang="${key}">\n<head>\n` +
136
168
  ` <meta charset="UTF-8">\n` +
137
169
  ` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
138
170
  ` <link rel="icon" type="image/svg+xml" href="/favicon.svg">\n` +
139
171
  ` ${cssLinks}\n` +
140
172
  ` <link rel="stylesheet" href="/bosia-tw.css${cacheBust}">\n` +
141
- ` <script>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
142
- ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`;
143
- _shellOpenCache.set(key, result);
144
- return result;
173
+ ` <script${n}>try{var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');else document.documentElement.classList.remove('dark')}catch(_){}</script>\n` +
174
+ ` <link rel="modulepreload" href="/dist/client/${distManifest.entry}${cacheBust}">`
175
+ );
145
176
  }
146
177
 
147
178
  const SPINNER =
@@ -208,25 +239,36 @@ export function buildHtmlTail(
208
239
  formData: any = null,
209
240
  ssr = true,
210
241
  bodyEndExtras?: string[],
242
+ nonce?: string,
243
+ pageDeps: any = null,
244
+ layoutDeps: any[] | null = null,
211
245
  ): string {
212
- let out = `<script>document.getElementById('__bs__').remove()</script>`;
246
+ const n = nonceAttr(nonce);
247
+ let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
213
248
  out += `\n<div id="app">${body}</div>`;
214
249
  if (head)
215
- out += `\n<script>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
250
+ out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
216
251
  if (csr) {
217
252
  const publicEnv = getPublicDynamicEnv();
218
253
  if (Object.keys(publicEnv).length > 0) {
219
- out += `\n<script>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
254
+ out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
255
+ }
256
+ out += `\n<script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>`;
257
+ out += `\n<script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>`;
258
+ if (formData != null) {
259
+ out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
220
260
  }
221
- const formInject =
222
- formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
223
261
  const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
224
- out +=
225
- `\n<script>${ssrFlag}window.__BOSIA_PAGE_DATA__=${safeJsonStringify(pageData)};` +
226
- `window.__BOSIA_LAYOUT_DATA__=${safeJsonStringify(layoutData)};${formInject}</script>`;
227
- out += `\n<script type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
262
+ const depsInject =
263
+ pageDeps !== null || layoutDeps !== null
264
+ ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
265
+ : "";
266
+ if (ssrFlag || depsInject) {
267
+ out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
268
+ }
269
+ out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
228
270
  } else if (isDev) {
229
- out += `\n<script>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
271
+ out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
230
272
  }
231
273
  if (bodyEndExtras?.length) {
232
274
  for (const fragment of bodyEndExtras) {
@@ -44,6 +44,17 @@ interface PrerenderTarget {
44
44
  export function substituteParams(pattern: string, entry: Record<string, string>): string {
45
45
  let resolved = pattern;
46
46
  for (const [key, value] of Object.entries(entry)) {
47
+ // `..` and `\` are never legitimate in a route segment — they let a build
48
+ // emit prerendered HTML outside the intended output tree. Forward slashes
49
+ // are only allowed for catch-all (`[...key]`) segments, which by design
50
+ // expand to multiple path parts. Validate accordingly.
51
+ const isRest = pattern.includes(`[...${key}]`);
52
+ if (/\\|\.\./.test(value) || (!isRest && value.includes("/"))) {
53
+ throw new Error(
54
+ `Prerender entries(): unsafe value "${value}" for [${key}] — ` +
55
+ `path traversal characters are not allowed in dynamic segment values.`,
56
+ );
57
+ }
47
58
  resolved = resolved.replace(`[...${key}]`, value);
48
59
  resolved = resolved.replace(`[${key}]`, value);
49
60
  }