@webjsdev/server 0.8.67 → 0.8.69
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/package.json +1 -1
- package/src/auth.js +87 -26
- package/src/dev/config.js +22 -0
- package/src/dev/handler.js +39 -1
- package/src/dev/serve.js +24 -1
- package/src/dev-embed-client.js +331 -0
- package/src/dev-embed.js +147 -0
- package/src/dev-overlay.js +8 -0
- package/src/dev-source-locations-hook.js +139 -0
- package/src/dev-source-locations.js +453 -0
- package/src/ssr/head.js +2 -1
- package/src/webjs-config-validate.js +3 -3
- package/webjs-config.schema.json +34 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only source locations on rendered elements (#1499).
|
|
3
|
+
*
|
|
4
|
+
* Under `webjs dev` with `WEBJS_SOURCE_LOCATIONS=1`, every element opening tag
|
|
5
|
+
* written in an `html` tagged template inside the app carries
|
|
6
|
+
* `data-webjs-src="<app-relative-file>:<line>"`, in SSR output AND in client
|
|
7
|
+
* renders, so a tool hosting the app (an inspector, click-to-edit in an
|
|
8
|
+
* embedding builder) can map a clicked DOM element back to the line that wrote
|
|
9
|
+
* it.
|
|
10
|
+
*
|
|
11
|
+
* Mechanism: a SOURCE transform, applied to the same module bytes on both
|
|
12
|
+
* sides. The dev server runs it over app modules it serves to the browser
|
|
13
|
+
* (`dev/serve.js`), and a load hook runs it over app modules the server imports
|
|
14
|
+
* for SSR (`module.registerHooks` on Node, `Bun.plugin` on Bun). Because both
|
|
15
|
+
* sides see one transform of one file, the SSR markup and the client render
|
|
16
|
+
* carry identical attributes.
|
|
17
|
+
*
|
|
18
|
+
* Why a source transform and not a runtime one inside `html`: the runtime knows
|
|
19
|
+
* a template's CALL SITE (from a stack) but not where each element sits inside
|
|
20
|
+
* it, because the static strings it receives drop the text of every `${...}`
|
|
21
|
+
* expression, so any multi-line hole would skew every line after it. Lexing the
|
|
22
|
+
* source keeps every line exact, needs no change to `@webjsdev/core`, and the
|
|
23
|
+
* inserted text never contains a newline, so every later line keeps its number
|
|
24
|
+
* (stack traces included; only columns on an annotated line shift).
|
|
25
|
+
*
|
|
26
|
+
* What is annotated: element opening tags in the STATIC text of an `html`
|
|
27
|
+
* template. The attribute goes right after the tag name, before any other
|
|
28
|
+
* attribute, so attribute, property (`.x=`), event (`@x=`) and boolean (`?x=`)
|
|
29
|
+
* holes keep their positions and their names. What is not: closing tags,
|
|
30
|
+
* comments, `<!doctype>`, the inside of a tag or an attribute value, the body of
|
|
31
|
+
* a raw-text element (`script`, `style`, `textarea`, `title`), descendants of
|
|
32
|
+
* `svg` / `math` (the root element itself is annotated), document-shell and
|
|
33
|
+
* head-only elements (`html`, `head`, `body`, `meta`, `link`, `base`, `title`,
|
|
34
|
+
* `script`, `style`, `noscript`, `template`, `slot`), `css` / `svg` tagged
|
|
35
|
+
* templates, and any untagged template literal.
|
|
36
|
+
*
|
|
37
|
+
* The lexer understands line and block comments, single and double quoted
|
|
38
|
+
* strings, template literals with nested `${}` (including nested templates),
|
|
39
|
+
* and regex literals (told apart from division by the previous token). It is a
|
|
40
|
+
* dev aid, so it fails OPEN: anything it cannot make sense of is returned
|
|
41
|
+
* unchanged rather than throwing.
|
|
42
|
+
*/
|
|
43
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
44
|
+
|
|
45
|
+
/** Elements never annotated, even at the top level of a template. */
|
|
46
|
+
const SKIP_TAGS = new Set([
|
|
47
|
+
'html', 'head', 'body', 'meta', 'link', 'base', 'title',
|
|
48
|
+
'script', 'style', 'noscript', 'template', 'slot',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** Raw-text elements: their body is text up to the matching close tag. */
|
|
52
|
+
const RAW_TAGS = new Set(['script', 'style', 'textarea', 'title']);
|
|
53
|
+
|
|
54
|
+
/** Foreign-content roots: annotate the root, never its descendants. */
|
|
55
|
+
const FOREIGN_TAGS = new Set(['svg', 'math']);
|
|
56
|
+
|
|
57
|
+
/** Keywords after which a `/` starts a regex rather than a division. */
|
|
58
|
+
const REGEX_AFTER_KEYWORDS = new Set([
|
|
59
|
+
'return', 'typeof', 'case', 'do', 'else', 'in', 'of', 'new', 'delete',
|
|
60
|
+
'void', 'throw', 'instanceof', 'yield', 'await',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** The env var that turns the feature on (dev only). */
|
|
64
|
+
export const SOURCE_LOCATIONS_ENV = 'WEBJS_SOURCE_LOCATIONS';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether source locations are on (#1499, #1504). The app's
|
|
68
|
+
* `webjs.dev.sourceLocations` sets the default; the env var, when set, wins
|
|
69
|
+
* either way (`1` / `true` on, `0` / `false` off), so one run can flip it
|
|
70
|
+
* without editing package.json. Read by the dev handler once, at construction;
|
|
71
|
+
* the caller also requires `dev`, so production never consults it.
|
|
72
|
+
*
|
|
73
|
+
* @param {Record<string, string | undefined>} [env]
|
|
74
|
+
* @param {unknown} [configured] the app's `webjs.dev.sourceLocations`
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function sourceLocationsRequested(env = process.env, configured = false) {
|
|
78
|
+
const v = String(env[SOURCE_LOCATIONS_ENV] || '').trim().toLowerCase();
|
|
79
|
+
if (v === '1' || v === 'true') return true;
|
|
80
|
+
if (v === '0' || v === 'false') return false;
|
|
81
|
+
return configured === true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether `abs` is an app module the transform should touch: inside `appDir`,
|
|
86
|
+
* not under `node_modules`, not a `*.server.*` module (server actions and
|
|
87
|
+
* server-only utilities never render to the page through a component, and the
|
|
88
|
+
* `'use server'` load hook owns them), and a JS / TS module.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} abs absolute file path (no query)
|
|
91
|
+
* @param {string} appDir absolute app root
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isSourceLocationCandidate(abs, appDir) {
|
|
95
|
+
if (!abs || !appDir) return false;
|
|
96
|
+
const rel = relative(appDir, abs);
|
|
97
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return false;
|
|
98
|
+
if (rel.split(/[\\/]/).includes('node_modules')) return false;
|
|
99
|
+
if (!/\.m?[jt]s$/.test(abs)) return false;
|
|
100
|
+
if (/\.server\.m?[jt]s$/.test(abs)) return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The attribute value for a file: the app-relative path with forward slashes.
|
|
106
|
+
* Characters that could end the attribute, the template literal, or start a
|
|
107
|
+
* hole are percent-encoded, so the inserted text is always inert.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} abs
|
|
110
|
+
* @param {string} appDir
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function sourceLocationFile(abs, appDir) {
|
|
114
|
+
return relative(appDir, abs).split(sep).join('/')
|
|
115
|
+
.replace(/["'<>&`$\\\s]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Annotate every element opening tag in the `html` templates of `source`.
|
|
120
|
+
* Returns the source unchanged when it has no `html` template (or on any
|
|
121
|
+
* lexing surprise). Never adds or removes a newline.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} source module source, JavaScript or erasable TypeScript
|
|
124
|
+
* @param {string} file the attribute's file part (app-relative, see `sourceLocationFile`)
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function annotateSourceLocations(source, file) {
|
|
128
|
+
if (typeof source !== 'string' || !source.includes('html')) return source;
|
|
129
|
+
try {
|
|
130
|
+
const inserts = collectInserts(source, file);
|
|
131
|
+
if (!inserts.length) return source;
|
|
132
|
+
let out = '';
|
|
133
|
+
let last = 0;
|
|
134
|
+
for (const [at, text] of inserts) {
|
|
135
|
+
out += source.slice(last, at) + text;
|
|
136
|
+
last = at;
|
|
137
|
+
}
|
|
138
|
+
return out + source.slice(last);
|
|
139
|
+
} catch {
|
|
140
|
+
return source;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Lex `src` and return `[offset, text]` insertions, in ascending order.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} src
|
|
148
|
+
* @param {string} file
|
|
149
|
+
* @returns {Array<[number, string]>}
|
|
150
|
+
*/
|
|
151
|
+
function collectInserts(src, file) {
|
|
152
|
+
/** @type {Array<[number, string]>} */
|
|
153
|
+
const inserts = [];
|
|
154
|
+
const n = src.length;
|
|
155
|
+
let i = 0;
|
|
156
|
+
// 1-based line of `src[i]`, maintained by `advance`-style bookkeeping: every
|
|
157
|
+
// place that skips characters counts the newlines it passes.
|
|
158
|
+
let line = 1;
|
|
159
|
+
// The previous significant token, for regex-vs-division: 'id' (with its
|
|
160
|
+
// text in prevWord), 'num', 'str', 'close' (`)` / `]` / `}` ending an
|
|
161
|
+
// expression), or 'punct'.
|
|
162
|
+
let prev = 'punct';
|
|
163
|
+
let prevWord = '';
|
|
164
|
+
let prevWordStart = -1;
|
|
165
|
+
// Stack of brace contexts. A '{' frame is an ordinary block; a 'tpl' frame
|
|
166
|
+
// is a `${` inside a template, and its matching `}` resumes that template.
|
|
167
|
+
/** @type {Array<{ kind: 'brace' } | { kind: 'tpl', html: HtmlState | null }>} */
|
|
168
|
+
const stack = [];
|
|
169
|
+
|
|
170
|
+
/** @param {number} from @param {number} to */
|
|
171
|
+
const countLines = (from, to) => {
|
|
172
|
+
for (let k = from; k < to; k++) if (src.charCodeAt(k) === 10) line++;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Scan template literal text starting at `i` (just after a backtick or a
|
|
177
|
+
* hole's closing brace). Stops after the closing backtick (returns 'end') or
|
|
178
|
+
* after a `${` (returns 'hole'). Feeds static text to the html machine.
|
|
179
|
+
* @param {HtmlState | null} html
|
|
180
|
+
* @returns {'end' | 'hole'}
|
|
181
|
+
*/
|
|
182
|
+
const scanTemplateText = (html) => {
|
|
183
|
+
while (i < n) {
|
|
184
|
+
const c = src[i];
|
|
185
|
+
if (c === '\\') {
|
|
186
|
+
// An escape is static text the html machine sees as an opaque char.
|
|
187
|
+
if (html) html.feed(src, i, line);
|
|
188
|
+
if (src[i + 1] === '\n') line++;
|
|
189
|
+
i += 2;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (c === '`') { i++; return 'end'; }
|
|
193
|
+
if (c === '$' && src[i + 1] === '{') { i += 2; return 'hole'; }
|
|
194
|
+
if (html) {
|
|
195
|
+
const ins = html.feed(src, i, line);
|
|
196
|
+
if (ins !== -1) inserts.push([ins, ` data-webjs-src="${file}:${line}"`]);
|
|
197
|
+
}
|
|
198
|
+
if (c === '\n') line++;
|
|
199
|
+
i++;
|
|
200
|
+
}
|
|
201
|
+
throw new Error('unterminated template');
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Enter a template literal whose backtick is at `i`.
|
|
206
|
+
* @param {boolean} isHtml
|
|
207
|
+
*/
|
|
208
|
+
const enterTemplate = (isHtml) => {
|
|
209
|
+
i++; // the backtick
|
|
210
|
+
const html = isHtml ? new HtmlState() : null;
|
|
211
|
+
const r = scanTemplateText(html);
|
|
212
|
+
if (r === 'hole') { stack.push({ kind: 'tpl', html }); prev = 'punct'; }
|
|
213
|
+
else prev = 'str';
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
while (i < n) {
|
|
217
|
+
const c = src[i];
|
|
218
|
+
const cc = src.charCodeAt(i);
|
|
219
|
+
// Whitespace.
|
|
220
|
+
if (c === '\n') { line++; i++; continue; }
|
|
221
|
+
if (c === ' ' || c === '\t' || c === '\r' || cc === 0xfeff || cc === 0xa0 || cc === 0x2028 || cc === 0x2029) { i++; continue; }
|
|
222
|
+
// Comments.
|
|
223
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
224
|
+
const end = src.indexOf('\n', i);
|
|
225
|
+
i = end === -1 ? n : end;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
229
|
+
const end = src.indexOf('*/', i + 2);
|
|
230
|
+
if (end === -1) throw new Error('unterminated comment');
|
|
231
|
+
countLines(i, end);
|
|
232
|
+
i = end + 2;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// Strings.
|
|
236
|
+
if (c === '"' || c === "'") {
|
|
237
|
+
let k = i + 1;
|
|
238
|
+
while (k < n && src[k] !== c) {
|
|
239
|
+
if (src[k] === '\\') k++;
|
|
240
|
+
else if (src[k] === '\n') throw new Error('unterminated string');
|
|
241
|
+
k++;
|
|
242
|
+
}
|
|
243
|
+
countLines(i, k);
|
|
244
|
+
i = k + 1;
|
|
245
|
+
prev = 'str';
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Template literals: tagged with `html` (and not a property access like
|
|
249
|
+
// `foo.html`) means annotate.
|
|
250
|
+
if (c === '`') {
|
|
251
|
+
const isHtml = prev === 'id' && prevWord === 'html' && !isPropertyAccess(src, prevWordStart);
|
|
252
|
+
enterTemplate(isHtml);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// Identifiers and keywords.
|
|
256
|
+
if (isIdStart(cc)) {
|
|
257
|
+
const start = i;
|
|
258
|
+
while (i < n && isIdPart(src.charCodeAt(i))) i++;
|
|
259
|
+
prevWord = src.slice(start, i);
|
|
260
|
+
prevWordStart = start;
|
|
261
|
+
prev = 'id';
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Numbers.
|
|
265
|
+
if (cc >= 48 && cc <= 57) {
|
|
266
|
+
while (i < n && /[0-9a-zA-Z_.]/.test(src[i])) i++;
|
|
267
|
+
prev = 'num';
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Regex literal vs division.
|
|
271
|
+
if (c === '/') {
|
|
272
|
+
const regexAllowed = prev === 'punct' || (prev === 'id' && REGEX_AFTER_KEYWORDS.has(prevWord));
|
|
273
|
+
if (regexAllowed) {
|
|
274
|
+
let k = i + 1;
|
|
275
|
+
let inClass = false;
|
|
276
|
+
while (k < n) {
|
|
277
|
+
const d = src[k];
|
|
278
|
+
if (d === '\\') { k += 2; continue; }
|
|
279
|
+
if (d === '\n') throw new Error('unterminated regex');
|
|
280
|
+
if (inClass) { if (d === ']') inClass = false; }
|
|
281
|
+
else if (d === '[') inClass = true;
|
|
282
|
+
else if (d === '/') break;
|
|
283
|
+
k++;
|
|
284
|
+
}
|
|
285
|
+
i = k + 1;
|
|
286
|
+
while (i < n && isIdPart(src.charCodeAt(i))) i++; // flags
|
|
287
|
+
prev = 'str';
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
i++;
|
|
291
|
+
prev = 'punct';
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
// Braces drive the template-hole stack.
|
|
295
|
+
if (c === '{') { stack.push({ kind: 'brace' }); i++; prev = 'punct'; continue; }
|
|
296
|
+
if (c === '}') {
|
|
297
|
+
const top = stack.pop();
|
|
298
|
+
i++;
|
|
299
|
+
if (top && top.kind === 'tpl') {
|
|
300
|
+
// Back inside the template the hole interrupted.
|
|
301
|
+
const r = scanTemplateText(top.html);
|
|
302
|
+
if (r === 'hole') { stack.push(top); prev = 'punct'; }
|
|
303
|
+
else prev = 'str';
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
prev = 'close';
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (c === ')' || c === ']') { i++; prev = 'close'; continue; }
|
|
310
|
+
// Any other punctuation.
|
|
311
|
+
i++;
|
|
312
|
+
prev = 'punct';
|
|
313
|
+
}
|
|
314
|
+
return inserts;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The HTML tokenizer state for one `html` template, carried across holes so an
|
|
319
|
+
* attribute value or a raw-text body that a hole interrupts stays recognised.
|
|
320
|
+
*/
|
|
321
|
+
class HtmlState {
|
|
322
|
+
constructor() {
|
|
323
|
+
/** @type {'text' | 'tag' | 'dq' | 'sq' | 'comment' | 'raw'} */
|
|
324
|
+
this.mode = 'text';
|
|
325
|
+
/** Tag name of the tag being read (lowercased), '' for a close / bang tag. */
|
|
326
|
+
this.tag = '';
|
|
327
|
+
/** True while reading a closing tag. */
|
|
328
|
+
this.closing = false;
|
|
329
|
+
/** Raw-text element whose body we are in. */
|
|
330
|
+
this.rawTag = '';
|
|
331
|
+
/** Depth inside svg / math. */
|
|
332
|
+
this.foreign = 0;
|
|
333
|
+
/** The previous char in `tag` mode, to spot a self-closing `/>`. */
|
|
334
|
+
this.lastTagChar = '';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Feed the static char at `src[i]`. Returns the offset to insert the
|
|
339
|
+
* attribute at (just past an annotatable tag name), else -1.
|
|
340
|
+
* @param {string} src
|
|
341
|
+
* @param {number} i
|
|
342
|
+
* @param {number} _line
|
|
343
|
+
* @returns {number}
|
|
344
|
+
*/
|
|
345
|
+
feed(src, i, _line) {
|
|
346
|
+
const c = src[i];
|
|
347
|
+
switch (this.mode) {
|
|
348
|
+
case 'text': {
|
|
349
|
+
if (c !== '<') return -1;
|
|
350
|
+
if (src.startsWith('<!--', i)) { this.mode = 'comment'; return -1; }
|
|
351
|
+
const next = src[i + 1];
|
|
352
|
+
if (next === '/') {
|
|
353
|
+
const name = readTagName(src, i + 2);
|
|
354
|
+
this.mode = 'tag';
|
|
355
|
+
this.closing = true;
|
|
356
|
+
this.tag = name.toLowerCase();
|
|
357
|
+
this.lastTagChar = '';
|
|
358
|
+
if (FOREIGN_TAGS.has(this.tag) && this.foreign > 0) this.foreign--;
|
|
359
|
+
return -1;
|
|
360
|
+
}
|
|
361
|
+
if (next === '!' || next === '?') {
|
|
362
|
+
this.mode = 'tag';
|
|
363
|
+
this.closing = true; // treated like a close tag: never annotated
|
|
364
|
+
this.tag = '';
|
|
365
|
+
this.lastTagChar = '';
|
|
366
|
+
return -1;
|
|
367
|
+
}
|
|
368
|
+
const name = readTagName(src, i + 1);
|
|
369
|
+
if (!name || !/^[A-Za-z]/.test(name)) return -1; // a bare `<` in text
|
|
370
|
+
this.mode = 'tag';
|
|
371
|
+
this.closing = false;
|
|
372
|
+
this.tag = name.toLowerCase();
|
|
373
|
+
this.lastTagChar = '';
|
|
374
|
+
if (this.foreign > 0 || SKIP_TAGS.has(this.tag)) return -1;
|
|
375
|
+
return i + 1 + name.length;
|
|
376
|
+
}
|
|
377
|
+
case 'comment': {
|
|
378
|
+
if (c === '>' && src[i - 1] === '-' && src[i - 2] === '-') this.mode = 'text';
|
|
379
|
+
return -1;
|
|
380
|
+
}
|
|
381
|
+
case 'dq': {
|
|
382
|
+
if (c === '"') this.mode = 'tag';
|
|
383
|
+
return -1;
|
|
384
|
+
}
|
|
385
|
+
case 'sq': {
|
|
386
|
+
if (c === "'") this.mode = 'tag';
|
|
387
|
+
return -1;
|
|
388
|
+
}
|
|
389
|
+
case 'raw': {
|
|
390
|
+
if (c === '<' && src[i + 1] === '/' &&
|
|
391
|
+
readTagName(src, i + 2).toLowerCase() === this.rawTag) {
|
|
392
|
+
this.mode = 'tag';
|
|
393
|
+
this.closing = true;
|
|
394
|
+
this.tag = this.rawTag;
|
|
395
|
+
this.rawTag = '';
|
|
396
|
+
this.lastTagChar = '';
|
|
397
|
+
}
|
|
398
|
+
return -1;
|
|
399
|
+
}
|
|
400
|
+
case 'tag': {
|
|
401
|
+
if (c === '"') { this.mode = 'dq'; return -1; }
|
|
402
|
+
if (c === "'") { this.mode = 'sq'; return -1; }
|
|
403
|
+
if (c === '>') {
|
|
404
|
+
const selfClosing = this.lastTagChar === '/';
|
|
405
|
+
this.mode = 'text';
|
|
406
|
+
if (!this.closing && !selfClosing) {
|
|
407
|
+
if (RAW_TAGS.has(this.tag)) { this.mode = 'raw'; this.rawTag = this.tag; }
|
|
408
|
+
else if (FOREIGN_TAGS.has(this.tag)) this.foreign++;
|
|
409
|
+
}
|
|
410
|
+
return -1;
|
|
411
|
+
}
|
|
412
|
+
if (c !== ' ' && c !== '\t' && c !== '\n' && c !== '\r') this.lastTagChar = c;
|
|
413
|
+
return -1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return -1;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Read an HTML tag name starting at `at` (letters, digits, `-`, `:`, `_`, `.`).
|
|
422
|
+
* @param {string} src
|
|
423
|
+
* @param {number} at
|
|
424
|
+
* @returns {string}
|
|
425
|
+
*/
|
|
426
|
+
function readTagName(src, at) {
|
|
427
|
+
let k = at;
|
|
428
|
+
while (k < src.length && /[A-Za-z0-9\-:_.]/.test(src[k])) k++;
|
|
429
|
+
return src.slice(at, k);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Whether the identifier starting at `start` is a member access (`x.html`,
|
|
434
|
+
* `x?.html`), skipping whitespace back to the previous character.
|
|
435
|
+
* @param {string} src
|
|
436
|
+
* @param {number} start
|
|
437
|
+
* @returns {boolean}
|
|
438
|
+
*/
|
|
439
|
+
function isPropertyAccess(src, start) {
|
|
440
|
+
let k = start - 1;
|
|
441
|
+
while (k >= 0 && /\s/.test(src[k])) k--;
|
|
442
|
+
return k >= 0 && src[k] === '.';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** @param {number} cc */
|
|
446
|
+
function isIdStart(cc) {
|
|
447
|
+
return (cc >= 65 && cc <= 90) || (cc >= 97 && cc <= 122) || cc === 36 || cc === 95 || cc > 127;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @param {number} cc */
|
|
451
|
+
function isIdPart(cc) {
|
|
452
|
+
return isIdStart(cc) || (cc >= 48 && cc <= 57);
|
|
453
|
+
}
|
package/src/ssr/head.js
CHANGED
|
@@ -6,6 +6,7 @@ import { jsonForScriptTag } from '../script-tag-json.js';
|
|
|
6
6
|
import { vendorIntegrityFor } from '../importmap.js';
|
|
7
7
|
import { publicEnvShim } from './env-shim.js';
|
|
8
8
|
import { clientRouterEnabled } from './client-router-flag.js';
|
|
9
|
+
import { embedScriptTag } from '../dev-embed.js';
|
|
9
10
|
|
|
10
11
|
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
11
12
|
// Set at boot and on each route rebuild from the route table, the same shape
|
|
@@ -845,7 +846,7 @@ export function wrapHead(opts) {
|
|
|
845
846
|
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
846
847
|
${metaTags.join('\n')}
|
|
847
848
|
<title>${escapeHtml(title)}</title>
|
|
848
|
-
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}${clientRouterEnabled() ? '' : `\n<script${n}>window.__WEBJS_CLIENT_ROUTER__=false;</script>`}
|
|
849
|
+
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}${embedScriptTag({ dev: opts.dev, nonce: opts.nonce })}${clientRouterEnabled() ? '' : `\n<script${n}>window.__WEBJS_CLIENT_ROUTER__=false;</script>`}
|
|
849
850
|
${importMapTag({ nonce: opts.nonce })}
|
|
850
851
|
${linkTags.join('\n')}
|
|
851
852
|
${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
|
|
@@ -41,9 +41,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
41
41
|
* `integer` leaf types.
|
|
42
42
|
*
|
|
43
43
|
* So a value is type-checked only when its schema declares `type: "boolean"` or
|
|
44
|
-
* `type: "integer"`, or an `enum`. Today that is 9 of the
|
|
45
|
-
* other
|
|
46
|
-
* `start`, `db`, `doctor`, `ci`) pass whatever they hold, `csp` because its schema is a
|
|
44
|
+
* `type: "integer"`, or an `enum`. Today that is 9 of the 20 top-level keys; the
|
|
45
|
+
* other 11 (`headers`, `redirects`, `allowedOrigins`, `basePath`, `csp`, `dev`,
|
|
46
|
+
* `start`, `db`, `doctor`, `ci`, `audit`) pass whatever they hold, `csp` because its schema is a
|
|
47
47
|
* `oneOf` with no `type` at all and the rest because theirs is `array`,
|
|
48
48
|
* `string`, or `object`. That is enough for the case this exists to close, a
|
|
49
49
|
* typo'd top-level key silently dropped, plus the leaf kinds a schema can decide
|
package/webjs-config.schema.json
CHANGED
|
@@ -202,6 +202,15 @@
|
|
|
202
202
|
"description": "Extra directories the dev live-reload watcher follows in addition to the appDir (#894), for content the app reads from outside its own tree (e.g. blog markdown in a repo-root blog/ dir). Each entry is resolved relative to the app root and may escape it (../blog). Read by the server (readDevWatchPathsFromApp).",
|
|
203
203
|
"type": "array",
|
|
204
204
|
"items": { "type": "string" }
|
|
205
|
+
},
|
|
206
|
+
"embedOrigins": {
|
|
207
|
+
"description": "Dev embed bridge (#1498, #1504), DEV-ONLY and off by default. Parent origins (e.g. https://builder.example) allowed to frame the dev server: X-Frame-Options is dropped and frame-ancestors widened for them, and every document carries a nonce-signed script that posts { source: 'webjs-embed', type } messages to such a parent and takes { source: 'webjs-embed-host', type } commands from it. A set WEBJS_EMBED_ORIGINS env var replaces this list. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
|
|
208
|
+
"type": "array",
|
|
209
|
+
"items": { "type": "string" }
|
|
210
|
+
},
|
|
211
|
+
"sourceLocations": {
|
|
212
|
+
"description": "Dev source locations (#1499, #1504), DEV-ONLY and off by default. When true, element opening tags in the app's html templates carry data-webjs-src=\"<app-relative-file>:<line>\" in SSR output and client renders. WEBJS_SOURCE_LOCATIONS=1 or 0 overrides it for one run. Read by the server (readDevToolingFromApp). Ignored by webjs start.",
|
|
213
|
+
"type": "boolean"
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
216
|
},
|
|
@@ -238,6 +247,31 @@
|
|
|
238
247
|
}
|
|
239
248
|
}
|
|
240
249
|
},
|
|
250
|
+
"audit": {
|
|
251
|
+
"description": "`webjs audit` (#1492): the dependency audit's failing level and its allowlist, in one place. `webjs audit` runs `npm audit` or `bun audit` (by the nearest lockfile) and fails on any advisory at or above `level` that `ignore` does not list. Read by the CLI (readAuditConfig in packages/cli/lib/audit.js), NOT the server. A malformed block (an unknown key, a bad level, an entry without an id or a reason) exits 1, so an allowlist cannot silently stop applying.",
|
|
252
|
+
"type": "object",
|
|
253
|
+
"additionalProperties": false,
|
|
254
|
+
"properties": {
|
|
255
|
+
"level": {
|
|
256
|
+
"description": "The lowest severity that fails the audit. Default high.",
|
|
257
|
+
"type": "string",
|
|
258
|
+
"enum": ["low", "moderate", "high", "critical"]
|
|
259
|
+
},
|
|
260
|
+
"ignore": {
|
|
261
|
+
"description": "Advisories the app accepts, each with the reason it is safe. Only for an advisory with no patched release that the app's users cannot reach; an id the audit no longer reports is printed as stale.",
|
|
262
|
+
"type": "array",
|
|
263
|
+
"items": {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"additionalProperties": false,
|
|
266
|
+
"required": ["id", "reason"],
|
|
267
|
+
"properties": {
|
|
268
|
+
"id": { "description": "The advisory id, GHSA-xxxx-xxxx-xxxx or CVE-YYYY-NNNN.", "type": "string", "pattern": "^(GHSA(-[0-9a-zA-Z]{4}){3}|CVE-[0-9]{4}-[0-9]+)$" },
|
|
269
|
+
"reason": { "description": "Why accepting it is safe (which dependency chain reaches it, and why that chain cannot reach users).", "type": "string", "minLength": 1 }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
241
275
|
"ci": {
|
|
242
276
|
"description": "Local CI (#1471): the step list `webjs ci` runs on a developer machine, and the same list a cloud pipeline runs by calling `npm run ci`, so the two cannot drift. Read by the CLI (readCiConfig in packages/cli/lib/ci-config.js), NOT the server. A step is a string (shorthand for a command whose title is the command), a { title, run, env? } command, or a { title, steps, parallel? } group. `parallel` is a slot count (default 1, sequential); a group nested inside a parallel group takes ONE slot and runs its steps in order, so it cannot declare `parallel` itself.",
|
|
243
277
|
"type": "object",
|