@multiplatform.one/config 7.42.0 → 7.44.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 +7 -2
- package/design-lint.mjs +64 -3
- package/lint.mjs +47 -2
- package/package.json +5 -3
- package/press-lint.d.ts +13 -0
- package/press-lint.mjs +255 -0
- package/src/lint.spec.ts +44 -0
- package/src/press-lint.spec.ts +115 -0
package/README.md
CHANGED
|
@@ -143,8 +143,13 @@ the enclosing `Svg` has a `viewBox`, including one passed through a spread, or
|
|
|
143
143
|
when the mark is a fragment drawn into an `Svg` elsewhere. Primitives inside an
|
|
144
144
|
`Svg` with no `viewBox`, and the `Svg` element's own size, are still reported.
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
A `focusStyle`, `focusVisibleStyle` or `focusWithinStyle` that paints its own
|
|
147
|
+
outline, box shadow, shadow or elevation is reported as `field-focus`. Focus
|
|
148
|
+
looks come from MPO: fields take `FIELD_FOCUS_STYLE` (the accent border), other
|
|
149
|
+
controls the theme's focus ring. Zeroing those keys is allowed.
|
|
150
|
+
|
|
151
|
+
Every violation is tagged with its rule: `field-focus`, `hex`, `geometry`,
|
|
152
|
+
`inline-spacing`, `raw-control`, `styled-mpo` or `token-file`.
|
|
148
153
|
|
|
149
154
|
#### Ratchet
|
|
150
155
|
|
package/design-lint.mjs
CHANGED
|
@@ -51,6 +51,11 @@ const svgPrimitives = new Set([
|
|
|
51
51
|
"Use",
|
|
52
52
|
]);
|
|
53
53
|
const svgRoots = new Set(["Svg", "Symbol"]);
|
|
54
|
+
// FZ-7717: focus looks come from MPO (FIELD_FOCUS_STYLE / the control ring).
|
|
55
|
+
// A use site that paints its own focus ring or shadow forks the look.
|
|
56
|
+
const focusPaint =
|
|
57
|
+
/^(?:outline(?:Width|Color|Offset)?|boxShadow|shadow(?:Color|Opacity|Radius|Offset)|elevation)$/;
|
|
58
|
+
const focusStates = new Set(["focusStyle", "focusVisibleStyle", "focusWithinStyle"]);
|
|
54
59
|
const tokenFactories = new Set([
|
|
55
60
|
"createTokens",
|
|
56
61
|
"createTamagui",
|
|
@@ -59,6 +64,7 @@ const tokenFactories = new Set([
|
|
|
59
64
|
"createFont",
|
|
60
65
|
]);
|
|
61
66
|
export const designRules = [
|
|
67
|
+
"field-focus",
|
|
62
68
|
"hex",
|
|
63
69
|
"geometry",
|
|
64
70
|
"inline-spacing",
|
|
@@ -67,7 +73,23 @@ export const designRules = [
|
|
|
67
73
|
"styled-mpo",
|
|
68
74
|
"token-file",
|
|
69
75
|
];
|
|
70
|
-
export function
|
|
76
|
+
export function isFreeformBoard(filename, projectRoot = process.cwd()) {
|
|
77
|
+
const root = path.resolve(projectRoot);
|
|
78
|
+
const absolute = path.resolve(root, filename);
|
|
79
|
+
const inArea = (name, base) =>
|
|
80
|
+
path
|
|
81
|
+
.relative(base, name)
|
|
82
|
+
.replaceAll("\\", "/")
|
|
83
|
+
.startsWith("apps/storybook-expo/boards/freeform/");
|
|
84
|
+
return (
|
|
85
|
+
inArea(absolute, root) &&
|
|
86
|
+
(!existsSync(absolute) ||
|
|
87
|
+
inArea(realpathSync(absolute), existsSync(root) ? realpathSync(root) : root))
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function inspectDesign(source, filename, projectRoot = process.cwd()) {
|
|
92
|
+
if (isFreeformBoard(filename, projectRoot)) return [];
|
|
71
93
|
const file = ts.createSourceFile(
|
|
72
94
|
filename,
|
|
73
95
|
source,
|
|
@@ -208,6 +230,42 @@ export function inspectDesign(source, filename) {
|
|
|
208
230
|
return spacing(value.whenTrue) || spacing(value.whenFalse);
|
|
209
231
|
return false;
|
|
210
232
|
}
|
|
233
|
+
function inert(node) {
|
|
234
|
+
const value = resolve(node);
|
|
235
|
+
if (ts.isNumericLiteral(value)) return Number(value.text) === 0;
|
|
236
|
+
if (ts.isStringLiteralLike(value)) return /^(?:0(?:px)?|none|transparent)$/.test(value.text);
|
|
237
|
+
return value.kind === ts.SyntaxKind.UndefinedKeyword || value.getText(file) === "undefined";
|
|
238
|
+
}
|
|
239
|
+
function paintsFocus(node, seen = new Set()) {
|
|
240
|
+
const value = resolve(node);
|
|
241
|
+
if (seen.has(value)) return undefined;
|
|
242
|
+
seen.add(value);
|
|
243
|
+
if (ts.isConditionalExpression(value))
|
|
244
|
+
return paintsFocus(value.whenTrue, seen) ?? paintsFocus(value.whenFalse, seen);
|
|
245
|
+
if (!ts.isObjectLiteralExpression(value)) return undefined;
|
|
246
|
+
for (const prop of value.properties) {
|
|
247
|
+
if (ts.isSpreadAssignment(prop)) {
|
|
248
|
+
const inner = paintsFocus(prop.expression, seen);
|
|
249
|
+
if (inner) return inner;
|
|
250
|
+
} else if (
|
|
251
|
+
ts.isPropertyAssignment(prop) &&
|
|
252
|
+
focusPaint.test(name(prop.name)) &&
|
|
253
|
+
!inert(prop.initializer)
|
|
254
|
+
)
|
|
255
|
+
return prop;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
function checkFocus(key, value, node) {
|
|
260
|
+
if (!focusStates.has(key)) return;
|
|
261
|
+
const painted = paintsFocus(value);
|
|
262
|
+
if (painted)
|
|
263
|
+
report(
|
|
264
|
+
node,
|
|
265
|
+
"field-focus",
|
|
266
|
+
`${key} paints its own ${name(painted.name)}; focus comes from MPO (FIELD_FOCUS_STYLE).`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
211
269
|
function report(node, rule, message) {
|
|
212
270
|
const { line, character } = file.getLineAndCharacterOfPosition(node.getStart(file));
|
|
213
271
|
errors.push(`${filename}:${line + 1}:${character + 1}: [${rule}] ${message}`);
|
|
@@ -244,6 +302,7 @@ export function inspectDesign(source, filename) {
|
|
|
244
302
|
: prop.initializer;
|
|
245
303
|
if (!initializer) return;
|
|
246
304
|
const key = name(prop.name);
|
|
305
|
+
checkFocus(key, initializer, prop);
|
|
247
306
|
if (layout.test(key) && numeric(initializer))
|
|
248
307
|
report(prop, "geometry", `${name(prop.name)} requires a token, not a numeric literal.`);
|
|
249
308
|
if (/style$/i.test(key) && spacing(initializer))
|
|
@@ -330,6 +389,7 @@ export function inspectDesign(source, filename) {
|
|
|
330
389
|
const value = ts.isJsxExpression(node.initializer)
|
|
331
390
|
? node.initializer.expression
|
|
332
391
|
: node.initializer;
|
|
392
|
+
if (value) checkFocus(key, value, node);
|
|
333
393
|
if (value && (key.startsWith("$") || /style$/i.test(key))) inspectProps(value);
|
|
334
394
|
if (value && layout.test(key) && numeric(value) && !svgArtwork(node.parent.parent))
|
|
335
395
|
report(node, "geometry", `${key} requires a token, not a numeric literal.`);
|
|
@@ -365,10 +425,11 @@ const ignoredDirectories = new Set([
|
|
|
365
425
|
"scripts",
|
|
366
426
|
]);
|
|
367
427
|
|
|
368
|
-
export function designFiles(input) {
|
|
428
|
+
export function designFiles(input, projectRoot = process.cwd()) {
|
|
369
429
|
const root = path.resolve(input);
|
|
370
430
|
const info = statSync(root);
|
|
371
431
|
const eligible = (filename) =>
|
|
432
|
+
!isFreeformBoard(path.resolve(root, filename), projectRoot) &&
|
|
372
433
|
/\.[cm]?[jt]sx?$/.test(filename) &&
|
|
373
434
|
!/\.(?:test|spec|stories|config|gen|generated|d)\.[cm]?[jt]sx?$/.test(filename);
|
|
374
435
|
if (info.isFile()) return eligible(root) ? [root] : [];
|
|
@@ -427,7 +488,7 @@ export function countDesign(errors) {
|
|
|
427
488
|
}
|
|
428
489
|
|
|
429
490
|
export function scanDesign(paths) {
|
|
430
|
-
const files = [...new Set(paths.flatMap(designFiles))];
|
|
491
|
+
const files = [...new Set(paths.flatMap((input) => designFiles(input)))];
|
|
431
492
|
if (!files.length) throw new Error("No application source files found in the supplied paths.");
|
|
432
493
|
const errors = files.flatMap((filename) =>
|
|
433
494
|
inspectDesign(readFileSync(filename, "utf8"), filename),
|
package/lint.mjs
CHANGED
|
@@ -79,6 +79,10 @@
|
|
|
79
79
|
* Size, spacing and radius are the preset's token scale; the only
|
|
80
80
|
* per-site control is Tamagui's own `size="$n"`. Specs are exempt: they
|
|
81
81
|
* are where the retirement is asserted.
|
|
82
|
+
* 17. Fail on a NESTED PRESS CLAIMANT (FZ-7792 NATIVE-PRESS-OWNER) — a view
|
|
83
|
+
* with `pressStyle` / `focusVisibleStyle` and no press handler of its own
|
|
84
|
+
* inside a JSX pressable. On native it claims the responder and the
|
|
85
|
+
* pressable's onPress never runs. See press-lint.mjs.
|
|
82
86
|
*
|
|
83
87
|
* Usage:
|
|
84
88
|
* import { runConventionChecks } from "@multiplatform.one/config/lint";
|
|
@@ -94,6 +98,7 @@ import { dirname, join, relative } from "node:path";
|
|
|
94
98
|
import { pathToFileURL } from "node:url";
|
|
95
99
|
import { DRAWING_PRAGMA, isDrawingFile, readDrawingRegistry } from "./drawing-registry.mjs";
|
|
96
100
|
import { findHexEscapes } from "./oxlint.mjs";
|
|
101
|
+
import { exportedPressClaimants, findNestedPressClaimants } from "./press-lint.mjs";
|
|
97
102
|
import { readHexEscapeRegistry, readSizeRecipeEscapeRegistry } from "./spec-registry.mjs";
|
|
98
103
|
|
|
99
104
|
/**
|
|
@@ -1531,7 +1536,7 @@ const STORY_HONESTY_MARKER_RE = /\bforeignContrastSpecimen\s*:\s*true\b/;
|
|
|
1531
1536
|
* forms reaches frappe through its @multiplatform.one/storybook devDependency,
|
|
1532
1537
|
* so the import would close a workspace cycle.
|
|
1533
1538
|
*/
|
|
1534
|
-
const STORY_HONESTY_EXEMPT_PREFIXES = ["public/frappe/"];
|
|
1539
|
+
const STORY_HONESTY_EXEMPT_PREFIXES = ["public/frappe/", "apps/storybook-expo/boards/freeform/"];
|
|
1535
1540
|
|
|
1536
1541
|
const TAMAGUI_IMPORT_RE = /import\s+([^;'"]*?)from\s*["']tamagui["']/g;
|
|
1537
1542
|
|
|
@@ -1930,6 +1935,7 @@ function readLayoutBaselines() {
|
|
|
1930
1935
|
/** Screen code: where a page composes chrome (not the catalog primitives). */
|
|
1931
1936
|
function isScreenSource(rel) {
|
|
1932
1937
|
const path = rel.replaceAll("\\", "/");
|
|
1938
|
+
if (path.startsWith("apps/storybook-expo/boards/freeform/")) return false;
|
|
1933
1939
|
return (
|
|
1934
1940
|
path.startsWith("public/backoffice/src/") ||
|
|
1935
1941
|
path.startsWith("public/frappe-ui/src/") ||
|
|
@@ -1998,7 +2004,12 @@ function checkHugContent() {
|
|
|
1998
2004
|
const seen = new Set();
|
|
1999
2005
|
for (const { rel, src } of sourceFiles()) {
|
|
2000
2006
|
const path = rel.replaceAll("\\", "/");
|
|
2001
|
-
if (
|
|
2007
|
+
if (
|
|
2008
|
+
!/\.(tsx|ts)$/.test(path) ||
|
|
2009
|
+
/\.stories\./.test(path) ||
|
|
2010
|
+
path.startsWith("apps/storybook-expo/boards/freeform/")
|
|
2011
|
+
)
|
|
2012
|
+
continue;
|
|
2002
2013
|
const live = nonCommentOffsets(src);
|
|
2003
2014
|
const hits = [...src.matchAll(SPREAD_JUSTIFY_RE)].filter((m) => live[m.index ?? 0] === 1);
|
|
2004
2015
|
const allowed = baselines.hugContent[path] ?? 0;
|
|
@@ -2093,6 +2104,35 @@ function checkAccentSolidOwners() {
|
|
|
2093
2104
|
return violations;
|
|
2094
2105
|
}
|
|
2095
2106
|
|
|
2107
|
+
/**
|
|
2108
|
+
* FZ-7792 NATIVE-PRESS-OWNER: see press-lint.mjs. Two passes, so a styled
|
|
2109
|
+
* claimant exported by one file is caught where another file nests it.
|
|
2110
|
+
*/
|
|
2111
|
+
function checkNativePressOwner() {
|
|
2112
|
+
/** @type {{ rel: string, src: string, abs: string }[]} */
|
|
2113
|
+
const files = [];
|
|
2114
|
+
walkOwnedSource((filePath) => {
|
|
2115
|
+
if (!/\.(?:tsx|jsx)$/.test(filePath) || /\.(?:spec|test)\./.test(filePath)) return;
|
|
2116
|
+
files.push({
|
|
2117
|
+
abs: filePath,
|
|
2118
|
+
rel: relative(ROOT, filePath).replaceAll("\\", "/"),
|
|
2119
|
+
src: readFileSync(filePath, "utf8"),
|
|
2120
|
+
});
|
|
2121
|
+
});
|
|
2122
|
+
/** @type {Set<string>} */
|
|
2123
|
+
const imported = new Set();
|
|
2124
|
+
for (const { abs, src } of files)
|
|
2125
|
+
for (const name of exportedPressClaimants(src, abs)) imported.add(name);
|
|
2126
|
+
/** @type {string[]} */
|
|
2127
|
+
const violations = [];
|
|
2128
|
+
for (const { abs, rel, src } of files)
|
|
2129
|
+
for (const hit of findNestedPressClaimants(src, abs, imported))
|
|
2130
|
+
violations.push(
|
|
2131
|
+
`${rel}:${hit.line} — <${hit.tag}> claims the press inside <${hit.owner}>; move its press look to the owner's group ("$group-<name>-press")`,
|
|
2132
|
+
);
|
|
2133
|
+
return violations;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2096
2136
|
/**
|
|
2097
2137
|
* Run structural convention checks over a consumer tree.
|
|
2098
2138
|
*
|
|
@@ -2125,6 +2165,7 @@ export function runConventionChecks(options = {}) {
|
|
|
2125
2165
|
const oneSidebar = checkOneSidebar();
|
|
2126
2166
|
const hugContent = checkHugContent();
|
|
2127
2167
|
const responsiveParity = checkResponsiveParity();
|
|
2168
|
+
const nativePressOwner = checkNativePressOwner();
|
|
2128
2169
|
let failed = false;
|
|
2129
2170
|
|
|
2130
2171
|
for (const [rule, list] of /** @type {const} */ ([
|
|
@@ -2140,6 +2181,10 @@ export function runConventionChecks(options = {}) {
|
|
|
2140
2181
|
"Convention (LC-103 RESPONSIVE-PARITY): a phone shows the same information. Content hidden on phones names its phone twin (⋯ menu, icon twin, sheet) with a `responsive-parity: <reason>` comment.",
|
|
2141
2182
|
responsiveParity,
|
|
2142
2183
|
],
|
|
2184
|
+
[
|
|
2185
|
+
'Convention (FZ-7792 NATIVE-PRESS-OWNER, check 17): one view owns a press. On native, a view with pressStyle or focusVisibleStyle and no handler claims the touch, and React Native gives it to the deepest claimant, so a pressable\'s onPress never runs when the tap lands on that child. Put `group="<name>"` on the pressable and move the child\'s press look to `"$group-<name>-press"`.',
|
|
2186
|
+
nativePressOwner,
|
|
2187
|
+
],
|
|
2143
2188
|
])) {
|
|
2144
2189
|
if (!list.length) continue;
|
|
2145
2190
|
failed = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/config",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.44.0",
|
|
4
4
|
"description": "Shared build and test configuration presets for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"config",
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
"!tsconfig/types",
|
|
47
47
|
"types",
|
|
48
48
|
"!types/.tsbuildinfo",
|
|
49
|
-
"design-lint.mjs"
|
|
49
|
+
"design-lint.mjs",
|
|
50
|
+
"press-lint.mjs",
|
|
51
|
+
"press-lint.d.ts"
|
|
50
52
|
],
|
|
51
53
|
"type": "module",
|
|
52
54
|
"sideEffects": false,
|
|
@@ -104,7 +106,7 @@
|
|
|
104
106
|
"access": "public"
|
|
105
107
|
},
|
|
106
108
|
"dependencies": {
|
|
107
|
-
"@multiplatform.one/utils": "^7.
|
|
109
|
+
"@multiplatform.one/utils": "^7.44.0",
|
|
108
110
|
"@tamagui/vite-plugin": "2.7.6",
|
|
109
111
|
"@vitejs/plugin-react": "^6.1.2",
|
|
110
112
|
"dotenv": "^17.4.2",
|
package/press-lint.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface NestedPressClaimant {
|
|
2
|
+
line: number;
|
|
3
|
+
tag: string;
|
|
4
|
+
owner: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function exportedPressClaimants(source: string, filename: string): string[];
|
|
8
|
+
|
|
9
|
+
export function findNestedPressClaimants(
|
|
10
|
+
source: string,
|
|
11
|
+
filename: string,
|
|
12
|
+
imported?: Set<string>,
|
|
13
|
+
): NestedPressClaimant[];
|
package/press-lint.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATIVE-PRESS-OWNER (FZ-7792): one view owns a press.
|
|
3
|
+
*
|
|
4
|
+
* On native without react-native-gesture-handler, tamagui synthesizes an
|
|
5
|
+
* onPress for any view carrying `pressStyle` or `focusVisibleStyle` and wires
|
|
6
|
+
* it through the responder system, whose onStartShouldSetResponder answers
|
|
7
|
+
* true. React Native grants a touch to the DEEPEST view that asks, so such a
|
|
8
|
+
* view inside a real pressable takes the tap: it paints its press colour and
|
|
9
|
+
* the pressable's onPress never runs. On web the DOM click bubbles to the
|
|
10
|
+
* pressable, so web specs, Storybook and jsdom all pass (Alert's action pill,
|
|
11
|
+
* FZ-7792).
|
|
12
|
+
*
|
|
13
|
+
* Flagged: a JSX element that claims the touch without handling it, nested
|
|
14
|
+
* among the JSX children of an element that handles it.
|
|
15
|
+
*
|
|
16
|
+
* - claims: a `pressStyle` or `focusVisibleStyle` prop, or a tag bound to
|
|
17
|
+
* `styled(...)` whose config carries either key at any depth (variants
|
|
18
|
+
* included), or to `styled()` of such a tag, in this file or imported by
|
|
19
|
+
* name from another scanned file that exports it;
|
|
20
|
+
* - handles: `onPress`, `onPressIn`, `onPressOut` or `onLongPress`, or being
|
|
21
|
+
* the child of an `asChild` element, which slots its handler in;
|
|
22
|
+
* - not flagged: a claimant that handles the press itself, renders `asChild`,
|
|
23
|
+
* is a text input (tamagui wires inputs' presses directly, never through
|
|
24
|
+
* the responder), carries `pointerEvents="box-none"`, or sits under (or
|
|
25
|
+
* carries) `pointerEvents="none"`.
|
|
26
|
+
*
|
|
27
|
+
* Fix: move the look onto the owner's group state (`group="name"` on the
|
|
28
|
+
* pressable, `"$group-name-press": {...}` on the child).
|
|
29
|
+
*/
|
|
30
|
+
import ts from "typescript";
|
|
31
|
+
|
|
32
|
+
const claimKeys = new Set(["pressStyle", "focusVisibleStyle"]);
|
|
33
|
+
const pressHandlers = new Set(["onPress", "onPressIn", "onPressOut", "onLongPress"]);
|
|
34
|
+
const inputTag = /(?:Input|TextArea)$/;
|
|
35
|
+
|
|
36
|
+
/** @param {ts.Node} node */
|
|
37
|
+
function keyName(node) {
|
|
38
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node)) return node.text;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {ts.Expression} expr */
|
|
43
|
+
function unwrap(expr) {
|
|
44
|
+
let current = expr;
|
|
45
|
+
while (
|
|
46
|
+
ts.isAsExpression(current) ||
|
|
47
|
+
ts.isSatisfiesExpression(current) ||
|
|
48
|
+
ts.isParenthesizedExpression(current)
|
|
49
|
+
)
|
|
50
|
+
current = current.expression;
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `styled(Base, {...})`, also under `.styleable(...)` and friends.
|
|
56
|
+
* @param {ts.Expression} expr
|
|
57
|
+
* @returns {ts.CallExpression | undefined}
|
|
58
|
+
*/
|
|
59
|
+
function styledCall(expr) {
|
|
60
|
+
let current = unwrap(expr);
|
|
61
|
+
while (ts.isCallExpression(current)) {
|
|
62
|
+
const callee = current.expression;
|
|
63
|
+
if (ts.isIdentifier(callee) && callee.text === "styled") return current;
|
|
64
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isCallExpression(callee.expression))
|
|
65
|
+
current = callee.expression;
|
|
66
|
+
else return undefined;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} source
|
|
73
|
+
* @param {string} filename
|
|
74
|
+
*/
|
|
75
|
+
function parse(source, filename) {
|
|
76
|
+
const file = ts.createSourceFile(
|
|
77
|
+
filename,
|
|
78
|
+
source,
|
|
79
|
+
ts.ScriptTarget.Latest,
|
|
80
|
+
true,
|
|
81
|
+
ts.ScriptKind.TSX,
|
|
82
|
+
);
|
|
83
|
+
/** @type {Map<string, ts.Expression>} */
|
|
84
|
+
const objects = new Map();
|
|
85
|
+
/** @type {Map<string, ts.CallExpression>} */
|
|
86
|
+
const styledCalls = new Map();
|
|
87
|
+
/** @type {Set<string>} */
|
|
88
|
+
const exported = new Set();
|
|
89
|
+
/** @type {Set<string>} */
|
|
90
|
+
const importedNames = new Set();
|
|
91
|
+
/** @param {ts.Node} node */
|
|
92
|
+
function collect(node) {
|
|
93
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
94
|
+
const init = unwrap(node.initializer);
|
|
95
|
+
if (ts.isObjectLiteralExpression(init)) objects.set(node.name.text, init);
|
|
96
|
+
const call = styledCall(init);
|
|
97
|
+
if (call) styledCalls.set(node.name.text, call);
|
|
98
|
+
const statement = node.parent?.parent;
|
|
99
|
+
if (
|
|
100
|
+
statement &&
|
|
101
|
+
ts.isVariableStatement(statement) &&
|
|
102
|
+
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
103
|
+
)
|
|
104
|
+
exported.add(node.name.text);
|
|
105
|
+
}
|
|
106
|
+
if (ts.isExportSpecifier(node) && !node.parent.parent.moduleSpecifier)
|
|
107
|
+
exported.add((node.propertyName ?? node.name).text);
|
|
108
|
+
if (ts.isImportSpecifier(node) && !node.isTypeOnly) importedNames.add(node.name.text);
|
|
109
|
+
ts.forEachChild(node, collect);
|
|
110
|
+
}
|
|
111
|
+
collect(file);
|
|
112
|
+
|
|
113
|
+
/** @param {ts.Node | undefined} node @param {Set<ts.Node>} seen @returns {boolean} */
|
|
114
|
+
function carriesClaim(node, seen = new Set()) {
|
|
115
|
+
if (!node || seen.has(node)) return false;
|
|
116
|
+
seen.add(node);
|
|
117
|
+
if (ts.isIdentifier(node)) return carriesClaim(objects.get(node.text), seen);
|
|
118
|
+
if (ts.isObjectLiteralExpression(node))
|
|
119
|
+
return node.properties.some((prop) => {
|
|
120
|
+
if (ts.isSpreadAssignment(prop)) return carriesClaim(prop.expression, seen);
|
|
121
|
+
const key = prop.name && keyName(prop.name);
|
|
122
|
+
if (key && claimKeys.has(key)) return true;
|
|
123
|
+
return ts.isPropertyAssignment(prop) && carriesClaim(prop.initializer, seen);
|
|
124
|
+
});
|
|
125
|
+
if (
|
|
126
|
+
ts.isParenthesizedExpression(node) ||
|
|
127
|
+
ts.isAsExpression(node) ||
|
|
128
|
+
ts.isSatisfiesExpression(node)
|
|
129
|
+
)
|
|
130
|
+
return carriesClaim(node.expression, seen);
|
|
131
|
+
if (ts.isConditionalExpression(node))
|
|
132
|
+
return carriesClaim(node.whenTrue, seen) || carriesClaim(node.whenFalse, seen);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @type {Map<string, boolean>} */
|
|
137
|
+
const cache = new Map();
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} name
|
|
140
|
+
* @param {Set<string>} imported claimant names other files export
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
function isClaimant(name, imported) {
|
|
144
|
+
if (cache.has(name)) return /** @type {boolean} */ (cache.get(name));
|
|
145
|
+
cache.set(name, false);
|
|
146
|
+
const call = styledCalls.get(name);
|
|
147
|
+
let result = false;
|
|
148
|
+
if (call) {
|
|
149
|
+
const [base, ...configs] = call.arguments;
|
|
150
|
+
result =
|
|
151
|
+
configs.some((config) => carriesClaim(config)) ||
|
|
152
|
+
(!!base && ts.isIdentifier(base) && isClaimant(base.text, imported));
|
|
153
|
+
} else if (importedNames.has(name) && imported.has(name)) {
|
|
154
|
+
result = true;
|
|
155
|
+
}
|
|
156
|
+
cache.set(name, result);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
/** @param {string} name */
|
|
160
|
+
function isInput(name) {
|
|
161
|
+
for (let current = name, hops = 0; current && hops < 8; hops++) {
|
|
162
|
+
if (inputTag.test(current)) return true;
|
|
163
|
+
const base = styledCalls.get(current)?.arguments[0];
|
|
164
|
+
current = base && ts.isIdentifier(base) ? base.text : "";
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return { file, exported, isClaimant, isInput };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Names this file exports that claim a press on native (a styled component
|
|
173
|
+
* whose config carries pressStyle or focusVisibleStyle).
|
|
174
|
+
*
|
|
175
|
+
* @param {string} source
|
|
176
|
+
* @param {string} filename
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
export function exportedPressClaimants(source, filename) {
|
|
180
|
+
if (!/pressStyle|focusVisibleStyle/.test(source)) return [];
|
|
181
|
+
const { exported, isClaimant, isInput } = parse(source, filename);
|
|
182
|
+
return [...exported].filter((name) => !isInput(name) && isClaimant(name, new Set()));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {string} source
|
|
187
|
+
* @param {string} filename
|
|
188
|
+
* @param {Set<string>} [imported] claimant names exported by other scanned files
|
|
189
|
+
* @returns {{ line: number, tag: string, owner: string }[]}
|
|
190
|
+
*/
|
|
191
|
+
export function findNestedPressClaimants(source, filename, imported = new Set()) {
|
|
192
|
+
if (!/on(?:Press|LongPress)|asChild/.test(source)) return [];
|
|
193
|
+
const { file, isClaimant, isInput } = parse(source, filename);
|
|
194
|
+
|
|
195
|
+
/** @param {ts.JsxOpeningLikeElement} el */
|
|
196
|
+
function attrs(el) {
|
|
197
|
+
/** @type {Map<string, ts.JsxAttribute>} */
|
|
198
|
+
const map = new Map();
|
|
199
|
+
for (const attr of el.attributes.properties)
|
|
200
|
+
if (ts.isJsxAttribute(attr)) map.set(attr.name.getText(file), attr);
|
|
201
|
+
return map;
|
|
202
|
+
}
|
|
203
|
+
/** @param {ts.JsxAttribute | undefined} attr */
|
|
204
|
+
function literal(attr) {
|
|
205
|
+
if (!attr?.initializer) return attr ? true : undefined;
|
|
206
|
+
if (ts.isStringLiteral(attr.initializer)) return attr.initializer.text;
|
|
207
|
+
const expr = ts.isJsxExpression(attr.initializer) ? attr.initializer.expression : undefined;
|
|
208
|
+
if (expr && ts.isStringLiteral(expr)) return expr.text;
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @type {{ line: number, tag: string, owner: string }[]} */
|
|
213
|
+
const found = [];
|
|
214
|
+
/**
|
|
215
|
+
* @param {ts.Node} node
|
|
216
|
+
* @param {string | undefined} owner the nearest pressable JSX ancestor
|
|
217
|
+
* @param {string | undefined} slot an `asChild` parent handing this element its handler
|
|
218
|
+
*/
|
|
219
|
+
function visit(node, owner, slot) {
|
|
220
|
+
const el = ts.isJsxElement(node)
|
|
221
|
+
? node.openingElement
|
|
222
|
+
: ts.isJsxSelfClosingElement(node)
|
|
223
|
+
? node
|
|
224
|
+
: undefined;
|
|
225
|
+
if (!el) {
|
|
226
|
+
ts.forEachChild(node, (child) =>
|
|
227
|
+
visit(child, owner, ts.isJsxExpression(node) ? undefined : slot),
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const tag = el.tagName.getText(file);
|
|
232
|
+
const props = attrs(el);
|
|
233
|
+
const pointerEvents = literal(props.get("pointerEvents"));
|
|
234
|
+
if (pointerEvents === "none") return;
|
|
235
|
+
const handles = !!slot || [...pressHandlers].some((key) => props.has(key));
|
|
236
|
+
const named = ts.isIdentifier(el.tagName);
|
|
237
|
+
const claims =
|
|
238
|
+
!(named && isInput(tag)) &&
|
|
239
|
+
([...claimKeys].some((key) => props.has(key)) || (named && isClaimant(tag, imported)));
|
|
240
|
+
if (owner && claims && !handles && !props.has("asChild") && pointerEvents !== "box-none") {
|
|
241
|
+
const { line } = file.getLineAndCharacterOfPosition(el.getStart(file));
|
|
242
|
+
found.push({ line: line + 1, tag, owner });
|
|
243
|
+
}
|
|
244
|
+
// Props render wherever the component puts them; only children are known
|
|
245
|
+
// to sit inside this element.
|
|
246
|
+
for (const attr of el.attributes.properties) visit(attr, owner, undefined);
|
|
247
|
+
if (ts.isJsxElement(node)) {
|
|
248
|
+
const childOwner = handles ? tag : owner;
|
|
249
|
+
const childSlot = props.has("asChild") ? tag : undefined;
|
|
250
|
+
for (const child of node.children) visit(child, childOwner, childSlot);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
visit(file, undefined, undefined);
|
|
254
|
+
return found;
|
|
255
|
+
}
|
package/src/lint.spec.ts
CHANGED
|
@@ -971,6 +971,22 @@ ${csfTail}`,
|
|
|
971
971
|
expect(messages).toContain(`${file}:2 — \`Input as TamaguiInput\` from "tamagui"`);
|
|
972
972
|
});
|
|
973
973
|
|
|
974
|
+
it("permits raw controls in freeform boards while preserving the catalog gate", () => {
|
|
975
|
+
const root = makeTree();
|
|
976
|
+
story(
|
|
977
|
+
root,
|
|
978
|
+
"apps/storybook-expo/boards/freeform/Demo.stories.tsx",
|
|
979
|
+
`import { Button } from "tamagui";${csfTail}`,
|
|
980
|
+
);
|
|
981
|
+
expect(run(root).code).toBe(0);
|
|
982
|
+
story(
|
|
983
|
+
root,
|
|
984
|
+
"apps/storybook-expo/stories/Demo.stories.tsx",
|
|
985
|
+
`import { Button } from "tamagui";${csfTail}`,
|
|
986
|
+
);
|
|
987
|
+
expect(run(root).messages).toContain("STORY-HONESTY");
|
|
988
|
+
});
|
|
989
|
+
|
|
974
990
|
it("passes once the story carries parameters.foreignContrastSpecimen: true", () => {
|
|
975
991
|
const root = makeTree();
|
|
976
992
|
story(
|
|
@@ -1549,3 +1565,31 @@ const value = theme.accentBackground;`);
|
|
|
1549
1565
|
expect(messages).toContain("public/components/src/Selected.tsx:2");
|
|
1550
1566
|
});
|
|
1551
1567
|
});
|
|
1568
|
+
|
|
1569
|
+
describe("freeform helper layout exploration", () => {
|
|
1570
|
+
it("exempts the board directory and retains layout checks on the same app helper", () => {
|
|
1571
|
+
const root = makeTree();
|
|
1572
|
+
mkdirSync(join(root, "docs"), { recursive: true });
|
|
1573
|
+
writeFileSync(
|
|
1574
|
+
join(root, "docs/layout-rule-baselines.json"),
|
|
1575
|
+
JSON.stringify({ secondSidebar: [], hugContent: {} }),
|
|
1576
|
+
);
|
|
1577
|
+
const board = join(root, "apps/storybook-expo/boards/freeform/Row.tsx");
|
|
1578
|
+
mkdirSync(join(board, ".."), { recursive: true });
|
|
1579
|
+
const source =
|
|
1580
|
+
'const row = <XStack justifyContent="space-between" $max-sm={{display:"none"}}><FilterSidebar/></XStack>;';
|
|
1581
|
+
writeFileSync(board, source);
|
|
1582
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1583
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
1584
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1585
|
+
try {
|
|
1586
|
+
expect(runConventionChecks({ roots: { root } })).toBe(0);
|
|
1587
|
+
writeFileSync(join(root, "apps/demo/Row.tsx"), source);
|
|
1588
|
+
expect(runConventionChecks({ roots: { root } })).toBe(1);
|
|
1589
|
+
} finally {
|
|
1590
|
+
error.mockRestore();
|
|
1591
|
+
log.mockRestore();
|
|
1592
|
+
warn.mockRestore();
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { exportedPressClaimants, findNestedPressClaimants } from "../press-lint.mjs";
|
|
3
|
+
|
|
4
|
+
const find = (source: string, imported?: Set<string>) =>
|
|
5
|
+
findNestedPressClaimants(source, "Fixture.tsx", imported).map((hit) => `${hit.tag}<${hit.owner}`);
|
|
6
|
+
|
|
7
|
+
describe("findNestedPressClaimants (FZ-7792 NATIVE-PRESS-OWNER)", () => {
|
|
8
|
+
it("flags a styled pressStyle pill inside its pressable target (Alert's action, before the fix)", () => {
|
|
9
|
+
const source = `
|
|
10
|
+
const Target = styled(View, { name: "T", pressStyle: { opacity: 0.9 } });
|
|
11
|
+
const Pill = styled(View, { name: "P", pressStyle: { backgroundColor: "$color6" } });
|
|
12
|
+
export const A = ({ onPress }) => (
|
|
13
|
+
<Target onPress={onPress}>
|
|
14
|
+
<Pill><Text>Save</Text></Pill>
|
|
15
|
+
</Target>
|
|
16
|
+
);`;
|
|
17
|
+
expect(find(source)).toEqual(["Pill<Target"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("passes the fix: the pill reads the owner's group press state", () => {
|
|
21
|
+
const source = `
|
|
22
|
+
const Target = styled(View, { name: "T", pressStyle: { opacity: 0.9 } });
|
|
23
|
+
const Pill = styled(View, { name: "P", "$group-action-press": { backgroundColor: "$color6" } });
|
|
24
|
+
export const A = ({ onPress }) => (
|
|
25
|
+
<Target group="action" onPress={onPress}>
|
|
26
|
+
<Pill><Text>Save</Text></Pill>
|
|
27
|
+
</Target>
|
|
28
|
+
);`;
|
|
29
|
+
expect(find(source)).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("flags an inline pressStyle or focusVisibleStyle prop, at any depth below the owner", () => {
|
|
33
|
+
const source = `
|
|
34
|
+
export const A = () => (
|
|
35
|
+
<View onPress={go}>
|
|
36
|
+
<XStack>
|
|
37
|
+
<View pressStyle={{ opacity: 0.5 }} />
|
|
38
|
+
<View focusVisibleStyle={{ outlineWidth: 2 }} />
|
|
39
|
+
</XStack>
|
|
40
|
+
</View>
|
|
41
|
+
);`;
|
|
42
|
+
expect(find(source)).toEqual(["View<View", "View<View"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("follows styled() of a claimant and a config object bound to a name", () => {
|
|
46
|
+
const source = `
|
|
47
|
+
const look = { variants: { on: { true: { pressStyle: { opacity: 0.5 } } } } };
|
|
48
|
+
const Base = styled(View, look);
|
|
49
|
+
const Ring = styled(Base, { name: "Ring" });
|
|
50
|
+
export const A = () => <Pressable onPress={go}><Ring /></Pressable>;`;
|
|
51
|
+
expect(find(source)).toEqual(["Ring<Pressable"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats the child of an asChild element as the owner it slots a handler into", () => {
|
|
55
|
+
const source = `
|
|
56
|
+
const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
|
|
57
|
+
export const A = () => (
|
|
58
|
+
<Toast.Close asChild>
|
|
59
|
+
<CloseTarget><Ring /></CloseTarget>
|
|
60
|
+
</Toast.Close>
|
|
61
|
+
);`;
|
|
62
|
+
expect(find(source)).toEqual(["Ring<CloseTarget"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves alone a claimant that owns its press, renders asChild, is an input or cannot be touched", () => {
|
|
66
|
+
const source = `
|
|
67
|
+
const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
|
|
68
|
+
const Field = styled(Input, { focusVisibleStyle: { borderColor: "$accent" } });
|
|
69
|
+
export const A = () => (
|
|
70
|
+
<View onPress={go}>
|
|
71
|
+
<Ring onPress={other} />
|
|
72
|
+
<Ring asChild><View /></Ring>
|
|
73
|
+
<Field />
|
|
74
|
+
<Input focusVisibleStyle={{ borderColor: "$accent" }} />
|
|
75
|
+
<Ring pointerEvents="box-none" />
|
|
76
|
+
<View pointerEvents="none"><Ring /></View>
|
|
77
|
+
</View>
|
|
78
|
+
);`;
|
|
79
|
+
expect(find(source)).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not treat a prop's element as a child of the owner", () => {
|
|
83
|
+
const source = `
|
|
84
|
+
const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
|
|
85
|
+
export const A = () => <Row onPress={go} trailing={<Ring />} />;`;
|
|
86
|
+
expect(find(source)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("ignores a claimant with no pressable around it", () => {
|
|
90
|
+
const source = `
|
|
91
|
+
const Ring = styled(View, { pressStyle: { opacity: 0.5 } });
|
|
92
|
+
export const A = () => <View><Ring /></View>;`;
|
|
93
|
+
expect(find(source)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("flags a claimant another file exports when it is imported by name", () => {
|
|
97
|
+
const source = `
|
|
98
|
+
import { Pill } from "./pill";
|
|
99
|
+
export const A = () => <View onPress={go}><Pill /></View>;`;
|
|
100
|
+
expect(find(source)).toEqual([]);
|
|
101
|
+
expect(find(source, new Set(["Pill"]))).toEqual(["Pill<View"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("exportedPressClaimants", () => {
|
|
106
|
+
it("lists exported styled claimants and skips inputs and plain views", () => {
|
|
107
|
+
const source = `
|
|
108
|
+
export const Pill = styled(View, { pressStyle: { opacity: 0.5 } });
|
|
109
|
+
const Ring = styled(View, { focusVisibleStyle: { outlineWidth: 2 } });
|
|
110
|
+
export const Plain = styled(View, { hoverStyle: { opacity: 0.5 } });
|
|
111
|
+
export const Field = styled(TextInput, { focusVisibleStyle: { outlineWidth: 2 } });
|
|
112
|
+
export { Ring };`;
|
|
113
|
+
expect(exportedPressClaimants(source, "Fixture.tsx").sort()).toEqual(["Pill", "Ring"]);
|
|
114
|
+
});
|
|
115
|
+
});
|