dalila 1.10.2 → 1.10.4
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/core/persist.js +28 -11
- package/dist/runtime/html-sinks.js +248 -16
- package/package.json +1 -1
- package/scripts/dev-server.cjs +390 -51
package/dist/core/persist.js
CHANGED
|
@@ -473,11 +473,27 @@ export function clearPersisted(name, storage = safeDefaultStorage() ?? {}) {
|
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
function escapeInlineScriptContent(script) {
|
|
476
|
-
return script
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
+
});
|
|
494
|
+
}
|
|
495
|
+
function stringifyInlineScriptLiteral(value) {
|
|
496
|
+
return escapeInlineScriptContent(JSON.stringify(value));
|
|
481
497
|
}
|
|
482
498
|
/**
|
|
483
499
|
* Generate a minimal inline script to prevent FOUC.
|
|
@@ -486,13 +502,14 @@ function escapeInlineScriptContent(script) {
|
|
|
486
502
|
*/
|
|
487
503
|
export function createPreloadScript(options) {
|
|
488
504
|
const { storageKey, defaultValue, target = 'documentElement', attribute = 'data-theme', storageType = 'localStorage', } = options;
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
const
|
|
505
|
+
const safeTarget = target === 'body' ? 'body' : 'documentElement';
|
|
506
|
+
const safeStorageType = storageType === 'sessionStorage' ? 'sessionStorage' : 'localStorage';
|
|
507
|
+
const k = stringifyInlineScriptLiteral(storageKey);
|
|
508
|
+
const d = stringifyInlineScriptLiteral(defaultValue);
|
|
509
|
+
const a = stringifyInlineScriptLiteral(attribute);
|
|
493
510
|
// Still minified
|
|
494
|
-
const script = `(function(){try{var s=${
|
|
495
|
-
return
|
|
511
|
+
const script = `(function(){try{var s=${safeStorageType}.getItem(${k});var v=s==null?${d}:JSON.parse(s);document.${safeTarget}.setAttribute(${a},v)}catch(e){document.${safeTarget}.setAttribute(${a},${d})}})();`;
|
|
512
|
+
return script;
|
|
496
513
|
}
|
|
497
514
|
export function createThemeScript(storageKey, defaultTheme = 'light') {
|
|
498
515
|
return createPreloadScript({
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
const TRUSTED_POLICY_CACHE_KEY = Symbol.for('dalila.runtime.trustedTypesPolicies');
|
|
2
2
|
const TRUSTED_POLICY_PARSE_SUFFIX = '--dalila-parse';
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
const EXECUTABLE_HTML_URL_ATTR_NAMES = new Set([
|
|
4
|
+
'href',
|
|
5
|
+
'src',
|
|
6
|
+
'xlink:href',
|
|
7
|
+
'formaction',
|
|
8
|
+
'action',
|
|
9
|
+
'poster',
|
|
10
|
+
]);
|
|
7
11
|
function getTrustedPolicyCache() {
|
|
8
12
|
const host = globalThis;
|
|
9
13
|
if (host[TRUSTED_POLICY_CACHE_KEY] instanceof Map) {
|
|
@@ -17,26 +21,254 @@ const trustedPolicyCache = getTrustedPolicyCache();
|
|
|
17
21
|
function normalizeHtmlUrlAttrValue(value) {
|
|
18
22
|
return value.replace(/[\u0000-\u0020\u007f]+/g, '').toLowerCase();
|
|
19
23
|
}
|
|
24
|
+
function isHtmlWhitespaceCode(code) {
|
|
25
|
+
return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
|
|
26
|
+
}
|
|
27
|
+
function isHtmlAttributeNameChar(code) {
|
|
28
|
+
return !Number.isNaN(code)
|
|
29
|
+
&& code !== 0x20
|
|
30
|
+
&& code !== 0x09
|
|
31
|
+
&& code !== 0x0a
|
|
32
|
+
&& code !== 0x0c
|
|
33
|
+
&& code !== 0x0d
|
|
34
|
+
&& code !== 0x22
|
|
35
|
+
&& code !== 0x27
|
|
36
|
+
&& code !== 0x2f
|
|
37
|
+
&& code !== 0x3c
|
|
38
|
+
&& code !== 0x3d
|
|
39
|
+
&& code !== 0x3e
|
|
40
|
+
&& code !== 0x60;
|
|
41
|
+
}
|
|
42
|
+
function isTagBoundaryChar(char) {
|
|
43
|
+
return !char || char === '/' || char === '>' || isHtmlWhitespaceCode(char.charCodeAt(0));
|
|
44
|
+
}
|
|
45
|
+
function getPreviousNonWhitespaceChar(value, end, start = 0) {
|
|
46
|
+
for (let index = end - 1; index >= start; index -= 1) {
|
|
47
|
+
if (!isHtmlWhitespaceCode(value.charCodeAt(index))) {
|
|
48
|
+
return value[index];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
function isHtmlTagStartChar(char) {
|
|
54
|
+
return !!char && /[A-Za-z/!?]/.test(char);
|
|
55
|
+
}
|
|
56
|
+
function findTagLikeStart(value, start, end = value.length) {
|
|
57
|
+
let index = value.indexOf('<', start);
|
|
58
|
+
while (index !== -1 && index < end) {
|
|
59
|
+
if (isHtmlTagStartChar(value[index + 1])) {
|
|
60
|
+
return index;
|
|
61
|
+
}
|
|
62
|
+
index = value.indexOf('<', index + 1);
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
function hasExecutableHtmlScriptTag(value) {
|
|
67
|
+
const lower = value.toLowerCase();
|
|
68
|
+
let searchIndex = 0;
|
|
69
|
+
while (searchIndex < lower.length) {
|
|
70
|
+
const index = lower.indexOf('<script', searchIndex);
|
|
71
|
+
if (index === -1)
|
|
72
|
+
return false;
|
|
73
|
+
if (isTagBoundaryChar(lower[index + 7])) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
searchIndex = index + 7;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function hasExecutableProtocol(value) {
|
|
81
|
+
return value.startsWith('javascript:')
|
|
82
|
+
|| value.startsWith('vbscript:')
|
|
83
|
+
|| value.startsWith('data:');
|
|
84
|
+
}
|
|
85
|
+
function hasExecutableHtmlEventAttribute(value) {
|
|
86
|
+
let index = 0;
|
|
87
|
+
while (index < value.length) {
|
|
88
|
+
const tagStart = value.indexOf('<', index);
|
|
89
|
+
if (tagStart === -1)
|
|
90
|
+
return false;
|
|
91
|
+
let cursor = tagStart + 1;
|
|
92
|
+
const firstCode = value.charCodeAt(cursor);
|
|
93
|
+
if (Number.isNaN(firstCode)
|
|
94
|
+
|| value[cursor] === '/'
|
|
95
|
+
|| value[cursor] === '!'
|
|
96
|
+
|| value[cursor] === '?') {
|
|
97
|
+
index = cursor;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
while (cursor < value.length
|
|
101
|
+
&& !isHtmlWhitespaceCode(value.charCodeAt(cursor))
|
|
102
|
+
&& 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
|
+
if (attrName.startsWith('on')) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
cursor += 1;
|
|
135
|
+
while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
|
|
136
|
+
cursor += 1;
|
|
137
|
+
}
|
|
138
|
+
if (cursor >= value.length) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const quote = value[cursor];
|
|
142
|
+
if (quote === '"' || quote === '\'') {
|
|
143
|
+
cursor += 1;
|
|
144
|
+
const closingQuoteIndex = value.indexOf(quote, cursor);
|
|
145
|
+
if (closingQuoteIndex === -1) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
cursor = closingQuoteIndex + 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
while (cursor < value.length
|
|
152
|
+
&& !isHtmlWhitespaceCode(value.charCodeAt(cursor))
|
|
153
|
+
&& value[cursor] !== '>') {
|
|
154
|
+
cursor += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
index = cursor + 1;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
20
161
|
function hasExecutableHtmlUrlAttribute(value) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
162
|
+
let index = 0;
|
|
163
|
+
while (index < value.length) {
|
|
164
|
+
const tagStart = value.indexOf('<', index);
|
|
165
|
+
if (tagStart === -1)
|
|
166
|
+
return false;
|
|
167
|
+
let cursor = tagStart + 1;
|
|
168
|
+
const firstCode = value.charCodeAt(cursor);
|
|
169
|
+
if (Number.isNaN(firstCode)
|
|
170
|
+
|| value[cursor] === '/'
|
|
171
|
+
|| value[cursor] === '!'
|
|
172
|
+
|| value[cursor] === '?') {
|
|
173
|
+
index = cursor;
|
|
27
174
|
continue;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
175
|
+
}
|
|
176
|
+
while (cursor < value.length && !isHtmlWhitespaceCode(value.charCodeAt(cursor)) && value[cursor] !== '>') {
|
|
177
|
+
cursor += 1;
|
|
178
|
+
}
|
|
179
|
+
while (cursor < value.length && value[cursor] !== '>') {
|
|
180
|
+
while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
|
|
181
|
+
cursor += 1;
|
|
182
|
+
}
|
|
183
|
+
if (cursor >= value.length || value[cursor] === '>') {
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (value[cursor] === '/') {
|
|
187
|
+
cursor += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const nameStart = cursor;
|
|
191
|
+
while (cursor < value.length && isHtmlAttributeNameChar(value.charCodeAt(cursor))) {
|
|
192
|
+
cursor += 1;
|
|
193
|
+
}
|
|
194
|
+
if (cursor === nameStart) {
|
|
195
|
+
cursor += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const attrName = value.slice(nameStart, cursor).toLowerCase();
|
|
199
|
+
while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
|
|
200
|
+
cursor += 1;
|
|
201
|
+
}
|
|
202
|
+
if (value[cursor] !== '=') {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
cursor += 1;
|
|
206
|
+
while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
|
|
207
|
+
cursor += 1;
|
|
208
|
+
}
|
|
209
|
+
if (cursor >= value.length) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
let attrValue = '';
|
|
213
|
+
let recoveryTagIndex = -1;
|
|
214
|
+
let unterminatedQuotedValue = false;
|
|
215
|
+
const quote = value[cursor];
|
|
216
|
+
if (quote === '"' || quote === '\'') {
|
|
217
|
+
cursor += 1;
|
|
218
|
+
const valueStart = cursor;
|
|
219
|
+
const closingQuoteIndex = value.indexOf(quote, valueStart);
|
|
220
|
+
const quotedValueEnd = closingQuoteIndex === -1 ? value.length : closingQuoteIndex;
|
|
221
|
+
recoveryTagIndex = findTagLikeStart(value, valueStart, quotedValueEnd);
|
|
222
|
+
if (closingQuoteIndex === -1) {
|
|
223
|
+
unterminatedQuotedValue = true;
|
|
224
|
+
const valueEnd = recoveryTagIndex === -1 ? value.length : recoveryTagIndex;
|
|
225
|
+
attrValue = value.slice(valueStart, valueEnd);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const hasSuspiciousQuotedRestart = recoveryTagIndex !== -1
|
|
229
|
+
&& getPreviousNonWhitespaceChar(value, closingQuoteIndex, valueStart) === '=';
|
|
230
|
+
if (hasSuspiciousQuotedRestart) {
|
|
231
|
+
unterminatedQuotedValue = true;
|
|
232
|
+
attrValue = value.slice(valueStart, recoveryTagIndex);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
cursor = closingQuoteIndex;
|
|
236
|
+
attrValue = value.slice(valueStart, cursor);
|
|
237
|
+
cursor += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const valueStart = cursor;
|
|
243
|
+
while (cursor < value.length
|
|
244
|
+
&& !isHtmlWhitespaceCode(value.charCodeAt(cursor))
|
|
245
|
+
&& value[cursor] !== '>') {
|
|
246
|
+
cursor += 1;
|
|
247
|
+
}
|
|
248
|
+
attrValue = value.slice(valueStart, cursor);
|
|
249
|
+
}
|
|
250
|
+
if (EXECUTABLE_HTML_URL_ATTR_NAMES.has(attrName)) {
|
|
251
|
+
const normalized = normalizeHtmlUrlAttrValue(attrValue);
|
|
252
|
+
if (normalized && hasExecutableProtocol(normalized)) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (unterminatedQuotedValue && recoveryTagIndex !== -1) {
|
|
257
|
+
return hasExecutableHtmlUrlAttribute(value.slice(recoveryTagIndex));
|
|
258
|
+
}
|
|
259
|
+
if (unterminatedQuotedValue) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
index = cursor + 1;
|
|
32
264
|
}
|
|
33
265
|
return false;
|
|
34
266
|
}
|
|
35
267
|
export function hasExecutableHtmlSinkPattern(value) {
|
|
36
268
|
if (!value)
|
|
37
269
|
return false;
|
|
38
|
-
return
|
|
39
|
-
||
|
|
270
|
+
return hasExecutableHtmlScriptTag(value)
|
|
271
|
+
|| hasExecutableHtmlEventAttribute(value)
|
|
40
272
|
|| hasExecutableHtmlUrlAttribute(value);
|
|
41
273
|
}
|
|
42
274
|
function getTrustedTypesApi() {
|
package/package.json
CHANGED
package/scripts/dev-server.cjs
CHANGED
|
@@ -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,325 @@ 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 toServedRelativePath(candidatePath) {
|
|
120
|
+
if (typeof candidatePath !== 'string' || candidatePath.length === 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const normalizedPath = path.resolve(candidatePath);
|
|
125
|
+
const relativePath = path.relative(rootDirAbs, normalizedPath);
|
|
126
|
+
if (relativePath === '') {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return relativePath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeServedPath(candidatePath) {
|
|
138
|
+
const relativePath = toServedRelativePath(candidatePath);
|
|
139
|
+
return relativePath == null ? null : path.join(rootDirAbs, relativePath);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function statServedPath(targetPath) {
|
|
143
|
+
const safePath = normalizeServedPath(targetPath);
|
|
144
|
+
if (!safePath) {
|
|
145
|
+
throw createForbiddenPathError();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
safePath,
|
|
150
|
+
stat: fs.statSync(safePath),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function existsServedPath(targetPath) {
|
|
155
|
+
const safePath = normalizeServedPath(targetPath);
|
|
156
|
+
return safePath ? fs.existsSync(safePath) : false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readServedFile(targetPath, encoding, callback) {
|
|
160
|
+
const safePath = normalizeServedPath(targetPath);
|
|
161
|
+
if (!safePath) {
|
|
162
|
+
queueMicrotask(() => callback(createForbiddenPathError()));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fs.readFile(safePath, encoding, callback);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function replaceServedPathExtension(targetPath, fromExtension, toExtension) {
|
|
170
|
+
const safePath = normalizeServedPath(targetPath);
|
|
171
|
+
if (!safePath || !safePath.endsWith(fromExtension)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return normalizeServedPath(`${safePath.slice(0, -fromExtension.length)}${toExtension}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function appendServedPathExtension(targetPath, extension) {
|
|
179
|
+
const safePath = normalizeServedPath(targetPath);
|
|
180
|
+
if (!safePath) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return normalizeServedPath(`${safePath}${extension}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function joinServedPath(targetPath, childPath) {
|
|
188
|
+
const safePath = normalizeServedPath(targetPath);
|
|
189
|
+
if (!safePath) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return normalizeServedPath(path.join(safePath, childPath));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function escapeInlineScriptContent(script) {
|
|
197
|
+
return script.replace(/--!>|-->|[<>\u2028\u2029]/g, (match) => {
|
|
198
|
+
switch (match) {
|
|
199
|
+
case '--!>':
|
|
200
|
+
return '--!\\u003E';
|
|
201
|
+
case '-->':
|
|
202
|
+
return '--\\u003E';
|
|
203
|
+
case '<':
|
|
204
|
+
return '\\u003C';
|
|
205
|
+
case '>':
|
|
206
|
+
return '\\u003E';
|
|
207
|
+
case '\u2028':
|
|
208
|
+
return '\\u2028';
|
|
209
|
+
case '\u2029':
|
|
210
|
+
return '\\u2029';
|
|
211
|
+
default:
|
|
212
|
+
return match;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stringifyInlineScriptPayload(value, indent = 0) {
|
|
218
|
+
const json = escapeInlineScriptContent(JSON.stringify(value, null, 2));
|
|
219
|
+
if (indent <= 0) {
|
|
220
|
+
return json;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const padding = ' '.repeat(indent);
|
|
224
|
+
return json
|
|
225
|
+
.split('\n')
|
|
226
|
+
.map((line) => `${padding}${line}`)
|
|
227
|
+
.join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function stringifyInlineScriptLiteral(value) {
|
|
231
|
+
return escapeInlineScriptContent(JSON.stringify(value));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizePreloadStorageType(storageType) {
|
|
235
|
+
return storageType === 'sessionStorage' ? 'sessionStorage' : 'localStorage';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isHtmlWhitespaceChar(char) {
|
|
239
|
+
return char === ' ' || char === '\n' || char === '\r' || char === '\t' || char === '\f';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isHtmlTagBoundary(char) {
|
|
243
|
+
return !char || isHtmlWhitespaceChar(char) || char === '>' || char === '/';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findHtmlTagEnd(html, startIndex) {
|
|
247
|
+
let quote = null;
|
|
248
|
+
|
|
249
|
+
for (let index = startIndex; index < html.length; index += 1) {
|
|
250
|
+
const char = html[index];
|
|
251
|
+
if (quote) {
|
|
252
|
+
if (char === quote) {
|
|
253
|
+
quote = null;
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (char === '"' || char === '\'') {
|
|
259
|
+
quote = char;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (char === '>') {
|
|
264
|
+
return index;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return -1;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function findScriptCloseTagStart(lower, searchIndex) {
|
|
272
|
+
let index = lower.indexOf('</script', searchIndex);
|
|
273
|
+
|
|
274
|
+
while (index !== -1) {
|
|
275
|
+
if (isHtmlTagBoundary(lower[index + 8])) {
|
|
276
|
+
return index;
|
|
277
|
+
}
|
|
278
|
+
index = lower.indexOf('</script', index + 8);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return -1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getHtmlAttributeValue(attributesSource, attributeName) {
|
|
285
|
+
const name = attributeName.toLowerCase();
|
|
286
|
+
let index = 0;
|
|
287
|
+
|
|
288
|
+
while (index < attributesSource.length) {
|
|
289
|
+
while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
|
|
290
|
+
index += 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (index >= attributesSource.length) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (attributesSource[index] === '/') {
|
|
298
|
+
index += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const nameStart = index;
|
|
303
|
+
while (
|
|
304
|
+
index < attributesSource.length
|
|
305
|
+
&& !isHtmlWhitespaceChar(attributesSource[index])
|
|
306
|
+
&& !['=', '>', '"', '\'', '`'].includes(attributesSource[index])
|
|
307
|
+
) {
|
|
308
|
+
index += 1;
|
|
309
|
+
}
|
|
310
|
+
if (index === nameStart) {
|
|
311
|
+
index += 1;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const currentName = attributesSource.slice(nameStart, index).toLowerCase();
|
|
316
|
+
|
|
317
|
+
while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
|
|
318
|
+
index += 1;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (attributesSource[index] !== '=') {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
index += 1;
|
|
325
|
+
|
|
326
|
+
while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
|
|
327
|
+
index += 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (index >= attributesSource.length) {
|
|
331
|
+
return currentName === name ? '' : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let value = '';
|
|
335
|
+
const quote = attributesSource[index];
|
|
336
|
+
if (quote === '"' || quote === '\'') {
|
|
337
|
+
index += 1;
|
|
338
|
+
const valueStart = index;
|
|
339
|
+
while (index < attributesSource.length && attributesSource[index] !== quote) {
|
|
340
|
+
index += 1;
|
|
341
|
+
}
|
|
342
|
+
value = attributesSource.slice(valueStart, index);
|
|
343
|
+
if (index < attributesSource.length) {
|
|
344
|
+
index += 1;
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const valueStart = index;
|
|
348
|
+
while (
|
|
349
|
+
index < attributesSource.length
|
|
350
|
+
&& !isHtmlWhitespaceChar(attributesSource[index])
|
|
351
|
+
&& attributesSource[index] !== '>'
|
|
352
|
+
) {
|
|
353
|
+
index += 1;
|
|
354
|
+
}
|
|
355
|
+
value = attributesSource.slice(valueStart, index);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (currentName === name) {
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function forEachHtmlScriptElement(html, visitor) {
|
|
367
|
+
const lower = html.toLowerCase();
|
|
368
|
+
let searchIndex = 0;
|
|
369
|
+
|
|
370
|
+
while (searchIndex < html.length) {
|
|
371
|
+
const openStart = lower.indexOf('<script', searchIndex);
|
|
372
|
+
if (openStart === -1) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (!isHtmlTagBoundary(lower[openStart + 7])) {
|
|
376
|
+
searchIndex = openStart + 7;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const openEnd = findHtmlTagEnd(html, openStart);
|
|
381
|
+
if (openEnd === -1) {
|
|
382
|
+
searchIndex = openStart + 7;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const closeStart = findScriptCloseTagStart(lower, openEnd + 1);
|
|
387
|
+
if (closeStart === -1) {
|
|
388
|
+
searchIndex = openStart + 7;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const closeEnd = findHtmlTagEnd(html, closeStart);
|
|
393
|
+
if (closeEnd === -1) {
|
|
394
|
+
searchIndex = closeStart + 8;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const element = {
|
|
399
|
+
attributesSource: html.slice(openStart + 7, openEnd),
|
|
400
|
+
content: html.slice(openEnd + 1, closeStart),
|
|
401
|
+
fullMatch: html.slice(openStart, closeEnd + 1),
|
|
402
|
+
start: openStart,
|
|
403
|
+
end: closeEnd + 1,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
if (visitor(element) === false) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
searchIndex = closeEnd + 1;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function findFirstHtmlScriptElementByType(html, type) {
|
|
415
|
+
let found = null;
|
|
416
|
+
|
|
417
|
+
forEachHtmlScriptElement(html, (element) => {
|
|
418
|
+
const scriptType = getHtmlAttributeValue(element.attributesSource, 'type');
|
|
419
|
+
if ((scriptType ?? '').toLowerCase() !== type) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
found = element;
|
|
424
|
+
return false;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return found;
|
|
428
|
+
}
|
|
429
|
+
|
|
110
430
|
/**
|
|
111
431
|
* Secure path resolution:
|
|
112
432
|
* - Strip leading slashes to treat URL as relative
|
|
@@ -126,18 +446,8 @@ function resolvePath(urlPath) {
|
|
|
126
446
|
const relativePath = decoded.replace(/^\/+/, '').replace(/\\/g, '/');
|
|
127
447
|
|
|
128
448
|
// Resolve to absolute path
|
|
129
|
-
const fsPath = path.resolve(
|
|
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;
|
|
449
|
+
const fsPath = path.resolve(rootDirAbs, relativePath);
|
|
450
|
+
return normalizeServedPath(fsPath);
|
|
141
451
|
}
|
|
142
452
|
|
|
143
453
|
function safeDecodeUrlPath(url) {
|
|
@@ -327,18 +637,17 @@ function createImportMapEntries(dalilaPath, sourceDirPath = '/src/') {
|
|
|
327
637
|
}
|
|
328
638
|
|
|
329
639
|
function createImportMapScript(dalilaPath, sourceDirPath = '/src/') {
|
|
330
|
-
const payload =
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
640
|
+
const payload = stringifyInlineScriptPayload(
|
|
641
|
+
{ imports: createImportMapEntries(dalilaPath, sourceDirPath) },
|
|
642
|
+
4
|
|
643
|
+
);
|
|
334
644
|
|
|
335
645
|
return ` <script type="importmap">\n${payload}\n </script>`;
|
|
336
646
|
}
|
|
337
647
|
|
|
338
648
|
function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
if (!match) {
|
|
649
|
+
const importMapElement = findFirstHtmlScriptElementByType(html, 'importmap');
|
|
650
|
+
if (!importMapElement) {
|
|
342
651
|
return {
|
|
343
652
|
html,
|
|
344
653
|
merged: false,
|
|
@@ -346,7 +655,7 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
|
|
|
346
655
|
};
|
|
347
656
|
}
|
|
348
657
|
|
|
349
|
-
const existingPayload =
|
|
658
|
+
const existingPayload = importMapElement.content.trim() || '{}';
|
|
350
659
|
let importMap;
|
|
351
660
|
try {
|
|
352
661
|
importMap = JSON.parse(existingPayload);
|
|
@@ -368,14 +677,11 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
|
|
|
368
677
|
...existingImports,
|
|
369
678
|
},
|
|
370
679
|
};
|
|
371
|
-
const payload =
|
|
372
|
-
.split('\n')
|
|
373
|
-
.map(line => ` ${line}`)
|
|
374
|
-
.join('\n');
|
|
680
|
+
const payload = stringifyInlineScriptPayload(mergedImportMap, 4);
|
|
375
681
|
const script = ` <script type="importmap">\n${payload}\n </script>`;
|
|
376
682
|
|
|
377
683
|
return {
|
|
378
|
-
html: html.
|
|
684
|
+
html: `${html.slice(0, importMapElement.start)}${html.slice(importMapElement.end)}`,
|
|
379
685
|
merged: true,
|
|
380
686
|
script,
|
|
381
687
|
};
|
|
@@ -468,14 +774,15 @@ function findTypeScriptFiles(dir, files = []) {
|
|
|
468
774
|
* Generate inline preload script
|
|
469
775
|
*/
|
|
470
776
|
function generatePreloadScript(name, defaultValue, storageType = 'localStorage') {
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
777
|
+
const safeStorageType = normalizePreloadStorageType(storageType);
|
|
778
|
+
const payload = stringifyInlineScriptLiteral({
|
|
779
|
+
key: name,
|
|
780
|
+
defaultValue,
|
|
781
|
+
storageType: safeStorageType,
|
|
782
|
+
});
|
|
783
|
+
const fallbackValue = stringifyInlineScriptLiteral(defaultValue);
|
|
784
|
+
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})}})();`;
|
|
785
|
+
return script;
|
|
479
786
|
}
|
|
480
787
|
|
|
481
788
|
function renderPreloadScriptTags(baseDir) {
|
|
@@ -578,8 +885,8 @@ function addLoadingAttributes(html) {
|
|
|
578
885
|
// ============================================================================
|
|
579
886
|
// Binding Injection (for HTML files that need runtime bindings)
|
|
580
887
|
// ============================================================================
|
|
581
|
-
function injectBindings(html,
|
|
582
|
-
const
|
|
888
|
+
function injectBindings(html, options = {}) {
|
|
889
|
+
const isPlaygroundPage = options.isPlaygroundPage === true;
|
|
583
890
|
// Different paths for dalila repo vs user projects
|
|
584
891
|
const dalilaPath = isDalilaRepo ? '/dist' : '/node_modules/dalila/dist';
|
|
585
892
|
const sourceDirPath = buildProjectSourceDirPath(projectDir);
|
|
@@ -760,7 +1067,7 @@ function injectBindings(html, requestPath) {
|
|
|
760
1067
|
}
|
|
761
1068
|
|
|
762
1069
|
// Dalila repo: only inject import map for non-playground pages
|
|
763
|
-
if (
|
|
1070
|
+
if (!isPlaygroundPage) {
|
|
764
1071
|
return injectHeadFragments(html, [importMap], {
|
|
765
1072
|
beforeModule: true,
|
|
766
1073
|
beforeStyles: true,
|
|
@@ -1004,7 +1311,9 @@ const server = http.createServer((req, res) => {
|
|
|
1004
1311
|
|
|
1005
1312
|
let targetPath = fsPath;
|
|
1006
1313
|
try {
|
|
1007
|
-
const
|
|
1314
|
+
const target = statServedPath(targetPath);
|
|
1315
|
+
targetPath = target.safePath;
|
|
1316
|
+
const stat = target.stat;
|
|
1008
1317
|
if (stat.isDirectory()) {
|
|
1009
1318
|
// Redirect directory URLs without trailing slash to include it
|
|
1010
1319
|
if (!requestPath.endsWith('/')) {
|
|
@@ -1012,13 +1321,23 @@ const server = http.createServer((req, res) => {
|
|
|
1012
1321
|
res.end();
|
|
1013
1322
|
return;
|
|
1014
1323
|
}
|
|
1015
|
-
|
|
1324
|
+
const indexPath = joinServedPath(targetPath, 'index.html');
|
|
1325
|
+
if (!indexPath) {
|
|
1326
|
+
send(res, 403, 'Forbidden');
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
targetPath = indexPath;
|
|
1016
1330
|
}
|
|
1017
|
-
} catch {
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
if (err && err.code === FORBIDDEN_PATH_ERROR_CODE) {
|
|
1333
|
+
send(res, 403, 'Forbidden');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1018
1337
|
// If .js file not found, try .ts alternative
|
|
1019
1338
|
if (targetPath.endsWith('.js')) {
|
|
1020
|
-
const tsPath = targetPath.
|
|
1021
|
-
if (
|
|
1339
|
+
const tsPath = replaceServedPathExtension(targetPath, '.js', '.ts');
|
|
1340
|
+
if (tsPath && existsServedPath(tsPath)) {
|
|
1022
1341
|
targetPath = tsPath;
|
|
1023
1342
|
} else {
|
|
1024
1343
|
send(res, 404, 'Not Found');
|
|
@@ -1026,11 +1345,11 @@ const server = http.createServer((req, res) => {
|
|
|
1026
1345
|
}
|
|
1027
1346
|
} else {
|
|
1028
1347
|
// Extensionless import — try .ts, then .js
|
|
1029
|
-
const tsPath = targetPath
|
|
1030
|
-
const jsPath = targetPath
|
|
1031
|
-
if (!path.extname(targetPath) && isScriptRequest &&
|
|
1348
|
+
const tsPath = appendServedPathExtension(targetPath, '.ts');
|
|
1349
|
+
const jsPath = appendServedPathExtension(targetPath, '.js');
|
|
1350
|
+
if (!path.extname(targetPath) && isScriptRequest && tsPath && existsServedPath(tsPath)) {
|
|
1032
1351
|
targetPath = tsPath;
|
|
1033
|
-
} else if (!path.extname(targetPath) && isScriptRequest &&
|
|
1352
|
+
} else if (!path.extname(targetPath) && isScriptRequest && jsPath && existsServedPath(jsPath)) {
|
|
1034
1353
|
targetPath = jsPath;
|
|
1035
1354
|
} else {
|
|
1036
1355
|
const spaFallback = resolveSpaFallbackPath(requestPath);
|
|
@@ -1052,8 +1371,12 @@ const server = http.createServer((req, res) => {
|
|
|
1052
1371
|
&& isScriptRequest
|
|
1053
1372
|
&& !isNavigationRequest;
|
|
1054
1373
|
if (isRawQuery || isHtmlModuleImport) {
|
|
1055
|
-
|
|
1374
|
+
readServedFile(targetPath, 'utf8', (err, source) => {
|
|
1056
1375
|
if (err) {
|
|
1376
|
+
if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
|
|
1377
|
+
send(res, 403, 'Forbidden');
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1057
1380
|
send(res, err.code === 'ENOENT' ? 404 : 500, err.code === 'ENOENT' ? 'Not Found' : 'Error');
|
|
1058
1381
|
return;
|
|
1059
1382
|
}
|
|
@@ -1069,8 +1392,12 @@ const server = http.createServer((req, res) => {
|
|
|
1069
1392
|
|
|
1070
1393
|
// TypeScript transpilation (only if ts available)
|
|
1071
1394
|
if (targetPath.endsWith('.ts') && ts) {
|
|
1072
|
-
|
|
1395
|
+
readServedFile(targetPath, 'utf8', (err, source) => {
|
|
1073
1396
|
if (err) {
|
|
1397
|
+
if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
|
|
1398
|
+
send(res, 403, 'Forbidden');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1074
1401
|
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
|
|
1075
1402
|
send(res, 404, 'Not Found');
|
|
1076
1403
|
return;
|
|
@@ -1104,15 +1431,23 @@ const server = http.createServer((req, res) => {
|
|
|
1104
1431
|
}
|
|
1105
1432
|
|
|
1106
1433
|
// Static file serving
|
|
1107
|
-
|
|
1434
|
+
readServedFile(targetPath, null, (err, data) => {
|
|
1108
1435
|
if (err) {
|
|
1436
|
+
if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
|
|
1437
|
+
send(res, 403, 'Forbidden');
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1109
1440
|
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
|
|
1110
1441
|
const spaFallback = resolveSpaFallbackPath(requestPath);
|
|
1111
1442
|
if (spaFallback) {
|
|
1112
1443
|
targetPath = spaFallback.fsPath;
|
|
1113
1444
|
resolvedRequestPath = spaFallback.requestPath;
|
|
1114
|
-
|
|
1445
|
+
readServedFile(targetPath, null, (fallbackErr, fallbackData) => {
|
|
1115
1446
|
if (fallbackErr) {
|
|
1447
|
+
if (fallbackErr.code === FORBIDDEN_PATH_ERROR_CODE) {
|
|
1448
|
+
send(res, 403, 'Forbidden');
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1116
1451
|
send(res, 404, 'Not Found');
|
|
1117
1452
|
return;
|
|
1118
1453
|
}
|
|
@@ -1125,7 +1460,9 @@ const server = http.createServer((req, res) => {
|
|
|
1125
1460
|
};
|
|
1126
1461
|
|
|
1127
1462
|
if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
|
|
1128
|
-
const html = injectBindings(htmlSource,
|
|
1463
|
+
const html = injectBindings(htmlSource, {
|
|
1464
|
+
isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
|
|
1465
|
+
});
|
|
1129
1466
|
writeResponseHead(res, 200, headers);
|
|
1130
1467
|
res.end(html);
|
|
1131
1468
|
return;
|
|
@@ -1157,7 +1494,9 @@ const server = http.createServer((req, res) => {
|
|
|
1157
1494
|
if (ext === '.html') {
|
|
1158
1495
|
const htmlSource = data.toString('utf8');
|
|
1159
1496
|
if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
|
|
1160
|
-
const html = injectBindings(htmlSource,
|
|
1497
|
+
const html = injectBindings(htmlSource, {
|
|
1498
|
+
isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
|
|
1499
|
+
});
|
|
1161
1500
|
writeResponseHead(res, 200, headers);
|
|
1162
1501
|
res.end(html);
|
|
1163
1502
|
return;
|