@multiplatform.one/config 7.46.0 → 7.47.1
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 +7 -2
- package/design-lint.mjs +71 -0
- package/lint.mjs +449 -4
- package/oxlint.d.ts +1 -0
- package/oxlint.mjs +115 -0
- package/package.json +2 -2
- package/src/lint.spec.ts +413 -0
- package/src/noVoicelessText.spec.ts +151 -0
package/README.md
CHANGED
|
@@ -148,8 +148,13 @@ outline, box shadow, shadow or elevation is reported as `field-focus`. Focus
|
|
|
148
148
|
looks come from MPO: fields take `FIELD_FOCUS_STYLE` (the accent border), other
|
|
149
149
|
controls the theme's focus ring. Zeroing those keys is allowed.
|
|
150
150
|
|
|
151
|
-
|
|
152
|
-
`
|
|
151
|
+
An icon-only button laid in a row beside a field (`Input`, `Select`, an
|
|
152
|
+
`Input.Box` and the other MPO fields) is reported as `field-action` (LC-149). A
|
|
153
|
+
field's own action, such as reveal, clear, copy or an eyedropper, is
|
|
154
|
+
`Input.Action` inside the field's trailing slot.
|
|
155
|
+
|
|
156
|
+
Every violation is tagged with its rule: `field-action`, `field-focus`, `hex`,
|
|
157
|
+
`geometry`, `inline-spacing`, `raw-control`, `styled-mpo` or `token-file`.
|
|
153
158
|
|
|
154
159
|
#### Ratchet
|
|
155
160
|
|
package/design-lint.mjs
CHANGED
|
@@ -56,6 +56,13 @@ const svgRoots = new Set(["Svg", "Symbol"]);
|
|
|
56
56
|
const focusPaint =
|
|
57
57
|
/^(?:outline(?:Width|Color|Offset)?|boxShadow|shadow(?:Color|Opacity|Radius|Offset)|elevation)$/;
|
|
58
58
|
const focusStates = new Set(["focusStyle", "focusVisibleStyle", "focusWithinStyle"]);
|
|
59
|
+
// LC-149 FIELD-TRAILING-ACTION: a field's own action (reveal, clear, copy,
|
|
60
|
+
// eyedropper) is Input.Action inside the field, never an icon button beside it.
|
|
61
|
+
const fieldTag =
|
|
62
|
+
/^(?:Input|InputParts|FormsInput|TextArea|SearchInput|CopyField|Password|PasswordInput|Select|Combobox|DatePicker|TimePicker|DatetimePicker|PhoneInput|Stepper|NumberInput|Duration)$|\.Box$/;
|
|
63
|
+
const actionTag =
|
|
64
|
+
/^(?:Button|IconButton|TamaguiButton|Pressable|TouchableOpacity|TouchableHighlight|button)$|\.Button$/;
|
|
65
|
+
const rowTag = /^(?:XStack|XGroup)$|\.XGroup$/;
|
|
59
66
|
const tokenFactories = new Set([
|
|
60
67
|
"createTokens",
|
|
61
68
|
"createTamagui",
|
|
@@ -64,6 +71,7 @@ const tokenFactories = new Set([
|
|
|
64
71
|
"createFont",
|
|
65
72
|
]);
|
|
66
73
|
export const designRules = [
|
|
74
|
+
"field-action",
|
|
67
75
|
"field-focus",
|
|
68
76
|
"hex",
|
|
69
77
|
"geometry",
|
|
@@ -266,6 +274,68 @@ export function inspectDesign(source, filename, projectRoot = process.cwd()) {
|
|
|
266
274
|
`${key} paints its own ${name(painted.name)}; focus comes from MPO (FIELD_FOCUS_STYLE).`,
|
|
267
275
|
);
|
|
268
276
|
}
|
|
277
|
+
function tagText(tag) {
|
|
278
|
+
return ts.isIdentifier(tag) ? referenceName(tag) : tag.getText(file);
|
|
279
|
+
}
|
|
280
|
+
function opening(element) {
|
|
281
|
+
return ts.isJsxElement(element) ? element.openingElement : element;
|
|
282
|
+
}
|
|
283
|
+
function attribute(element, key) {
|
|
284
|
+
return opening(element).attributes.properties.find(
|
|
285
|
+
(attr) => ts.isJsxAttribute(attr) && name(attr.name) === key,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
function iconOnly(element) {
|
|
289
|
+
const children = ts.isJsxElement(element) ? element.children : [];
|
|
290
|
+
const hasText = children.some(
|
|
291
|
+
(child) =>
|
|
292
|
+
(ts.isJsxText(child) && child.text.trim() !== "") ||
|
|
293
|
+
((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) &&
|
|
294
|
+
!tagText(opening(child).tagName).endsWith("Icon")) ||
|
|
295
|
+
(ts.isJsxExpression(child) &&
|
|
296
|
+
!!child.expression &&
|
|
297
|
+
!ts.isJsxElement(child.expression) &&
|
|
298
|
+
!ts.isJsxSelfClosingElement(child.expression)),
|
|
299
|
+
);
|
|
300
|
+
if (hasText) return false;
|
|
301
|
+
if (attribute(element, "icon")) return true;
|
|
302
|
+
return children.some(
|
|
303
|
+
(child) =>
|
|
304
|
+
(ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) &&
|
|
305
|
+
tagText(opening(child).tagName).endsWith("Icon"),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
function isRow(element) {
|
|
309
|
+
if (rowTag.test(tagText(opening(element).tagName))) return true;
|
|
310
|
+
const direction = attribute(element, "flexDirection");
|
|
311
|
+
return !!direction?.initializer && /["']row["']/.test(direction.initializer.getText(file));
|
|
312
|
+
}
|
|
313
|
+
function checkFieldAction(element) {
|
|
314
|
+
if (!isRow(element)) return;
|
|
315
|
+
const children = element.children.flatMap((child) => {
|
|
316
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return [child];
|
|
317
|
+
if (
|
|
318
|
+
ts.isJsxExpression(child) &&
|
|
319
|
+
child.expression &&
|
|
320
|
+
ts.isBinaryExpression(child.expression) &&
|
|
321
|
+
child.expression.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
|
|
322
|
+
) {
|
|
323
|
+
const right = child.expression.right;
|
|
324
|
+
const inner = ts.isParenthesizedExpression(right) ? right.expression : right;
|
|
325
|
+
if (ts.isJsxElement(inner) || ts.isJsxSelfClosingElement(inner)) return [inner];
|
|
326
|
+
}
|
|
327
|
+
return [];
|
|
328
|
+
});
|
|
329
|
+
if (!children.some((child) => fieldTag.test(tagText(opening(child).tagName)))) return;
|
|
330
|
+
for (const child of children) {
|
|
331
|
+
if (actionTag.test(tagText(opening(child).tagName)) && iconOnly(child))
|
|
332
|
+
report(
|
|
333
|
+
child,
|
|
334
|
+
"field-action",
|
|
335
|
+
"A field's own action is Input.Action inside the field, not a button beside it.",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
269
339
|
function report(node, rule, message) {
|
|
270
340
|
const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
|
|
271
341
|
errors.push(`${filename}:${line + 1}:${character + 1}: [${rule}] ${message}`);
|
|
@@ -325,6 +395,7 @@ export function inspectDesign(source, filename, projectRoot = process.cwd()) {
|
|
|
325
395
|
"raw-icon",
|
|
326
396
|
"Wrap Phosphor icons in themed() before rendering, or pass the component to an MPO icon slot.",
|
|
327
397
|
);
|
|
398
|
+
if (ts.isJsxElement(node)) checkFieldAction(node);
|
|
328
399
|
if (
|
|
329
400
|
!tokenFile &&
|
|
330
401
|
ts.isCallExpression(node) &&
|
package/lint.mjs
CHANGED
|
@@ -1296,11 +1296,74 @@ function checkReactNativeWebExports() {
|
|
|
1296
1296
|
return { ok: true, violations };
|
|
1297
1297
|
}
|
|
1298
1298
|
|
|
1299
|
+
const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
1300
|
+
"return",
|
|
1301
|
+
"typeof",
|
|
1302
|
+
"case",
|
|
1303
|
+
"in",
|
|
1304
|
+
"of",
|
|
1305
|
+
"void",
|
|
1306
|
+
"delete",
|
|
1307
|
+
"throw",
|
|
1308
|
+
"yield",
|
|
1309
|
+
"await",
|
|
1310
|
+
"else",
|
|
1311
|
+
"do",
|
|
1312
|
+
]);
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* True when the `/` at `index` opens a regex literal rather than dividing.
|
|
1316
|
+
* @param {string} src
|
|
1317
|
+
* @param {number} index
|
|
1318
|
+
*/
|
|
1319
|
+
function startsRegex(src, index) {
|
|
1320
|
+
let at = index - 1;
|
|
1321
|
+
while (at >= 0 && /\s/.test(src[at])) at--;
|
|
1322
|
+
if (at < 0) return true;
|
|
1323
|
+
const prev = src[at];
|
|
1324
|
+
if (/[\w$]/.test(prev)) {
|
|
1325
|
+
let start = at;
|
|
1326
|
+
while (start > 0 && /[\w$]/.test(src[start - 1])) start--;
|
|
1327
|
+
return REGEX_PRECEDING_KEYWORDS.has(src.slice(start, at + 1));
|
|
1328
|
+
}
|
|
1329
|
+
return !/[)\]"'`]/.test(prev);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* The offset just past the regex literal that opens at `index`, flags
|
|
1334
|
+
* included. A line break before the closing `/` means it was not a regex
|
|
1335
|
+
* after all, and the scan resumes right after the `/`.
|
|
1336
|
+
* @param {string} src
|
|
1337
|
+
* @param {number} index
|
|
1338
|
+
*/
|
|
1339
|
+
function regexEnd(src, index) {
|
|
1340
|
+
let i = index + 1;
|
|
1341
|
+
let inClass = false;
|
|
1342
|
+
while (i < src.length) {
|
|
1343
|
+
const char = src[i];
|
|
1344
|
+
if (char === "\n") return index + 1;
|
|
1345
|
+
if (char === "\\") {
|
|
1346
|
+
i += 2;
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
if (char === "[") inClass = true;
|
|
1350
|
+
else if (char === "]") inClass = false;
|
|
1351
|
+
else if (char === "/" && !inClass) break;
|
|
1352
|
+
i++;
|
|
1353
|
+
}
|
|
1354
|
+
i++;
|
|
1355
|
+
while (i < src.length && /[a-z]/i.test(src[i])) i++;
|
|
1356
|
+
return i;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1299
1359
|
/**
|
|
1300
|
-
* Offsets in `src` that are live CODE — not inside a comment, a string
|
|
1301
|
-
* template literal. One left-to-right pass.
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1360
|
+
* Offsets in `src` that are live CODE — not inside a comment, a string, a
|
|
1361
|
+
* template literal or a regex literal. One left-to-right pass. A `${…}`
|
|
1362
|
+
* interpolation is treated as string, which can only ever make a check miss
|
|
1363
|
+
* something. A regex literal has to be skipped: a quote inside one, as in
|
|
1364
|
+
* `/^['"]/`, would otherwise open a string and flip the mask for the rest of
|
|
1365
|
+
* the file. A `/` starts a regex when the code before it cannot end an
|
|
1366
|
+
* expression (an operator, an opening bracket, a keyword such as `return`).
|
|
1304
1367
|
*
|
|
1305
1368
|
* @param {string} src
|
|
1306
1369
|
* @returns {Uint8Array} 1 at every code offset
|
|
@@ -1321,6 +1384,10 @@ function codeOffsets(src) {
|
|
|
1321
1384
|
i += 2;
|
|
1322
1385
|
continue;
|
|
1323
1386
|
}
|
|
1387
|
+
if (char === "/" && startsRegex(src, i)) {
|
|
1388
|
+
i = regexEnd(src, i);
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1324
1391
|
if (char === '"' || char === "'" || char === "`") {
|
|
1325
1392
|
const quote = char;
|
|
1326
1393
|
i++;
|
|
@@ -1405,6 +1472,364 @@ function checkImportMetaInPublicSource() {
|
|
|
1405
1472
|
return violations;
|
|
1406
1473
|
}
|
|
1407
1474
|
|
|
1475
|
+
// ── bare `instanceof` of a web-only global (FZ-7791) ──────────────────────
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Classes a browser defines and Hermes, GJS or a bare Node may not. Naming a
|
|
1479
|
+
* missing global is a ReferenceError, and `instanceof` names it even when the
|
|
1480
|
+
* value on the left is a plain object, so the throw lands before the code it
|
|
1481
|
+
* guards ever runs. React Native ships some of these (Blob, FormData, Headers);
|
|
1482
|
+
* they are listed anyway, because GJS and a consumer's own polyfill set differ.
|
|
1483
|
+
*/
|
|
1484
|
+
const WEB_ONLY_CLASSES = [
|
|
1485
|
+
"ReadableStream",
|
|
1486
|
+
"WritableStream",
|
|
1487
|
+
"TransformStream",
|
|
1488
|
+
"TextDecoderStream",
|
|
1489
|
+
"TextEncoderStream",
|
|
1490
|
+
"CompressionStream",
|
|
1491
|
+
"DecompressionStream",
|
|
1492
|
+
"Blob",
|
|
1493
|
+
"File",
|
|
1494
|
+
"FileList",
|
|
1495
|
+
"FormData",
|
|
1496
|
+
"URLSearchParams",
|
|
1497
|
+
"Headers",
|
|
1498
|
+
"Request",
|
|
1499
|
+
"Response",
|
|
1500
|
+
"BroadcastChannel",
|
|
1501
|
+
"MessageChannel",
|
|
1502
|
+
"MessagePort",
|
|
1503
|
+
"DOMException",
|
|
1504
|
+
"Event",
|
|
1505
|
+
"EventTarget",
|
|
1506
|
+
"CustomEvent",
|
|
1507
|
+
"KeyboardEvent",
|
|
1508
|
+
"MouseEvent",
|
|
1509
|
+
"PointerEvent",
|
|
1510
|
+
"TouchEvent",
|
|
1511
|
+
"FocusEvent",
|
|
1512
|
+
"Node",
|
|
1513
|
+
"Element",
|
|
1514
|
+
"Document",
|
|
1515
|
+
"Window",
|
|
1516
|
+
"ShadowRoot",
|
|
1517
|
+
"DataTransfer",
|
|
1518
|
+
"Worker",
|
|
1519
|
+
"CryptoKey",
|
|
1520
|
+
];
|
|
1521
|
+
|
|
1522
|
+
const WEB_ONLY_INSTANCEOF_RE = new RegExp(
|
|
1523
|
+
String.raw`\binstanceof\s+(${WEB_ONLY_CLASSES.join("|")}|HTML\w*Element|SVG\w*Element)\b(?!\s*\.)`,
|
|
1524
|
+
"g",
|
|
1525
|
+
);
|
|
1526
|
+
|
|
1527
|
+
/** Files that only ever load in a browser engine: a web, webview or extension twin. */
|
|
1528
|
+
const WEB_ONLY_FILE_RE = /\.(web|webext|tauri)\.(tsx|ts|jsx|js|mjs)$/;
|
|
1529
|
+
const WEB_ONLY_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs|cjs|mts|cts)$/;
|
|
1530
|
+
|
|
1531
|
+
/** public/test-utils is the vitest DOM harness; it never ships into an app bundle. */
|
|
1532
|
+
const WEB_ONLY_EXEMPT_PACKAGES = new Set(["test-utils"]);
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* The code that runs before `index` in its own block: from the innermost
|
|
1536
|
+
* unclosed `{` (or the top of the file) up to `index`, with every block that
|
|
1537
|
+
* opened and closed in between dropped. A `typeof X` there guards the
|
|
1538
|
+
* `instanceof X` after it, which covers the same-expression form,
|
|
1539
|
+
* `typeof Blob !== "undefined" && v instanceof Blob`, and an early return,
|
|
1540
|
+
* `if (typeof Element === "undefined") return false;`. A guard inside a
|
|
1541
|
+
* sibling callback or another function does not count.
|
|
1542
|
+
* @param {string} src
|
|
1543
|
+
* @param {Uint8Array} mask
|
|
1544
|
+
* @param {number} index
|
|
1545
|
+
*/
|
|
1546
|
+
function blockBefore(src, mask, index) {
|
|
1547
|
+
let depth = 0;
|
|
1548
|
+
let kept = "";
|
|
1549
|
+
for (let at = index - 1; at >= 0; at--) {
|
|
1550
|
+
const char = src[at];
|
|
1551
|
+
if (mask[at] === 1 && char === "}") {
|
|
1552
|
+
depth++;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (mask[at] === 1 && char === "{") {
|
|
1556
|
+
if (depth === 0) break;
|
|
1557
|
+
depth--;
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (depth === 0) kept = char + kept;
|
|
1561
|
+
}
|
|
1562
|
+
return kept;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* True when the file binds `name` itself (an import, a declaration), so its
|
|
1567
|
+
* `instanceof name` is not the global at all: prosemirror's Node, a local File.
|
|
1568
|
+
* Only a binding in code counts, not one a comment mentions.
|
|
1569
|
+
* @param {string} src
|
|
1570
|
+
* @param {Uint8Array} mask
|
|
1571
|
+
* @param {string} name
|
|
1572
|
+
*/
|
|
1573
|
+
function bindsName(src, mask, name) {
|
|
1574
|
+
const binding = new RegExp(
|
|
1575
|
+
String.raw`\bimport\s[^;]*\b${name}\b(?!\s+as\b)[^;]*\bfrom\b|\b(?:const|let|var|class|function)\s+${name}\b`,
|
|
1576
|
+
"g",
|
|
1577
|
+
);
|
|
1578
|
+
for (const match of src.matchAll(binding)) {
|
|
1579
|
+
if (mask[match.index ?? 0] === 1) return true;
|
|
1580
|
+
}
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* `x instanceof ReadableStream` in a public package's runtime source, with no
|
|
1586
|
+
* `typeof ReadableStream` earlier in the same block (FZ-7791).
|
|
1587
|
+
*
|
|
1588
|
+
* HttpClient.request checked its body with a bare `instanceof ReadableStream`.
|
|
1589
|
+
* Hermes has no ReadableStream, so every POST and PUT with a JSON body threw
|
|
1590
|
+
* a ReferenceError before fetch on every native MPO app, while web, where the
|
|
1591
|
+
* global exists, stayed green. Guard it in place
|
|
1592
|
+
* (`typeof X !== "undefined" && v instanceof X`) or move it into a .web twin.
|
|
1593
|
+
*
|
|
1594
|
+
* Exempt: specs and stories, files that load only in a browser engine
|
|
1595
|
+
* (.web/.webext/.tauri), Node-only tooling (a value import of a `node:`
|
|
1596
|
+
* builtin), and a file that binds the name itself.
|
|
1597
|
+
*/
|
|
1598
|
+
function checkWebOnlyInstanceof() {
|
|
1599
|
+
/** @type {string[]} */
|
|
1600
|
+
const violations = [];
|
|
1601
|
+
if (!existsSync(PUBLIC_DIR)) return violations;
|
|
1602
|
+
for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
|
|
1603
|
+
if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
|
|
1604
|
+
if (WEB_ONLY_EXEMPT_PACKAGES.has(pkg.name)) continue;
|
|
1605
|
+
walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
|
|
1606
|
+
if (!WEB_ONLY_SCANNED_RE.test(filePath) || filePath.endsWith(".d.ts")) return;
|
|
1607
|
+
if (/\.(spec|test|stories|story)\./.test(filePath) || WEB_ONLY_FILE_RE.test(filePath)) return;
|
|
1608
|
+
const src = readFileSync(filePath, "utf8");
|
|
1609
|
+
if (!src.includes("instanceof")) return;
|
|
1610
|
+
if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
|
|
1611
|
+
const mask = codeOffsets(src);
|
|
1612
|
+
for (const match of src.matchAll(WEB_ONLY_INSTANCEOF_RE)) {
|
|
1613
|
+
const index = match.index ?? 0;
|
|
1614
|
+
if (mask[index] !== 1) continue;
|
|
1615
|
+
const name = match[1];
|
|
1616
|
+
if (bindsName(src, mask, name)) continue;
|
|
1617
|
+
const guard = new RegExp(String.raw`\btypeof\s+(?:globalThis\.)?${name}\b`);
|
|
1618
|
+
if (guard.test(blockBefore(src, mask, index))) continue;
|
|
1619
|
+
violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)} instanceof ${name}`);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
return violations;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// ── window/document DOM calls without a native guard (FZ-7799) ────────────
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Window members a browser defines and React Native's `window` does not. RN
|
|
1630
|
+
* makes `window` the global object, so `typeof window === "undefined"` passes
|
|
1631
|
+
* there, and `window.addEventListener(...)` throws "undefined is not a
|
|
1632
|
+
* function" (FZ-7798, SortableList).
|
|
1633
|
+
*/
|
|
1634
|
+
const WINDOW_DOM_METHODS = [
|
|
1635
|
+
"addEventListener",
|
|
1636
|
+
"removeEventListener",
|
|
1637
|
+
"dispatchEvent",
|
|
1638
|
+
"matchMedia",
|
|
1639
|
+
"getComputedStyle",
|
|
1640
|
+
];
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* A call, `window.addEventListener(`, or any member of `document`, which is a
|
|
1644
|
+
* ReferenceError on its own where there is none. Not `x.document`, and not an
|
|
1645
|
+
* optional call, `window.matchMedia?.(...)`.
|
|
1646
|
+
*/
|
|
1647
|
+
const NATIVE_DOM_CALL_RE = new RegExp(
|
|
1648
|
+
String.raw`(?<![\w$.])(?:(window)\s*\??\.\s*(${WINDOW_DOM_METHODS.join("|")})\b(?=\s*\()|(document)\s*\??\.\s*([A-Za-z_$][\w$]*))`,
|
|
1649
|
+
"g",
|
|
1650
|
+
);
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* A guard that holds on native: a function-type check of a window member,
|
|
1654
|
+
* `typeof window.addEventListener === "function"`, a truthiness test of one,
|
|
1655
|
+
* `!window.matchMedia`, a check that a document exists,
|
|
1656
|
+
* `typeof document === "undefined"` (React Native has none), or a web-only
|
|
1657
|
+
* branch, `isWeb` / `Platform.OS === "web"`, which is false on every native
|
|
1658
|
+
* build. `typeof window` alone is the bug, so it does not count.
|
|
1659
|
+
*/
|
|
1660
|
+
const NATIVE_DOM_GUARD_RE = new RegExp(
|
|
1661
|
+
[
|
|
1662
|
+
String.raw`\btypeof\s+(?:\(\s*)?(?:globalThis\s*\??\.\s*)?(?:window\s*\??\.\s*[A-Za-z_$]|document\b)`,
|
|
1663
|
+
String.raw`(?<![\w$.])window\s*\??\.\s*(?:${WINDOW_DOM_METHODS.join("|")})\b(?!\s*(?:\?\.\s*)?\()`,
|
|
1664
|
+
String.raw`\bisWeb\b`,
|
|
1665
|
+
String.raw`\bPlatform\.OS\s*[!=]==?\s*["']web["']`,
|
|
1666
|
+
].join("|"),
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* True when `filePath` has a native twin beside it (`index.native.tsx` next to
|
|
1671
|
+
* `index.tsx`, or both `.ios` and `.android`), so Metro never loads it on a
|
|
1672
|
+
* phone and only the web resolves it.
|
|
1673
|
+
* @param {string} filePath
|
|
1674
|
+
*/
|
|
1675
|
+
function hasNativeTwin(filePath) {
|
|
1676
|
+
const match = /^(.*)\.(tsx|ts|jsx|js)$/.exec(filePath);
|
|
1677
|
+
if (!match || /\.(native|ios|android)$/.test(match[1])) return false;
|
|
1678
|
+
const twin = (/** @type {string} */ platform) =>
|
|
1679
|
+
["tsx", "ts", "jsx", "js"].some((ext) => existsSync(`${match[1]}.${platform}.${ext}`));
|
|
1680
|
+
return twin("native") || (twin("ios") && twin("android"));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* The code before `index` in every block that encloses it, up to the top of
|
|
1685
|
+
* the file, with each block that opened and closed in between dropped. A guard
|
|
1686
|
+
* at the top of an effect covers the cleanup it returns; a guard inside a
|
|
1687
|
+
* sibling function does not.
|
|
1688
|
+
* @param {string} src
|
|
1689
|
+
* @param {Uint8Array} mask
|
|
1690
|
+
* @param {number} index
|
|
1691
|
+
*/
|
|
1692
|
+
function enclosingBefore(src, mask, index) {
|
|
1693
|
+
let depth = 0;
|
|
1694
|
+
let kept = "";
|
|
1695
|
+
for (let at = index - 1; at >= 0; at--) {
|
|
1696
|
+
const char = src[at];
|
|
1697
|
+
if (mask[at] === 1 && char === "}") {
|
|
1698
|
+
depth++;
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (mask[at] === 1 && char === "{") {
|
|
1702
|
+
if (depth > 0) depth--;
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
if (depth === 0) kept = char + kept;
|
|
1706
|
+
}
|
|
1707
|
+
return kept;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Functions this file declares whose own body is a native guard, such as
|
|
1712
|
+
* `function canPickFile() { return typeof document !== "undefined"; }`, so a
|
|
1713
|
+
* call to one guards like the check it wraps. Only the same file counts.
|
|
1714
|
+
* @param {string} src
|
|
1715
|
+
* @param {Uint8Array} mask
|
|
1716
|
+
*/
|
|
1717
|
+
function guardHelpers(src, mask) {
|
|
1718
|
+
/** @type {string[]} */
|
|
1719
|
+
const names = [];
|
|
1720
|
+
const declaration =
|
|
1721
|
+
/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(|\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*(?::\s*[\w$<>[\]|\s]+)?=>/g;
|
|
1722
|
+
for (const match of src.matchAll(declaration)) {
|
|
1723
|
+
const start = match.index ?? 0;
|
|
1724
|
+
if (mask[start] !== 1) continue;
|
|
1725
|
+
const name = match[1] ?? match[2];
|
|
1726
|
+
let at = start + match[0].length;
|
|
1727
|
+
let depth = match[1] ? 1 : 0;
|
|
1728
|
+
let end = src.length;
|
|
1729
|
+
for (; at < src.length; at++) {
|
|
1730
|
+
if (mask[at] !== 1) continue;
|
|
1731
|
+
const char = src[at];
|
|
1732
|
+
if (char === "(" || char === "{") depth++;
|
|
1733
|
+
else if (char === ")" || char === "}") {
|
|
1734
|
+
depth--;
|
|
1735
|
+
if (depth < 0) {
|
|
1736
|
+
end = at;
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
if (depth === 0 && char === "}") {
|
|
1740
|
+
end = at + 1;
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
} else if (depth === 0 && (char === ";" || char === "\n")) {
|
|
1744
|
+
end = at;
|
|
1745
|
+
break;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (NATIVE_DOM_GUARD_RE.test(src.slice(start, end))) names.push(name);
|
|
1749
|
+
}
|
|
1750
|
+
return names;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* True when `name` is bound in this file other than as the global: an import,
|
|
1755
|
+
* a declaration, a typed parameter, `(document: GraphDoc) =>`, or a
|
|
1756
|
+
* destructured local, `const { document } = query` or
|
|
1757
|
+
* `const [document, setDocument] = useState(...)`. Passing the global along,
|
|
1758
|
+
* `f(window)`, is not a binding.
|
|
1759
|
+
* @param {string} src
|
|
1760
|
+
* @param {Uint8Array} mask
|
|
1761
|
+
* @param {string} name
|
|
1762
|
+
*/
|
|
1763
|
+
function bindsNameOrParam(src, mask, name) {
|
|
1764
|
+
if (bindsName(src, mask, name)) return true;
|
|
1765
|
+
const param = new RegExp(
|
|
1766
|
+
String.raw`[(,]\s*${name}\s*\??:\s*[A-Z{]|\b(?:const|let|var)\s*[[{][^\]}]*\b${name}\b[^\]}]*[\]}]\s*=`,
|
|
1767
|
+
"g",
|
|
1768
|
+
);
|
|
1769
|
+
for (const match of src.matchAll(param)) {
|
|
1770
|
+
if (mask[match.index ?? 0] === 1) return true;
|
|
1771
|
+
}
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* `window.addEventListener(...)`, `window.matchMedia(...)` or any
|
|
1777
|
+
* `document.<member>` in a public package's runtime source, with no native
|
|
1778
|
+
* guard earlier in an enclosing block (FZ-7799).
|
|
1779
|
+
*
|
|
1780
|
+
* React Native defines `window` as the global but leaves out the DOM, and has
|
|
1781
|
+
* no `document` at all. FZ-7798 found SortableList guarding with
|
|
1782
|
+
* `typeof window === "undefined"`, which passes on native, so mounting it threw
|
|
1783
|
+
* on window.addEventListener. Guard in place with
|
|
1784
|
+
* `typeof window.addEventListener === "function"` or
|
|
1785
|
+
* `typeof document !== "undefined"` (or call a same-file helper whose body is
|
|
1786
|
+
* that check), call it optionally (`window.addEventListener?.(...)`), or move
|
|
1787
|
+
* it into a .web twin.
|
|
1788
|
+
*
|
|
1789
|
+
* Exempt like the instanceof check: specs and stories, .web/.webext/.tauri
|
|
1790
|
+
* files, Node-only tooling and public/test-utils; and a file that binds the
|
|
1791
|
+
* name itself.
|
|
1792
|
+
*/
|
|
1793
|
+
function checkNativeDomCalls() {
|
|
1794
|
+
/** @type {string[]} */
|
|
1795
|
+
const violations = [];
|
|
1796
|
+
if (!existsSync(PUBLIC_DIR)) return violations;
|
|
1797
|
+
for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
|
|
1798
|
+
if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
|
|
1799
|
+
if (WEB_ONLY_EXEMPT_PACKAGES.has(pkg.name)) continue;
|
|
1800
|
+
walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => {
|
|
1801
|
+
if (!WEB_ONLY_SCANNED_RE.test(filePath) || filePath.endsWith(".d.ts")) return;
|
|
1802
|
+
if (/\.(spec|test|stories|story)\./.test(filePath) || WEB_ONLY_FILE_RE.test(filePath)) return;
|
|
1803
|
+
const src = readFileSync(filePath, "utf8");
|
|
1804
|
+
if (!src.includes("window") && !src.includes("document")) return;
|
|
1805
|
+
if (hasNativeTwin(filePath)) return;
|
|
1806
|
+
if (NODE_BUILTIN_IMPORT_RE.test(src)) return;
|
|
1807
|
+
const mask = codeOffsets(src);
|
|
1808
|
+
const helpers = guardHelpers(src, mask);
|
|
1809
|
+
const helperCall = helpers.length
|
|
1810
|
+
? new RegExp(String.raw`(?<!function\s+)\b(?:${helpers.join("|")})\s*\(`)
|
|
1811
|
+
: null;
|
|
1812
|
+
for (const match of src.matchAll(NATIVE_DOM_CALL_RE)) {
|
|
1813
|
+
const index = match.index ?? 0;
|
|
1814
|
+
if (mask[index] !== 1) continue;
|
|
1815
|
+
const name = match[1] ?? match[3];
|
|
1816
|
+
const member = match[2] ?? match[4];
|
|
1817
|
+
if (
|
|
1818
|
+
name === "window" &&
|
|
1819
|
+
/\btypeof\s*\(?\s*$/.test(src.slice(Math.max(0, index - 12), index))
|
|
1820
|
+
) {
|
|
1821
|
+
continue;
|
|
1822
|
+
}
|
|
1823
|
+
if (bindsNameOrParam(src, mask, name)) continue;
|
|
1824
|
+
const before = enclosingBefore(src, mask, index);
|
|
1825
|
+
if (NATIVE_DOM_GUARD_RE.test(before) || helperCall?.test(before)) continue;
|
|
1826
|
+
violations.push(`${relative(ROOT, filePath)}:${lineAt(src, index)} ${name}.${member}`);
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
return violations;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1408
1833
|
// ── React Flow selectors outside the token bridge (MPO-191) ───────────────
|
|
1409
1834
|
|
|
1410
1835
|
const REACT_FLOW_SELECTOR_RE = /\.react-flow__[\w-]+/g;
|
|
@@ -2153,6 +2578,8 @@ export function runConventionChecks(options = {}) {
|
|
|
2153
2578
|
const metroFallbacks = checkMetroSubpathFallbacks();
|
|
2154
2579
|
const rnWebExports = checkReactNativeWebExports();
|
|
2155
2580
|
const importMeta = checkImportMetaInPublicSource();
|
|
2581
|
+
const webOnlyInstanceof = checkWebOnlyInstanceof();
|
|
2582
|
+
const nativeDomCalls = checkNativeDomCalls();
|
|
2156
2583
|
const graphVars = checkGraphVarsCoverage();
|
|
2157
2584
|
const retiredScaleKnobs = checkRetiredScaleKnobs();
|
|
2158
2585
|
const overlayOwnership = checkOverlayOwnership();
|
|
@@ -2359,6 +2786,24 @@ export function runConventionChecks(options = {}) {
|
|
|
2359
2786
|
console.error("");
|
|
2360
2787
|
}
|
|
2361
2788
|
|
|
2789
|
+
if (webOnlyInstanceof.length) {
|
|
2790
|
+
failed = true;
|
|
2791
|
+
console.error(
|
|
2792
|
+
'Convention (FZ-7791 NO BARE instanceof OF A WEB-ONLY GLOBAL IN public/*/src): `v instanceof ReadableStream` names the global, and a runtime without it (Hermes has no ReadableStream; GJS and bare Node lack other DOM and WHATWG classes) throws a ReferenceError before the code it guards runs. Guard it earlier in the same block, `typeof ReadableStream !== "undefined" && v instanceof ReadableStream`, or move it into a .web twin. Specs, stories, .web/.webext/.tauri files and Node-only tooling (a value import of a `node:` builtin) are exempt.\n',
|
|
2793
|
+
);
|
|
2794
|
+
for (const msg of webOnlyInstanceof) console.error(` ${msg}`);
|
|
2795
|
+
console.error("");
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (nativeDomCalls.length) {
|
|
2799
|
+
failed = true;
|
|
2800
|
+
console.error(
|
|
2801
|
+
'Convention (FZ-7799 NO UNGUARDED window/document DOM CALL IN public/*/src): React Native makes `window` the global but gives it no addEventListener, removeEventListener, dispatchEvent, matchMedia or getComputedStyle, and has no `document`, so `typeof window === "undefined"` is not a native guard. Guard earlier in an enclosing block with `typeof window.addEventListener === "function"` or `typeof document !== "undefined"`, call it optionally (`window.addEventListener?.(...)`), or move it into a .web twin. Specs, stories, .web/.webext/.tauri files and Node-only tooling are exempt.\n',
|
|
2802
|
+
);
|
|
2803
|
+
for (const msg of nativeDomCalls) console.error(` ${msg}`);
|
|
2804
|
+
console.error("");
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2362
2807
|
if (tokens.violations.length) {
|
|
2363
2808
|
failed = true;
|
|
2364
2809
|
console.error(
|
package/oxlint.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const CHROME_PROPS: readonly string[];
|
|
|
4
4
|
export const LAYOUT_PROPS: readonly string[];
|
|
5
5
|
export const GEOMETRY_PROPS: readonly string[];
|
|
6
6
|
export const MARGIN_PROPS: readonly string[];
|
|
7
|
+
export const RAW_TEXT_NAMES: readonly string[];
|
|
7
8
|
export function isPaletteModule(filename: string | undefined): boolean;
|
|
8
9
|
export function matchColorLiteral(value: unknown): string | undefined;
|
|
9
10
|
export function findHexEscapes(src: string): { line: number; reason: string }[];
|
package/oxlint.mjs
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* Tamagui*-prefixed typography escape hatches (TamaguiText, TamaguiHeading,
|
|
18
18
|
* TamaguiH1…, TamaguiParagraph, TamaguiSizableText, TamaguiAnchor) outside
|
|
19
19
|
* public/. House Text/Heading shadows are the bare names (MPO-20).
|
|
20
|
+
* mpo-conventions/no-voiceless-text — inside public/, raw tamagui typography
|
|
21
|
+
* (an import from tamagui, a Tamagui* hatch, or a styled() copy of one)
|
|
22
|
+
* renders only with a knob voice: a spread fragment or an explicit
|
|
23
|
+
* fontFamily, or nested in a text parent it inherits from. On native a
|
|
24
|
+
* bare text node takes no family, so bodyFont never reaches it (LC-137,
|
|
25
|
+
* FZ-7739). Everything else rides the house Text.
|
|
20
26
|
* mpo-conventions/no-chrome-props — ban chrome (background, radius, type,
|
|
21
27
|
* shadow, elevation) as props on imported @multiplatform.one/* components,
|
|
22
28
|
* even at a valid token. Layout props (flex, alignSelf, sibling margins)
|
|
@@ -89,6 +95,26 @@ function sourceText(context) {
|
|
|
89
95
|
const RAW_TYPOGRAPHY_HATCH = /^Tamagui(?:Text|SizableText|Paragraph|Heading|H[1-6]|Anchor)$/;
|
|
90
96
|
const HOUSE_PACKAGE = /^@multiplatform\.one\//;
|
|
91
97
|
|
|
98
|
+
/** Tamagui's own text primitives, by the name their packages export. */
|
|
99
|
+
export const RAW_TEXT_NAMES = Object.freeze([
|
|
100
|
+
"Text",
|
|
101
|
+
"SizableText",
|
|
102
|
+
"Paragraph",
|
|
103
|
+
"Anchor",
|
|
104
|
+
"Heading",
|
|
105
|
+
"H1",
|
|
106
|
+
"H2",
|
|
107
|
+
"H3",
|
|
108
|
+
"H4",
|
|
109
|
+
"H5",
|
|
110
|
+
"H6",
|
|
111
|
+
"Label",
|
|
112
|
+
]);
|
|
113
|
+
const RAW_TEXT_NAME_SET = new Set(RAW_TEXT_NAMES);
|
|
114
|
+
const TAMAGUI_TEXT_SOURCE = /^(?:tamagui|@tamagui\/(?:core|web|text|label))$/;
|
|
115
|
+
/** A JSX element that holds text: a native text child inherits its family. */
|
|
116
|
+
const TEXT_ELEMENT = /(?:^|\.)(?:[A-Za-z]*Text|Paragraph|Anchor|Label|Heading|H[1-6])$/;
|
|
117
|
+
|
|
92
118
|
/**
|
|
93
119
|
* Chrome props banned on imported `@multiplatform.one/*` components
|
|
94
120
|
* (including Tamagui shorthands). Layout composition through `{...props}`
|
|
@@ -515,6 +541,95 @@ export const plugin = {
|
|
|
515
541
|
};
|
|
516
542
|
},
|
|
517
543
|
},
|
|
544
|
+
"no-voiceless-text": {
|
|
545
|
+
meta: {
|
|
546
|
+
type: "problem",
|
|
547
|
+
docs: {
|
|
548
|
+
description:
|
|
549
|
+
"LC-137 / FZ-7739: raw tamagui typography in public/ must carry its knob voice (a spread fragment or fontFamily) or sit inside a text parent; plain text rides the house Text, SizableText or Paragraph.",
|
|
550
|
+
},
|
|
551
|
+
schema: [],
|
|
552
|
+
messages: {
|
|
553
|
+
voiceless:
|
|
554
|
+
"{{name}} is raw tamagui typography with no knob voice. On native it renders the system face whatever bodyFont says (LC-137). Render it through the house Text, SizableText or Paragraph (@multiplatform.one/theme, re-exported by components), or spread its knob voice (knobProps.body, label or heading).",
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
create(context) {
|
|
558
|
+
const raw = new Set();
|
|
559
|
+
/** Per open JSXElement: does it hold text? */
|
|
560
|
+
const stack = [];
|
|
561
|
+
const collect = (statement) => {
|
|
562
|
+
const node =
|
|
563
|
+
statement?.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
564
|
+
if (node?.type === "ImportDeclaration") {
|
|
565
|
+
if (node.importKind === "type") return false;
|
|
566
|
+
const fromTamagui = TAMAGUI_TEXT_SOURCE.test(node.source?.value ?? "");
|
|
567
|
+
let added = false;
|
|
568
|
+
for (const specifier of node.specifiers ?? []) {
|
|
569
|
+
if (specifier.type !== "ImportSpecifier" || specifier.importKind === "type") continue;
|
|
570
|
+
const imported = specifier.imported?.name ?? specifier.imported?.value;
|
|
571
|
+
const local = specifier.local?.name;
|
|
572
|
+
if (typeof imported !== "string" || !local || raw.has(local)) continue;
|
|
573
|
+
if (
|
|
574
|
+
(fromTamagui && RAW_TEXT_NAME_SET.has(imported)) ||
|
|
575
|
+
RAW_TYPOGRAPHY_HATCH.test(imported)
|
|
576
|
+
) {
|
|
577
|
+
raw.add(local);
|
|
578
|
+
added = true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return added;
|
|
582
|
+
}
|
|
583
|
+
if (node?.type !== "VariableDeclaration") return false;
|
|
584
|
+
let added = false;
|
|
585
|
+
for (const declarator of node.declarations ?? []) {
|
|
586
|
+
const init = declarator.init;
|
|
587
|
+
const local = declarator.id?.type === "Identifier" ? declarator.id.name : undefined;
|
|
588
|
+
if (!local || raw.has(local) || init?.type !== "CallExpression") continue;
|
|
589
|
+
if (init.callee?.type !== "Identifier" || init.callee.name !== "styled") continue;
|
|
590
|
+
const base = init.arguments?.[0];
|
|
591
|
+
if (base?.type !== "Identifier" || !raw.has(base.name)) continue;
|
|
592
|
+
const config = init.arguments?.[1];
|
|
593
|
+
const namesFamily =
|
|
594
|
+
config?.type === "ObjectExpression" &&
|
|
595
|
+
(config.properties ?? []).some(
|
|
596
|
+
(prop) =>
|
|
597
|
+
prop.type === "Property" && (prop.key?.name ?? prop.key?.value) === "fontFamily",
|
|
598
|
+
);
|
|
599
|
+
if (!namesFamily) {
|
|
600
|
+
raw.add(local);
|
|
601
|
+
added = true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return added;
|
|
605
|
+
};
|
|
606
|
+
return {
|
|
607
|
+
Program(node) {
|
|
608
|
+
// styled() copies can chain and sit below their first use, so the
|
|
609
|
+
// module scope settles before any JSX is judged.
|
|
610
|
+
let grew = true;
|
|
611
|
+
while (grew) grew = (node.body ?? []).map(collect).some(Boolean);
|
|
612
|
+
},
|
|
613
|
+
JSXElement(node) {
|
|
614
|
+
const name = jsxName(node.openingElement?.name);
|
|
615
|
+
stack.push(raw.has(name) || TEXT_ELEMENT.test(name));
|
|
616
|
+
},
|
|
617
|
+
"JSXElement:exit"() {
|
|
618
|
+
stack.pop();
|
|
619
|
+
},
|
|
620
|
+
JSXOpeningElement(node) {
|
|
621
|
+
const name = jsxName(node.name);
|
|
622
|
+
if (!raw.has(name)) return;
|
|
623
|
+
if (stack.slice(0, -1).some(Boolean)) return;
|
|
624
|
+
const voiced = (node.attributes ?? []).some(
|
|
625
|
+
(attr) => attr.type === "JSXSpreadAttribute" || jsxAttrName(attr) === "fontFamily",
|
|
626
|
+
);
|
|
627
|
+
if (voiced) return;
|
|
628
|
+
context.report({ node, messageId: "voiceless", data: { name } });
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
},
|
|
518
633
|
"no-chrome-props": {
|
|
519
634
|
meta: {
|
|
520
635
|
type: "problem",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/config",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.47.1",
|
|
4
4
|
"description": "Shared build and test configuration presets for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"config",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"access": "public"
|
|
107
107
|
},
|
|
108
108
|
"dependencies": {
|
|
109
|
-
"@multiplatform.one/utils": "^7.
|
|
109
|
+
"@multiplatform.one/utils": "^7.47.1",
|
|
110
110
|
"@tamagui/vite-plugin": "2.7.6",
|
|
111
111
|
"@vitejs/plugin-react": "^6.1.2",
|
|
112
112
|
"dotenv": "^17.4.2",
|
package/src/lint.spec.ts
CHANGED
|
@@ -552,6 +552,419 @@ export type Where = PathLike;
|
|
|
552
552
|
});
|
|
553
553
|
});
|
|
554
554
|
|
|
555
|
+
describe("no bare instanceof of a web-only global in public/*/src (FZ-7791)", () => {
|
|
556
|
+
function run(root: string) {
|
|
557
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
558
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
559
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
560
|
+
const code = runConventionChecks({ roots: { root } });
|
|
561
|
+
const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
|
|
562
|
+
error.mockRestore();
|
|
563
|
+
log.mockRestore();
|
|
564
|
+
warn.mockRestore();
|
|
565
|
+
return { code, messages };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function publicSrc(root: string, pkg: string, name: string, body: string) {
|
|
569
|
+
mkdirSync(join(root, "public", pkg, "src"), { recursive: true });
|
|
570
|
+
writeFileSync(join(root, "public", pkg, "src", name), body);
|
|
571
|
+
return join("public", pkg, "src", name);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
it("fails the HttpClient body check that threw on Hermes, naming the file and line", () => {
|
|
575
|
+
const root = makeTree();
|
|
576
|
+
const file = publicSrc(
|
|
577
|
+
root,
|
|
578
|
+
"frappe",
|
|
579
|
+
"http.ts",
|
|
580
|
+
`export function isBodyInit(body: unknown) {
|
|
581
|
+
return (
|
|
582
|
+
body instanceof ArrayBuffer ||
|
|
583
|
+
body instanceof ReadableStream ||
|
|
584
|
+
ArrayBuffer.isView(body)
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
`,
|
|
588
|
+
);
|
|
589
|
+
const { code, messages } = run(root);
|
|
590
|
+
expect(code).toBe(1);
|
|
591
|
+
expect(messages).toContain("NO BARE instanceof OF A WEB-ONLY GLOBAL");
|
|
592
|
+
expect(messages).toContain(`${file}:4 instanceof ReadableStream`);
|
|
593
|
+
expect(messages).not.toContain("instanceof ArrayBuffer");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("fails DOM classes too, including the HTML*Element family", () => {
|
|
597
|
+
const root = makeTree();
|
|
598
|
+
const file = publicSrc(
|
|
599
|
+
root,
|
|
600
|
+
"forms",
|
|
601
|
+
"focus.ts",
|
|
602
|
+
"export const isInput = (node: unknown) => node instanceof HTMLInputElement;\n",
|
|
603
|
+
);
|
|
604
|
+
const { code, messages } = run(root);
|
|
605
|
+
expect(code).toBe(1);
|
|
606
|
+
expect(messages).toContain(`${file}:1 instanceof HTMLInputElement`);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("passes a typeof guard in the same expression or an early return in the same block", () => {
|
|
610
|
+
const root = makeTree();
|
|
611
|
+
publicSrc(
|
|
612
|
+
root,
|
|
613
|
+
"frappe",
|
|
614
|
+
"guarded.ts",
|
|
615
|
+
`export function isStream(body: unknown) {
|
|
616
|
+
return typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
|
|
617
|
+
}
|
|
618
|
+
export function isElement(target: unknown) {
|
|
619
|
+
if (typeof Element === "undefined") return false;
|
|
620
|
+
if (!(target instanceof Element)) return false;
|
|
621
|
+
return target.closest("a") !== null;
|
|
622
|
+
}
|
|
623
|
+
`,
|
|
624
|
+
);
|
|
625
|
+
expect(run(root).code).toBe(0);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("does not let a guard in another function cover this one", () => {
|
|
629
|
+
const root = makeTree();
|
|
630
|
+
const file = publicSrc(
|
|
631
|
+
root,
|
|
632
|
+
"table",
|
|
633
|
+
"rowPress.ts",
|
|
634
|
+
`export function hasDom() {
|
|
635
|
+
return typeof Element !== "undefined";
|
|
636
|
+
}
|
|
637
|
+
export function inRow(next: unknown) {
|
|
638
|
+
return next instanceof Element;
|
|
639
|
+
}
|
|
640
|
+
`,
|
|
641
|
+
);
|
|
642
|
+
const { code, messages } = run(root);
|
|
643
|
+
expect(code).toBe(1);
|
|
644
|
+
expect(messages).toContain(`${file}:5 instanceof Element`);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("does not let a guard inside a closed sibling block cover the code after it", () => {
|
|
648
|
+
const root = makeTree();
|
|
649
|
+
const file = publicSrc(
|
|
650
|
+
root,
|
|
651
|
+
"components",
|
|
652
|
+
"useFocus.ts",
|
|
653
|
+
`export function useFocus(node: unknown, onMount: (cb: () => void) => void) {
|
|
654
|
+
onMount(() => {
|
|
655
|
+
if (typeof HTMLElement === "undefined") return;
|
|
656
|
+
});
|
|
657
|
+
return node instanceof HTMLElement ? node : null;
|
|
658
|
+
}
|
|
659
|
+
export const isEl = (v: unknown) => v instanceof Element;
|
|
660
|
+
export function hasElement() {
|
|
661
|
+
return typeof Element !== "undefined";
|
|
662
|
+
}
|
|
663
|
+
`,
|
|
664
|
+
);
|
|
665
|
+
const { code, messages } = run(root);
|
|
666
|
+
expect(code).toBe(1);
|
|
667
|
+
expect(messages).toContain(`${file}:5 instanceof HTMLElement`);
|
|
668
|
+
expect(messages).toContain(`${file}:7 instanceof Element`);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("keeps reading code after a regex literal that holds a quote", () => {
|
|
672
|
+
const root = makeTree();
|
|
673
|
+
const file = publicSrc(
|
|
674
|
+
root,
|
|
675
|
+
"theme",
|
|
676
|
+
"audit.ts",
|
|
677
|
+
`export const unquote = (v: string) => v.replace(/^['"]|['"]$/g, "");
|
|
678
|
+
export const scoped = (scope: unknown) => (scope instanceof Element ? [scope] : []);
|
|
679
|
+
`,
|
|
680
|
+
);
|
|
681
|
+
const { code, messages } = run(root);
|
|
682
|
+
expect(code).toBe(1);
|
|
683
|
+
expect(messages).toContain(`${file}:2 instanceof Element`);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("does not exempt a file whose comment only mentions an import of the name", () => {
|
|
687
|
+
const root = makeTree();
|
|
688
|
+
const file = publicSrc(
|
|
689
|
+
root,
|
|
690
|
+
"forms",
|
|
691
|
+
"picker.ts",
|
|
692
|
+
`// import the File from the picker and pass it from here
|
|
693
|
+
export const isFile = (v: unknown) => v instanceof File;
|
|
694
|
+
`,
|
|
695
|
+
);
|
|
696
|
+
const { code, messages } = run(root);
|
|
697
|
+
expect(code).toBe(1);
|
|
698
|
+
expect(messages).toContain(`${file}:2 instanceof File`);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("exempts comments and strings, web twins, specs, Node tooling and a locally bound name", () => {
|
|
702
|
+
const root = makeTree();
|
|
703
|
+
publicSrc(
|
|
704
|
+
root,
|
|
705
|
+
"frappe",
|
|
706
|
+
"doc.ts",
|
|
707
|
+
`// a bare \`body instanceof ReadableStream\` threw on Hermes
|
|
708
|
+
export const why = "x instanceof Blob is banned";
|
|
709
|
+
`,
|
|
710
|
+
);
|
|
711
|
+
publicSrc(
|
|
712
|
+
root,
|
|
713
|
+
"components",
|
|
714
|
+
"focus.web.ts",
|
|
715
|
+
"export const f = (n: unknown) => n instanceof HTMLElement;\n",
|
|
716
|
+
);
|
|
717
|
+
publicSrc(
|
|
718
|
+
root,
|
|
719
|
+
"components",
|
|
720
|
+
"focus.spec.ts",
|
|
721
|
+
"export const f = (n: unknown) => n instanceof HTMLElement;\n",
|
|
722
|
+
);
|
|
723
|
+
publicSrc(
|
|
724
|
+
root,
|
|
725
|
+
"cli",
|
|
726
|
+
"upload.ts",
|
|
727
|
+
`import { readFileSync } from "node:fs";
|
|
728
|
+
export const isBlob = (v: unknown) => v instanceof Blob;
|
|
729
|
+
export const read = readFileSync;
|
|
730
|
+
`,
|
|
731
|
+
);
|
|
732
|
+
publicSrc(
|
|
733
|
+
root,
|
|
734
|
+
"rich-text",
|
|
735
|
+
"schema.ts",
|
|
736
|
+
`import { Node } from "prosemirror-model";
|
|
737
|
+
export const isDoc = (v: unknown) => v instanceof Node;
|
|
738
|
+
`,
|
|
739
|
+
);
|
|
740
|
+
expect(run(root).code).toBe(0);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
describe("no unguarded window/document DOM call in public/*/src (FZ-7799)", () => {
|
|
745
|
+
function run(root: string) {
|
|
746
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
747
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
748
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
749
|
+
const code = runConventionChecks({ roots: { root } });
|
|
750
|
+
const messages = error.mock.calls.map((call) => String(call[0])).join("\n");
|
|
751
|
+
error.mockRestore();
|
|
752
|
+
log.mockRestore();
|
|
753
|
+
warn.mockRestore();
|
|
754
|
+
return { code, messages };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function publicSrc(root: string, pkg: string, name: string, body: string) {
|
|
758
|
+
const dir = join(root, "public", pkg, "src", ...name.split("/").slice(0, -1));
|
|
759
|
+
mkdirSync(dir, { recursive: true });
|
|
760
|
+
writeFileSync(join(root, "public", pkg, "src", name), body);
|
|
761
|
+
return join("public", pkg, "src", name);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
it("fails the SortableList keydown listener that a typeof window guard let through on React Native", () => {
|
|
765
|
+
const root = makeTree();
|
|
766
|
+
const file = publicSrc(
|
|
767
|
+
root,
|
|
768
|
+
"components",
|
|
769
|
+
"useSortableRows.ts",
|
|
770
|
+
`export function listen(enabled: boolean, onKey: (e: KeyboardEvent) => void) {
|
|
771
|
+
if (!enabled || typeof window === "undefined") return;
|
|
772
|
+
window.addEventListener("keydown", onKey);
|
|
773
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
774
|
+
}
|
|
775
|
+
`,
|
|
776
|
+
);
|
|
777
|
+
const { code, messages } = run(root);
|
|
778
|
+
expect(code).toBe(1);
|
|
779
|
+
expect(messages).toContain("NO UNGUARDED window/document DOM CALL");
|
|
780
|
+
expect(messages).toContain(`${file}:3 window.addEventListener`);
|
|
781
|
+
expect(messages).toContain(`${file}:4 window.removeEventListener`);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("fails window.matchMedia, window.dispatchEvent and any document member with no guard", () => {
|
|
785
|
+
const root = makeTree();
|
|
786
|
+
const file = publicSrc(
|
|
787
|
+
root,
|
|
788
|
+
"theme",
|
|
789
|
+
"dom.ts",
|
|
790
|
+
`export const dark = () => window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
791
|
+
export const ping = () => window.dispatchEvent(new Event("ping"));
|
|
792
|
+
export const lock = () => {
|
|
793
|
+
document.body.style.overflow = "hidden";
|
|
794
|
+
};
|
|
795
|
+
`,
|
|
796
|
+
);
|
|
797
|
+
const { code, messages } = run(root);
|
|
798
|
+
expect(code).toBe(1);
|
|
799
|
+
expect(messages).toContain(`${file}:1 window.matchMedia`);
|
|
800
|
+
expect(messages).toContain(`${file}:2 window.dispatchEvent`);
|
|
801
|
+
expect(messages).toContain(`${file}:4 document.body`);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("fails typeof document.addEventListener on its own, which reads a document native lacks", () => {
|
|
805
|
+
const root = makeTree();
|
|
806
|
+
const file = publicSrc(
|
|
807
|
+
root,
|
|
808
|
+
"backoffice",
|
|
809
|
+
"shortcuts.ts",
|
|
810
|
+
`export function bind(onKey: (e: KeyboardEvent) => void) {
|
|
811
|
+
if (typeof document.addEventListener !== "function") return;
|
|
812
|
+
document.addEventListener("keydown", onKey);
|
|
813
|
+
}
|
|
814
|
+
`,
|
|
815
|
+
);
|
|
816
|
+
const { code, messages } = run(root);
|
|
817
|
+
expect(code).toBe(1);
|
|
818
|
+
expect(messages).toContain(`${file}:2 document.addEventListener`);
|
|
819
|
+
expect(messages).not.toContain(`${file}:3`);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("passes a function-type check, a document check, an optional call, a truthiness test and a web-only branch", () => {
|
|
823
|
+
const root = makeTree();
|
|
824
|
+
publicSrc(
|
|
825
|
+
root,
|
|
826
|
+
"components",
|
|
827
|
+
"guarded.ts",
|
|
828
|
+
`import { Platform } from "react-native";
|
|
829
|
+
import { isWeb } from "tamagui";
|
|
830
|
+
export function listen(onKey: (e: KeyboardEvent) => void) {
|
|
831
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
832
|
+
window.addEventListener("keydown", onKey);
|
|
833
|
+
return () => {
|
|
834
|
+
window.removeEventListener("keydown", onKey);
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
export function lock() {
|
|
838
|
+
if (typeof document === "undefined") return;
|
|
839
|
+
document.body.style.overflow = "hidden";
|
|
840
|
+
}
|
|
841
|
+
export const reduced = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
842
|
+
export function dark() {
|
|
843
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
844
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
845
|
+
}
|
|
846
|
+
export function webOnly(onKey: (e: KeyboardEvent) => void) {
|
|
847
|
+
if (!isWeb) return;
|
|
848
|
+
window.addEventListener("keydown", onKey);
|
|
849
|
+
}
|
|
850
|
+
export function platformOnly() {
|
|
851
|
+
if (Platform.OS !== "web") return null;
|
|
852
|
+
return document.activeElement;
|
|
853
|
+
}
|
|
854
|
+
`,
|
|
855
|
+
);
|
|
856
|
+
expect(run(root).code).toBe(0);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("passes a call to a same-file helper whose body is the guard", () => {
|
|
860
|
+
const root = makeTree();
|
|
861
|
+
publicSrc(
|
|
862
|
+
root,
|
|
863
|
+
"backoffice",
|
|
864
|
+
"picker.ts",
|
|
865
|
+
`export function canPickFile(): boolean {
|
|
866
|
+
return typeof document !== "undefined" && typeof document.createElement === "function";
|
|
867
|
+
}
|
|
868
|
+
export function pickFile() {
|
|
869
|
+
if (!canPickFile()) return undefined;
|
|
870
|
+
return document.createElement("input");
|
|
871
|
+
}
|
|
872
|
+
`,
|
|
873
|
+
);
|
|
874
|
+
expect(run(root).code).toBe(0);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("does not let a guard in a sibling function cover this one", () => {
|
|
878
|
+
const root = makeTree();
|
|
879
|
+
const file = publicSrc(
|
|
880
|
+
root,
|
|
881
|
+
"forms",
|
|
882
|
+
"escape.ts",
|
|
883
|
+
`export function hasDom() {
|
|
884
|
+
return typeof document !== "undefined";
|
|
885
|
+
}
|
|
886
|
+
export function useEscape(onKey: (e: KeyboardEvent) => void) {
|
|
887
|
+
document.addEventListener("keydown", onKey);
|
|
888
|
+
}
|
|
889
|
+
`,
|
|
890
|
+
);
|
|
891
|
+
const { code, messages } = run(root);
|
|
892
|
+
expect(code).toBe(1);
|
|
893
|
+
expect(messages).toContain(`${file}:5 document.addEventListener`);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("exempts web twins, a file with a native twin, specs, comments, strings and a local document", () => {
|
|
897
|
+
const root = makeTree();
|
|
898
|
+
publicSrc(
|
|
899
|
+
root,
|
|
900
|
+
"components",
|
|
901
|
+
"keys.web.ts",
|
|
902
|
+
'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
|
|
903
|
+
);
|
|
904
|
+
publicSrc(
|
|
905
|
+
root,
|
|
906
|
+
"components",
|
|
907
|
+
"SheetModal/index.tsx",
|
|
908
|
+
'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
|
|
909
|
+
);
|
|
910
|
+
publicSrc(root, "components", "SheetModal/index.native.tsx", "export const on = () => {};\n");
|
|
911
|
+
publicSrc(
|
|
912
|
+
root,
|
|
913
|
+
"components",
|
|
914
|
+
"keys.spec.ts",
|
|
915
|
+
'export const on = (f: () => void) => window.addEventListener("keydown", f);\n',
|
|
916
|
+
);
|
|
917
|
+
publicSrc(
|
|
918
|
+
root,
|
|
919
|
+
"frappe",
|
|
920
|
+
"doc.ts",
|
|
921
|
+
`// window.addEventListener threw on Hermes
|
|
922
|
+
export const why = "document.body is missing on native";
|
|
923
|
+
`,
|
|
924
|
+
);
|
|
925
|
+
publicSrc(
|
|
926
|
+
root,
|
|
927
|
+
"node-graph",
|
|
928
|
+
"legal.ts",
|
|
929
|
+
`export function ids(query: { document: { nodes: { id: string }[] } }) {
|
|
930
|
+
const { document } = query;
|
|
931
|
+
return document.nodes.map((node) => node.id);
|
|
932
|
+
}
|
|
933
|
+
export const count = (document: { nodes: unknown[] }) => document.nodes.length;
|
|
934
|
+
`,
|
|
935
|
+
);
|
|
936
|
+
publicSrc(
|
|
937
|
+
root,
|
|
938
|
+
"node-graph",
|
|
939
|
+
"useDoc.ts",
|
|
940
|
+
`import { useState } from "react";
|
|
941
|
+
export function useDoc() {
|
|
942
|
+
const [document, setDocument] = useState<{ nodes: unknown[] } | null>(null);
|
|
943
|
+
return { size: document?.nodes.length ?? 0, setDocument };
|
|
944
|
+
}
|
|
945
|
+
`,
|
|
946
|
+
);
|
|
947
|
+
expect(run(root).code).toBe(0);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("does not treat passing the global along as a local binding", () => {
|
|
951
|
+
const root = makeTree();
|
|
952
|
+
const file = publicSrc(
|
|
953
|
+
root,
|
|
954
|
+
"web3",
|
|
955
|
+
"inject.ts",
|
|
956
|
+
`export function inject(register: (w: unknown) => void, onReady: () => void) {
|
|
957
|
+
register(window);
|
|
958
|
+
window.addEventListener("ready", onReady);
|
|
959
|
+
}
|
|
960
|
+
`,
|
|
961
|
+
);
|
|
962
|
+
const { code, messages } = run(root);
|
|
963
|
+
expect(code).toBe(1);
|
|
964
|
+
expect(messages).toContain(`${file}:3 window.addEventListener`);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
555
968
|
describe("a scaffolded project, where apps/one is renamed and public/ is pruned (MPO-318)", () => {
|
|
556
969
|
function run(root: string) {
|
|
557
970
|
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
// FZ-7739 / LC-137: on native a raw tamagui text node takes no family and
|
|
10
|
+
// inherits none, so bodyFont never reaches it. These run the real oxlint
|
|
11
|
+
// binary over real TSX, so the AST shapes are oxlint's own.
|
|
12
|
+
const packageDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const oxlintBin = join(
|
|
14
|
+
dirname(createRequire(import.meta.url).resolve("oxlint/package.json")),
|
|
15
|
+
"bin",
|
|
16
|
+
"oxlint",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const project = mkdtempSync(join(tmpdir(), "mpo-voiceless-text-"));
|
|
20
|
+
afterAll(() => rmSync(project, { recursive: true, force: true }));
|
|
21
|
+
|
|
22
|
+
function lint(source: string): string[] {
|
|
23
|
+
mkdirSync(join(project, "node_modules", "@multiplatform.one"), { recursive: true });
|
|
24
|
+
try {
|
|
25
|
+
symlinkSync(packageDir, join(project, "node_modules", "@multiplatform.one", "config"));
|
|
26
|
+
} catch {
|
|
27
|
+
// already linked by an earlier case
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(
|
|
30
|
+
join(project, ".oxlintrc.json"),
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
jsPlugins: ["@multiplatform.one/config/oxlint"],
|
|
33
|
+
rules: { "mpo-conventions/no-voiceless-text": "error" },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
const file = "public/demo/src/Demo.tsx";
|
|
37
|
+
mkdirSync(dirname(join(project, file)), { recursive: true });
|
|
38
|
+
writeFileSync(join(project, file), source);
|
|
39
|
+
let out: string;
|
|
40
|
+
try {
|
|
41
|
+
out = execFileSync(oxlintBin, ["-c", ".oxlintrc.json", "-f", "unix", file], {
|
|
42
|
+
cwd: project,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
out = String((error as { stdout?: string }).stdout ?? "");
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
.split("\n")
|
|
50
|
+
.filter((line) => line.includes("no-voiceless-text"))
|
|
51
|
+
.map((line) => /^[^:]+:(\d+):/.exec(line)?.[1] ?? line);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("mpo-conventions/no-voiceless-text (FZ-7739, LC-137)", () => {
|
|
55
|
+
it("reports raw text with no voice and stays silent on a spread voice or a family", () => {
|
|
56
|
+
const lines = lint(
|
|
57
|
+
[
|
|
58
|
+
'import { useResolvedKnobs } from "@multiplatform.one/theme";',
|
|
59
|
+
'import { Paragraph, SizableText, Text } from "tamagui";',
|
|
60
|
+
"export function Demo() {",
|
|
61
|
+
" const { knobProps } = useResolvedKnobs();",
|
|
62
|
+
" return (",
|
|
63
|
+
" <>",
|
|
64
|
+
" <Text>dead</Text>",
|
|
65
|
+
' <SizableText size="$2">dead</SizableText>',
|
|
66
|
+
" <Paragraph>dead</Paragraph>",
|
|
67
|
+
" <Text {...knobProps.body}>voiced</Text>",
|
|
68
|
+
' <Text fontFamily="$mono">code</Text>',
|
|
69
|
+
" </>",
|
|
70
|
+
" );",
|
|
71
|
+
"}",
|
|
72
|
+
"",
|
|
73
|
+
].join("\n"),
|
|
74
|
+
);
|
|
75
|
+
expect(lines).toEqual(["7", "8", "9"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("lets a text nested in another text inherit, and still reports one inside a view", () => {
|
|
79
|
+
const lines = lint(
|
|
80
|
+
[
|
|
81
|
+
'import { Text } from "@multiplatform.one/theme";',
|
|
82
|
+
'import { Text as TamaguiText, View } from "tamagui";',
|
|
83
|
+
"export function Demo() {",
|
|
84
|
+
" return (",
|
|
85
|
+
" <View>",
|
|
86
|
+
" <Text>",
|
|
87
|
+
' house <TamaguiText fontWeight="700">bold</TamaguiText>',
|
|
88
|
+
" </Text>",
|
|
89
|
+
" <TamaguiText>dead</TamaguiText>",
|
|
90
|
+
" </View>",
|
|
91
|
+
" );",
|
|
92
|
+
"}",
|
|
93
|
+
"",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
);
|
|
96
|
+
expect(lines).toEqual(["9"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("follows styled() copies, even ones declared below their use", () => {
|
|
100
|
+
const lines = lint(
|
|
101
|
+
[
|
|
102
|
+
'import { styled, Text } from "tamagui";',
|
|
103
|
+
"export function Demo() {",
|
|
104
|
+
" return (",
|
|
105
|
+
" <>",
|
|
106
|
+
" <Caption>dead</Caption>",
|
|
107
|
+
" <Mono>fixed family</Mono>",
|
|
108
|
+
" </>",
|
|
109
|
+
" );",
|
|
110
|
+
"}",
|
|
111
|
+
'const Caption = styled(Text, { color: "$color11" });',
|
|
112
|
+
'const Mono = styled(Text, { fontFamily: "$mono" });',
|
|
113
|
+
"",
|
|
114
|
+
].join("\n"),
|
|
115
|
+
);
|
|
116
|
+
expect(lines).toEqual(["5"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("covers the Tamagui* hatches and stays silent on house names and other packages", () => {
|
|
120
|
+
const lines = lint(
|
|
121
|
+
[
|
|
122
|
+
'import { TamaguiParagraph, Text } from "@multiplatform.one/components";',
|
|
123
|
+
'import { Text as Other } from "some-other-package";',
|
|
124
|
+
"export function Demo() {",
|
|
125
|
+
" return (",
|
|
126
|
+
" <>",
|
|
127
|
+
" <TamaguiParagraph>dead</TamaguiParagraph>",
|
|
128
|
+
" <Text>house</Text>",
|
|
129
|
+
" <Other>other</Other>",
|
|
130
|
+
" </>",
|
|
131
|
+
" );",
|
|
132
|
+
"}",
|
|
133
|
+
"",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
);
|
|
136
|
+
expect(lines).toEqual(["6"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores type-only imports", () => {
|
|
140
|
+
expect(
|
|
141
|
+
lint(
|
|
142
|
+
[
|
|
143
|
+
'import type { TextProps } from "tamagui";',
|
|
144
|
+
'import { Text } from "@multiplatform.one/theme";',
|
|
145
|
+
"export const Demo = (props: TextProps) => <Text {...props} />;",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n"),
|
|
148
|
+
),
|
|
149
|
+
).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|