@colixsystems/widget-sdk 0.275.1 → 0.275.2
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 +5 -1
- package/dist/linter.cjs +444 -162
- package/dist/linter.js +444 -164
- package/package.json +1 -1
package/dist/linter.js
CHANGED
|
@@ -408,15 +408,22 @@ const _ASSET_SPECIFIER_RE = /\.(css|png|jpe?g|gif)$/i;
|
|
|
408
408
|
const _ICON_DEFAULT_CONFIGURED_RE =
|
|
409
409
|
/\bIcon\s*\.\s*Default\s*(\.\s*mergeOptions\s*\(|\.\s*imagePath\s*=)/;
|
|
410
410
|
|
|
411
|
-
//
|
|
411
|
+
// 1-based line of any index in `code`, by binary search over the newline
|
|
412
|
+
// offsets: counting from the top per finding is quadratic (sc-8204).
|
|
412
413
|
function _lineCounter(code) {
|
|
413
|
-
|
|
414
|
-
let
|
|
414
|
+
const newlines = [];
|
|
415
|
+
for (let i = code.indexOf("\n"); i !== -1; i = code.indexOf("\n", i + 1)) {
|
|
416
|
+
newlines.push(i);
|
|
417
|
+
}
|
|
415
418
|
return (index) => {
|
|
416
|
-
|
|
417
|
-
|
|
419
|
+
let lo = 0;
|
|
420
|
+
let hi = newlines.length;
|
|
421
|
+
while (lo < hi) {
|
|
422
|
+
const mid = (lo + hi) >> 1;
|
|
423
|
+
if (newlines[mid] < index) lo = mid + 1;
|
|
424
|
+
else hi = mid;
|
|
418
425
|
}
|
|
419
|
-
return
|
|
426
|
+
return lo + 1;
|
|
420
427
|
};
|
|
421
428
|
}
|
|
422
429
|
|
|
@@ -464,6 +471,9 @@ function _leafletRules(source) {
|
|
|
464
471
|
let tag;
|
|
465
472
|
while ((tag = _MARKER_TAG_RE.exec(code))) {
|
|
466
473
|
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
474
|
+
// Resume past the tag even when it passes: rescanning a tag nested in
|
|
475
|
+
// its attributes made unclosed runs quadratic (sc-8204).
|
|
476
|
+
_MARKER_TAG_RE.lastIndex = end;
|
|
467
477
|
if (/(^|\s)icon\s*=/.test(code.slice(tag.index, end))) continue;
|
|
468
478
|
const line = markerLineAt(tag.index);
|
|
469
479
|
findings.push({
|
|
@@ -481,7 +491,6 @@ function _leafletRules(source) {
|
|
|
481
491
|
line,
|
|
482
492
|
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
483
493
|
});
|
|
484
|
-
_MARKER_TAG_RE.lastIndex = end;
|
|
485
494
|
}
|
|
486
495
|
return findings;
|
|
487
496
|
}
|
|
@@ -1023,8 +1032,32 @@ const LUCIDE_NAME_SET = new Set(LUCIDE_ICON_NAMES);
|
|
|
1023
1032
|
// `[^}]*` (not `[\s\S]*?`) so the brace capture can never cross a `}` into a
|
|
1024
1033
|
// neighbouring import — otherwise a preceding `import { View, Pressable } from
|
|
1025
1034
|
// "react-native"` is swallowed and its names get validated as lucide icons.
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1035
|
+
const LUCIDE_IMPORT_HEAD_RE = /import\s+(?:[A-Za-z0-9_$]+\s*,\s*)?\{/g;
|
|
1036
|
+
const LUCIDE_IMPORT_TAIL_RE = /\}\s*from\s*["']lucide-react-native["']/y;
|
|
1037
|
+
|
|
1038
|
+
// `import { … } from "lucide-react-native"` clauses. The names run to the
|
|
1039
|
+
// NEXT `}`, so every head sharing that brace shares one tail check; one
|
|
1040
|
+
// regex retried that run from each unclosed `import {` (sc-8204).
|
|
1041
|
+
function _lucideImports(source) {
|
|
1042
|
+
const found = [];
|
|
1043
|
+
const tails = new Map();
|
|
1044
|
+
LUCIDE_IMPORT_HEAD_RE.lastIndex = 0;
|
|
1045
|
+
let head;
|
|
1046
|
+
while ((head = LUCIDE_IMPORT_HEAD_RE.exec(source))) {
|
|
1047
|
+
const from = LUCIDE_IMPORT_HEAD_RE.lastIndex;
|
|
1048
|
+
const close = source.indexOf("}", from);
|
|
1049
|
+
if (close === -1) break;
|
|
1050
|
+
if (!tails.has(close)) {
|
|
1051
|
+
LUCIDE_IMPORT_TAIL_RE.lastIndex = close;
|
|
1052
|
+
tails.set(close, LUCIDE_IMPORT_TAIL_RE.test(source) ? LUCIDE_IMPORT_TAIL_RE.lastIndex : -1);
|
|
1053
|
+
}
|
|
1054
|
+
const end = tails.get(close);
|
|
1055
|
+
if (end === -1) continue;
|
|
1056
|
+
found.push({ index: head.index, names: source.slice(from, close) });
|
|
1057
|
+
LUCIDE_IMPORT_HEAD_RE.lastIndex = end;
|
|
1058
|
+
}
|
|
1059
|
+
return found;
|
|
1060
|
+
}
|
|
1028
1061
|
|
|
1029
1062
|
// lucide re-exports each base icon as `<Name>`, `<Name>Icon`, and `Lucide<Name>`;
|
|
1030
1063
|
// the committed set holds only the base names, so normalise the alias forms
|
|
@@ -1040,11 +1073,10 @@ function _isValidLucideName(name) {
|
|
|
1040
1073
|
function _lucideIconRules(source) {
|
|
1041
1074
|
const findings = [];
|
|
1042
1075
|
const sourceLines = source.split(/\r?\n/);
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const
|
|
1047
|
-
for (const raw of (m[1] || "").split(",")) {
|
|
1076
|
+
const lineAt = _lineCounter(source);
|
|
1077
|
+
for (const m of _lucideImports(source)) {
|
|
1078
|
+
const line = lineAt(m.index);
|
|
1079
|
+
for (const raw of m.names.split(",")) {
|
|
1048
1080
|
// Strip any inline comment from the specifier token so a `{ Home, // x`
|
|
1049
1081
|
// comment can't glue onto and hide the next specifier (e.g. `House`).
|
|
1050
1082
|
const cleaned = raw
|
|
@@ -1127,6 +1159,25 @@ const _EXPRESSION_LEAD_WORDS = new Set([
|
|
|
1127
1159
|
"instanceof",
|
|
1128
1160
|
]);
|
|
1129
1161
|
|
|
1162
|
+
// The offset just past the whitespace run ending before `at`.
|
|
1163
|
+
function _skipSpaceBack(code, at) {
|
|
1164
|
+
while (at > 0 && /\s/.test(code[at - 1])) at--;
|
|
1165
|
+
return at;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function _skipSpaceForward(code, at) {
|
|
1169
|
+
while (at < code.length && /\s/.test(code[at])) at++;
|
|
1170
|
+
return at;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// The identifier ending at `last`; leading digits are not part of it.
|
|
1174
|
+
function _wordEndingAt(code, last) {
|
|
1175
|
+
let start = last + 1;
|
|
1176
|
+
while (start > 0 && /[A-Za-z0-9_$]/.test(code[start - 1])) start--;
|
|
1177
|
+
while (start <= last && /[0-9]/.test(code[start])) start++;
|
|
1178
|
+
return code.slice(start, last + 1);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1130
1181
|
// 1-based line of the first unbound reference to `name`, or 0 when the name is
|
|
1131
1182
|
// bound, never referenced, or referenced in a form this scan cannot decide.
|
|
1132
1183
|
function _unboundReferenceLine(code, name) {
|
|
@@ -1136,22 +1187,24 @@ function _unboundReferenceLine(code, name) {
|
|
|
1136
1187
|
const word = new RegExp(`\\b${name}\\b`, "g");
|
|
1137
1188
|
let firstIndex = -1;
|
|
1138
1189
|
let match;
|
|
1190
|
+
// Every look-around walks a few characters from the match: slicing and
|
|
1191
|
+
// regex-testing the whole prefix per match was quadratic (sc-8204).
|
|
1139
1192
|
while ((match = word.exec(code))) {
|
|
1140
|
-
const
|
|
1193
|
+
const leadAt = _skipSpaceBack(code, match.index) - 1;
|
|
1194
|
+
const lead = leadAt >= 0 ? code[leadAt] : "";
|
|
1141
1195
|
// `obj.NAME` / `obj?.NAME` / `{...NAME}` — never this binding.
|
|
1142
|
-
if (
|
|
1143
|
-
const lead = before.slice(-1);
|
|
1196
|
+
if (lead === ".") continue;
|
|
1144
1197
|
if (/[A-Za-z0-9_$]/.test(lead)) {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
} else if (lead === ">" && !before.endsWith("=>")) {
|
|
1198
|
+
if (!_EXPRESSION_LEAD_WORDS.has(_wordEndingAt(code, leadAt))) return 0;
|
|
1199
|
+
} else if (lead === ">" && code[leadAt - 1] !== "=") {
|
|
1148
1200
|
// A `>` that is not an arrow closes a JSX tag, so what follows is text.
|
|
1149
1201
|
return 0;
|
|
1150
1202
|
}
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1203
|
+
const opensTag =
|
|
1204
|
+
lead === "<" ||
|
|
1205
|
+
(lead === "/" && code[_skipSpaceBack(code, leadAt) - 1] === "<");
|
|
1206
|
+
const next = code[_skipSpaceForward(code, match.index + name.length)];
|
|
1207
|
+
if (!opensTag && next !== "(" && next !== ".") return 0;
|
|
1155
1208
|
if (firstIndex < 0) firstIndex = match.index;
|
|
1156
1209
|
}
|
|
1157
1210
|
if (firstIndex < 0) return 0;
|
|
@@ -1247,10 +1300,11 @@ function _bundledAssetRules(source, assetFiles) {
|
|
|
1247
1300
|
// Comments blanked, strings kept: the file name IS a string literal.
|
|
1248
1301
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1249
1302
|
const sourceLines = source.split(/\r?\n/);
|
|
1303
|
+
const lineAt = _lineCounter(code);
|
|
1250
1304
|
for (const match of code.matchAll(BUNDLED_ASSET_CALL_RE)) {
|
|
1251
1305
|
const name = match[1] || match[2] || match[3] || "";
|
|
1252
1306
|
if (name.includes("${") || assetFiles.includes(name)) continue;
|
|
1253
|
-
const line =
|
|
1307
|
+
const line = lineAt(match.index);
|
|
1254
1308
|
findings.push({
|
|
1255
1309
|
rule: "bundled-asset-missing",
|
|
1256
1310
|
severity: "error",
|
|
@@ -1291,6 +1345,7 @@ function _paymentCurrencyRules(source) {
|
|
|
1291
1345
|
// Comments blanked, strings kept: the currency code IS a string literal.
|
|
1292
1346
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1293
1347
|
const sourceLines = source.split(/\r?\n/);
|
|
1348
|
+
const lineAt = _lineCounter(code);
|
|
1294
1349
|
let from = 0;
|
|
1295
1350
|
for (;;) {
|
|
1296
1351
|
const at = code.indexOf(REQUEST_PAYMENT_CALL, from);
|
|
@@ -1301,7 +1356,7 @@ function _paymentCurrencyRules(source) {
|
|
|
1301
1356
|
);
|
|
1302
1357
|
if (!match) continue;
|
|
1303
1358
|
// Line of the currency literal itself, not of the call.
|
|
1304
|
-
const line =
|
|
1359
|
+
const line = lineAt(at + match.index);
|
|
1305
1360
|
findings.push({
|
|
1306
1361
|
rule: "payment-currency",
|
|
1307
1362
|
severity: "error",
|
|
@@ -1480,22 +1535,36 @@ function _datastoreErrorHandlingRules(source) {
|
|
|
1480
1535
|
// the deps array is wrong (sc-1579's volatile-query gate exists because of
|
|
1481
1536
|
// exactly this mistake). Flags only when the file has NOT already reached
|
|
1482
1537
|
// for `useStableQuery`, the hook that removes the deps array entirely.
|
|
1538
|
+
// The first `const NAME = useMemo(` of every name, in one pass: a regex
|
|
1539
|
+
// per queried name rescanned the source per call (sc-8204). A name holding
|
|
1540
|
+
// `$` never matched the old per-name pattern, so it is left out.
|
|
1541
|
+
function _firstUseMemoConsts(code) {
|
|
1542
|
+
const first = new Map();
|
|
1543
|
+
const re = /\bconst\s+([A-Za-z_][\w$]*)\s*=\s*useMemo\s*\(/g;
|
|
1544
|
+
let m;
|
|
1545
|
+
while ((m = re.exec(code))) {
|
|
1546
|
+
if (!m[1].includes("$") && !first.has(m[1])) first.set(m[1], m.index);
|
|
1547
|
+
}
|
|
1548
|
+
return first;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1483
1551
|
function _rawUseMemoIntoDatastoreQueryRules(source) {
|
|
1484
1552
|
const code = _stripNonCode(source);
|
|
1485
1553
|
if (/\buseStableQuery\s*\(/.test(code)) return [];
|
|
1486
1554
|
const callRe = /\buseDatastoreQuery\s*\(\s*[^,()]+,\s*([A-Za-z_$][\w$]*)\s*[,)]/g;
|
|
1487
1555
|
const findings = [];
|
|
1488
1556
|
const sourceLines = source.split(/\r?\n/);
|
|
1557
|
+
const lineAt = _lineCounter(code);
|
|
1558
|
+
const memoAt = _firstUseMemoConsts(code);
|
|
1489
1559
|
const seen = new Set();
|
|
1490
1560
|
let m;
|
|
1491
1561
|
while ((m = callRe.exec(code))) {
|
|
1492
1562
|
const varName = m[1];
|
|
1493
1563
|
if (seen.has(varName)) continue;
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
if (!memoMatch || memoMatch.index >= m.index) continue;
|
|
1564
|
+
const memoIndex = memoAt.get(varName);
|
|
1565
|
+
if (memoIndex === undefined || memoIndex >= m.index) continue;
|
|
1497
1566
|
seen.add(varName);
|
|
1498
|
-
const line =
|
|
1567
|
+
const line = lineAt(memoIndex);
|
|
1499
1568
|
findings.push({
|
|
1500
1569
|
rule: "raw-useMemo-into-datastore-query",
|
|
1501
1570
|
severity: "warning",
|
|
@@ -1568,32 +1637,83 @@ function _rowActionGatingRules(source) {
|
|
|
1568
1637
|
// The useDatastoreMutation() call whose update or delete the widget uses,
|
|
1569
1638
|
// either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
|
|
1570
1639
|
function _rowMutationCall(code) {
|
|
1571
|
-
const
|
|
1572
|
-
for (const
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1640
|
+
const rowVerbCallers = new Set();
|
|
1641
|
+
for (const m of code.matchAll(/\b(\w+)\s*\.\s*(?:update|delete)\s*\(/g)) {
|
|
1642
|
+
rowVerbCallers.add(m[1]);
|
|
1643
|
+
}
|
|
1644
|
+
for (const call of _mutationHookCalls(code)) {
|
|
1645
|
+
// An empty `{}` read as a name before: the regex built "undefined".
|
|
1646
|
+
const usesRowVerb = call.destructured
|
|
1647
|
+
? /\b(update|delete)\b/.test(call.destructured)
|
|
1648
|
+
: rowVerbCallers.has(String(call.name));
|
|
1649
|
+
if (usesRowVerb) return call;
|
|
1578
1650
|
}
|
|
1579
1651
|
return null;
|
|
1580
1652
|
}
|
|
1581
1653
|
|
|
1654
|
+
const _MUTATION_TAIL_RE = /\}\s*=\s*useDatastoreMutation\s*\(/y;
|
|
1655
|
+
const _NAMED_MUTATION_RE = /\b(\w+)\s*=\s*useDatastoreMutation\s*\(/g;
|
|
1656
|
+
|
|
1657
|
+
// Every `{ … } = useDatastoreMutation(` / `name = useDatastoreMutation(`, in
|
|
1658
|
+
// source order, as one `(?:\{([^}]*)\}|\b(\w+))…` regex would match them.
|
|
1659
|
+
// The destructured names run to the NEXT `}`, so each `}` is checked once;
|
|
1660
|
+
// the regex re-read that run from every unclosed `{` (sc-8204).
|
|
1661
|
+
function _mutationHookCalls(code) {
|
|
1662
|
+
const calls = [];
|
|
1663
|
+
const tails = new Map();
|
|
1664
|
+
let named = null;
|
|
1665
|
+
let brace = code.indexOf("{");
|
|
1666
|
+
let close = -1;
|
|
1667
|
+
for (let from = 0; ; ) {
|
|
1668
|
+
if (named === null || named.index < from) {
|
|
1669
|
+
_NAMED_MUTATION_RE.lastIndex = from;
|
|
1670
|
+
named = _NAMED_MUTATION_RE.exec(code) || { index: Infinity };
|
|
1671
|
+
}
|
|
1672
|
+
let destructured = null;
|
|
1673
|
+
for (; brace !== -1 && brace < named.index; brace = code.indexOf("{", brace + 1)) {
|
|
1674
|
+
if (brace < from) continue;
|
|
1675
|
+
if (close <= brace) close = code.indexOf("}", brace + 1);
|
|
1676
|
+
if (close === -1) {
|
|
1677
|
+
brace = -1;
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
if (!tails.has(close)) {
|
|
1681
|
+
_MUTATION_TAIL_RE.lastIndex = close;
|
|
1682
|
+
tails.set(close, _MUTATION_TAIL_RE.test(code) ? _MUTATION_TAIL_RE.lastIndex : -1);
|
|
1683
|
+
}
|
|
1684
|
+
if (tails.get(close) !== -1) {
|
|
1685
|
+
destructured = { index: brace, destructured: code.slice(brace + 1, close) };
|
|
1686
|
+
break;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (destructured) {
|
|
1690
|
+
calls.push(destructured);
|
|
1691
|
+
from = tails.get(close);
|
|
1692
|
+
} else if (named.index !== Infinity) {
|
|
1693
|
+
calls.push({ index: named.index, name: named[1] });
|
|
1694
|
+
from = named.index + named[0].length;
|
|
1695
|
+
} else return calls;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1582
1699
|
function _imagePercentHeightRules(source) {
|
|
1583
1700
|
const findings = [];
|
|
1584
1701
|
// Comments are blanked (string contents kept) so a commented-out example —
|
|
1585
1702
|
// including the one in this rule's own docs — is never flagged.
|
|
1586
1703
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1587
1704
|
const sourceLines = source.split(/\r?\n/);
|
|
1705
|
+
const lineAt = _lineCounter(code);
|
|
1588
1706
|
_IMAGE_TAG_RE.lastIndex = 0;
|
|
1589
1707
|
let tag;
|
|
1590
1708
|
while ((tag = _IMAGE_TAG_RE.exec(code))) {
|
|
1591
1709
|
const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
|
|
1710
|
+
// A tag nested in these attributes is inside `attrs` already (sc-8204).
|
|
1711
|
+
_IMAGE_TAG_RE.lastIndex = end;
|
|
1592
1712
|
const attrs = code.slice(tag.index, end);
|
|
1593
1713
|
_PERCENT_HEIGHT_RE.lastIndex = 0;
|
|
1594
1714
|
const hit = _PERCENT_HEIGHT_RE.exec(attrs);
|
|
1595
1715
|
if (!hit) continue;
|
|
1596
|
-
const line =
|
|
1716
|
+
const line = lineAt(tag.index + hit.index);
|
|
1597
1717
|
findings.push({
|
|
1598
1718
|
rule: "image-percent-height",
|
|
1599
1719
|
severity: "warning",
|
|
@@ -1608,9 +1728,6 @@ function _imagePercentHeightRules(source) {
|
|
|
1608
1728
|
line,
|
|
1609
1729
|
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
1610
1730
|
});
|
|
1611
|
-
// One finding per <Image>; a second percentage height on the same tag is
|
|
1612
|
-
// the same defect.
|
|
1613
|
-
_IMAGE_TAG_RE.lastIndex = end;
|
|
1614
1731
|
}
|
|
1615
1732
|
return findings;
|
|
1616
1733
|
}
|
|
@@ -1649,10 +1766,11 @@ function _flexBasisPercentRules(source) {
|
|
|
1649
1766
|
// so this rule's own documentation must not flag itself.
|
|
1650
1767
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1651
1768
|
const sourceLines = source.split(/\r?\n/);
|
|
1769
|
+
const lineAt = _lineCounter(code);
|
|
1652
1770
|
_FLEX_BASIS_PERCENT_RE.lastIndex = 0;
|
|
1653
1771
|
let hit;
|
|
1654
1772
|
while ((hit = _FLEX_BASIS_PERCENT_RE.exec(code))) {
|
|
1655
|
-
const line =
|
|
1773
|
+
const line = lineAt(hit.index);
|
|
1656
1774
|
findings.push({
|
|
1657
1775
|
rule: "flex-basis-percent",
|
|
1658
1776
|
severity: "warning",
|
|
@@ -1687,42 +1805,45 @@ const _SIDE_MARGIN_KEY_RE =
|
|
|
1687
1805
|
const _ZERO_VALUE_RE =
|
|
1688
1806
|
/^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
|
|
1689
1807
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1808
|
+
|
|
1809
|
+
// The object's own keys only: a nested object's margin is not this box's.
|
|
1810
|
+
// Blanked rather than cut, so an offset here is the same offset in the source.
|
|
1811
|
+
// Partner offset of every brace matched by stack in `text`, else -1.
|
|
1812
|
+
function _bracePartners(text) {
|
|
1813
|
+
const partner = new Int32Array(text.length).fill(-1);
|
|
1814
|
+
const opens = [];
|
|
1815
|
+
for (let i = 0; i < text.length; i++) {
|
|
1816
|
+
if (text[i] === "{") opens.push(i);
|
|
1817
|
+
else if (text[i] === "}" && opens.length > 0) {
|
|
1818
|
+
const open = opens.pop();
|
|
1819
|
+
partner[open] = i;
|
|
1820
|
+
partner[i] = open;
|
|
1698
1821
|
}
|
|
1699
1822
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1823
|
+
return partner;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// An object's own level: each nested `{…}` collapsed to one space, with the
|
|
1827
|
+
// offset in `text` of every character kept. Walking only that level means
|
|
1828
|
+
// an ancestor never re-reads its descendants (sc-8204).
|
|
1829
|
+
function _ownKeys(text, partner, [start, end]) {
|
|
1830
|
+
let own = "";
|
|
1831
|
+
const at = [];
|
|
1832
|
+
for (let i = start; i < end; i++) {
|
|
1833
|
+
const close = partner[i];
|
|
1834
|
+
const nested = text[i] === "{" && i !== start && close > i && close < end - 1;
|
|
1835
|
+
own += nested ? " " : text[i];
|
|
1836
|
+
at.push(i);
|
|
1837
|
+
if (nested) i = close;
|
|
1704
1838
|
}
|
|
1705
|
-
return
|
|
1839
|
+
return { own, at };
|
|
1706
1840
|
}
|
|
1707
1841
|
|
|
1708
|
-
|
|
1709
|
-
// Blanked rather than cut, so an offset here is the same offset in the source.
|
|
1710
|
-
function _ownKeysMask(structure, [start, end]) {
|
|
1711
|
-
let body = structure.slice(start, end);
|
|
1712
|
-
let previous;
|
|
1713
|
-
do {
|
|
1714
|
-
previous = body;
|
|
1715
|
-
body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
|
|
1716
|
-
} while (body !== previous);
|
|
1717
|
-
return body;
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
function _hasSideMargin(code, structure, span) {
|
|
1721
|
-
const keys = _ownKeysMask(structure, span);
|
|
1842
|
+
function _hasSideMargin(code, keys) {
|
|
1722
1843
|
_SIDE_MARGIN_KEY_RE.lastIndex = 0;
|
|
1723
1844
|
let key;
|
|
1724
|
-
while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
|
|
1725
|
-
const valueAt =
|
|
1845
|
+
while ((key = _SIDE_MARGIN_KEY_RE.exec(keys.own))) {
|
|
1846
|
+
const valueAt = keys.at[key.index + key[0].length - 1] + 1;
|
|
1726
1847
|
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
|
|
1727
1848
|
}
|
|
1728
1849
|
return false;
|
|
@@ -1733,14 +1854,21 @@ function _fullWidthMarginRules(source) {
|
|
|
1733
1854
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
1734
1855
|
// Strings blanked, so a brace inside a literal never unbalances the match.
|
|
1735
1856
|
const structure = _stripNonCode(source);
|
|
1857
|
+
const index = _jsxScanIndex(structure);
|
|
1858
|
+
const partner = _bracePartners(structure);
|
|
1859
|
+
const margined = new Map();
|
|
1736
1860
|
const sourceLines = source.split(/\r?\n/);
|
|
1861
|
+
const lineAt = _lineCounter(code);
|
|
1737
1862
|
_FULL_WIDTH_RE.lastIndex = 0;
|
|
1738
1863
|
let hit;
|
|
1739
1864
|
while ((hit = _FULL_WIDTH_RE.exec(code))) {
|
|
1740
|
-
const span =
|
|
1865
|
+
const span = index.enclosingObject(hit.index);
|
|
1741
1866
|
if (!span) continue;
|
|
1742
|
-
if (!
|
|
1743
|
-
|
|
1867
|
+
if (!margined.has(span[0])) {
|
|
1868
|
+
margined.set(span[0], _hasSideMargin(code, _ownKeys(structure, partner, span)));
|
|
1869
|
+
}
|
|
1870
|
+
if (!margined.get(span[0])) continue;
|
|
1871
|
+
const line = lineAt(hit.index);
|
|
1744
1872
|
findings.push({
|
|
1745
1873
|
rule: "full-width-margin-overflow",
|
|
1746
1874
|
severity: "warning",
|
|
@@ -1779,53 +1907,98 @@ function _styleEntryOffsets(structure) {
|
|
|
1779
1907
|
return offsets;
|
|
1780
1908
|
}
|
|
1781
1909
|
|
|
1782
|
-
//
|
|
1783
|
-
|
|
1910
|
+
// What a style expression's objects say, ORed: the object literals it
|
|
1911
|
+
// spans, plus the named entries it references anywhere inside.
|
|
1912
|
+
function _styleRangeTraits(scan, [from, to]) {
|
|
1784
1913
|
const { structure } = scan;
|
|
1785
|
-
|
|
1914
|
+
let row = false;
|
|
1915
|
+
let bordered = false;
|
|
1786
1916
|
for (let i = from + 1; i < to - 1; i++) {
|
|
1787
1917
|
if (structure[i] !== "{") continue;
|
|
1788
|
-
const span =
|
|
1918
|
+
const span = scan.index.enclosingObject(i + 1);
|
|
1789
1919
|
if (!span) continue;
|
|
1790
|
-
|
|
1920
|
+
const traits = _styleObjectTraits(scan, span);
|
|
1921
|
+
row = row || traits.row;
|
|
1922
|
+
bordered = bordered || traits.bordered;
|
|
1791
1923
|
i = span[1] - 1;
|
|
1792
1924
|
}
|
|
1793
|
-
|
|
1794
|
-
const
|
|
1925
|
+
const refs = _styleRefTraits(scan);
|
|
1926
|
+
const first = _firstAtOrAfter(refs.offsets, from);
|
|
1927
|
+
const last = _firstAtOrAfter(refs.offsets, to);
|
|
1928
|
+
return {
|
|
1929
|
+
row: row || refs.rows[last] > refs.rows[first],
|
|
1930
|
+
bordered: bordered || refs.bordered[last] > refs.bordered[first],
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// Every `name.key` reference in the source, with running counts of those
|
|
1935
|
+
// naming a row / bordered entry, so a range is answered by subtraction
|
|
1936
|
+
// instead of re-reading it per nested parent (sc-8204).
|
|
1937
|
+
function _styleRefTraits(scan) {
|
|
1938
|
+
if (scan.refs) return scan.refs;
|
|
1939
|
+
if (!scan.entries) scan.entries = _styleEntryOffsets(scan.structure);
|
|
1940
|
+
const offsets = [];
|
|
1941
|
+
const rows = [0];
|
|
1942
|
+
const bordered = [0];
|
|
1795
1943
|
_STYLE_REF_RE.lastIndex = 0;
|
|
1796
|
-
const refs = structure.slice(from, to);
|
|
1797
1944
|
let ref;
|
|
1798
|
-
while ((ref = _STYLE_REF_RE.exec(
|
|
1799
|
-
|
|
1800
|
-
const
|
|
1801
|
-
const
|
|
1802
|
-
|
|
1945
|
+
while ((ref = _STYLE_REF_RE.exec(scan.structure))) {
|
|
1946
|
+
const at = scan.entries.get(ref[1]);
|
|
1947
|
+
const span = at === undefined ? null : scan.index.enclosingObject(at);
|
|
1948
|
+
const traits = span ? _styleObjectTraits(scan, span) : null;
|
|
1949
|
+
offsets.push(ref.index);
|
|
1950
|
+
rows.push(rows[rows.length - 1] + (traits && traits.row ? 1 : 0));
|
|
1951
|
+
bordered.push(bordered[bordered.length - 1] + (traits && traits.bordered ? 1 : 0));
|
|
1952
|
+
}
|
|
1953
|
+
scan.refs = { offsets, rows, bordered };
|
|
1954
|
+
return scan.refs;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function _firstAtOrAfter(sorted, value) {
|
|
1958
|
+
let lo = 0;
|
|
1959
|
+
let hi = sorted.length;
|
|
1960
|
+
while (lo < hi) {
|
|
1961
|
+
const mid = (lo + hi) >> 1;
|
|
1962
|
+
if (sorted[mid] < value) lo = mid + 1;
|
|
1963
|
+
else hi = mid;
|
|
1803
1964
|
}
|
|
1804
|
-
return
|
|
1965
|
+
return lo;
|
|
1805
1966
|
}
|
|
1806
1967
|
|
|
1807
1968
|
function _isBorderedRow(scan, tag) {
|
|
1808
|
-
const {
|
|
1969
|
+
const { structure } = scan;
|
|
1809
1970
|
const attrs = structure.slice(tag.attrs[0], tag.attrs[1]);
|
|
1810
1971
|
const style = _STYLE_ATTR_RE.exec(attrs);
|
|
1811
1972
|
if (!style) return false;
|
|
1812
1973
|
const open = tag.attrs[0] + style.index + style[0].length - 1;
|
|
1813
|
-
const
|
|
1814
|
-
if (
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
}
|
|
1974
|
+
const close = scan.index.braceClose(open);
|
|
1975
|
+
if (close === -1) return false;
|
|
1976
|
+
const traits = _styleRangeTraits(scan, [open, close + 1]);
|
|
1977
|
+
return traits.row && traits.bordered;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Memoised per object: every row naming one shared style object would
|
|
1981
|
+
// otherwise re-read all of it, quadratic in the row count (sc-8204).
|
|
1982
|
+
function _styleObjectTraits(scan, span) {
|
|
1983
|
+
const known = scan.traits.get(span[0]);
|
|
1984
|
+
if (known) return known;
|
|
1985
|
+
const { code, structure } = scan;
|
|
1986
|
+
if (!scan.partners) {
|
|
1987
|
+
scan.partners = { code: _bracePartners(code), structure: _bracePartners(structure) };
|
|
1988
|
+
}
|
|
1989
|
+
const keys = _ownKeys(structure, scan.partners.structure, span);
|
|
1990
|
+
const traits = {
|
|
1991
|
+
row: _ROW_KEY_RE.test(_ownKeys(code, scan.partners.code, span).own),
|
|
1992
|
+
bordered: false,
|
|
1993
|
+
};
|
|
1994
|
+
_BORDER_WIDTH_KEY_RE.lastIndex = 0;
|
|
1995
|
+
let key;
|
|
1996
|
+
while ((key = _BORDER_WIDTH_KEY_RE.exec(keys.own))) {
|
|
1997
|
+
const valueAt = keys.at[key.index + key[0].length - 1] + 1;
|
|
1998
|
+
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) traits.bordered = true;
|
|
1827
1999
|
}
|
|
1828
|
-
|
|
2000
|
+
scan.traits.set(span[0], traits);
|
|
2001
|
+
return traits;
|
|
1829
2002
|
}
|
|
1830
2003
|
|
|
1831
2004
|
function _selfClosing(structure, end) {
|
|
@@ -1834,13 +2007,46 @@ function _selfClosing(structure, end) {
|
|
|
1834
2007
|
return structure[i] === "/";
|
|
1835
2008
|
}
|
|
1836
2009
|
|
|
2010
|
+
// The open-element stack, with where each name sits on it, so a closing
|
|
2011
|
+
// tag finds its opener without scanning the stack per close (sc-8204).
|
|
2012
|
+
function _openTagStack() {
|
|
2013
|
+
const tags = [];
|
|
2014
|
+
const byName = new Map();
|
|
2015
|
+
const truncate = (length) => {
|
|
2016
|
+
while (tags.length > length) {
|
|
2017
|
+
const tag = tags.pop();
|
|
2018
|
+
if (tag) byName.get(tag.name).pop();
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
return {
|
|
2022
|
+
top: () => tags[tags.length - 1],
|
|
2023
|
+
push: (tag) => {
|
|
2024
|
+
if (tag) {
|
|
2025
|
+
if (!byName.has(tag.name)) byName.set(tag.name, []);
|
|
2026
|
+
byName.get(tag.name).push(tags.length);
|
|
2027
|
+
}
|
|
2028
|
+
tags.push(tag);
|
|
2029
|
+
},
|
|
2030
|
+
pop: () => truncate(Math.max(0, tags.length - 1)),
|
|
2031
|
+
close: (name) => {
|
|
2032
|
+
const at = byName.get(name);
|
|
2033
|
+
if (at && at.length > 0) truncate(at[at.length - 1]);
|
|
2034
|
+
},
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1837
2038
|
function _fieldInBorderedRowRules(source) {
|
|
1838
2039
|
const findings = [];
|
|
1839
2040
|
const scan = {
|
|
1840
2041
|
code: _stripNonCode(source, { keepStrings: true }),
|
|
1841
2042
|
structure: _stripNonCode(source),
|
|
1842
2043
|
entries: null,
|
|
2044
|
+
index: null,
|
|
2045
|
+
traits: new Map(),
|
|
2046
|
+
partners: null,
|
|
2047
|
+
refs: null,
|
|
1843
2048
|
};
|
|
2049
|
+
scan.index = _jsxScanIndex(scan.structure);
|
|
1844
2050
|
const { code, structure } = scan;
|
|
1845
2051
|
const sourceLines = source.split(/\r?\n/);
|
|
1846
2052
|
// Tokens arrive in source order, so one running count serves every finding.
|
|
@@ -1860,7 +2066,7 @@ function _fieldInBorderedRowRules(source) {
|
|
|
1860
2066
|
snippet: (sourceLines[n - 1] || "").trim().slice(0, 200),
|
|
1861
2067
|
});
|
|
1862
2068
|
};
|
|
1863
|
-
const open =
|
|
2069
|
+
const open = _openTagStack();
|
|
1864
2070
|
_JSX_TOKEN_RE.lastIndex = 0;
|
|
1865
2071
|
let token;
|
|
1866
2072
|
while ((token = _JSX_TOKEN_RE.exec(structure))) {
|
|
@@ -1873,15 +2079,14 @@ function _fieldInBorderedRowRules(source) {
|
|
|
1873
2079
|
continue;
|
|
1874
2080
|
}
|
|
1875
2081
|
if (closing) {
|
|
1876
|
-
|
|
1877
|
-
if (at >= 0) open.length = at;
|
|
2082
|
+
open.close(name);
|
|
1878
2083
|
continue;
|
|
1879
2084
|
}
|
|
1880
|
-
const end =
|
|
2085
|
+
const end = scan.index.tagEnd(token.index);
|
|
1881
2086
|
if (end === -1) break;
|
|
1882
2087
|
const tag = { name, attrs: [token.index + token[0].length, end], borderedRow: undefined };
|
|
1883
2088
|
if (_FIELD_TAG_NAMES.includes(name)) {
|
|
1884
|
-
const parent = open
|
|
2089
|
+
const parent = open.top();
|
|
1885
2090
|
if (parent && parent.borderedRow === undefined) parent.borderedRow = _isBorderedRow(scan, parent);
|
|
1886
2091
|
if (parent && parent.borderedRow) {
|
|
1887
2092
|
report(
|
|
@@ -1935,27 +2140,42 @@ const _WIDTH_HOLDER_RE =
|
|
|
1935
2140
|
const _LAYOUT_FEED_WINDOW = 80;
|
|
1936
2141
|
|
|
1937
2142
|
function _measuredWidthNames(code) {
|
|
2143
|
+
const fedByLayout = _layoutFedCallees(code);
|
|
1938
2144
|
const names = [];
|
|
1939
2145
|
_WIDTH_HOLDER_RE.lastIndex = 0;
|
|
1940
2146
|
let held;
|
|
1941
2147
|
while ((held = _WIDTH_HOLDER_RE.exec(code))) {
|
|
1942
2148
|
const [, value, setter, hook] = held;
|
|
1943
|
-
if (hook === "useContainerWidth") {
|
|
2149
|
+
if (hook === "useContainerWidth" || fedByLayout.has(setter)) {
|
|
1944
2150
|
names.push(value);
|
|
1945
|
-
continue;
|
|
1946
2151
|
}
|
|
1947
|
-
const fedByLayout = new RegExp(
|
|
1948
|
-
`\\b${setter}\\s*\\([\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
|
|
1949
|
-
);
|
|
1950
|
-
if (fedByLayout.test(code)) names.push(value);
|
|
1951
2152
|
}
|
|
1952
2153
|
return names;
|
|
1953
2154
|
}
|
|
1954
2155
|
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
2156
|
+
// Every callee with a layout width among its first arguments, in one pass:
|
|
2157
|
+
// a regex per declared setter rescanned the source per declaration (sc-8204).
|
|
2158
|
+
function _layoutFedCallees(code) {
|
|
2159
|
+
const fed = new Set();
|
|
2160
|
+
const call = /\b(\w+)\s*\(/g;
|
|
2161
|
+
const feed = new RegExp(
|
|
2162
|
+
`[\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
|
|
2163
|
+
"y",
|
|
1958
2164
|
);
|
|
2165
|
+
let callee;
|
|
2166
|
+
while ((callee = call.exec(code))) {
|
|
2167
|
+
feed.lastIndex = call.lastIndex;
|
|
2168
|
+
if (feed.test(code)) fed.add(callee[1]);
|
|
2169
|
+
}
|
|
2170
|
+
return fed;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function _sizesFromMeasuredWidth(code, names) {
|
|
2174
|
+
if (names.length === 0) return false;
|
|
2175
|
+
const operands = new Set();
|
|
2176
|
+
for (const m of code.matchAll(/\b(\w+)\s*[-/*]/g)) operands.add(m[1]);
|
|
2177
|
+
for (const m of code.matchAll(/[-/*]\s*\b(\w+)\b/g)) operands.add(m[1]);
|
|
2178
|
+
return names.some((name) => operands.has(name));
|
|
1959
2179
|
}
|
|
1960
2180
|
|
|
1961
2181
|
function _measuredPaddingRules(source) {
|
|
@@ -1965,6 +2185,7 @@ function _measuredPaddingRules(source) {
|
|
|
1965
2185
|
const measured = _measuredWidthNames(code);
|
|
1966
2186
|
if (!_sizesFromMeasuredWidth(code, measured)) return [];
|
|
1967
2187
|
const sourceLines = source.split(/\r?\n/);
|
|
2188
|
+
const lineAt = _lineCounter(code);
|
|
1968
2189
|
const findings = [];
|
|
1969
2190
|
_JSX_TAG_RE.lastIndex = 0;
|
|
1970
2191
|
let tag;
|
|
@@ -1977,9 +2198,7 @@ function _measuredPaddingRules(source) {
|
|
|
1977
2198
|
if (!padded) continue;
|
|
1978
2199
|
// Report the padding, not the tag: on a multi-line open tag the style line
|
|
1979
2200
|
// is the actionable one.
|
|
1980
|
-
const line =
|
|
1981
|
-
.slice(0, tag.index + padded.index)
|
|
1982
|
-
.split(/\r?\n/).length;
|
|
2201
|
+
const line = lineAt(tag.index + padded.index);
|
|
1983
2202
|
findings.push({
|
|
1984
2203
|
rule: "measured-width-ignores-padding",
|
|
1985
2204
|
severity: "warning",
|
|
@@ -2139,6 +2358,17 @@ function _hardcodedDesignRules(source) {
|
|
|
2139
2358
|
* map, an array of sources, or one source string
|
|
2140
2359
|
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
|
|
2141
2360
|
*/
|
|
2361
|
+
// Whether `\b<name>\b` matches `code`, from one pass over its words: a
|
|
2362
|
+
// regex per styleSchema field re-read the whole bundle per field (sc-8204).
|
|
2363
|
+
function _wordReader(code) {
|
|
2364
|
+
const words = new Set(code.match(/\w+/g));
|
|
2365
|
+
return (name) => {
|
|
2366
|
+
if (!name.includes("$")) return words.has(name);
|
|
2367
|
+
// Unescaped, `$` asserts end of input, so only the tail can match.
|
|
2368
|
+
return new RegExp(`\\b${name}\\b`).test(code.slice(-(name.length + 1)));
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2142
2372
|
export function lintStyleWiring(manifest, files) {
|
|
2143
2373
|
const empty = { ok: true, findings: [] };
|
|
2144
2374
|
const schema = manifest && manifest.styleSchema;
|
|
@@ -2154,12 +2384,13 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2154
2384
|
? _WHOLE_WIDGET_STYLE_FIELDS
|
|
2155
2385
|
: new Set();
|
|
2156
2386
|
const findings = [];
|
|
2387
|
+
const isRead = _wordReader(code);
|
|
2157
2388
|
for (const field of Object.keys(schema)) {
|
|
2158
2389
|
// A key that is not a bare identifier cannot be scanned by name; the
|
|
2159
2390
|
// manifest validator rejects those anyway.
|
|
2160
2391
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
2161
2392
|
if (appliedByHelper.has(field)) continue;
|
|
2162
|
-
if (
|
|
2393
|
+
if (isRead(field)) continue;
|
|
2163
2394
|
findings.push({
|
|
2164
2395
|
rule: "style-field-unread",
|
|
2165
2396
|
severity: "warning",
|
|
@@ -2263,49 +2494,92 @@ function _isAuthoredCopy(text) {
|
|
|
2263
2494
|
return !_SEPARATOR_GLYPHS.has(text);
|
|
2264
2495
|
}
|
|
2265
2496
|
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
for (let i =
|
|
2273
|
-
const ch =
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2497
|
+
// Tag ends and brace spans over a STRING-BLANKED mask, where quotes are
|
|
2498
|
+
// already gone, so only brace depth decides. Answered from one index per
|
|
2499
|
+
// source: a scan per match was quadratic, and a file of bare `<Text`
|
|
2500
|
+
// wedged the API pod for a minute (sc-8204).
|
|
2501
|
+
function _jsxScanIndex(mask) {
|
|
2502
|
+
const depth = new Int32Array(mask.length + 1);
|
|
2503
|
+
for (let i = 0; i < mask.length; i++) {
|
|
2504
|
+
const ch = mask[i];
|
|
2505
|
+
depth[i + 1] = depth[i] + (ch === "{" ? 1 : ch === "}" ? -1 : 0);
|
|
2506
|
+
}
|
|
2507
|
+
let tagEnds = null;
|
|
2508
|
+
let braceCloses = null;
|
|
2509
|
+
let braceOpens = null;
|
|
2510
|
+
const closes = () => {
|
|
2511
|
+
if (!braceCloses) braceCloses = _nextAtOrBelow(mask, depth, "}");
|
|
2512
|
+
return braceCloses;
|
|
2513
|
+
};
|
|
2514
|
+
return {
|
|
2515
|
+
// The `>` that CLOSES a JSX tag, skipping any `>` inside a `{…}`
|
|
2516
|
+
// expression — `onPress={() => x}` carries one, and treating it as the
|
|
2517
|
+
// tag end reports the handler body as copy.
|
|
2518
|
+
tagEnd: (from) => {
|
|
2519
|
+
if (!tagEnds) tagEnds = _nextAtOrBelow(mask, depth, ">");
|
|
2520
|
+
return tagEnds[from];
|
|
2521
|
+
},
|
|
2522
|
+
// The `}` balancing the `{` at `from`, or -1.
|
|
2523
|
+
braceClose: (from) => (mask[from] === "{" ? closes()[from + 1] : -1),
|
|
2524
|
+
// The innermost `{…}` around offset `at`, as [start, end), or null.
|
|
2525
|
+
enclosingObject: (at) => {
|
|
2526
|
+
if (!braceOpens) braceOpens = _previousOpenAtOrBelow(mask, depth);
|
|
2527
|
+
const start = braceOpens[at];
|
|
2528
|
+
const end = closes()[at];
|
|
2529
|
+
return start === -1 || end === -1 ? null : [start, end + 1];
|
|
2530
|
+
},
|
|
2531
|
+
};
|
|
2285
2532
|
}
|
|
2286
2533
|
|
|
2287
|
-
//
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2534
|
+
// For every offset i, the first j >= i holding `ch` with depth[j] at or
|
|
2535
|
+
// below depth[i], else -1. One right-to-left pass keeps the candidates on a
|
|
2536
|
+
// stack; a nearer one no deeper dominates, so depth rises toward the top
|
|
2537
|
+
// and each query is a binary search.
|
|
2538
|
+
function _nextAtOrBelow(mask, depth, ch) {
|
|
2539
|
+
const n = mask.length;
|
|
2540
|
+
const nearest = new Int32Array(n + 1).fill(-1);
|
|
2541
|
+
const stack = new Int32Array(n);
|
|
2542
|
+
let top = 0;
|
|
2543
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
2544
|
+
if (mask[i] === ch) {
|
|
2545
|
+
while (top > 0 && depth[stack[top - 1]] >= depth[i]) top--;
|
|
2546
|
+
stack[top++] = i;
|
|
2298
2547
|
}
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2548
|
+
nearest[i] = _topmostAtOrBelow(stack, top, depth, 0, depth[i]);
|
|
2549
|
+
}
|
|
2550
|
+
return nearest;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// The mirror for opening braces: for every offset k, the last `{` at i < k
|
|
2554
|
+
// whose inside, depth[i + 1], is at or below depth[k], else -1.
|
|
2555
|
+
function _previousOpenAtOrBelow(mask, depth) {
|
|
2556
|
+
const n = mask.length;
|
|
2557
|
+
const nearest = new Int32Array(n + 1).fill(-1);
|
|
2558
|
+
const stack = new Int32Array(n);
|
|
2559
|
+
let top = 0;
|
|
2560
|
+
for (let k = 0; k <= n; k++) {
|
|
2561
|
+
nearest[k] = _topmostAtOrBelow(stack, top, depth, 1, depth[k]);
|
|
2562
|
+
if (k < n && mask[k] === "{") {
|
|
2563
|
+
while (top > 0 && depth[stack[top - 1] + 1] >= depth[k + 1]) top--;
|
|
2564
|
+
stack[top++] = k;
|
|
2304
2565
|
}
|
|
2305
2566
|
}
|
|
2306
|
-
return
|
|
2567
|
+
return nearest;
|
|
2307
2568
|
}
|
|
2308
2569
|
|
|
2570
|
+
function _topmostAtOrBelow(stack, top, depth, shift, limit) {
|
|
2571
|
+
let lo = 0;
|
|
2572
|
+
let hi = top - 1;
|
|
2573
|
+
let found = -1;
|
|
2574
|
+
while (lo <= hi) {
|
|
2575
|
+
const mid = (lo + hi) >> 1;
|
|
2576
|
+
if (depth[stack[mid] + shift] <= limit) {
|
|
2577
|
+
found = stack[mid];
|
|
2578
|
+
lo = mid + 1;
|
|
2579
|
+
} else hi = mid - 1;
|
|
2580
|
+
}
|
|
2581
|
+
return found;
|
|
2582
|
+
}
|
|
2309
2583
|
// The non-escape branch excludes `\` so the two branches can never both
|
|
2310
2584
|
// match the same character. With a bare `.`, escape pairs before a quote
|
|
2311
2585
|
// the anchor then rejects backtrack exponentially — 60 characters of an
|
|
@@ -2314,8 +2588,7 @@ function _bracedSpan(code, from) {
|
|
|
2314
2588
|
//
|
|
2315
2589
|
// It excludes the line terminators too, because a character class — unlike
|
|
2316
2590
|
// `.` — matches them, which would widen the literal past its own line.
|
|
2317
|
-
// An odd trailing backslash run therefore no longer matches
|
|
2318
|
-
// shares the escape rule and never yields one.
|
|
2591
|
+
// An odd trailing backslash run therefore no longer matches.
|
|
2319
2592
|
const _TEXT_LITERAL_CHILD_RE =
|
|
2320
2593
|
/^(["'`])((?:\\.|(?!\1)[^\\\r\n\u2028\u2029])*)\1$/;
|
|
2321
2594
|
|
|
@@ -2331,16 +2604,23 @@ function _textLiteralOccurrences(codes, masks) {
|
|
|
2331
2604
|
// not read as markup. Both masks preserve offsets, so the child is read
|
|
2332
2605
|
// back from the kept-strings source at the very same index.
|
|
2333
2606
|
const mask = masks[f];
|
|
2607
|
+
const index = _jsxScanIndex(mask);
|
|
2608
|
+
const lineAt = _lineCounter(code);
|
|
2609
|
+
// Unclosed tags share the next closing `>`, and so share its child: read
|
|
2610
|
+
// each child once, or `<Text <Text … >` re-reads it per tag.
|
|
2611
|
+
const readEnds = new Set();
|
|
2334
2612
|
const re = /<Text\b/g;
|
|
2335
2613
|
let m;
|
|
2336
2614
|
while ((m = re.exec(mask)) !== null) {
|
|
2337
|
-
const end =
|
|
2338
|
-
if (end === -1) continue;
|
|
2615
|
+
const end = index.tagEnd(m.index);
|
|
2616
|
+
if (end === -1 || readEnds.has(end)) continue;
|
|
2617
|
+
readEnds.add(end);
|
|
2339
2618
|
let i = end + 1;
|
|
2340
2619
|
while (i < code.length && /\s/.test(code[i])) i++;
|
|
2341
2620
|
let text = null;
|
|
2342
2621
|
if (code[i] === "{") {
|
|
2343
|
-
const
|
|
2622
|
+
const close = index.braceClose(i);
|
|
2623
|
+
const inner = close === -1 ? null : code.slice(i + 1, close);
|
|
2344
2624
|
const lit = inner && inner.trim().match(_TEXT_LITERAL_CHILD_RE);
|
|
2345
2625
|
// A template carrying `${…}` interpolates a value, so it is not a literal.
|
|
2346
2626
|
if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
|
|
@@ -2352,7 +2632,7 @@ function _textLiteralOccurrences(codes, masks) {
|
|
|
2352
2632
|
text = text.trim();
|
|
2353
2633
|
if (!_isAuthoredCopy(text) || byText.has(text)) continue;
|
|
2354
2634
|
// Split-impl carries the same copy twice; report the string, not the file.
|
|
2355
|
-
byText.set(text,
|
|
2635
|
+
byText.set(text, lineAt(i));
|
|
2356
2636
|
}
|
|
2357
2637
|
}
|
|
2358
2638
|
return [...byText.entries()].map(([text, line]) => ({ text, line }));
|