dalila 1.9.22 → 1.9.25

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 (42) hide show
  1. package/README.md +11 -0
  2. package/dist/cli/index.js +27 -5
  3. package/dist/cli/routes-generator.js +1 -1
  4. package/dist/cli/security-smoke.d.ts +8 -0
  5. package/dist/cli/security-smoke.js +502 -0
  6. package/dist/components/ui/runtime.d.ts +3 -1
  7. package/dist/components/ui/runtime.js +2 -0
  8. package/dist/core/html.d.ts +10 -3
  9. package/dist/core/html.js +10 -3
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.js +1 -0
  12. package/dist/core/observability.d.ts +14 -0
  13. package/dist/core/observability.js +56 -0
  14. package/dist/core/persist.js +9 -1
  15. package/dist/core/signal.d.ts +25 -1
  16. package/dist/core/signal.js +40 -9
  17. package/dist/form/parse-form-data.js +2 -2
  18. package/dist/form/path-utils.d.ts +1 -0
  19. package/dist/form/path-utils.js +36 -0
  20. package/dist/http/client.js +63 -16
  21. package/dist/runtime/bind-if-directive.d.ts +18 -0
  22. package/dist/runtime/bind-if-directive.js +96 -0
  23. package/dist/runtime/bind-lazy-directive.d.ts +13 -0
  24. package/dist/runtime/bind-lazy-directive.js +139 -0
  25. package/dist/runtime/bind-list-directives.d.ts +21 -0
  26. package/dist/runtime/bind-list-directives.js +533 -0
  27. package/dist/runtime/bind-portal.d.ts +21 -0
  28. package/dist/runtime/bind-portal.js +95 -0
  29. package/dist/runtime/bind.d.ts +83 -2
  30. package/dist/runtime/bind.js +883 -967
  31. package/dist/runtime/boundary.d.ts +3 -3
  32. package/dist/runtime/boundary.js +20 -4
  33. package/dist/runtime/fromHtml.d.ts +10 -0
  34. package/dist/runtime/fromHtml.js +6 -4
  35. package/dist/runtime/html-sinks.d.ts +20 -0
  36. package/dist/runtime/html-sinks.js +165 -0
  37. package/dist/runtime/index.d.ts +2 -2
  38. package/dist/runtime/index.js +1 -1
  39. package/dist/runtime/lazy.d.ts +1 -1
  40. package/dist/runtime/lazy.js +1 -1
  41. package/package.json +95 -2
  42. package/scripts/dev-server.cjs +466 -123
package/README.md CHANGED
@@ -44,6 +44,8 @@ const ctx = {
44
44
  bind(document.getElementById('app')!, ctx);
45
45
  ```
46
46
 
47
+ For bundle-sensitive or no-bundler builds, prefer leaf subpaths like `dalila/core/signal` and `dalila/runtime/bind`.
48
+
47
49
  ## Docs
48
50
 
49
51
  ### Start here
@@ -89,12 +91,21 @@ bind(document.getElementById('app')!, ctx);
89
91
 
90
92
  - [Template Check CLI](./docs/cli/check.md)
91
93
  - [Devtools Extension](./devtools-extension/README.md)
94
+ - [Production Guide](./docs/production.md)
95
+ - [Security Hardening](./docs/security-hardening.md)
96
+ - [Threat Model](./docs/threat-model.md)
97
+ - [Recipes](./docs/recipes.md)
98
+ - [Upgrade Guide](./docs/upgrade.md)
99
+ - [Release Checklist](./docs/release-checklist.md)
100
+ - [Security Release Notes](./docs/security-release-notes.md)
92
101
 
93
102
  ## Packages
94
103
 
95
104
  ```txt
96
105
  dalila → signals, scope, persist, forms, resources, query, mutations
97
106
  dalila/runtime → bind(), mount(), configure(), components, lazy, transitions
107
+ dalila/core/* → leaf core modules for tighter bundles
108
+ dalila/runtime/* → leaf runtime modules for tighter bundles
98
109
  dalila/context → createContext(), provide(), inject()
99
110
  dalila/router → createRouter(), file-based routes, preloading
100
111
  dalila/http → createHttpClient()
package/dist/cli/index.js CHANGED
@@ -16,7 +16,7 @@ Usage:
16
16
  dalila routes init Initialize app and generate routes outputs
17
17
  dalila routes watch [options] Watch routes and regenerate outputs on changes
18
18
  dalila routes --help Show routes command help
19
- dalila check [path] [--strict] Static analysis of HTML templates against loaders
19
+ dalila check [path] [--strict] Static analysis plus project security smoke
20
20
  dalila help Show this help message
21
21
 
22
22
  Options:
@@ -55,10 +55,11 @@ function showCheckHelp() {
55
55
  🐰✂️ Dalila CLI - Check
56
56
 
57
57
  Usage:
58
- dalila check [path] [options] Static analysis of HTML templates
58
+ dalila check [path] [options] Static analysis of HTML templates + project security smoke
59
59
 
60
60
  Validates that identifiers used in HTML templates ({expr}, d-* directives)
61
61
  match the return type of the corresponding loader() in TypeScript.
62
+ Also runs project-wide security smoke checks for obvious raw HTML / XSS patterns.
62
63
 
63
64
  Arguments:
64
65
  [path] App directory to check (default: src/app)
@@ -160,6 +161,18 @@ function resolveDefaultAppDir(cwd) {
160
161
  }
161
162
  return path.join(appRoot, relToRoot);
162
163
  }
164
+ function resolveDefaultSecuritySmokePath(cwd) {
165
+ const resolvedCwd = path.resolve(cwd);
166
+ const projectRoot = findProjectRoot(resolvedCwd);
167
+ if (!projectRoot) {
168
+ return resolveDefaultAppDir(cwd);
169
+ }
170
+ const srcRoot = path.join(projectRoot, 'src');
171
+ if (fs.existsSync(srcRoot) && fs.statSync(srcRoot).isDirectory()) {
172
+ return srcRoot;
173
+ }
174
+ return resolveDefaultAppDir(cwd);
175
+ }
163
176
  function resolveGenerateConfig(cliArgs, cwd = process.cwd()) {
164
177
  const dirIndex = cliArgs.indexOf('--dir');
165
178
  const outputIndex = cliArgs.indexOf('--output');
@@ -368,9 +381,18 @@ async function main() {
368
381
  const appDir = positional[0]
369
382
  ? path.resolve(positional[0])
370
383
  : resolveDefaultAppDir(process.cwd());
371
- const { runCheck } = await import('./check.js');
372
- const exitCode = await runCheck(appDir, { strict });
373
- process.exit(exitCode);
384
+ const smokePath = positional[0]
385
+ ? appDir
386
+ : resolveDefaultSecuritySmokePath(process.cwd());
387
+ const [{ runCheck }, { runSecuritySmokeChecks }] = await Promise.all([
388
+ import('./check.js'),
389
+ import('./security-smoke.js'),
390
+ ]);
391
+ const [checkExitCode, smokeExitCode] = await Promise.all([
392
+ runCheck(appDir, { strict }),
393
+ runSecuritySmokeChecks(smokePath),
394
+ ]);
395
+ process.exit(checkExitCode !== 0 || smokeExitCode !== 0 ? 1 : 0);
374
396
  }
375
397
  }
376
398
  else if (command === '--help' || command === '-h') {
@@ -1023,7 +1023,7 @@ export async function generateRoutesFile(routesDir, outputPath) {
1023
1023
  const importLines = [];
1024
1024
  importLines.push(`import type { RouteTable } from 'dalila/router';`);
1025
1025
  if (hasHtml) {
1026
- importLines.push(`import { fromHtml } from 'dalila';`);
1026
+ importLines.push(`import { fromHtml } from 'dalila/runtime/from-html';`);
1027
1027
  }
1028
1028
  if (imports.length > 0) {
1029
1029
  importLines.push(...imports);
@@ -0,0 +1,8 @@
1
+ export interface SecuritySmokeFinding {
2
+ filePath: string;
3
+ line: number;
4
+ col: number;
5
+ severity: 'error' | 'warning';
6
+ message: string;
7
+ }
8
+ export declare function runSecuritySmokeChecks(scanPath: string): Promise<number>;
@@ -0,0 +1,502 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ const INLINE_EVENT_HANDLER_MESSAGE = 'Inline event handler found in template. Use d-on-* instead.';
4
+ const JAVASCRIPT_URL_MESSAGE = 'Executable javascript: URL found in template sink.';
5
+ const EXECUTABLE_DATA_URL_MESSAGE = 'Executable data: URL found in template sink.';
6
+ const DANGEROUS_URL_PROTOCOL_MESSAGE = 'Dangerous URL protocol found in template sink.';
7
+ const SRCDOC_WARNING_MESSAGE = 'srcdoc embeds raw HTML. Review this iframe content as trusted-only.';
8
+ const D_HTML_WARNING_MESSAGE = 'd-html renders raw HTML. Keep the source trusted or sanitized.';
9
+ const D_ATTR_SRCDOC_WARNING_MESSAGE = 'd-attr-srcdoc writes raw iframe markup. Review the source as trusted-only.';
10
+ const HTML_INLINE_HANDLER_ATTR_NAMES = new Set([
11
+ 'onabort',
12
+ 'onafterprint',
13
+ 'onanimationcancel',
14
+ 'onanimationend',
15
+ 'onanimationiteration',
16
+ 'onanimationstart',
17
+ 'onauxclick',
18
+ 'onbeforeinput',
19
+ 'onbeforematch',
20
+ 'onbeforeprint',
21
+ 'onbeforetoggle',
22
+ 'onbeforeunload',
23
+ 'onbegin',
24
+ 'onblur',
25
+ 'oncancel',
26
+ 'oncanplay',
27
+ 'oncanplaythrough',
28
+ 'onchange',
29
+ 'onclick',
30
+ 'onclose',
31
+ 'oncontextlost',
32
+ 'oncontextmenu',
33
+ 'oncontextrestored',
34
+ 'oncopy',
35
+ 'oncuechange',
36
+ 'oncut',
37
+ 'ondblclick',
38
+ 'ondrag',
39
+ 'ondragend',
40
+ 'ondragenter',
41
+ 'ondragleave',
42
+ 'ondragover',
43
+ 'ondragstart',
44
+ 'ondrop',
45
+ 'ondurationchange',
46
+ 'onemptied',
47
+ 'onend',
48
+ 'onended',
49
+ 'onerror',
50
+ 'onfocus',
51
+ 'onformdata',
52
+ 'onfullscreenchange',
53
+ 'onfullscreenerror',
54
+ 'ongotpointercapture',
55
+ 'onhashchange',
56
+ 'oninput',
57
+ 'oninvalid',
58
+ 'onkeydown',
59
+ 'onkeypress',
60
+ 'onkeyup',
61
+ 'onlanguagechange',
62
+ 'onload',
63
+ 'onloadeddata',
64
+ 'onloadedmetadata',
65
+ 'onloadstart',
66
+ 'onlostpointercapture',
67
+ 'onmessage',
68
+ 'onmessageerror',
69
+ 'onmousedown',
70
+ 'onmouseenter',
71
+ 'onmouseleave',
72
+ 'onmousemove',
73
+ 'onmouseout',
74
+ 'onmouseover',
75
+ 'onmouseup',
76
+ 'onoffline',
77
+ 'ononline',
78
+ 'onpagehide',
79
+ 'onpageshow',
80
+ 'onpaste',
81
+ 'onpause',
82
+ 'onplay',
83
+ 'onplaying',
84
+ 'onpointercancel',
85
+ 'onpointerdown',
86
+ 'onpointerenter',
87
+ 'onpointerleave',
88
+ 'onpointermove',
89
+ 'onpointerout',
90
+ 'onpointerover',
91
+ 'onpointerrawupdate',
92
+ 'onpointerup',
93
+ 'onpopstate',
94
+ 'onprogress',
95
+ 'onratechange',
96
+ 'onrepeat',
97
+ 'onreset',
98
+ 'onresize',
99
+ 'onscroll',
100
+ 'onscrollend',
101
+ 'onsecuritypolicyviolation',
102
+ 'onseeked',
103
+ 'onseeking',
104
+ 'onselect',
105
+ 'onselectionchange',
106
+ 'onselectstart',
107
+ 'onslotchange',
108
+ 'onstalled',
109
+ 'onstorage',
110
+ 'onsubmit',
111
+ 'onsuspend',
112
+ 'ontimeupdate',
113
+ 'ontoggle',
114
+ 'ontransitioncancel',
115
+ 'ontransitionend',
116
+ 'ontransitionrun',
117
+ 'ontransitionstart',
118
+ 'onunhandledrejection',
119
+ 'onunload',
120
+ 'onvolumechange',
121
+ 'onwaiting',
122
+ 'onwebkitanimationend',
123
+ 'onwebkitanimationiteration',
124
+ 'onwebkitanimationstart',
125
+ 'onwebkittransitionend',
126
+ 'onwheel',
127
+ ]);
128
+ const HTML_URL_ATTR_NAMES = new Set([
129
+ 'href',
130
+ 'src',
131
+ 'xlink:href',
132
+ 'formaction',
133
+ 'action',
134
+ 'poster',
135
+ 'data',
136
+ ]);
137
+ const SAFE_URL_PROTOCOLS = new Set([
138
+ 'http:',
139
+ 'https:',
140
+ 'mailto:',
141
+ 'tel:',
142
+ 'sms:',
143
+ 'blob:',
144
+ ]);
145
+ const EXECUTABLE_DATA_URL_PATTERN = /^data:(?:text\/html|application\/xhtml\+xml|image\/svg\+xml)\b/i;
146
+ const SOURCE_SECURITY_PATTERNS = [
147
+ {
148
+ severity: 'warning',
149
+ regex: /\b(?:innerHTML|outerHTML)\s*=/g,
150
+ message: 'Raw HTML sink assignment found in source. Review the input as trusted-only.',
151
+ },
152
+ {
153
+ severity: 'warning',
154
+ regex: /\binsertAdjacentHTML\s*\(/g,
155
+ message: 'insertAdjacentHTML() found in source. Review the inserted markup as trusted-only.',
156
+ },
157
+ {
158
+ severity: 'warning',
159
+ regex: /\bdocument\.write\s*\(/g,
160
+ message: 'document.write() found in source. Review the written markup as trusted-only.',
161
+ },
162
+ {
163
+ severity: 'warning',
164
+ regex: /\bfromHtml\s*\(/g,
165
+ message: 'fromHtml() found in source. Pass only trusted template markup.',
166
+ },
167
+ ];
168
+ function findProjectRoot(startDir) {
169
+ let current = path.resolve(startDir);
170
+ while (true) {
171
+ if (fs.existsSync(path.join(current, 'package.json'))
172
+ || fs.existsSync(path.join(current, 'tsconfig.json'))) {
173
+ return current;
174
+ }
175
+ const parent = path.dirname(current);
176
+ if (parent === current) {
177
+ return null;
178
+ }
179
+ current = parent;
180
+ }
181
+ }
182
+ function findExistingAncestor(startPath) {
183
+ let current = path.resolve(startPath);
184
+ while (!fs.existsSync(current)) {
185
+ const parent = path.dirname(current);
186
+ if (parent === current) {
187
+ return current;
188
+ }
189
+ current = parent;
190
+ }
191
+ return current;
192
+ }
193
+ function resolveSecuritySmokeScope(scanPath) {
194
+ const resolvedPath = path.resolve(scanPath);
195
+ const existingPath = findExistingAncestor(resolvedPath);
196
+ const startDir = fs.statSync(existingPath).isDirectory()
197
+ ? existingPath
198
+ : path.dirname(existingPath);
199
+ const projectRoot = findProjectRoot(startDir) ?? startDir;
200
+ return {
201
+ projectRoot,
202
+ scanRoot: resolvedPath,
203
+ };
204
+ }
205
+ function collectCandidateFiles(dir, files = []) {
206
+ if (!fs.existsSync(dir))
207
+ return files;
208
+ const stat = fs.statSync(dir);
209
+ if (stat.isFile()) {
210
+ const ext = path.extname(dir).toLowerCase();
211
+ if (ext === '.html' || ext === '.ts' || ext === '.js') {
212
+ files.push(dir);
213
+ }
214
+ return files;
215
+ }
216
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
217
+ if (entry.name.startsWith('.'))
218
+ continue;
219
+ if (entry.name === 'dist' || entry.name === 'node_modules')
220
+ continue;
221
+ const fullPath = path.join(dir, entry.name);
222
+ if (entry.isDirectory()) {
223
+ collectCandidateFiles(fullPath, files);
224
+ continue;
225
+ }
226
+ if (!entry.isFile())
227
+ continue;
228
+ const ext = path.extname(entry.name).toLowerCase();
229
+ if (ext === '.html' || ext === '.ts' || ext === '.js') {
230
+ files.push(fullPath);
231
+ }
232
+ }
233
+ return files;
234
+ }
235
+ function getLineCol(source, offset) {
236
+ const before = source.slice(0, offset);
237
+ const lines = before.split('\n');
238
+ return {
239
+ line: lines.length,
240
+ col: lines[lines.length - 1].length + 1,
241
+ };
242
+ }
243
+ function isHtmlTagStartChar(char) {
244
+ return !!char && /[a-zA-Z]/.test(char);
245
+ }
246
+ function collectHtmlStartTags(source) {
247
+ const tags = [];
248
+ let i = 0;
249
+ while (i < source.length) {
250
+ if (source[i] !== '<') {
251
+ i += 1;
252
+ continue;
253
+ }
254
+ const next = source[i + 1];
255
+ if (next === '/' || next === '!' || next === '?' || !isHtmlTagStartChar(next)) {
256
+ i += 1;
257
+ continue;
258
+ }
259
+ i += 1;
260
+ const tagNameStart = i;
261
+ while (i < source.length && /[\w:-]/.test(source[i])) {
262
+ i += 1;
263
+ }
264
+ const tagName = source.slice(tagNameStart, i);
265
+ if (!tagName) {
266
+ i += 1;
267
+ continue;
268
+ }
269
+ const attrs = [];
270
+ let closed = false;
271
+ while (i < source.length) {
272
+ while (i < source.length && /\s/.test(source[i])) {
273
+ i += 1;
274
+ }
275
+ if (i >= source.length)
276
+ break;
277
+ if (source[i] === '>') {
278
+ i += 1;
279
+ closed = true;
280
+ break;
281
+ }
282
+ if (source[i] === '/' && source[i + 1] === '>') {
283
+ i += 2;
284
+ closed = true;
285
+ break;
286
+ }
287
+ if (source[i] === '/') {
288
+ i += 1;
289
+ continue;
290
+ }
291
+ const attrStart = i;
292
+ while (i < source.length && !/[\s=/>]/.test(source[i])) {
293
+ i += 1;
294
+ }
295
+ if (i === attrStart) {
296
+ i += 1;
297
+ continue;
298
+ }
299
+ const name = source.slice(attrStart, i);
300
+ while (i < source.length && /\s/.test(source[i])) {
301
+ i += 1;
302
+ }
303
+ let value = null;
304
+ if (source[i] === '=') {
305
+ i += 1;
306
+ while (i < source.length && /\s/.test(source[i])) {
307
+ i += 1;
308
+ }
309
+ if (i >= source.length)
310
+ break;
311
+ const quote = source[i];
312
+ if (quote === '"' || quote === "'") {
313
+ i += 1;
314
+ const valueStart = i;
315
+ while (i < source.length && source[i] !== quote) {
316
+ i += 1;
317
+ }
318
+ value = source.slice(valueStart, i);
319
+ if (i < source.length) {
320
+ i += 1;
321
+ }
322
+ }
323
+ else {
324
+ const valueStart = i;
325
+ while (i < source.length && !/[\s>]/.test(source[i])) {
326
+ i += 1;
327
+ }
328
+ value = source.slice(valueStart, i);
329
+ }
330
+ }
331
+ attrs.push({ name, value, index: attrStart });
332
+ }
333
+ if (closed) {
334
+ tags.push({ tagName, attrs });
335
+ }
336
+ }
337
+ return tags;
338
+ }
339
+ function normalizeProtocolCheckValue(value) {
340
+ return value.replace(/[\u0000-\u0020\u007f]+/g, '').toLowerCase();
341
+ }
342
+ function extractUrlProtocol(value) {
343
+ const match = value.match(/^([a-z][a-z0-9+\-.]*):/i);
344
+ return match ? `${match[1].toLowerCase()}:` : null;
345
+ }
346
+ function classifyDangerousHtmlUrl(tagName, attrName, value) {
347
+ const normalizedAttrName = attrName.toLowerCase();
348
+ if (!HTML_URL_ATTR_NAMES.has(normalizedAttrName))
349
+ return null;
350
+ if (normalizedAttrName === 'data' && tagName.toLowerCase() !== 'object') {
351
+ return null;
352
+ }
353
+ if (value == null)
354
+ return null;
355
+ const normalized = normalizeProtocolCheckValue(value);
356
+ if (!normalized
357
+ || normalized.startsWith('/')
358
+ || normalized.startsWith('./')
359
+ || normalized.startsWith('../')
360
+ || normalized.startsWith('?')
361
+ || normalized.startsWith('#')) {
362
+ return null;
363
+ }
364
+ const protocol = extractUrlProtocol(normalized);
365
+ if (!protocol)
366
+ return null;
367
+ if (protocol === 'javascript:')
368
+ return JAVASCRIPT_URL_MESSAGE;
369
+ if (EXECUTABLE_DATA_URL_PATTERN.test(normalized))
370
+ return EXECUTABLE_DATA_URL_MESSAGE;
371
+ if (!SAFE_URL_PROTOCOLS.has(protocol))
372
+ return DANGEROUS_URL_PROTOCOL_MESSAGE;
373
+ return null;
374
+ }
375
+ function collectHtmlTemplateFindings(source, filePath, findings) {
376
+ for (const tag of collectHtmlStartTags(source)) {
377
+ for (const attr of tag.attrs) {
378
+ const attrName = attr.name.toLowerCase();
379
+ const { line, col } = getLineCol(source, attr.index);
380
+ if (HTML_INLINE_HANDLER_ATTR_NAMES.has(attrName)) {
381
+ findings.push({
382
+ filePath,
383
+ line,
384
+ col,
385
+ severity: 'error',
386
+ message: INLINE_EVENT_HANDLER_MESSAGE,
387
+ });
388
+ }
389
+ const dangerousUrlMessage = classifyDangerousHtmlUrl(tag.tagName, attrName, attr.value);
390
+ if (dangerousUrlMessage) {
391
+ findings.push({
392
+ filePath,
393
+ line,
394
+ col,
395
+ severity: 'error',
396
+ message: dangerousUrlMessage,
397
+ });
398
+ }
399
+ if (attrName === 'srcdoc') {
400
+ findings.push({
401
+ filePath,
402
+ line,
403
+ col,
404
+ severity: 'warning',
405
+ message: SRCDOC_WARNING_MESSAGE,
406
+ });
407
+ }
408
+ if (attrName === 'd-html') {
409
+ findings.push({
410
+ filePath,
411
+ line,
412
+ col,
413
+ severity: 'warning',
414
+ message: D_HTML_WARNING_MESSAGE,
415
+ });
416
+ }
417
+ if (attrName === 'd-attr-srcdoc') {
418
+ findings.push({
419
+ filePath,
420
+ line,
421
+ col,
422
+ severity: 'warning',
423
+ message: D_ATTR_SRCDOC_WARNING_MESSAGE,
424
+ });
425
+ }
426
+ }
427
+ }
428
+ }
429
+ function collectPatternFindings(source, filePath, patterns, findings) {
430
+ for (const pattern of patterns) {
431
+ pattern.regex.lastIndex = 0;
432
+ let match = null;
433
+ while ((match = pattern.regex.exec(source)) !== null) {
434
+ const { line, col } = getLineCol(source, match.index);
435
+ findings.push({
436
+ filePath,
437
+ line,
438
+ col,
439
+ severity: pattern.severity,
440
+ message: pattern.message,
441
+ });
442
+ if (match[0].length === 0) {
443
+ pattern.regex.lastIndex += 1;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ export async function runSecuritySmokeChecks(scanPath) {
449
+ const { projectRoot, scanRoot } = resolveSecuritySmokeScope(scanPath);
450
+ if (!fs.existsSync(scanRoot)) {
451
+ return 1;
452
+ }
453
+ const findings = [];
454
+ const files = collectCandidateFiles(scanRoot);
455
+ for (const filePath of files) {
456
+ const source = fs.readFileSync(filePath, 'utf8');
457
+ const ext = path.extname(filePath).toLowerCase();
458
+ if (ext === '.html') {
459
+ collectHtmlTemplateFindings(source, filePath, findings);
460
+ continue;
461
+ }
462
+ collectPatternFindings(source, filePath, SOURCE_SECURITY_PATTERNS, findings);
463
+ }
464
+ const errors = findings.filter((finding) => finding.severity === 'error');
465
+ const warnings = findings.filter((finding) => finding.severity === 'warning');
466
+ console.log('🛡 Dalila Security Smoke');
467
+ if (findings.length === 0) {
468
+ console.log('✅ No dangerous template patterns found');
469
+ console.log('');
470
+ return 0;
471
+ }
472
+ const grouped = new Map();
473
+ for (const finding of findings) {
474
+ const bucket = grouped.get(finding.filePath) ?? [];
475
+ bucket.push(finding);
476
+ grouped.set(finding.filePath, bucket);
477
+ }
478
+ for (const [filePath, bucket] of grouped) {
479
+ console.log(` ${path.relative(projectRoot, filePath)}`);
480
+ for (const finding of bucket) {
481
+ const prefix = finding.severity === 'error' ? '❌' : '⚠️';
482
+ console.log(` ${`${finding.line}:${finding.col}`.padEnd(8)} ${prefix} ${finding.message}`);
483
+ }
484
+ console.log('');
485
+ }
486
+ const summaryParts = [];
487
+ if (errors.length > 0) {
488
+ summaryParts.push(`${errors.length} error${errors.length === 1 ? '' : 's'}`);
489
+ }
490
+ if (warnings.length > 0) {
491
+ summaryParts.push(`${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
492
+ }
493
+ const summary = summaryParts.join(' and ');
494
+ if (errors.length > 0) {
495
+ console.log(`❌ Security smoke found ${summary}`);
496
+ console.log('');
497
+ return 1;
498
+ }
499
+ console.log(`⚠️ Security smoke found ${summary}`);
500
+ console.log('');
501
+ return 0;
502
+ }
@@ -1,11 +1,13 @@
1
1
  import { type Signal } from "../../core/signal.js";
2
- import { type BindContext } from "../../runtime/bind.js";
2
+ import { type BindContext, type BindOptions } from "../../runtime/bind.js";
3
3
  import type { Calendar, Combobox, Dialog, Drawer, Dropdown, Dropzone, PopoverMount, TabsMount, Toast } from "./ui-types.js";
4
4
  export interface MountUIOptions {
5
5
  context?: BindContext;
6
6
  events?: string[];
7
7
  theme?: boolean;
8
8
  sliderValue?: Signal<string>;
9
+ sanitizeHtml?: BindOptions["sanitizeHtml"];
10
+ security?: BindOptions["security"];
9
11
  dialogs?: Record<string, Dialog>;
10
12
  drawers?: Record<string, Drawer>;
11
13
  dropdowns?: Record<string, Dropdown>;
@@ -354,6 +354,8 @@ export function mountUI(root, options) {
354
354
  withScope(scope, () => {
355
355
  cleanups.push(bind(mountedRoot, ctx, {
356
356
  events: options.events ?? DEFAULT_EVENTS,
357
+ sanitizeHtml: options.sanitizeHtml,
358
+ security: options.security,
357
359
  }));
358
360
  // Attach dialogs
359
361
  for (const [key, dialog] of Object.entries(options.dialogs ?? {})) {
@@ -7,10 +7,17 @@
7
7
  */
8
8
  type HTMLValue = string | number | boolean | null | undefined | Node | DocumentFragment | HTMLValue[];
9
9
  /**
10
- * Tagged template for safe HTML construction.
10
+ * Tagged template for safer HTML construction.
11
11
  *
12
- * Interpolated values are injected as DOM nodes (not raw HTML),
13
- * preventing XSS. Returns a DocumentFragment ready for insertion.
12
+ * Interpolated values are injected as DOM nodes (not raw HTML) for text/structure
13
+ * contexts, which prevents markup breakout in those positions.
14
+ *
15
+ * Security note:
16
+ * - This does NOT sanitize attribute values or URL protocols.
17
+ * - Interpolating untrusted values into attributes like `href`, `src`, `srcdoc`,
18
+ * `on*`, etc. can still be unsafe.
19
+ *
20
+ * Returns a DocumentFragment ready for insertion.
14
21
  *
15
22
  * ```ts
16
23
  * const fragment = html`<p>Hello, ${name}!</p>`;
package/dist/core/html.js CHANGED
@@ -90,10 +90,17 @@ function replaceAttributeTokens(element, values) {
90
90
  }
91
91
  }
92
92
  /**
93
- * Tagged template for safe HTML construction.
93
+ * Tagged template for safer HTML construction.
94
94
  *
95
- * Interpolated values are injected as DOM nodes (not raw HTML),
96
- * preventing XSS. Returns a DocumentFragment ready for insertion.
95
+ * Interpolated values are injected as DOM nodes (not raw HTML) for text/structure
96
+ * contexts, which prevents markup breakout in those positions.
97
+ *
98
+ * Security note:
99
+ * - This does NOT sanitize attribute values or URL protocols.
100
+ * - Interpolating untrusted values into attributes like `href`, `src`, `srcdoc`,
101
+ * `on*`, etc. can still be unsafe.
102
+ *
103
+ * Returns a DocumentFragment ready for insertion.
97
104
  *
98
105
  * ```ts
99
106
  * const fragment = html`<p>Hello, ${name}!</p>`;
@@ -1,5 +1,6 @@
1
1
  export * from "./scope.js";
2
2
  export * from "./signal.js";
3
+ export * from "./observability.js";
3
4
  export { watch, onCleanup, useEvent, useInterval, useTimeout, useFetch } from "./watch.js";
4
5
  export * from "./when.js";
5
6
  export * from "./match.js";