@webjsdev/server 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -5
- package/index.js +21 -3
- package/package.json +4 -6
- package/src/actions.js +6 -6
- package/src/auth.js +1 -1
- package/src/cache.js +19 -2
- package/src/check.js +226 -95
- package/src/component-elision.js +797 -0
- package/src/component-scanner.js +8 -2
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +479 -94
- package/src/importmap.js +282 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +150 -13
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1231 -103
- package/src/websocket.js +3 -1
package/src/importmap.js
CHANGED
|
@@ -1,40 +1,302 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { digestHex } from './crypto-utils.js';
|
|
4
|
+
import { jsonForScriptTag } from './script-tag-json.js';
|
|
5
|
+
|
|
6
|
+
// Local attribute escaper. Matches ssr.js's escapeAttr (the source
|
|
7
|
+
// of truth for HTML attribute escaping in this package). Kept inline
|
|
8
|
+
// to avoid a cross-file dependency for one small helper.
|
|
9
|
+
function escapeAttr(s) {
|
|
10
|
+
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
/**
|
|
2
14
|
* Build the import map JSON injected into every SSR HTML document.
|
|
3
15
|
*
|
|
4
16
|
* Additional vendor entries are added automatically when the bare-import
|
|
5
|
-
* scanner discovers npm packages used by client code
|
|
6
|
-
*
|
|
17
|
+
* scanner discovers npm packages used by client code. The resolution
|
|
18
|
+
* happens via `vendor.js`'s `resolveVendorImports`, which reads the
|
|
19
|
+
* committed `.webjs/vendor/importmap.json` if present, else calls
|
|
20
|
+
* `api.jspm.io/generate` once at boot. Browser fetches vendor packages
|
|
21
|
+
* directly from jspm.io's CDN (default) or from local `/__webjs/vendor/`
|
|
22
|
+
* paths (after `webjs vendor pin --download`).
|
|
7
23
|
*/
|
|
8
24
|
|
|
9
25
|
/** @type {Record<string, string>} */
|
|
10
26
|
let _extraEntries = {};
|
|
11
27
|
|
|
12
28
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
29
|
+
* SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
|
|
30
|
+
* Populated only when a pin file with `integrity` is present;
|
|
31
|
+
* live-API mode skips it.
|
|
32
|
+
* @type {Record<string, string>}
|
|
33
|
+
*/
|
|
34
|
+
let _vendorIntegrity = {};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge additional vendor entries into the import map and precompute
|
|
38
|
+
* the importmap-hash so `importMapHash()` can stay synchronous on the
|
|
39
|
+
* per-request SSR hot path. Called by the dev server at boot and on
|
|
40
|
+
* every vendor rebuild.
|
|
41
|
+
*
|
|
15
42
|
* @param {Record<string, string>} entries
|
|
43
|
+
* @param {Record<string, string>} [integrity] SRI hashes keyed by URL
|
|
44
|
+
* @returns {Promise<void>}
|
|
16
45
|
*/
|
|
17
|
-
export function setVendorEntries(entries) {
|
|
46
|
+
export async function setVendorEntries(entries, integrity) {
|
|
18
47
|
_extraEntries = entries;
|
|
48
|
+
_vendorIntegrity = integrity || {};
|
|
49
|
+
_importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stable SHA-256 of the current importmap JSON, used as the
|
|
54
|
+
* `data-webjs-build` attribute on `<script type="importmap">` and
|
|
55
|
+
* as the `X-Webjs-Build` response header on every SSR response.
|
|
56
|
+
*
|
|
57
|
+
* Purpose: the X-Webjs-Have partial-response optimization in ssr.js
|
|
58
|
+
* short-circuits at the outermost cached layout and returns only the
|
|
59
|
+
* inner body (no head, no importmap). Without the build header the
|
|
60
|
+
* client router has no way to detect a deploy that bumped the
|
|
61
|
+
* importmap. After a `webjs vendor pin` rerun the user's next
|
|
62
|
+
* intra-shell nav would stay on the stale importmap and the new
|
|
63
|
+
* vendor URLs would never load. The header lets applySwap detect
|
|
64
|
+
* the change and hard-reload before applying the swap.
|
|
65
|
+
*
|
|
66
|
+
* Synchronous accessor. The hash is precomputed eagerly inside
|
|
67
|
+
* `setVendorEntries` (which the dev server `await`s during boot and
|
|
68
|
+
* on every rebuild) so the per-request SSR hot path can return the
|
|
69
|
+
* cached string without crossing a Promise boundary.
|
|
70
|
+
*
|
|
71
|
+
* Returns an empty string if `setVendorEntries` has never run; the
|
|
72
|
+
* client router treats an empty `X-Webjs-Build` as "version unknown"
|
|
73
|
+
* and skips the importmap drift check, which is the right behaviour
|
|
74
|
+
* for tests / embedding contexts that never set vendor entries.
|
|
75
|
+
*
|
|
76
|
+
* @returns {string} e.g. `abc123…` (hex, 64 chars)
|
|
77
|
+
*/
|
|
78
|
+
let _importMapHash = '';
|
|
79
|
+
export function importMapHash() {
|
|
80
|
+
return _importMapHash;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Look up the SRI integrity hash for a vendor URL, or empty string if
|
|
85
|
+
* none. Used by ssr.js to add `integrity="..."` to modulepreload tags
|
|
86
|
+
* pointing at vendor URLs.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} url
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function vendorIntegrityFor(url) {
|
|
92
|
+
return _vendorIntegrity[url] || '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The `@webjsdev/core` install's importmap entries, derived from its
|
|
97
|
+
* own `package.json` `exports` field. Populated by `setCoreInstall`
|
|
98
|
+
* at boot.
|
|
99
|
+
*
|
|
100
|
+
* Initialized to the two minimum-safe defaults (the bare specifier
|
|
101
|
+
* pointing at the browser source-mode entry and the catch-all prefix
|
|
102
|
+
* pointing at `src/`) so any consumer that calls `buildImportMap()`
|
|
103
|
+
* before `setCoreInstall` runs still gets a usable map. Pre-#118 the
|
|
104
|
+
* legacy `coreMappings` were always derived from a boolean and so
|
|
105
|
+
* could not be empty; this keeps that fail-open posture for embedded
|
|
106
|
+
* SSR test helpers and one-shot tooling that imports `importmap.js`
|
|
107
|
+
* without booting `dev.js`.
|
|
108
|
+
*
|
|
109
|
+
* @type {Record<string, string>}
|
|
110
|
+
*/
|
|
111
|
+
let _coreEntries = {
|
|
112
|
+
'@webjsdev/core': '/__webjs/core/index-browser.js',
|
|
113
|
+
'@webjsdev/core/': '/__webjs/core/src/',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bind the importmap to a specific `@webjsdev/core` install. The
|
|
118
|
+
* builder reads the package's `package.json` exports field once and
|
|
119
|
+
* derives one importmap entry per exported subpath, picking the
|
|
120
|
+
* `default` (bundled `dist/`) condition when `distMode` is true and
|
|
121
|
+
* the `source` (per-file `src/`) condition when it's false. The
|
|
122
|
+
* derivation lets the framework drop the 9-line hardcoded mapping
|
|
123
|
+
* table that used to live here, and means the importmap follows the
|
|
124
|
+
* shipped package whenever subpaths are renamed or added.
|
|
125
|
+
*
|
|
126
|
+
* The bare `@webjsdev/core` specifier still hardcodes its target
|
|
127
|
+
* (the browser-only entry shipped at `index-browser.js` /
|
|
128
|
+
* `dist/webjs-core-browser.js`) because that file is not declared
|
|
129
|
+
* in the exports field, by design: it is a server-stripped surface
|
|
130
|
+
* meant for the importmap-driven browser route, not Node resolution.
|
|
131
|
+
*
|
|
132
|
+
* Called once by `dev.js` at boot. Not re-called on file-watcher
|
|
133
|
+
* rebuilds today; if `@webjsdev/core/package.json` is edited in a
|
|
134
|
+
* long-running dev session (e.g. workspace dev that runs a fresh
|
|
135
|
+
* `npm run build:dist`), the derivation is refreshed on next server
|
|
136
|
+
* restart, not on the watcher tick. Pre-#118 the legacy
|
|
137
|
+
* `setCoreDistMode` had the same behaviour: only the dist-presence
|
|
138
|
+
* boolean was watched, not the package.json itself.
|
|
139
|
+
*
|
|
140
|
+
* Like `setVendorEntries`, the importmap-hash is recomputed eagerly
|
|
141
|
+
* so `importMapHash()` stays synchronous on the per-request SSR
|
|
142
|
+
* hot path.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} coreDir absolute path to the resolved `@webjsdev/core` install
|
|
145
|
+
* @param {boolean} distMode true when both `webjs-core.js` and `webjs-core-browser.js` exist in `dist/`
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
export async function setCoreInstall(coreDir, distMode) {
|
|
149
|
+
_coreEntries = buildCoreEntries(coreDir, !!distMode);
|
|
150
|
+
_importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap()));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read `<coreDir>/package.json` and derive importmap entries from
|
|
155
|
+
* its `exports` field. The function is pure (no side effects) and
|
|
156
|
+
* exported so tests can exercise the derivation directly.
|
|
157
|
+
*
|
|
158
|
+
* For each subpath in `exports` whose value is an object form, emit
|
|
159
|
+
* one entry. Pick the `default` value in dist mode (a bundled
|
|
160
|
+
* `dist/webjs-core-*.js`) and the `source` value in src mode (a
|
|
161
|
+
* per-file `src/*.js`). When `source` is absent (e.g. `./component`,
|
|
162
|
+
* whose shape is `{ types, default }` and whose `default` is itself
|
|
163
|
+
* a `src/` path), fall back to `default` in src mode so the import
|
|
164
|
+
* still resolves with a `.js` extension on the URL.
|
|
165
|
+
*
|
|
166
|
+
* Subpaths with a plain string value (`./client`, `./server`,
|
|
167
|
+
* `./registry`, `./signals`, `./package.json`) are not mapped
|
|
168
|
+
* explicitly; the catch-all `@webjsdev/core/` prefix routes them
|
|
169
|
+
* through `/__webjs/core/src/`. Future-added subpaths added in
|
|
170
|
+
* string form land on the catch-all the same way.
|
|
171
|
+
*
|
|
172
|
+
* Path-traversal guard: any `default` / `source` value that contains
|
|
173
|
+
* `..` is skipped. The trust boundary today is the framework's own
|
|
174
|
+
* `@webjsdev/core/package.json`, but the guard makes a future shift
|
|
175
|
+
* to user-controlled `coreDir` (e.g. via a `--core-dir` flag) safe
|
|
176
|
+
* by construction.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} coreDir
|
|
179
|
+
* @param {boolean} distMode
|
|
180
|
+
* @returns {Record<string, string>}
|
|
181
|
+
*/
|
|
182
|
+
export function buildCoreEntries(coreDir, distMode) {
|
|
183
|
+
/** @type {Record<string, string>} */
|
|
184
|
+
const out = {
|
|
185
|
+
// Bare specifier: browser-only entry, slim by design (drops
|
|
186
|
+
// render-server, expose, setCspNonceProvider). Node-side
|
|
187
|
+
// consumers resolve via the package.json exports `default`
|
|
188
|
+
// condition and land on `index.js` instead.
|
|
189
|
+
'@webjsdev/core': distMode
|
|
190
|
+
? '/__webjs/core/dist/webjs-core-browser.js'
|
|
191
|
+
: '/__webjs/core/index-browser.js',
|
|
192
|
+
// Catch-all: source-only subpaths (`./client`, `./server`,
|
|
193
|
+
// `./component`, `./registry`, `./signals`) and any future
|
|
194
|
+
// subpath not yet enumerated by exports still resolve.
|
|
195
|
+
'@webjsdev/core/': '/__webjs/core/src/',
|
|
196
|
+
};
|
|
197
|
+
let pkg;
|
|
198
|
+
try {
|
|
199
|
+
pkg = JSON.parse(readFileSync(join(coreDir, 'package.json'), 'utf8'));
|
|
200
|
+
} catch {
|
|
201
|
+
// Without a readable package.json the bare + catch-all entries
|
|
202
|
+
// above are the minimum useful map. Per-subpath entries stay
|
|
203
|
+
// missing; the catch-all picks them up at src/<name> (callers
|
|
204
|
+
// would need to include the .js extension in their import).
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
const exportsField = pkg && pkg.exports;
|
|
208
|
+
if (!exportsField || typeof exportsField !== 'object') return out;
|
|
209
|
+
for (const [subpath, entry] of Object.entries(exportsField)) {
|
|
210
|
+
// Skip the bare `.` (handled above) and any non-subpath key.
|
|
211
|
+
if (subpath === '.' || !subpath.startsWith('./') || subpath.endsWith('/')) continue;
|
|
212
|
+
// Only object-form entries with both default and source carry
|
|
213
|
+
// a dist mapping. String-form entries (`./client`,
|
|
214
|
+
// `./component` once it loses its types, …) stay on the
|
|
215
|
+
// catch-all.
|
|
216
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
217
|
+
// Pick the condition for the requested mode. In src mode,
|
|
218
|
+
// entries that lack a `source` (e.g. `./component`, whose
|
|
219
|
+
// package.json shape is `{ types, default }`) fall back to
|
|
220
|
+
// `entry.default`. The fallback URL is still a `src/` path on
|
|
221
|
+
// those entries, so it resolves correctly without forcing users
|
|
222
|
+
// to add a `.js` extension to subpath imports.
|
|
223
|
+
let targetRel = distMode ? entry.default : entry.source;
|
|
224
|
+
if (typeof targetRel !== 'string') targetRel = entry.default;
|
|
225
|
+
if (typeof targetRel !== 'string' || !targetRel.startsWith('./')) continue;
|
|
226
|
+
// Reject paths containing `..` to guard against a malformed or
|
|
227
|
+
// adversarial `exports` field producing a path-traversal URL.
|
|
228
|
+
// The check is deliberately broad: `..` substring catches both
|
|
229
|
+
// `../etc/passwd` and `./foo/../bar`.
|
|
230
|
+
if (targetRel.includes('..')) continue;
|
|
231
|
+
// `./directives` → `@webjsdev/core/directives`,
|
|
232
|
+
// `./dist/webjs-core-directives.js` → `/__webjs/core/dist/webjs-core-directives.js`.
|
|
233
|
+
out['@webjsdev/core' + subpath.slice(1)] = '/__webjs/core/' + targetRel.slice(2);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
19
236
|
}
|
|
20
237
|
|
|
21
238
|
export function buildImportMap() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'@webjsdev/core/': '/__webjs/core/src/',
|
|
26
|
-
'@webjsdev/core/client-router': '/__webjs/core/src/router-client.js',
|
|
27
|
-
'@webjsdev/core/lazy-loader': '/__webjs/core/src/lazy-loader.js',
|
|
28
|
-
'@webjsdev/core/directives': '/__webjs/core/src/directives.js',
|
|
29
|
-
'@webjsdev/core/context': '/__webjs/core/src/context.js',
|
|
30
|
-
'@webjsdev/core/testing': '/__webjs/core/src/testing.js',
|
|
31
|
-
'@webjsdev/core/task': '/__webjs/core/src/task.js',
|
|
32
|
-
..._extraEntries,
|
|
33
|
-
},
|
|
239
|
+
const merged = {
|
|
240
|
+
..._coreEntries,
|
|
241
|
+
..._extraEntries,
|
|
34
242
|
};
|
|
243
|
+
// Sort keys so logically-identical importmaps serialize byte-for-byte
|
|
244
|
+
// identically. The client router compares textContent to detect
|
|
245
|
+
// post-deploy importmap mismatches; without a stable order the
|
|
246
|
+
// scanner's filesystem-iteration order could change between deploys
|
|
247
|
+
// (e.g. after a file rename) and trigger a spurious hard reload
|
|
248
|
+
// even though the content didn't actually change.
|
|
249
|
+
/** @type {Record<string, string>} */
|
|
250
|
+
const imports = {};
|
|
251
|
+
for (const k of Object.keys(merged).sort()) imports[k] = merged[k];
|
|
252
|
+
|
|
253
|
+
// Emit `integrity` per the importmap-integrity spec (Chrome 132+,
|
|
254
|
+
// Safari 18.4+, Firefox flagged). Browsers without support ignore
|
|
255
|
+
// the field; per-tag SRI on modulepreload covers them.
|
|
256
|
+
//
|
|
257
|
+
// Filter orphan integrity entries (URLs that aren't actually in
|
|
258
|
+
// imports). The browser only consults integrity for URLs that
|
|
259
|
+
// resolve through the importmap, so orphans are harmless but bloat
|
|
260
|
+
// the JSON, defeat the importMapHash stability invariant on
|
|
261
|
+
// unrelated pin file edits, and leak removed URLs to the wire.
|
|
262
|
+
const out = { imports };
|
|
263
|
+
const usedUrls = new Set(Object.values(imports));
|
|
264
|
+
const intKeys = Object.keys(_vendorIntegrity).filter(k => usedUrls.has(k)).sort();
|
|
265
|
+
if (intKeys.length) {
|
|
266
|
+
/** @type {Record<string, string>} */
|
|
267
|
+
const integrity = {};
|
|
268
|
+
for (const k of intKeys) integrity[k] = _vendorIntegrity[k];
|
|
269
|
+
out.integrity = integrity;
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
35
272
|
}
|
|
36
273
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Serialise the import map to an HTML script tag string.
|
|
276
|
+
*
|
|
277
|
+
* When `nonce` is provided (extracted from the incoming
|
|
278
|
+
* Content-Security-Policy header by ssr.js), it's emitted as
|
|
279
|
+
* `nonce="..."` on the script tag. Strict-CSP apps using
|
|
280
|
+
* `script-src 'nonce-...'` require this; without it the browser
|
|
281
|
+
* blocks the importmap and every bare-specifier import fails.
|
|
282
|
+
*
|
|
283
|
+
* Defense-in-depth: JSON content is run through `jsonForScriptTag`
|
|
284
|
+
* so a string value containing `</script>` (e.g. a maliciously
|
|
285
|
+
* crafted vendor URL that somehow slipped past the jspm.io filter)
|
|
286
|
+
* cannot close the importmap tag early and inject script content.
|
|
287
|
+
*
|
|
288
|
+
* @param {{ nonce?: string }} [opts]
|
|
289
|
+
*/
|
|
290
|
+
export function importMapTag(opts = {}) {
|
|
291
|
+
// Full attribute escape, not just `"` to `"`. The nonce arrives
|
|
292
|
+
// from the request's CSP header (parsed by ssr.js), which we treat
|
|
293
|
+
// as untrusted input even though CSP spec restricts nonce charset to
|
|
294
|
+
// base64-ish. A misconfigured upstream emitting `nonce-<bad>` should
|
|
295
|
+
// not get its `<` rendered raw into our HTML.
|
|
296
|
+
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
297
|
+
// Stamp the build hash so the client router can detect post-deploy
|
|
298
|
+
// importmap changes on intra-shell partial-response navigations.
|
|
299
|
+
// See importMapHash() above for the rationale.
|
|
300
|
+
const b = ` data-webjs-build="${importMapHash()}"`;
|
|
301
|
+
return `<script type="importmap"${n}${b}>${jsonForScriptTag(buildImportMap())}</script>`;
|
|
40
302
|
}
|
package/src/js-scan.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level lexical scanning helpers shared by the convention validator
|
|
3
|
+
* (`check.js`) and the component-elision analyser (`component-elision.js`).
|
|
4
|
+
*
|
|
5
|
+
* These are deliberately a hand-rolled lexer, NOT a full TS parse. The
|
|
6
|
+
* framework prioritises fast dev-time rebuilds; a real parser would be ~50x
|
|
7
|
+
* slower for patterns this shallow. The lexer tracks the JS lexical grammar
|
|
8
|
+
* (strings, regex literals, comments, and templates with nested `${...}`
|
|
9
|
+
* interpolation) so structural scanners never trip on a literal's contents.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keywords after which a `/` opens a regex literal rather than dividing
|
|
14
|
+
* (`return /re/`, `typeof /re/`). After a plain identifier or number a `/` is
|
|
15
|
+
* division. Used by the lexer's regex-versus-division decision.
|
|
16
|
+
*/
|
|
17
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
18
|
+
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
|
|
19
|
+
'do', 'else', 'case', 'yield', 'await', 'throw',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return `src` with the BODY of every comment, single-quoted string,
|
|
24
|
+
* double-quoted string, and template literal replaced by spaces (with
|
|
25
|
+
* newlines preserved). Quote delimiters / comment markers themselves
|
|
26
|
+
* are kept so the brace counter and other structural scanners still
|
|
27
|
+
* see the surrounding shape. Positions (line + column) are preserved
|
|
28
|
+
* exactly, so a violation reported against the redacted source maps
|
|
29
|
+
* back to the same line/column in the original.
|
|
30
|
+
*
|
|
31
|
+
* The point: lint rules that pattern-match across raw source (regex
|
|
32
|
+
* for `class X extends WebComponent`, `enum`, `register('tag')`,
|
|
33
|
+
* etc.) must not match the same pattern when it appears as a
|
|
34
|
+
* code-example string INSIDE an `html\`...\`` template body. Docs
|
|
35
|
+
* pages legitimately render such examples to teach users; without
|
|
36
|
+
* redaction the scanner reads them as real declarations and emits
|
|
37
|
+
* false positives.
|
|
38
|
+
*
|
|
39
|
+
* Template literals split by tag + shape:
|
|
40
|
+
*
|
|
41
|
+
* Preserved verbatim only when ALL of: untagged, no newline in the
|
|
42
|
+
* body, no `${...}` interpolation. This is the "backticks as a
|
|
43
|
+
* quote-style alias" shape, e.g. `` register(`my-tag`) ``, where
|
|
44
|
+
* the backtick literal is morally a short string argument. Lint
|
|
45
|
+
* rules then read it the same way they read `register('my-tag')`.
|
|
46
|
+
*
|
|
47
|
+
* Blanked in every other case:
|
|
48
|
+
* (a) TAGGED templates like `` html`...` ``, `` css`...` ``,
|
|
49
|
+
* `` Class.method`...` ``, which carry multi-line code-shaped
|
|
50
|
+
* strings in docs pages and JSDoc examples.
|
|
51
|
+
* (b) Multi-line untagged literals, typically code-shaped
|
|
52
|
+
* fixtures the linter should not read in place.
|
|
53
|
+
* (c) Interpolated literals; the `${...}` body is dynamic and
|
|
54
|
+
* cannot be statically validated anyway.
|
|
55
|
+
*
|
|
56
|
+
* A real `register('foo')` call inside a blanked region (e.g.
|
|
57
|
+
* inside a tagged interpolation `` html`${X.register('foo')}` ``)
|
|
58
|
+
* disappears from the lint surface. Accepted trade-off: register()
|
|
59
|
+
* calls in practice live at top-level in component files, not
|
|
60
|
+
* inside template interpolations.
|
|
61
|
+
*
|
|
62
|
+
* Regex literals ARE tracked. A `/.../` in expression position (decided by
|
|
63
|
+
* the previous significant token, the standard regex-versus-division rule)
|
|
64
|
+
* has its body blanked with the `/` delimiters kept, so a quote, brace, or
|
|
65
|
+
* comment-like sequence inside a regex cannot desync the walker. Template
|
|
66
|
+
* literals are tracked with full `${...}` interpolation and arbitrary
|
|
67
|
+
* nesting, so a nested `` html`...${html`...`}...` `` is delimited correctly
|
|
68
|
+
* (the inner backtick is not mistaken for the outer close).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} src
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function redactStringsAndTemplates(src) {
|
|
74
|
+
const n = src.length;
|
|
75
|
+
let out = '';
|
|
76
|
+
let i = 0;
|
|
77
|
+
// Previous significant token in code position, tracked as we walk (more
|
|
78
|
+
// robust than scanning `out`, whose tail is blanked spaces inside a hole).
|
|
79
|
+
// `lastSig` is the last non-whitespace source char; `lastWord` is the last
|
|
80
|
+
// identifier. Both drive regex-versus-division and tagged-template decisions.
|
|
81
|
+
let lastSig = '';
|
|
82
|
+
let lastWord = '';
|
|
83
|
+
// Whether `lastWord` was a property access (`.of`, `?.in`). A member named
|
|
84
|
+
// like a keyword is a value, never a regex-preceding keyword.
|
|
85
|
+
let lastWordIsProp = false;
|
|
86
|
+
// Whether the last two significant chars formed a postfix `++` / `--`. A
|
|
87
|
+
// postfix increment/decrement yields a value, so a following `/` is division
|
|
88
|
+
// (`a++ / 2`), not a regex start. Without this the `/` opens a phantom regex
|
|
89
|
+
// that blanks to the next `/`, swallowing a following module-scope call.
|
|
90
|
+
let lastWasIncDec = false;
|
|
91
|
+
// After a literal (string/regex/template) the next `/` is division and the
|
|
92
|
+
// next backtick is a tag, so mark a value-ender.
|
|
93
|
+
const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; };
|
|
94
|
+
|
|
95
|
+
// `/` opens a regex unless the previous token is a value (identifier that is
|
|
96
|
+
// not a regex-preceding keyword, number, `)`, `]`, or a literal).
|
|
97
|
+
const isRegex = () => {
|
|
98
|
+
if (lastSig === '') return true;
|
|
99
|
+
if (lastSig === ')' || lastSig === ']') return false;
|
|
100
|
+
if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false;
|
|
101
|
+
if (lastWasIncDec) return false; // postfix `a++` / `a--` is a value
|
|
102
|
+
if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
|
|
103
|
+
return true;
|
|
104
|
+
};
|
|
105
|
+
// A template is tagged when the previous token is a value.
|
|
106
|
+
const isTagged = () => /[\w$)\]'"`]/.test(lastSig);
|
|
107
|
+
|
|
108
|
+
const scanLineComment = () => {
|
|
109
|
+
out += '//'; i += 2;
|
|
110
|
+
while (i < n && src[i] !== '\n') { out += ' '; i++; }
|
|
111
|
+
};
|
|
112
|
+
const scanBlockComment = () => {
|
|
113
|
+
out += '/*'; i += 2;
|
|
114
|
+
while (i < n) {
|
|
115
|
+
if (src[i] === '*' && src[i + 1] === '/') { out += '*/'; i += 2; return; }
|
|
116
|
+
out += src[i] === '\n' ? '\n' : ' '; i++;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const scanRegex = () => {
|
|
120
|
+
out += '/'; i++;
|
|
121
|
+
let inClass = false;
|
|
122
|
+
while (i < n) {
|
|
123
|
+
const d = src[i];
|
|
124
|
+
if (d === '\\' && i + 1 < n) { out += ' '; i += 2; continue; }
|
|
125
|
+
if (d === '\n') break; // unterminated regex
|
|
126
|
+
if (d === '[') inClass = true;
|
|
127
|
+
else if (d === ']') inClass = false;
|
|
128
|
+
else if (d === '/' && !inClass) { out += '/'; i++; break; }
|
|
129
|
+
out += ' '; i++;
|
|
130
|
+
}
|
|
131
|
+
markValue();
|
|
132
|
+
};
|
|
133
|
+
// Strings: KEEP the body verbatim at top level (so tag-name-has-hyphen can
|
|
134
|
+
// read register('foo')); blank it when inside an already-blanked hole.
|
|
135
|
+
const scanString = (q, blank) => {
|
|
136
|
+
out += q; i++;
|
|
137
|
+
while (i < n) {
|
|
138
|
+
if (src[i] === '\\' && i + 1 < n) { out += blank ? ' ' : src[i] + src[i + 1]; i += 2; continue; }
|
|
139
|
+
if (src[i] === q) { out += q; i++; break; }
|
|
140
|
+
if (src[i] === '\n') { out += '\n'; i++; break; } // unterminated
|
|
141
|
+
out += blank ? ' ' : src[i]; i++;
|
|
142
|
+
}
|
|
143
|
+
markValue();
|
|
144
|
+
};
|
|
145
|
+
// Template literal. `forceBlank` is set when already inside a blanked hole
|
|
146
|
+
// (everything nested blanks regardless of tag/shape).
|
|
147
|
+
const scanTemplate = (forceBlank) => {
|
|
148
|
+
const tagged = isTagged();
|
|
149
|
+
let hasInterp = false, hasNewline = false, closed = false, depth = 0, k = i + 1;
|
|
150
|
+
while (k < n) {
|
|
151
|
+
const ch = src[k];
|
|
152
|
+
if (ch === '\\') { k += 2; continue; }
|
|
153
|
+
if (depth === 0 && ch === '`') { closed = true; break; }
|
|
154
|
+
if (ch === '$' && src[k + 1] === '{') { hasInterp = true; depth++; k += 2; continue; }
|
|
155
|
+
else if (ch === '{' && depth > 0) depth++;
|
|
156
|
+
else if (ch === '}' && depth > 0) depth--;
|
|
157
|
+
if (ch === '\n') hasNewline = true;
|
|
158
|
+
k++;
|
|
159
|
+
}
|
|
160
|
+
const verbatim = !forceBlank && !tagged && closed && !hasNewline && !hasInterp;
|
|
161
|
+
out += '`'; i++;
|
|
162
|
+
if (verbatim) {
|
|
163
|
+
while (i < n) {
|
|
164
|
+
if (src[i] === '\\' && i + 1 < n) { out += src[i] + src[i + 1]; i += 2; continue; }
|
|
165
|
+
if (src[i] === '`') { out += '`'; i++; break; }
|
|
166
|
+
out += src[i]; i++;
|
|
167
|
+
}
|
|
168
|
+
markValue();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Blanked template: blank the literal text, recurse through `${...}` holes
|
|
172
|
+
// (scanned as blanked code, so nested templates/strings/regexes inside a
|
|
173
|
+
// hole are delimited correctly and never desync the outer scan).
|
|
174
|
+
while (i < n) {
|
|
175
|
+
const c = src[i];
|
|
176
|
+
if (c === '\\' && i + 1 < n) { out += ' '; out += src[i + 1] === '\n' ? '\n' : ' '; i += 2; continue; }
|
|
177
|
+
if (c === '`') { out += '`'; i++; break; }
|
|
178
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
179
|
+
out += ' '; i += 2;
|
|
180
|
+
scanCode(true, true);
|
|
181
|
+
if (i < n && src[i] === '}') { out += ' '; i++; }
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
out += c === '\n' ? '\n' : ' '; i++;
|
|
185
|
+
}
|
|
186
|
+
markValue();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Scan code. `stopHole`: return at the `}` that closes the enclosing template
|
|
190
|
+
// hole (the caller emits it). `blank`: emit spaces for code (inside a blanked
|
|
191
|
+
// hole). Literals are always lexed so braces/quotes inside them never count.
|
|
192
|
+
function scanCode(stopHole, blank) {
|
|
193
|
+
let brace = 0;
|
|
194
|
+
while (i < n) {
|
|
195
|
+
const c = src[i], next = src[i + 1];
|
|
196
|
+
if (stopHole && c === '}' && brace === 0) return;
|
|
197
|
+
if (c === '/' && next === '/') { scanLineComment(); continue; }
|
|
198
|
+
if (c === '/' && next === '*') { scanBlockComment(); continue; }
|
|
199
|
+
if (c === '/' && isRegex()) { scanRegex(); continue; }
|
|
200
|
+
if (c === "'" || c === '"') { scanString(c, blank); continue; }
|
|
201
|
+
if (c === '`') { scanTemplate(blank); continue; }
|
|
202
|
+
if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
|
|
203
|
+
if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
|
|
204
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
205
|
+
const prop = lastSig === '.'; // member access -> a value, not a keyword
|
|
206
|
+
let w = '';
|
|
207
|
+
while (i < n && /[\w$]/.test(src[i])) { w += src[i]; out += blank ? ' ' : src[i]; i++; }
|
|
208
|
+
lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (/\s/.test(c)) { out += c === '\n' ? '\n' : (blank ? ' ' : c); i++; continue; }
|
|
212
|
+
// A `++` / `--` repeats the operator char; the second one forms a postfix
|
|
213
|
+
// op when it followed a value (identifier / `)` / `]`), the only case that
|
|
214
|
+
// matters for the regex-vs-division decision here.
|
|
215
|
+
lastWasIncDec = (c === '+' || c === '-') && c === lastSig;
|
|
216
|
+
lastSig = c; lastWord = ''; out += blank ? ' ' : c; i++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
scanCode(false, false);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Extract the body of every `class … extends WebComponent { … }` block.
|
|
226
|
+
* Brace-counts to handle nested template literals, methods, and arrow
|
|
227
|
+
* functions. String state is tracked so braces inside strings/templates
|
|
228
|
+
* don't shift depth.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} content
|
|
231
|
+
* @returns {string[]}
|
|
232
|
+
*/
|
|
233
|
+
export function extractWebComponentClassBodies(content) {
|
|
234
|
+
const bodies = [];
|
|
235
|
+
const re = /class\s+\w+\s+extends\s+WebComponent\s*\{/g;
|
|
236
|
+
let m;
|
|
237
|
+
while ((m = re.exec(content)) !== null) {
|
|
238
|
+
const bodyStart = m.index + m[0].length;
|
|
239
|
+
const end = matchClosingBrace(content, bodyStart);
|
|
240
|
+
if (end !== -1) bodies.push(content.slice(bodyStart, end));
|
|
241
|
+
}
|
|
242
|
+
return bodies;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Walk forward from `start` (just after an opening `{`) and return the
|
|
247
|
+
* index of the matching `}`. Tracks string/template-literal state so
|
|
248
|
+
* `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
|
|
249
|
+
* Returns -1 if no balanced brace is found.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} s
|
|
252
|
+
* @param {number} start
|
|
253
|
+
*/
|
|
254
|
+
export function matchClosingBrace(s, start) {
|
|
255
|
+
let depth = 1;
|
|
256
|
+
let i = start;
|
|
257
|
+
let str = ''; // '', "'", '"', or backtick
|
|
258
|
+
while (i < s.length) {
|
|
259
|
+
const c = s[i];
|
|
260
|
+
if (str) {
|
|
261
|
+
if (c === '\\') { i += 2; continue; }
|
|
262
|
+
if (c === str) str = '';
|
|
263
|
+
else if (str === '`' && c === '$' && s[i + 1] === '{') {
|
|
264
|
+
// template hole, count its closing `}` toward our brace depth.
|
|
265
|
+
depth++;
|
|
266
|
+
i += 2;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
|
|
273
|
+
if (c === '/' && s[i + 1] === '/') { // line comment
|
|
274
|
+
while (i < s.length && s[i] !== '\n') i++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (c === '/' && s[i + 1] === '*') { // block comment
|
|
278
|
+
i += 2;
|
|
279
|
+
while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
|
280
|
+
i += 2;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (c === '{') depth++;
|
|
284
|
+
else if (c === '}') { depth--; if (depth === 0) return i; }
|
|
285
|
+
i++;
|
|
286
|
+
}
|
|
287
|
+
return -1;
|
|
288
|
+
}
|