dalila 1.10.1 → 1.10.3

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.
@@ -473,11 +473,24 @@ export function clearPersisted(name, storage = safeDefaultStorage() ?? {}) {
473
473
  }
474
474
  }
475
475
  function escapeInlineScriptContent(script) {
476
- return script
477
- .replace(/</g, '\\x3C')
478
- .replace(/-->/g, '--\\x3E')
479
- .replace(/\u2028/g, '\\u2028')
480
- .replace(/\u2029/g, '\\u2029');
476
+ return script.replace(/--!>|-->|[<>\u2028\u2029]/g, (match) => {
477
+ switch (match) {
478
+ case '--!>':
479
+ return '--!\\u003E';
480
+ case '-->':
481
+ return '--\\u003E';
482
+ case '<':
483
+ return '\\u003C';
484
+ case '>':
485
+ return '\\u003E';
486
+ case '\u2028':
487
+ return '\\u2028';
488
+ case '\u2029':
489
+ return '\\u2029';
490
+ default:
491
+ return match;
492
+ }
493
+ });
481
494
  }
482
495
  /**
483
496
  * Generate a minimal inline script to prevent FOUC.
@@ -1,8 +1,14 @@
1
1
  const TRUSTED_POLICY_CACHE_KEY = Symbol.for('dalila.runtime.trustedTypesPolicies');
2
2
  const TRUSTED_POLICY_PARSE_SUFFIX = '--dalila-parse';
3
- const EXECUTABLE_HTML_SCRIPT_PATTERN = /<script[\s/>]/i;
4
3
  const EXECUTABLE_HTML_EVENT_ATTR_PATTERN = /<[^>]+\son[a-z0-9:_-]+\s*=/i;
5
- const EXECUTABLE_HTML_URL_ATTR_PATTERN = /<[^>]+\s(?:href|src|xlink:href|formaction|action|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
4
+ const EXECUTABLE_HTML_URL_ATTR_NAMES = new Set([
5
+ 'href',
6
+ 'src',
7
+ 'xlink:href',
8
+ 'formaction',
9
+ 'action',
10
+ 'poster',
11
+ ]);
6
12
  const EXECUTABLE_DATA_URL_PATTERN = /^data:(?:text\/html|application\/xhtml\+xml|image\/svg\+xml)\b/i;
7
13
  function getTrustedPolicyCache() {
8
14
  const host = globalThis;
@@ -17,25 +23,177 @@ const trustedPolicyCache = getTrustedPolicyCache();
17
23
  function normalizeHtmlUrlAttrValue(value) {
18
24
  return value.replace(/[\u0000-\u0020\u007f]+/g, '').toLowerCase();
19
25
  }
26
+ function isHtmlWhitespaceCode(code) {
27
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
28
+ }
29
+ function isHtmlAttributeNameChar(code) {
30
+ return !Number.isNaN(code)
31
+ && code !== 0x20
32
+ && code !== 0x09
33
+ && code !== 0x0a
34
+ && code !== 0x0c
35
+ && code !== 0x0d
36
+ && code !== 0x22
37
+ && code !== 0x27
38
+ && code !== 0x2f
39
+ && code !== 0x3c
40
+ && code !== 0x3d
41
+ && code !== 0x3e
42
+ && code !== 0x60;
43
+ }
44
+ function isTagBoundaryChar(char) {
45
+ return !char || /[\s/>]/.test(char);
46
+ }
47
+ function getPreviousNonWhitespaceChar(value, end, start = 0) {
48
+ for (let index = end - 1; index >= start; index -= 1) {
49
+ if (!isHtmlWhitespaceCode(value.charCodeAt(index))) {
50
+ return value[index];
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ function isHtmlTagStartChar(char) {
56
+ return !!char && /[A-Za-z/!?]/.test(char);
57
+ }
58
+ function findTagLikeStart(value, start, end = value.length) {
59
+ let index = value.indexOf('<', start);
60
+ while (index !== -1 && index < end) {
61
+ if (isHtmlTagStartChar(value[index + 1])) {
62
+ return index;
63
+ }
64
+ index = value.indexOf('<', index + 1);
65
+ }
66
+ return -1;
67
+ }
68
+ function hasExecutableHtmlScriptTag(value) {
69
+ const lower = value.toLowerCase();
70
+ let searchIndex = 0;
71
+ while (searchIndex < lower.length) {
72
+ const index = lower.indexOf('<script', searchIndex);
73
+ if (index === -1)
74
+ return false;
75
+ if (isTagBoundaryChar(lower[index + 7])) {
76
+ return true;
77
+ }
78
+ searchIndex = index + 7;
79
+ }
80
+ return false;
81
+ }
82
+ function hasExecutableProtocol(value) {
83
+ return value.startsWith('javascript:')
84
+ || value.startsWith('vbscript:')
85
+ || EXECUTABLE_DATA_URL_PATTERN.test(value);
86
+ }
20
87
  function hasExecutableHtmlUrlAttribute(value) {
21
- EXECUTABLE_HTML_URL_ATTR_PATTERN.lastIndex = 0;
22
- let match = null;
23
- while ((match = EXECUTABLE_HTML_URL_ATTR_PATTERN.exec(value)) !== null) {
24
- const attrValue = match[1] ?? match[2] ?? match[3] ?? '';
25
- const normalized = normalizeHtmlUrlAttrValue(attrValue);
26
- if (!normalized)
88
+ let index = 0;
89
+ while (index < value.length) {
90
+ const tagStart = value.indexOf('<', index);
91
+ if (tagStart === -1)
92
+ return false;
93
+ let cursor = tagStart + 1;
94
+ const firstCode = value.charCodeAt(cursor);
95
+ if (Number.isNaN(firstCode)
96
+ || value[cursor] === '/'
97
+ || value[cursor] === '!'
98
+ || value[cursor] === '?') {
99
+ index = cursor;
27
100
  continue;
28
- if (normalized.startsWith('javascript:'))
29
- return true;
30
- if (EXECUTABLE_DATA_URL_PATTERN.test(normalized))
31
- return true;
101
+ }
102
+ while (cursor < value.length && !isHtmlWhitespaceCode(value.charCodeAt(cursor)) && value[cursor] !== '>') {
103
+ cursor += 1;
104
+ }
105
+ while (cursor < value.length && value[cursor] !== '>') {
106
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
107
+ cursor += 1;
108
+ }
109
+ if (cursor >= value.length || value[cursor] === '>') {
110
+ break;
111
+ }
112
+ if (value[cursor] === '/') {
113
+ cursor += 1;
114
+ continue;
115
+ }
116
+ const nameStart = cursor;
117
+ while (cursor < value.length && isHtmlAttributeNameChar(value.charCodeAt(cursor))) {
118
+ cursor += 1;
119
+ }
120
+ if (cursor === nameStart) {
121
+ cursor += 1;
122
+ continue;
123
+ }
124
+ const attrName = value.slice(nameStart, cursor).toLowerCase();
125
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
126
+ cursor += 1;
127
+ }
128
+ if (value[cursor] !== '=') {
129
+ continue;
130
+ }
131
+ cursor += 1;
132
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
133
+ cursor += 1;
134
+ }
135
+ if (cursor >= value.length) {
136
+ break;
137
+ }
138
+ let attrValue = '';
139
+ let recoveryTagIndex = -1;
140
+ let unterminatedQuotedValue = false;
141
+ const quote = value[cursor];
142
+ if (quote === '"' || quote === '\'') {
143
+ cursor += 1;
144
+ const valueStart = cursor;
145
+ const closingQuoteIndex = value.indexOf(quote, valueStart);
146
+ const quotedValueEnd = closingQuoteIndex === -1 ? value.length : closingQuoteIndex;
147
+ recoveryTagIndex = findTagLikeStart(value, valueStart, quotedValueEnd);
148
+ if (closingQuoteIndex === -1) {
149
+ unterminatedQuotedValue = true;
150
+ const valueEnd = recoveryTagIndex === -1 ? value.length : recoveryTagIndex;
151
+ attrValue = value.slice(valueStart, valueEnd);
152
+ }
153
+ else {
154
+ const hasSuspiciousQuotedRestart = recoveryTagIndex !== -1
155
+ && getPreviousNonWhitespaceChar(value, closingQuoteIndex, valueStart) === '=';
156
+ if (hasSuspiciousQuotedRestart) {
157
+ unterminatedQuotedValue = true;
158
+ attrValue = value.slice(valueStart, recoveryTagIndex);
159
+ }
160
+ else {
161
+ cursor = closingQuoteIndex;
162
+ attrValue = value.slice(valueStart, cursor);
163
+ cursor += 1;
164
+ }
165
+ }
166
+ }
167
+ else {
168
+ const valueStart = cursor;
169
+ while (cursor < value.length
170
+ && !isHtmlWhitespaceCode(value.charCodeAt(cursor))
171
+ && value[cursor] !== '>') {
172
+ cursor += 1;
173
+ }
174
+ attrValue = value.slice(valueStart, cursor);
175
+ }
176
+ if (EXECUTABLE_HTML_URL_ATTR_NAMES.has(attrName)) {
177
+ const normalized = normalizeHtmlUrlAttrValue(attrValue);
178
+ if (normalized && hasExecutableProtocol(normalized)) {
179
+ return true;
180
+ }
181
+ }
182
+ if (unterminatedQuotedValue && recoveryTagIndex !== -1) {
183
+ return hasExecutableHtmlUrlAttribute(value.slice(recoveryTagIndex));
184
+ }
185
+ if (unterminatedQuotedValue) {
186
+ return false;
187
+ }
188
+ }
189
+ index = cursor + 1;
32
190
  }
33
191
  return false;
34
192
  }
35
193
  export function hasExecutableHtmlSinkPattern(value) {
36
194
  if (!value)
37
195
  return false;
38
- return EXECUTABLE_HTML_SCRIPT_PATTERN.test(value)
196
+ return hasExecutableHtmlScriptTag(value)
39
197
  || EXECUTABLE_HTML_EVENT_ATTR_PATTERN.test(value)
40
198
  || hasExecutableHtmlUrlAttribute(value);
41
199
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,6 +27,7 @@ function resolveServerConfig(argv = process.argv.slice(2), cwd = process.cwd())
27
27
  const serverConfig = resolveServerConfig();
28
28
  const projectDir = serverConfig.projectDir;
29
29
  const rootDir = serverConfig.rootDir;
30
+ const rootDirAbs = fs.existsSync(rootDir) ? fs.realpathSync(rootDir) : path.resolve(rootDir);
30
31
  const distMode = serverConfig.distMode;
31
32
  const isDalilaRepo = serverConfig.isDalilaRepo;
32
33
  const defaultEntry = serverConfig.defaultEntry;
@@ -107,6 +108,313 @@ function send(res, status, body, headers = {}) {
107
108
  res.end(body);
108
109
  }
109
110
 
111
+ const FORBIDDEN_PATH_ERROR_CODE = 'DALILA_FORBIDDEN_PATH';
112
+
113
+ function createForbiddenPathError() {
114
+ const error = new Error('Forbidden');
115
+ error.code = FORBIDDEN_PATH_ERROR_CODE;
116
+ return error;
117
+ }
118
+
119
+ function isPathInsideRoot(candidatePath) {
120
+ const normalizedPath = path.resolve(candidatePath);
121
+ const relativePath = path.relative(rootDirAbs, normalizedPath);
122
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
123
+ }
124
+
125
+ function normalizeServedPath(candidatePath) {
126
+ if (typeof candidatePath !== 'string' || candidatePath.length === 0) {
127
+ return null;
128
+ }
129
+
130
+ const normalizedPath = path.resolve(candidatePath);
131
+ return isPathInsideRoot(normalizedPath) ? normalizedPath : null;
132
+ }
133
+
134
+ function statServedPath(targetPath) {
135
+ const safePath = normalizeServedPath(targetPath);
136
+ if (!safePath) {
137
+ throw createForbiddenPathError();
138
+ }
139
+
140
+ return {
141
+ safePath,
142
+ stat: fs.statSync(safePath),
143
+ };
144
+ }
145
+
146
+ function existsServedPath(targetPath) {
147
+ const safePath = normalizeServedPath(targetPath);
148
+ return safePath ? fs.existsSync(safePath) : false;
149
+ }
150
+
151
+ function readServedFile(targetPath, encoding, callback) {
152
+ const safePath = normalizeServedPath(targetPath);
153
+ if (!safePath) {
154
+ queueMicrotask(() => callback(createForbiddenPathError()));
155
+ return;
156
+ }
157
+
158
+ fs.readFile(safePath, encoding, callback);
159
+ }
160
+
161
+ function replaceServedPathExtension(targetPath, fromExtension, toExtension) {
162
+ const safePath = normalizeServedPath(targetPath);
163
+ if (!safePath || !safePath.endsWith(fromExtension)) {
164
+ return null;
165
+ }
166
+
167
+ return normalizeServedPath(`${safePath.slice(0, -fromExtension.length)}${toExtension}`);
168
+ }
169
+
170
+ function appendServedPathExtension(targetPath, extension) {
171
+ const safePath = normalizeServedPath(targetPath);
172
+ if (!safePath) {
173
+ return null;
174
+ }
175
+
176
+ return normalizeServedPath(`${safePath}${extension}`);
177
+ }
178
+
179
+ function joinServedPath(targetPath, childPath) {
180
+ const safePath = normalizeServedPath(targetPath);
181
+ if (!safePath) {
182
+ return null;
183
+ }
184
+
185
+ return normalizeServedPath(path.join(safePath, childPath));
186
+ }
187
+
188
+ function escapeInlineScriptContent(script) {
189
+ return script.replace(/--!>|-->|[<>\u2028\u2029]/g, (match) => {
190
+ switch (match) {
191
+ case '--!>':
192
+ return '--!\\u003E';
193
+ case '-->':
194
+ return '--\\u003E';
195
+ case '<':
196
+ return '\\u003C';
197
+ case '>':
198
+ return '\\u003E';
199
+ case '\u2028':
200
+ return '\\u2028';
201
+ case '\u2029':
202
+ return '\\u2029';
203
+ default:
204
+ return match;
205
+ }
206
+ });
207
+ }
208
+
209
+ function stringifyInlineScriptPayload(value, indent = 0) {
210
+ const json = escapeInlineScriptContent(JSON.stringify(value, null, 2));
211
+ if (indent <= 0) {
212
+ return json;
213
+ }
214
+
215
+ const padding = ' '.repeat(indent);
216
+ return json
217
+ .split('\n')
218
+ .map((line) => `${padding}${line}`)
219
+ .join('\n');
220
+ }
221
+
222
+ function normalizePreloadStorageType(storageType) {
223
+ return storageType === 'sessionStorage' ? 'sessionStorage' : 'localStorage';
224
+ }
225
+
226
+ function isHtmlWhitespaceChar(char) {
227
+ return char === ' ' || char === '\n' || char === '\r' || char === '\t' || char === '\f';
228
+ }
229
+
230
+ function isHtmlTagBoundary(char) {
231
+ return !char || isHtmlWhitespaceChar(char) || char === '>' || char === '/';
232
+ }
233
+
234
+ function findHtmlTagEnd(html, startIndex) {
235
+ let quote = null;
236
+
237
+ for (let index = startIndex; index < html.length; index += 1) {
238
+ const char = html[index];
239
+ if (quote) {
240
+ if (char === quote) {
241
+ quote = null;
242
+ }
243
+ continue;
244
+ }
245
+
246
+ if (char === '"' || char === '\'') {
247
+ quote = char;
248
+ continue;
249
+ }
250
+
251
+ if (char === '>') {
252
+ return index;
253
+ }
254
+ }
255
+
256
+ return -1;
257
+ }
258
+
259
+ function findScriptCloseTagStart(lower, searchIndex) {
260
+ let index = lower.indexOf('</script', searchIndex);
261
+
262
+ while (index !== -1) {
263
+ if (isHtmlTagBoundary(lower[index + 8])) {
264
+ return index;
265
+ }
266
+ index = lower.indexOf('</script', index + 8);
267
+ }
268
+
269
+ return -1;
270
+ }
271
+
272
+ function getHtmlAttributeValue(attributesSource, attributeName) {
273
+ const name = attributeName.toLowerCase();
274
+ let index = 0;
275
+
276
+ while (index < attributesSource.length) {
277
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
278
+ index += 1;
279
+ }
280
+
281
+ if (index >= attributesSource.length) {
282
+ return null;
283
+ }
284
+
285
+ if (attributesSource[index] === '/') {
286
+ index += 1;
287
+ continue;
288
+ }
289
+
290
+ const nameStart = index;
291
+ while (
292
+ index < attributesSource.length
293
+ && !isHtmlWhitespaceChar(attributesSource[index])
294
+ && !['=', '>', '"', '\'', '`'].includes(attributesSource[index])
295
+ ) {
296
+ index += 1;
297
+ }
298
+ if (index === nameStart) {
299
+ index += 1;
300
+ continue;
301
+ }
302
+
303
+ const currentName = attributesSource.slice(nameStart, index).toLowerCase();
304
+
305
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
306
+ index += 1;
307
+ }
308
+
309
+ if (attributesSource[index] !== '=') {
310
+ continue;
311
+ }
312
+ index += 1;
313
+
314
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
315
+ index += 1;
316
+ }
317
+
318
+ if (index >= attributesSource.length) {
319
+ return currentName === name ? '' : null;
320
+ }
321
+
322
+ let value = '';
323
+ const quote = attributesSource[index];
324
+ if (quote === '"' || quote === '\'') {
325
+ index += 1;
326
+ const valueStart = index;
327
+ while (index < attributesSource.length && attributesSource[index] !== quote) {
328
+ index += 1;
329
+ }
330
+ value = attributesSource.slice(valueStart, index);
331
+ if (index < attributesSource.length) {
332
+ index += 1;
333
+ }
334
+ } else {
335
+ const valueStart = index;
336
+ while (
337
+ index < attributesSource.length
338
+ && !isHtmlWhitespaceChar(attributesSource[index])
339
+ && attributesSource[index] !== '>'
340
+ ) {
341
+ index += 1;
342
+ }
343
+ value = attributesSource.slice(valueStart, index);
344
+ }
345
+
346
+ if (currentName === name) {
347
+ return value;
348
+ }
349
+ }
350
+
351
+ return null;
352
+ }
353
+
354
+ function forEachHtmlScriptElement(html, visitor) {
355
+ const lower = html.toLowerCase();
356
+ let searchIndex = 0;
357
+
358
+ while (searchIndex < html.length) {
359
+ const openStart = lower.indexOf('<script', searchIndex);
360
+ if (openStart === -1) {
361
+ return;
362
+ }
363
+ if (!isHtmlTagBoundary(lower[openStart + 7])) {
364
+ searchIndex = openStart + 7;
365
+ continue;
366
+ }
367
+
368
+ const openEnd = findHtmlTagEnd(html, openStart);
369
+ if (openEnd === -1) {
370
+ searchIndex = openStart + 7;
371
+ continue;
372
+ }
373
+
374
+ const closeStart = findScriptCloseTagStart(lower, openEnd + 1);
375
+ if (closeStart === -1) {
376
+ searchIndex = openStart + 7;
377
+ continue;
378
+ }
379
+
380
+ const closeEnd = findHtmlTagEnd(html, closeStart);
381
+ if (closeEnd === -1) {
382
+ searchIndex = closeStart + 8;
383
+ continue;
384
+ }
385
+
386
+ const element = {
387
+ attributesSource: html.slice(openStart + 7, openEnd),
388
+ content: html.slice(openEnd + 1, closeStart),
389
+ fullMatch: html.slice(openStart, closeEnd + 1),
390
+ start: openStart,
391
+ end: closeEnd + 1,
392
+ };
393
+
394
+ if (visitor(element) === false) {
395
+ return;
396
+ }
397
+
398
+ searchIndex = closeEnd + 1;
399
+ }
400
+ }
401
+
402
+ function findFirstHtmlScriptElementByType(html, type) {
403
+ let found = null;
404
+
405
+ forEachHtmlScriptElement(html, (element) => {
406
+ const scriptType = getHtmlAttributeValue(element.attributesSource, 'type');
407
+ if ((scriptType ?? '').toLowerCase() !== type) {
408
+ return true;
409
+ }
410
+
411
+ found = element;
412
+ return false;
413
+ });
414
+
415
+ return found;
416
+ }
417
+
110
418
  /**
111
419
  * Secure path resolution:
112
420
  * - Strip leading slashes to treat URL as relative
@@ -126,18 +434,8 @@ function resolvePath(urlPath) {
126
434
  const relativePath = decoded.replace(/^\/+/, '').replace(/\\/g, '/');
127
435
 
128
436
  // Resolve to absolute path
129
- const fsPath = path.resolve(rootDir, relativePath);
130
-
131
- // Normalize for comparison (handles .., ., etc.)
132
- const normalizedPath = path.normalize(fsPath);
133
-
134
- // Security check: must be within rootDir
135
- // Use startsWith with path.sep to prevent /root matching /rootkit
136
- if (!normalizedPath.startsWith(rootDir + path.sep) && normalizedPath !== rootDir) {
137
- return null;
138
- }
139
-
140
- return normalizedPath;
437
+ const fsPath = path.resolve(rootDirAbs, relativePath);
438
+ return normalizeServedPath(fsPath);
141
439
  }
142
440
 
143
441
  function safeDecodeUrlPath(url) {
@@ -327,18 +625,17 @@ function createImportMapEntries(dalilaPath, sourceDirPath = '/src/') {
327
625
  }
328
626
 
329
627
  function createImportMapScript(dalilaPath, sourceDirPath = '/src/') {
330
- const payload = JSON.stringify({ imports: createImportMapEntries(dalilaPath, sourceDirPath) }, null, 2)
331
- .split('\n')
332
- .map(line => ` ${line}`)
333
- .join('\n');
628
+ const payload = stringifyInlineScriptPayload(
629
+ { imports: createImportMapEntries(dalilaPath, sourceDirPath) },
630
+ 4
631
+ );
334
632
 
335
633
  return ` <script type="importmap">\n${payload}\n </script>`;
336
634
  }
337
635
 
338
636
  function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
339
- const importMapPattern = /<script\b[^>]*type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i;
340
- const match = html.match(importMapPattern);
341
- if (!match) {
637
+ const importMapElement = findFirstHtmlScriptElementByType(html, 'importmap');
638
+ if (!importMapElement) {
342
639
  return {
343
640
  html,
344
641
  merged: false,
@@ -346,7 +643,7 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
346
643
  };
347
644
  }
348
645
 
349
- const existingPayload = match[1]?.trim() || '{}';
646
+ const existingPayload = importMapElement.content.trim() || '{}';
350
647
  let importMap;
351
648
  try {
352
649
  importMap = JSON.parse(existingPayload);
@@ -368,14 +665,11 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
368
665
  ...existingImports,
369
666
  },
370
667
  };
371
- const payload = JSON.stringify(mergedImportMap, null, 2)
372
- .split('\n')
373
- .map(line => ` ${line}`)
374
- .join('\n');
668
+ const payload = stringifyInlineScriptPayload(mergedImportMap, 4);
375
669
  const script = ` <script type="importmap">\n${payload}\n </script>`;
376
670
 
377
671
  return {
378
- html: html.replace(importMapPattern, ''),
672
+ html: `${html.slice(0, importMapElement.start)}${html.slice(importMapElement.end)}`,
379
673
  merged: true,
380
674
  script,
381
675
  };
@@ -468,14 +762,15 @@ function findTypeScriptFiles(dir, files = []) {
468
762
  * Generate inline preload script
469
763
  */
470
764
  function generatePreloadScript(name, defaultValue, storageType = 'localStorage') {
471
- const k = JSON.stringify(name);
472
- const d = JSON.stringify(defaultValue);
473
- const script = `(function(){try{var v=${storageType}.getItem(${k});document.documentElement.setAttribute('data-theme',v?JSON.parse(v):${d})}catch(e){document.documentElement.setAttribute('data-theme',${d})}})();`;
474
- return script
475
- .replace(/</g, '\\x3C')
476
- .replace(/-->/g, '--\\x3E')
477
- .replace(/\u2028/g, '\\u2028')
478
- .replace(/\u2029/g, '\\u2029');
765
+ const safeStorageType = normalizePreloadStorageType(storageType);
766
+ const payload = JSON.stringify({
767
+ key: name,
768
+ defaultValue,
769
+ storageType: safeStorageType,
770
+ });
771
+ const fallbackValue = JSON.stringify(defaultValue);
772
+ const script = `(function(){try{var p=${payload};var s=window[p.storageType];var v=s.getItem(p.key);document.documentElement.setAttribute('data-theme',v==null?p.defaultValue:JSON.parse(v))}catch(e){document.documentElement.setAttribute('data-theme',${fallbackValue})}})();`;
773
+ return escapeInlineScriptContent(script);
479
774
  }
480
775
 
481
776
  function renderPreloadScriptTags(baseDir) {
@@ -578,8 +873,8 @@ function addLoadingAttributes(html) {
578
873
  // ============================================================================
579
874
  // Binding Injection (for HTML files that need runtime bindings)
580
875
  // ============================================================================
581
- function injectBindings(html, requestPath) {
582
- const normalizedPath = normalizeHtmlRequestPath(requestPath);
876
+ function injectBindings(html, options = {}) {
877
+ const isPlaygroundPage = options.isPlaygroundPage === true;
583
878
  // Different paths for dalila repo vs user projects
584
879
  const dalilaPath = isDalilaRepo ? '/dist' : '/node_modules/dalila/dist';
585
880
  const sourceDirPath = buildProjectSourceDirPath(projectDir);
@@ -760,7 +1055,7 @@ function injectBindings(html, requestPath) {
760
1055
  }
761
1056
 
762
1057
  // Dalila repo: only inject import map for non-playground pages
763
- if (normalizedPath !== '/examples/playground/index.html') {
1058
+ if (!isPlaygroundPage) {
764
1059
  return injectHeadFragments(html, [importMap], {
765
1060
  beforeModule: true,
766
1061
  beforeStyles: true,
@@ -1004,7 +1299,9 @@ const server = http.createServer((req, res) => {
1004
1299
 
1005
1300
  let targetPath = fsPath;
1006
1301
  try {
1007
- const stat = fs.statSync(targetPath);
1302
+ const target = statServedPath(targetPath);
1303
+ targetPath = target.safePath;
1304
+ const stat = target.stat;
1008
1305
  if (stat.isDirectory()) {
1009
1306
  // Redirect directory URLs without trailing slash to include it
1010
1307
  if (!requestPath.endsWith('/')) {
@@ -1012,13 +1309,23 @@ const server = http.createServer((req, res) => {
1012
1309
  res.end();
1013
1310
  return;
1014
1311
  }
1015
- targetPath = path.join(targetPath, 'index.html');
1312
+ const indexPath = joinServedPath(targetPath, 'index.html');
1313
+ if (!indexPath) {
1314
+ send(res, 403, 'Forbidden');
1315
+ return;
1316
+ }
1317
+ targetPath = indexPath;
1016
1318
  }
1017
- } catch {
1319
+ } catch (err) {
1320
+ if (err && err.code === FORBIDDEN_PATH_ERROR_CODE) {
1321
+ send(res, 403, 'Forbidden');
1322
+ return;
1323
+ }
1324
+
1018
1325
  // If .js file not found, try .ts alternative
1019
1326
  if (targetPath.endsWith('.js')) {
1020
- const tsPath = targetPath.replace(/\.js$/, '.ts');
1021
- if (fs.existsSync(tsPath)) {
1327
+ const tsPath = replaceServedPathExtension(targetPath, '.js', '.ts');
1328
+ if (tsPath && existsServedPath(tsPath)) {
1022
1329
  targetPath = tsPath;
1023
1330
  } else {
1024
1331
  send(res, 404, 'Not Found');
@@ -1026,11 +1333,11 @@ const server = http.createServer((req, res) => {
1026
1333
  }
1027
1334
  } else {
1028
1335
  // Extensionless import — try .ts, then .js
1029
- const tsPath = targetPath + '.ts';
1030
- const jsPath = targetPath + '.js';
1031
- if (!path.extname(targetPath) && isScriptRequest && fs.existsSync(tsPath)) {
1336
+ const tsPath = appendServedPathExtension(targetPath, '.ts');
1337
+ const jsPath = appendServedPathExtension(targetPath, '.js');
1338
+ if (!path.extname(targetPath) && isScriptRequest && tsPath && existsServedPath(tsPath)) {
1032
1339
  targetPath = tsPath;
1033
- } else if (!path.extname(targetPath) && isScriptRequest && fs.existsSync(jsPath)) {
1340
+ } else if (!path.extname(targetPath) && isScriptRequest && jsPath && existsServedPath(jsPath)) {
1034
1341
  targetPath = jsPath;
1035
1342
  } else {
1036
1343
  const spaFallback = resolveSpaFallbackPath(requestPath);
@@ -1052,8 +1359,12 @@ const server = http.createServer((req, res) => {
1052
1359
  && isScriptRequest
1053
1360
  && !isNavigationRequest;
1054
1361
  if (isRawQuery || isHtmlModuleImport) {
1055
- fs.readFile(targetPath, 'utf8', (err, source) => {
1362
+ readServedFile(targetPath, 'utf8', (err, source) => {
1056
1363
  if (err) {
1364
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1365
+ send(res, 403, 'Forbidden');
1366
+ return;
1367
+ }
1057
1368
  send(res, err.code === 'ENOENT' ? 404 : 500, err.code === 'ENOENT' ? 'Not Found' : 'Error');
1058
1369
  return;
1059
1370
  }
@@ -1069,8 +1380,12 @@ const server = http.createServer((req, res) => {
1069
1380
 
1070
1381
  // TypeScript transpilation (only if ts available)
1071
1382
  if (targetPath.endsWith('.ts') && ts) {
1072
- fs.readFile(targetPath, 'utf8', (err, source) => {
1383
+ readServedFile(targetPath, 'utf8', (err, source) => {
1073
1384
  if (err) {
1385
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1386
+ send(res, 403, 'Forbidden');
1387
+ return;
1388
+ }
1074
1389
  if (err.code === 'ENOENT' || err.code === 'EISDIR') {
1075
1390
  send(res, 404, 'Not Found');
1076
1391
  return;
@@ -1104,15 +1419,23 @@ const server = http.createServer((req, res) => {
1104
1419
  }
1105
1420
 
1106
1421
  // Static file serving
1107
- fs.readFile(targetPath, (err, data) => {
1422
+ readServedFile(targetPath, null, (err, data) => {
1108
1423
  if (err) {
1424
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1425
+ send(res, 403, 'Forbidden');
1426
+ return;
1427
+ }
1109
1428
  if (err.code === 'ENOENT' || err.code === 'EISDIR') {
1110
1429
  const spaFallback = resolveSpaFallbackPath(requestPath);
1111
1430
  if (spaFallback) {
1112
1431
  targetPath = spaFallback.fsPath;
1113
1432
  resolvedRequestPath = spaFallback.requestPath;
1114
- fs.readFile(targetPath, (fallbackErr, fallbackData) => {
1433
+ readServedFile(targetPath, null, (fallbackErr, fallbackData) => {
1115
1434
  if (fallbackErr) {
1435
+ if (fallbackErr.code === FORBIDDEN_PATH_ERROR_CODE) {
1436
+ send(res, 403, 'Forbidden');
1437
+ return;
1438
+ }
1116
1439
  send(res, 404, 'Not Found');
1117
1440
  return;
1118
1441
  }
@@ -1125,7 +1448,9 @@ const server = http.createServer((req, res) => {
1125
1448
  };
1126
1449
 
1127
1450
  if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
1128
- const html = injectBindings(htmlSource, resolvedRequestPath);
1451
+ const html = injectBindings(htmlSource, {
1452
+ isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
1453
+ });
1129
1454
  writeResponseHead(res, 200, headers);
1130
1455
  res.end(html);
1131
1456
  return;
@@ -1157,7 +1482,9 @@ const server = http.createServer((req, res) => {
1157
1482
  if (ext === '.html') {
1158
1483
  const htmlSource = data.toString('utf8');
1159
1484
  if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
1160
- const html = injectBindings(htmlSource, resolvedRequestPath);
1485
+ const html = injectBindings(htmlSource, {
1486
+ isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
1487
+ });
1161
1488
  writeResponseHead(res, 200, headers);
1162
1489
  res.end(html);
1163
1490
  return;
@@ -13,7 +13,11 @@
13
13
  transition:
14
14
  width var(--d-duration-slow) var(--d-ease),
15
15
  max-width var(--d-duration-slow) var(--d-ease),
16
- padding var(--d-duration) var(--d-ease);
16
+ padding var(--d-duration) var(--d-ease),
17
+ transform var(--d-duration-slow) var(--d-ease),
18
+ opacity var(--d-duration) var(--d-ease),
19
+ visibility var(--d-duration) var(--d-ease),
20
+ box-shadow var(--d-duration) var(--d-ease);
17
21
  }
18
22
 
19
23
  .d-side-bar-shell {
@@ -100,6 +104,24 @@
100
104
  display: none;
101
105
  }
102
106
 
107
+ .d-side-bar-backdrop {
108
+ position: fixed;
109
+ inset: 0;
110
+ display: block;
111
+ padding: 0;
112
+ border: 0;
113
+ background: var(--d-surface-overlay);
114
+ opacity: 0;
115
+ pointer-events: none;
116
+ transition: opacity var(--d-duration) var(--d-ease);
117
+ z-index: calc(var(--d-z-overlay) - 1);
118
+ }
119
+
120
+ .d-side-bar-backdrop.visible {
121
+ opacity: 1;
122
+ pointer-events: auto;
123
+ }
124
+
103
125
  .d-side-bar-inner {
104
126
  display: flex;
105
127
  flex-direction: column;
@@ -412,3 +434,37 @@
412
434
  background: linear-gradient(145deg, var(--d-primary-500), var(--d-accent-500));
413
435
  color: #fff;
414
436
  }
437
+
438
+ @media (max-width: 840px) {
439
+ .d-side-bar-mobile-shell {
440
+ display: block;
441
+ width: 100%;
442
+ }
443
+
444
+ .d-side-bar-mobile-shell .d-side-bar {
445
+ position: fixed;
446
+ inset: var(--d-space-4) auto var(--d-space-4) var(--d-space-4);
447
+ width: min(20rem, calc(100vw - (var(--d-space-4) * 2)));
448
+ max-width: calc(100vw - (var(--d-space-4) * 2));
449
+ min-height: auto;
450
+ max-height: calc(100dvh - (var(--d-space-4) * 2));
451
+ overflow-y: auto;
452
+ box-shadow: var(--d-shadow-lg);
453
+ transform: translateX(calc(-100% - var(--d-space-4)));
454
+ opacity: 0;
455
+ visibility: hidden;
456
+ pointer-events: none;
457
+ z-index: var(--d-z-overlay);
458
+ }
459
+
460
+ .d-side-bar-mobile-shell .d-side-bar.mobile-open {
461
+ transform: translateX(0);
462
+ opacity: 1;
463
+ visibility: visible;
464
+ pointer-events: auto;
465
+ }
466
+
467
+ .d-side-bar-mobile-shell .d-side-bar-rail {
468
+ display: none;
469
+ }
470
+ }