@colixsystems/widget-sdk 0.187.0 → 0.189.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 +37 -1
- package/dist/cli.js +14 -3
- package/dist/contract.cjs +64 -3
- package/dist/contract.js +64 -3
- package/dist/dev-shims.js +6 -69
- package/dist/index.d.ts +4 -0
- package/dist/linter.cjs +210 -54
- package/dist/linter.js +211 -64
- package/package.json +3 -2
- package/src/contract.js +5317 -0
- package/src/dev-shims.js +6 -69
package/dist/linter.cjs
CHANGED
|
@@ -221,8 +221,25 @@ function bannedIdentifiers() {
|
|
|
221
221
|
|
|
222
222
|
// REQ-WSDK-PLATFORM §3.4, §8: import-specifier validation. See linter.js
|
|
223
223
|
// for the source-of-truth comment.
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
// The import clause follows the grammar (default, `* as ns`, `{ … }`), so
|
|
225
|
+
// prose that merely says "import … from" rarely matches. A brace list stops
|
|
226
|
+
// at the next import/export keyword and no two quantifiers share whitespace,
|
|
227
|
+
// keeping the whole-source scan linear on hostile input.
|
|
228
|
+
const _ID_CHAR = "[\\p{ID_Continue}$\\\\]";
|
|
229
|
+
const _NAMED = `\\{(?:(?!\\b(?:import|export)\\b)(?:[^}"';]|"[^"\\n]*"|'[^'\\n]*'))*\\}`;
|
|
230
|
+
const _NAMESPACE = `\\*\\s*as\\s+${_ID_CHAR}+`;
|
|
231
|
+
const _IMPORT_CLAUSE =
|
|
232
|
+
`(?:${_ID_CHAR}+(?:\\s*,\\s*(?:${_NAMESPACE}|${_NAMED}))?` +
|
|
233
|
+
`|${_NAMESPACE}|${_NAMED})`;
|
|
234
|
+
const IMPORT_SPECIFIER_RE = new RegExp(
|
|
235
|
+
// Not part of an identifier, a member access, CSS `@import` or JSX text.
|
|
236
|
+
`(?<![\\p{ID_Continue}$.@#'"\`>])` +
|
|
237
|
+
`(?:import(?!${_ID_CHAR})\\s*(?:${_IMPORT_CLAUSE}\\s*from\\s*)?(?:\\(\\s*)?` +
|
|
238
|
+
`|export(?!${_ID_CHAR})\\s*(?:\\*(?:\\s*as\\s+${_ID_CHAR}+)?|${_NAMED})\\s*from\\s*` +
|
|
239
|
+
`|require(?!${_ID_CHAR})\\s*\\(\\s*)` +
|
|
240
|
+
`['"]([^'"\\s]+)['"]`,
|
|
241
|
+
"gu",
|
|
242
|
+
);
|
|
226
243
|
|
|
227
244
|
function _classifySpecifier(spec) {
|
|
228
245
|
if (
|
|
@@ -252,10 +269,30 @@ function _packageRoot(spec) {
|
|
|
252
269
|
const parts = spec.split("/");
|
|
253
270
|
// A traversal / empty segment is a non-canonical specifier — never resolve it
|
|
254
271
|
// to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
|
|
255
|
-
if (parts.some((p) => p === "" || p === "." || p === ".."))
|
|
272
|
+
if (parts.some((p) => p === "" || p === "." || p === ".." || p.includes("\\"))) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
256
275
|
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
257
276
|
}
|
|
258
277
|
|
|
278
|
+
/**
|
|
279
|
+
* The package roots whose SUBPATH imports load in a file whose manifest is
|
|
280
|
+
* narrowed to it (`narrowManifestForFile`). The web loader shims the packages
|
|
281
|
+
* the host provides by exact name, so a file that ships to web may import a
|
|
282
|
+
* subpath only of a bundled vetted package; Metro, on native, resolves any
|
|
283
|
+
* real subpath of a vetted package. Shared with the agent render sandbox.
|
|
284
|
+
*/
|
|
285
|
+
function subpathImportRootsFor(manifest) {
|
|
286
|
+
const platforms =
|
|
287
|
+
manifest && Array.isArray(manifest.supportedPlatforms)
|
|
288
|
+
? manifest.supportedPlatforms
|
|
289
|
+
: [];
|
|
290
|
+
const nativeOnly = platforms.length > 0 && !platforms.includes("web");
|
|
291
|
+
return nativeOnly
|
|
292
|
+
? [...CONTRACT.allowedBareImports]
|
|
293
|
+
: [...CONTRACT.subpathImportRoots];
|
|
294
|
+
}
|
|
295
|
+
|
|
259
296
|
function _importRules(source, manifest) {
|
|
260
297
|
const findings = [];
|
|
261
298
|
const allowed = new Map(
|
|
@@ -275,60 +312,86 @@ function _importRules(source, manifest) {
|
|
|
275
312
|
manifest.supportedPlatforms.length > 0
|
|
276
313
|
? new Set(manifest.supportedPlatforms)
|
|
277
314
|
: null;
|
|
315
|
+
const subpathRoots = new Set(subpathImportRootsFor(manifest));
|
|
278
316
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
317
|
+
// Whole-source scan: a Prettier-wrapped import spans several lines. Both
|
|
318
|
+
// the raw and the comment-masked text are read, because the masker cannot
|
|
319
|
+
// see regex literals and a `/"/` would blank a real import out of it.
|
|
320
|
+
const specifiersByEnd = new Map();
|
|
321
|
+
for (const text of [source, _stripNonCode(source, { keepStrings: true })]) {
|
|
282
322
|
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
283
323
|
let m;
|
|
284
|
-
while ((m = IMPORT_SPECIFIER_RE.exec(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
324
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(text))) {
|
|
325
|
+
specifiersByEnd.set(m.index + m[0].length, m[1]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
329
|
+
const sourceLines = source.split(/\r?\n/);
|
|
330
|
+
const lineAt = _lineCounter(source);
|
|
331
|
+
const ordered = [...specifiersByEnd].sort((a, b) => a[0] - b[0]);
|
|
332
|
+
for (const [end, spec] of ordered) {
|
|
333
|
+
const line = lineAt(end);
|
|
334
|
+
const snippet = (sourceLines[line - 1] || "").trim().slice(0, 200);
|
|
335
|
+
const kind = _classifySpecifier(spec);
|
|
336
|
+
if (kind === "relative-ok") continue;
|
|
337
|
+
if (kind === "relative-escape") {
|
|
338
|
+
findings.push({
|
|
339
|
+
rule: "no-parent-relative-import",
|
|
340
|
+
label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
|
|
341
|
+
line,
|
|
342
|
+
snippet,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (kind === "absolute") {
|
|
347
|
+
findings.push({
|
|
348
|
+
rule: "no-absolute-import",
|
|
349
|
+
label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
|
|
350
|
+
line,
|
|
351
|
+
snippet,
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// Bare specifier — validate against the vetted list. A subpath/asset
|
|
356
|
+
// import (leaflet/dist/leaflet.css) resolves to its vetted package root.
|
|
357
|
+
let entry = allowed.get(spec);
|
|
358
|
+
const root = entry ? null : _packageRoot(spec);
|
|
359
|
+
if (root && subpathRoots.has(root)) entry = allowed.get(root);
|
|
360
|
+
if (!entry) {
|
|
361
|
+
findings.push({
|
|
362
|
+
rule: "import-not-vetted",
|
|
363
|
+
label:
|
|
364
|
+
root && allowed.has(root)
|
|
365
|
+
? `import "${spec}" is a subpath of "${root}", which the web ` +
|
|
366
|
+
`host provides by its exact name only, so it cannot load in ` +
|
|
367
|
+
`the web Player — import from "${root}" itself (a file that ` +
|
|
368
|
+
`ships only to native may keep the subpath)`
|
|
369
|
+
: `import "${spec}" is not on the vetted package list — see ` +
|
|
370
|
+
`CONTRACT.vettedImports for the current set or open a request to add it`,
|
|
371
|
+
line,
|
|
372
|
+
snippet,
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// If the manifest declares supportedPlatforms, every imported
|
|
377
|
+
// specifier must support every declared platform. A widget that
|
|
378
|
+
// imports react-native-maps (native-only) cannot honestly claim
|
|
379
|
+
// supportedPlatforms: ["web", "native"] from a single source file —
|
|
380
|
+
// it must either drop "web" from the manifest, OR move that import
|
|
381
|
+
// into widget.native.jsx so the web bundle doesn't see it.
|
|
382
|
+
if (declaredPlatforms) {
|
|
383
|
+
for (const wanted of declaredPlatforms) {
|
|
384
|
+
if (!entry.platforms.includes(wanted)) {
|
|
385
|
+
findings.push({
|
|
386
|
+
rule: "import-platform-mismatch",
|
|
387
|
+
label:
|
|
388
|
+
`"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
|
|
389
|
+
`manifest claims supportedPlatforms includes "${wanted}". ` +
|
|
390
|
+
`Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
|
|
391
|
+
`or drop "${wanted}" from manifest.supportedPlatforms.`,
|
|
392
|
+
line,
|
|
393
|
+
snippet,
|
|
394
|
+
});
|
|
332
395
|
}
|
|
333
396
|
}
|
|
334
397
|
}
|
|
@@ -336,6 +399,97 @@ function _importRules(source, manifest) {
|
|
|
336
399
|
return findings;
|
|
337
400
|
}
|
|
338
401
|
|
|
402
|
+
// sc-10466 — Leaflet is bundled per widget; no host supplies its stylesheet.
|
|
403
|
+
// Without `leaflet/dist/leaflet.css` the tiles, panes and markers are
|
|
404
|
+
// unpositioned: a patch of map in an empty box, and no visible pin. And once
|
|
405
|
+
// the CSS is injected inline its default marker image resolves against the
|
|
406
|
+
// page URL and 404s, so a `<Marker>` needs an explicit `icon`.
|
|
407
|
+
const _LEAFLET_ROOTS = new Set(["leaflet", "react-leaflet"]);
|
|
408
|
+
const _LEAFLET_CSS = "leaflet/dist/leaflet.css";
|
|
409
|
+
const _MARKER_TAG_RE = /<Marker\b/g;
|
|
410
|
+
|
|
411
|
+
const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
|
|
412
|
+
const _ICON_DEFAULT_CONFIGURED_RE =
|
|
413
|
+
/\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
|
|
414
|
+
|
|
415
|
+
// Line of an index in `code`, counted forward: indexes must not decrease.
|
|
416
|
+
function _lineCounter(code) {
|
|
417
|
+
let line = 1;
|
|
418
|
+
let at = 0;
|
|
419
|
+
return (index) => {
|
|
420
|
+
for (; at < index; at += 1) {
|
|
421
|
+
if (code.charCodeAt(at) === 10) line += 1;
|
|
422
|
+
}
|
|
423
|
+
return line;
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function _leafletRules(source) {
|
|
428
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
429
|
+
const sourceLines = source.split(/\r?\n/);
|
|
430
|
+
let firstLeafletLine = 0;
|
|
431
|
+
let importsCss = false;
|
|
432
|
+
let importsReactLeaflet = false;
|
|
433
|
+
const importLineAt = _lineCounter(code);
|
|
434
|
+
// Whole-source scan: a Prettier-wrapped import spans several lines.
|
|
435
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
436
|
+
let m;
|
|
437
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(code))) {
|
|
438
|
+
const spec = m[1];
|
|
439
|
+
if (spec === _LEAFLET_CSS) importsCss = true;
|
|
440
|
+
const root = _packageRoot(spec);
|
|
441
|
+
if (!_LEAFLET_ROOTS.has(root) || _ASSET_SPECIFIER_RE.test(spec)) continue;
|
|
442
|
+
if (root === "react-leaflet") importsReactLeaflet = true;
|
|
443
|
+
if (!firstLeafletLine) firstLeafletLine = importLineAt(m.index + m[0].length);
|
|
444
|
+
}
|
|
445
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
446
|
+
if (!firstLeafletLine) return [];
|
|
447
|
+
const findings = [];
|
|
448
|
+
if (!importsCss) {
|
|
449
|
+
findings.push({
|
|
450
|
+
rule: "leaflet-css-missing",
|
|
451
|
+
severity: "error",
|
|
452
|
+
label:
|
|
453
|
+
`this file uses Leaflet but never imports its stylesheet — without ` +
|
|
454
|
+
`it the map draws a partial patch of tiles inside an empty box and ` +
|
|
455
|
+
`markers are invisible. Add \`import "${_LEAFLET_CSS}";\` to this file.`,
|
|
456
|
+
line: firstLeafletLine,
|
|
457
|
+
snippet: (sourceLines[firstLeafletLine - 1] || "").trim().slice(0, 200),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (
|
|
461
|
+
!importsReactLeaflet ||
|
|
462
|
+
_ICON_DEFAULT_CONFIGURED_RE.test(_stripNonCode(source))
|
|
463
|
+
) {
|
|
464
|
+
return findings;
|
|
465
|
+
}
|
|
466
|
+
const markerLineAt = _lineCounter(code);
|
|
467
|
+
_MARKER_TAG_RE.lastIndex = 0;
|
|
468
|
+
let tag;
|
|
469
|
+
while ((tag = _MARKER_TAG_RE.exec(code))) {
|
|
470
|
+
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
471
|
+
if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
|
|
472
|
+
const line = markerLineAt(tag.index);
|
|
473
|
+
findings.push({
|
|
474
|
+
rule: "leaflet-default-marker-icon",
|
|
475
|
+
severity: "warning",
|
|
476
|
+
label:
|
|
477
|
+
`<Marker> uses Leaflet's default icon, whose image path cannot ` +
|
|
478
|
+
`resolve inside a widget bundle, so the pin does not show. Import the ` +
|
|
479
|
+
`images (\`import markerIcon from "leaflet/dist/images/marker-icon.png"\`, ` +
|
|
480
|
+
`plus marker-icon-2x.png and marker-shadow.png), build ` +
|
|
481
|
+
`\`L.icon({ iconUrl, iconRetinaUrl, shadowUrl, iconSize: [25, 41], ` +
|
|
482
|
+
`iconAnchor: [12, 41] })\` once at module scope, and pass it as ` +
|
|
483
|
+
`\`icon={…}\`. Warning only — a widget that sets \`L.Icon.Default\` ` +
|
|
484
|
+
`itself (\`mergeOptions\` / \`imagePath\`) is not flagged.`,
|
|
485
|
+
line,
|
|
486
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
487
|
+
});
|
|
488
|
+
_MARKER_TAG_RE.lastIndex = end;
|
|
489
|
+
}
|
|
490
|
+
return findings;
|
|
491
|
+
}
|
|
492
|
+
|
|
339
493
|
// Explicit opt-out. A widget legitimately calling a THIRD-PARTY API with a
|
|
340
494
|
// relative path against an `axios` baseURL cannot avoid the substring match,
|
|
341
495
|
// and post-sc-3589 an unavoidable finding burns AI repair turns on correct
|
|
@@ -1853,6 +2007,7 @@ function lintSource(source, options) {
|
|
|
1853
2007
|
severity: f.severity || "error",
|
|
1854
2008
|
})),
|
|
1855
2009
|
);
|
|
2010
|
+
findings.push(..._leafletRules(source));
|
|
1856
2011
|
findings.push(..._hostApiUrlRules(source));
|
|
1857
2012
|
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
|
1858
2013
|
findings.push(..._htmlInContentRules(source));
|
|
@@ -1896,4 +2051,5 @@ module.exports = {
|
|
|
1896
2051
|
lintEditability,
|
|
1897
2052
|
bannedIdentifiers,
|
|
1898
2053
|
narrowManifestForFile,
|
|
2054
|
+
subpathImportRootsFor,
|
|
1899
2055
|
};
|
package/dist/linter.js
CHANGED
|
@@ -94,9 +94,10 @@ export function bannedIdentifiers() {
|
|
|
94
94
|
//
|
|
95
95
|
// Recognised forms (string-level, no AST):
|
|
96
96
|
// import X from "spec"
|
|
97
|
-
// import { … } from "spec"
|
|
97
|
+
// import { … } from "spec" (also wrapped over several lines)
|
|
98
98
|
// import * as X from "spec"
|
|
99
99
|
// import "spec"
|
|
100
|
+
// export { … } from "spec" / export * from "spec"
|
|
100
101
|
// const X = require("spec") // CJS — for completeness
|
|
101
102
|
//
|
|
102
103
|
// Relative imports within the bundle (`./foo.js`, `./shared/util.js`) are
|
|
@@ -104,8 +105,25 @@ export function bannedIdentifiers() {
|
|
|
104
105
|
// between `widget.web.jsx` and `widget.native.jsx`. Parent-directory
|
|
105
106
|
// (`../`) and absolute (`/`, `http(s):`) paths are rejected to keep the
|
|
106
107
|
// bundle scope sealed.
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
// The import clause follows the grammar (default, `* as ns`, `{ … }`), so
|
|
109
|
+
// prose that merely says "import … from" rarely matches. A brace list stops
|
|
110
|
+
// at the next import/export keyword and no two quantifiers share whitespace,
|
|
111
|
+
// keeping the whole-source scan linear on hostile input.
|
|
112
|
+
const _ID_CHAR = "[\\p{ID_Continue}$\\\\]";
|
|
113
|
+
const _NAMED = `\\{(?:(?!\\b(?:import|export)\\b)(?:[^}"';]|"[^"\\n]*"|'[^'\\n]*'))*\\}`;
|
|
114
|
+
const _NAMESPACE = `\\*\\s*as\\s+${_ID_CHAR}+`;
|
|
115
|
+
const _IMPORT_CLAUSE =
|
|
116
|
+
`(?:${_ID_CHAR}+(?:\\s*,\\s*(?:${_NAMESPACE}|${_NAMED}))?` +
|
|
117
|
+
`|${_NAMESPACE}|${_NAMED})`;
|
|
118
|
+
const IMPORT_SPECIFIER_RE = new RegExp(
|
|
119
|
+
// Not part of an identifier, a member access, CSS `@import` or JSX text.
|
|
120
|
+
`(?<![\\p{ID_Continue}$.@#'"\`>])` +
|
|
121
|
+
`(?:import(?!${_ID_CHAR})\\s*(?:${_IMPORT_CLAUSE}\\s*from\\s*)?(?:\\(\\s*)?` +
|
|
122
|
+
`|export(?!${_ID_CHAR})\\s*(?:\\*(?:\\s*as\\s+${_ID_CHAR}+)?|${_NAMED})\\s*from\\s*` +
|
|
123
|
+
`|require(?!${_ID_CHAR})\\s*\\(\\s*)` +
|
|
124
|
+
`['"]([^'"\\s]+)['"]`,
|
|
125
|
+
"gu",
|
|
126
|
+
);
|
|
109
127
|
|
|
110
128
|
function _classifySpecifier(spec) {
|
|
111
129
|
if (
|
|
@@ -135,10 +153,30 @@ function _packageRoot(spec) {
|
|
|
135
153
|
const parts = spec.split("/");
|
|
136
154
|
// A traversal / empty segment is a non-canonical specifier — never resolve it
|
|
137
155
|
// to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
|
|
138
|
-
if (parts.some((p) => p === "" || p === "." || p === ".."))
|
|
156
|
+
if (parts.some((p) => p === "" || p === "." || p === ".." || p.includes("\\"))) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
139
159
|
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
140
160
|
}
|
|
141
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The package roots whose SUBPATH imports load in a file whose manifest is
|
|
164
|
+
* narrowed to it (`narrowManifestForFile`). The web loader shims the packages
|
|
165
|
+
* the host provides by exact name, so a file that ships to web may import a
|
|
166
|
+
* subpath only of a bundled vetted package; Metro, on native, resolves any
|
|
167
|
+
* real subpath of a vetted package. Shared with the agent render sandbox.
|
|
168
|
+
*/
|
|
169
|
+
export function subpathImportRootsFor(manifest) {
|
|
170
|
+
const platforms =
|
|
171
|
+
manifest && Array.isArray(manifest.supportedPlatforms)
|
|
172
|
+
? manifest.supportedPlatforms
|
|
173
|
+
: [];
|
|
174
|
+
const nativeOnly = platforms.length > 0 && !platforms.includes("web");
|
|
175
|
+
return nativeOnly
|
|
176
|
+
? [...CONTRACT.allowedBareImports]
|
|
177
|
+
: [...CONTRACT.subpathImportRoots];
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
function _importRules(source, manifest) {
|
|
143
181
|
const findings = [];
|
|
144
182
|
const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
|
|
@@ -159,69 +197,86 @@ function _importRules(source, manifest) {
|
|
|
159
197
|
manifest.supportedPlatforms.length > 0
|
|
160
198
|
? new Set(manifest.supportedPlatforms)
|
|
161
199
|
: null;
|
|
200
|
+
const subpathRoots = new Set(subpathImportRootsFor(manifest));
|
|
162
201
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
202
|
+
// Whole-source scan: a Prettier-wrapped import spans several lines. Both
|
|
203
|
+
// the raw and the comment-masked text are read, because the masker cannot
|
|
204
|
+
// see regex literals and a `/"/` would blank a real import out of it.
|
|
205
|
+
const specifiersByEnd = new Map();
|
|
206
|
+
for (const text of [source, _stripNonCode(source, { keepStrings: true })]) {
|
|
167
207
|
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
168
208
|
let m;
|
|
169
|
-
while ((m = IMPORT_SPECIFIER_RE.exec(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
209
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(text))) {
|
|
210
|
+
specifiersByEnd.set(m.index + m[0].length, m[1]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
214
|
+
const sourceLines = source.split(/\r?\n/);
|
|
215
|
+
const lineAt = _lineCounter(source);
|
|
216
|
+
const ordered = [...specifiersByEnd].sort((a, b) => a[0] - b[0]);
|
|
217
|
+
for (const [end, spec] of ordered) {
|
|
218
|
+
const line = lineAt(end);
|
|
219
|
+
const snippet = (sourceLines[line - 1] || "").trim().slice(0, 200);
|
|
220
|
+
const kind = _classifySpecifier(spec);
|
|
221
|
+
if (kind === "relative-ok") continue;
|
|
222
|
+
if (kind === "relative-escape") {
|
|
223
|
+
findings.push({
|
|
224
|
+
rule: "no-parent-relative-import",
|
|
225
|
+
label: `relative import "${spec}" escapes the bundle (\`../\`) — only sibling-file imports (\`./foo.js\`) are allowed`,
|
|
226
|
+
line,
|
|
227
|
+
snippet,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (kind === "absolute") {
|
|
232
|
+
findings.push({
|
|
233
|
+
rule: "no-absolute-import",
|
|
234
|
+
label: `absolute import "${spec}" is not allowed — use a vetted bare specifier or a sibling-file (\`./foo.js\`) import`,
|
|
235
|
+
line,
|
|
236
|
+
snippet,
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Bare specifier — validate against the vetted list. A subpath/asset
|
|
241
|
+
// import (leaflet/dist/leaflet.css) resolves to its vetted package root.
|
|
242
|
+
let entry = allowed.get(spec);
|
|
243
|
+
const root = entry ? null : _packageRoot(spec);
|
|
244
|
+
if (root && subpathRoots.has(root)) entry = allowed.get(root);
|
|
245
|
+
if (!entry) {
|
|
246
|
+
findings.push({
|
|
247
|
+
rule: "import-not-vetted",
|
|
248
|
+
label:
|
|
249
|
+
root && allowed.has(root)
|
|
250
|
+
? `import "${spec}" is a subpath of "${root}", which the web ` +
|
|
251
|
+
`host provides by its exact name only, so it cannot load in ` +
|
|
252
|
+
`the web Player — import from "${root}" itself (a file that ` +
|
|
253
|
+
`ships only to native may keep the subpath)`
|
|
254
|
+
: `import "${spec}" is not on the vetted package list — see ` +
|
|
255
|
+
`CONTRACT.vettedImports for the current set or open a request to add it`,
|
|
256
|
+
line,
|
|
257
|
+
snippet,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// If the manifest declares supportedPlatforms, every imported
|
|
262
|
+
// specifier must support every declared platform. A widget that
|
|
263
|
+
// imports react-native-maps (native-only) cannot honestly claim
|
|
264
|
+
// supportedPlatforms: ["web", "native"] from a single source file —
|
|
265
|
+
// it must either drop "web" from the manifest, OR move that import
|
|
266
|
+
// into widget.native.jsx so the web bundle doesn't see it.
|
|
267
|
+
if (declaredPlatforms) {
|
|
268
|
+
for (const wanted of declaredPlatforms) {
|
|
269
|
+
if (!entry.platforms.includes(wanted)) {
|
|
270
|
+
findings.push({
|
|
271
|
+
rule: "import-platform-mismatch",
|
|
272
|
+
label:
|
|
273
|
+
`"${spec}" supports [${entry.platforms.join(", ")}] but the ` +
|
|
274
|
+
`manifest claims supportedPlatforms includes "${wanted}". ` +
|
|
275
|
+
`Move this import into widget.${wanted === "web" ? "native" : "web"}.jsx, ` +
|
|
276
|
+
`or drop "${wanted}" from manifest.supportedPlatforms.`,
|
|
277
|
+
line,
|
|
278
|
+
snippet,
|
|
279
|
+
});
|
|
225
280
|
}
|
|
226
281
|
}
|
|
227
282
|
}
|
|
@@ -229,6 +284,97 @@ function _importRules(source, manifest) {
|
|
|
229
284
|
return findings;
|
|
230
285
|
}
|
|
231
286
|
|
|
287
|
+
// sc-10466 — Leaflet is bundled per widget; no host supplies its stylesheet.
|
|
288
|
+
// Without `leaflet/dist/leaflet.css` the tiles, panes and markers are
|
|
289
|
+
// unpositioned: a patch of map in an empty box, and no visible pin. And once
|
|
290
|
+
// the CSS is injected inline its default marker image resolves against the
|
|
291
|
+
// page URL and 404s, so a `<Marker>` needs an explicit `icon`.
|
|
292
|
+
const _LEAFLET_ROOTS = new Set(["leaflet", "react-leaflet"]);
|
|
293
|
+
const _LEAFLET_CSS = "leaflet/dist/leaflet.css";
|
|
294
|
+
const _MARKER_TAG_RE = /<Marker\b/g;
|
|
295
|
+
|
|
296
|
+
const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
|
|
297
|
+
const _ICON_DEFAULT_CONFIGURED_RE =
|
|
298
|
+
/\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
|
|
299
|
+
|
|
300
|
+
// Line of an index in `code`, counted forward: indexes must not decrease.
|
|
301
|
+
function _lineCounter(code) {
|
|
302
|
+
let line = 1;
|
|
303
|
+
let at = 0;
|
|
304
|
+
return (index) => {
|
|
305
|
+
for (; at < index; at += 1) {
|
|
306
|
+
if (code.charCodeAt(at) === 10) line += 1;
|
|
307
|
+
}
|
|
308
|
+
return line;
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function _leafletRules(source) {
|
|
313
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
314
|
+
const sourceLines = source.split(/\r?\n/);
|
|
315
|
+
let firstLeafletLine = 0;
|
|
316
|
+
let importsCss = false;
|
|
317
|
+
let importsReactLeaflet = false;
|
|
318
|
+
const importLineAt = _lineCounter(code);
|
|
319
|
+
// Whole-source scan: a Prettier-wrapped import spans several lines.
|
|
320
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
321
|
+
let m;
|
|
322
|
+
while ((m = IMPORT_SPECIFIER_RE.exec(code))) {
|
|
323
|
+
const spec = m[1];
|
|
324
|
+
if (spec === _LEAFLET_CSS) importsCss = true;
|
|
325
|
+
const root = _packageRoot(spec);
|
|
326
|
+
if (!_LEAFLET_ROOTS.has(root) || _ASSET_SPECIFIER_RE.test(spec)) continue;
|
|
327
|
+
if (root === "react-leaflet") importsReactLeaflet = true;
|
|
328
|
+
if (!firstLeafletLine) firstLeafletLine = importLineAt(m.index + m[0].length);
|
|
329
|
+
}
|
|
330
|
+
IMPORT_SPECIFIER_RE.lastIndex = 0;
|
|
331
|
+
if (!firstLeafletLine) return [];
|
|
332
|
+
const findings = [];
|
|
333
|
+
if (!importsCss) {
|
|
334
|
+
findings.push({
|
|
335
|
+
rule: "leaflet-css-missing",
|
|
336
|
+
severity: "error",
|
|
337
|
+
label:
|
|
338
|
+
`this file uses Leaflet but never imports its stylesheet — without ` +
|
|
339
|
+
`it the map draws a partial patch of tiles inside an empty box and ` +
|
|
340
|
+
`markers are invisible. Add \`import "${_LEAFLET_CSS}";\` to this file.`,
|
|
341
|
+
line: firstLeafletLine,
|
|
342
|
+
snippet: (sourceLines[firstLeafletLine - 1] || "").trim().slice(0, 200),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
!importsReactLeaflet ||
|
|
347
|
+
_ICON_DEFAULT_CONFIGURED_RE.test(_stripNonCode(source))
|
|
348
|
+
) {
|
|
349
|
+
return findings;
|
|
350
|
+
}
|
|
351
|
+
const markerLineAt = _lineCounter(code);
|
|
352
|
+
_MARKER_TAG_RE.lastIndex = 0;
|
|
353
|
+
let tag;
|
|
354
|
+
while ((tag = _MARKER_TAG_RE.exec(code))) {
|
|
355
|
+
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
356
|
+
if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
|
|
357
|
+
const line = markerLineAt(tag.index);
|
|
358
|
+
findings.push({
|
|
359
|
+
rule: "leaflet-default-marker-icon",
|
|
360
|
+
severity: "warning",
|
|
361
|
+
label:
|
|
362
|
+
`<Marker> uses Leaflet's default icon, whose image path cannot ` +
|
|
363
|
+
`resolve inside a widget bundle, so the pin does not show. Import the ` +
|
|
364
|
+
`images (\`import markerIcon from "leaflet/dist/images/marker-icon.png"\`, ` +
|
|
365
|
+
`plus marker-icon-2x.png and marker-shadow.png), build ` +
|
|
366
|
+
`\`L.icon({ iconUrl, iconRetinaUrl, shadowUrl, iconSize: [25, 41], ` +
|
|
367
|
+
`iconAnchor: [12, 41] })\` once at module scope, and pass it as ` +
|
|
368
|
+
`\`icon={…}\`. Warning only — a widget that sets \`L.Icon.Default\` ` +
|
|
369
|
+
`itself (\`mergeOptions\` / \`imagePath\`) is not flagged.`,
|
|
370
|
+
line,
|
|
371
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
372
|
+
});
|
|
373
|
+
_MARKER_TAG_RE.lastIndex = end;
|
|
374
|
+
}
|
|
375
|
+
return findings;
|
|
376
|
+
}
|
|
377
|
+
|
|
232
378
|
// REQ-WSDK-PLATFORM §3.5: soft warning when source contains host-API URL
|
|
233
379
|
// substrings. `severity: "warning"` so a HUMAN-authored submission still
|
|
234
380
|
// publishes and the marketplace review queue flags it for a human pass.
|
|
@@ -1925,6 +2071,7 @@ export function lintSource(source, options) {
|
|
|
1925
2071
|
severity: f.severity || "error",
|
|
1926
2072
|
})),
|
|
1927
2073
|
);
|
|
2074
|
+
findings.push(..._leafletRules(source));
|
|
1928
2075
|
// REQ-WSDK-PLATFORM §3.5: soft host-API URL warning (does not block).
|
|
1929
2076
|
findings.push(..._hostApiUrlRules(source));
|
|
1930
2077
|
// sc-6970 — soft warning: HTML markup spliced into a content string.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"README.md",
|
|
46
46
|
"LICENSE",
|
|
47
47
|
"src/webbundle.js",
|
|
48
|
-
"src/dev-shims.js"
|
|
48
|
+
"src/dev-shims.js",
|
|
49
|
+
"src/contract.js"
|
|
49
50
|
],
|
|
50
51
|
"scripts": {
|
|
51
52
|
"build": "node scripts/build.js",
|