@decocms/blocks-cli 7.12.1 → 7.14.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"lint:unused": "knip"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@decocms/blocks": "7.
|
|
33
|
+
"@decocms/blocks": "7.14.0",
|
|
34
34
|
"ts-morph": "^27.0.0",
|
|
35
35
|
"tsx": "^4.22.5"
|
|
36
36
|
},
|
|
@@ -1677,6 +1677,93 @@ const ruleVtexProxyHandlerMissing: Rule = {
|
|
|
1677
1677
|
},
|
|
1678
1678
|
};
|
|
1679
1679
|
|
|
1680
|
+
/* ------------------------------------------------------------------ */
|
|
1681
|
+
/* Rule 15 — module-level signal .value reads without useStore */
|
|
1682
|
+
/* ------------------------------------------------------------------ */
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Detects the "frozen UI" pattern: a module-level signal whose `.value`
|
|
1686
|
+
* is read inside a React component without a `useStore()` subscription.
|
|
1687
|
+
*
|
|
1688
|
+
* In Preact, `.value` reads are automatically reactive. In React they
|
|
1689
|
+
* are just synchronous property accesses — no subscription is registered,
|
|
1690
|
+
* so the component renders once with the initial value and never updates.
|
|
1691
|
+
*
|
|
1692
|
+
* Two-pass approach:
|
|
1693
|
+
* 1. Find every `const name = signal(...)` at module scope (line starts
|
|
1694
|
+
* with optional `export const` — indented declarations inside
|
|
1695
|
+
* functions are skipped). First declaration wins when the same name
|
|
1696
|
+
* appears in multiple files (Map keyed by name deduplicates).
|
|
1697
|
+
* 2. For each .tsx consumer, check per-signal: does it read `name.value`
|
|
1698
|
+
* AND lack a `useStore(name.store` call? Both conditions must hold for
|
|
1699
|
+
* a finding — a file that subscribes to signal A but reads signal B
|
|
1700
|
+
* raw is still flagged for B.
|
|
1701
|
+
*/
|
|
1702
|
+
const MODULE_SIGNAL_RE =
|
|
1703
|
+
/^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
|
|
1704
|
+
|
|
1705
|
+
const ruleSignalValueReads: Rule = {
|
|
1706
|
+
id: "signal-value-reads",
|
|
1707
|
+
title: "Module-level signal .value reads without useStore()",
|
|
1708
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1709
|
+
const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
|
|
1710
|
+
|
|
1711
|
+
// Pass 1: collect module-level signal declarations, first occurrence wins.
|
|
1712
|
+
// Map<signalName, declaredIn> deduplicates names across files so Pass 2
|
|
1713
|
+
// never emits two findings for the same (file, signalName) pair.
|
|
1714
|
+
const moduleSignals = new Map<string, string>();
|
|
1715
|
+
for (const abs of tsFiles) {
|
|
1716
|
+
const content = fs.readText(abs);
|
|
1717
|
+
const re = new RegExp(MODULE_SIGNAL_RE.source, "gm");
|
|
1718
|
+
let match: RegExpExecArray | null;
|
|
1719
|
+
while ((match = re.exec(content)) !== null) {
|
|
1720
|
+
const name = match[1];
|
|
1721
|
+
if (!moduleSignals.has(name)) {
|
|
1722
|
+
moduleSignals.set(name, abs.slice(siteDir.length + 1));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
if (moduleSignals.size === 0) return [];
|
|
1728
|
+
|
|
1729
|
+
// Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
|
|
1730
|
+
const valueReMap = new Map<string, RegExp>(
|
|
1731
|
+
[...moduleSignals.keys()].map((name) => [name, new RegExp(`\\b${name}\\.value\\b`)]),
|
|
1732
|
+
);
|
|
1733
|
+
|
|
1734
|
+
// Pass 2: for each .tsx file, check per-signal whether .value is read
|
|
1735
|
+
// without a matching useStore(name.store call in the same file.
|
|
1736
|
+
const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
|
|
1737
|
+
const findings: Finding[] = [];
|
|
1738
|
+
|
|
1739
|
+
for (const abs of tsxFiles) {
|
|
1740
|
+
const content = fs.readText(abs);
|
|
1741
|
+
const rel = abs.slice(siteDir.length + 1);
|
|
1742
|
+
|
|
1743
|
+
for (const [name, declaredIn] of moduleSignals) {
|
|
1744
|
+
const valueRe = valueReMap.get(name)!;
|
|
1745
|
+
if (!valueRe.test(content)) continue;
|
|
1746
|
+
// Per-signal subscription check: useStore(name.store is the canonical
|
|
1747
|
+
// subscription pattern. A file that subscribes to a different signal
|
|
1748
|
+
// is not considered subscribed to this one.
|
|
1749
|
+
const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
|
|
1750
|
+
if (subscribedRe.test(content)) continue;
|
|
1751
|
+
|
|
1752
|
+
findings.push({
|
|
1753
|
+
rule: "signal-value-reads",
|
|
1754
|
+
severity: "warning",
|
|
1755
|
+
file: rel,
|
|
1756
|
+
message: `'${name}.value' read without useStore() — component will not re-render when the signal changes`,
|
|
1757
|
+
fix: `Use useStore(${name}.store, (s) => s) to subscribe, or replace with useQuery / useMutation`,
|
|
1758
|
+
meta: { signalName: name, declaredIn },
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
return findings;
|
|
1764
|
+
},
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1680
1767
|
export const ALL_RULES: Rule[] = [
|
|
1681
1768
|
ruleDeadLibShims,
|
|
1682
1769
|
ruleObsoleteVitePlugins,
|
|
@@ -1692,6 +1779,7 @@ export const ALL_RULES: Rule[] = [
|
|
|
1692
1779
|
ruleLockfileMissing,
|
|
1693
1780
|
ruleLockfileDrift,
|
|
1694
1781
|
rulePackageManagerMissing,
|
|
1782
|
+
ruleSignalValueReads,
|
|
1695
1783
|
];
|
|
1696
1784
|
|
|
1697
1785
|
/** Exported for direct unit tests. */
|
|
@@ -1715,5 +1803,6 @@ export const _internals = {
|
|
|
1715
1803
|
ruleLockfileMissing,
|
|
1716
1804
|
ruleLockfileDrift,
|
|
1717
1805
|
rulePackageManagerMissing,
|
|
1806
|
+
ruleSignalValueReads,
|
|
1718
1807
|
},
|
|
1719
1808
|
};
|
|
@@ -1793,3 +1793,184 @@ describe("rule: package-manager-missing", () => {
|
|
|
1793
1793
|
expect(updated.packageManager).toMatch(/^bun@/);
|
|
1794
1794
|
});
|
|
1795
1795
|
});
|
|
1796
|
+
|
|
1797
|
+
/* ------------------------------------------------------------------ */
|
|
1798
|
+
/* signal-value-reads rule */
|
|
1799
|
+
/* ------------------------------------------------------------------ */
|
|
1800
|
+
|
|
1801
|
+
describe("rule: signal-value-reads", () => {
|
|
1802
|
+
it("flags a .tsx file that reads a module-level signal .value without useStore()", () => {
|
|
1803
|
+
const fs = makeFs({
|
|
1804
|
+
"/site/src/sdk/useAutocomplete.ts":
|
|
1805
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1806
|
+
"export const loading = signal(false);\n" +
|
|
1807
|
+
"export const suggestions = signal(null);\n",
|
|
1808
|
+
"/site/src/components/Searchbar.tsx":
|
|
1809
|
+
'import { loading, suggestions } from "~/sdk/useAutocomplete";\n' +
|
|
1810
|
+
"export default function Searchbar() {\n" +
|
|
1811
|
+
" const { products = [] } = suggestions.value ?? {};\n" +
|
|
1812
|
+
" return loading.value ? <Spinner /> : <ul>{products.map(p => <li>{p.name}</li>)}</ul>;\n" +
|
|
1813
|
+
"}\n",
|
|
1814
|
+
});
|
|
1815
|
+
const report = runAudit(SITE, fs);
|
|
1816
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1817
|
+
// Two signals read without useStore — one finding per signal per file.
|
|
1818
|
+
expect(r.findings.length).toBeGreaterThanOrEqual(1);
|
|
1819
|
+
const files = r.findings.map((f) => f.file);
|
|
1820
|
+
expect(files.every((f) => f === "src/components/Searchbar.tsx")).toBe(true);
|
|
1821
|
+
expect(r.findings[0].severity).toBe("warning");
|
|
1822
|
+
expect(r.findings[0].fix).toContain("useStore");
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
it("does NOT flag when the .tsx file also calls useStore()", () => {
|
|
1826
|
+
const fs = makeFs({
|
|
1827
|
+
"/site/src/sdk/useAutocomplete.ts":
|
|
1828
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1829
|
+
"export const loading = signal(false);\n",
|
|
1830
|
+
"/site/src/components/Searchbar.tsx":
|
|
1831
|
+
'import { useStore } from "@tanstack/react-store";\n' +
|
|
1832
|
+
'import { loading } from "~/sdk/useAutocomplete";\n' +
|
|
1833
|
+
"export default function Searchbar() {\n" +
|
|
1834
|
+
" const isLoading = useStore(loading.store, (s) => s);\n" +
|
|
1835
|
+
" return isLoading ? <Spinner /> : <div />;\n" +
|
|
1836
|
+
"}\n",
|
|
1837
|
+
});
|
|
1838
|
+
const report = runAudit(SITE, fs);
|
|
1839
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1840
|
+
expect(r.findings).toEqual([]);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it("does NOT flag signal declared inside a function body (not module-level)", () => {
|
|
1844
|
+
const fs = makeFs({
|
|
1845
|
+
"/site/src/hooks/useCounter.ts":
|
|
1846
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1847
|
+
"export function useCounter() {\n" +
|
|
1848
|
+
" const count = signal(0);\n" + // indented — not module-level
|
|
1849
|
+
" return count;\n" +
|
|
1850
|
+
"}\n",
|
|
1851
|
+
"/site/src/components/Counter.tsx":
|
|
1852
|
+
'import { useCounter } from "~/hooks/useCounter";\n' +
|
|
1853
|
+
"export default function Counter() {\n" +
|
|
1854
|
+
" const count = useCounter();\n" +
|
|
1855
|
+
" return <div>{count.value}</div>;\n" +
|
|
1856
|
+
"}\n",
|
|
1857
|
+
});
|
|
1858
|
+
const report = runAudit(SITE, fs);
|
|
1859
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1860
|
+
expect(r.findings).toEqual([]);
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it("does NOT flag .ts files (non-.tsx), which are not React render bodies", () => {
|
|
1864
|
+
const fs = makeFs({
|
|
1865
|
+
"/site/src/sdk/state.ts":
|
|
1866
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1867
|
+
"export const count = signal(0);\n",
|
|
1868
|
+
// A plain .ts file that reads .value — not a React component, no JSX.
|
|
1869
|
+
"/site/src/utils/helper.ts":
|
|
1870
|
+
'import { count } from "~/sdk/state";\n' +
|
|
1871
|
+
"export function getCount() { return count.value; }\n",
|
|
1872
|
+
});
|
|
1873
|
+
const report = runAudit(SITE, fs);
|
|
1874
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1875
|
+
expect(r.findings).toEqual([]);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it("flags only the unsubscribed signal when two signals coexist, one subscribed and one not", () => {
|
|
1879
|
+
const fs = makeFs({
|
|
1880
|
+
"/site/src/sdk/state.ts":
|
|
1881
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1882
|
+
"export const loading = signal(false);\n" +
|
|
1883
|
+
'export const query = signal("");\n',
|
|
1884
|
+
// loading is correctly subscribed via useStore(loading.store, ...).
|
|
1885
|
+
// query.value is read raw — no useStore(query.store) call anywhere.
|
|
1886
|
+
// Per-signal check: loading should produce no finding; query should.
|
|
1887
|
+
"/site/src/components/Search.tsx":
|
|
1888
|
+
'import { useStore } from "@tanstack/react-store";\n' +
|
|
1889
|
+
'import { loading, query } from "~/sdk/state";\n' +
|
|
1890
|
+
"export default function Search() {\n" +
|
|
1891
|
+
" const isLoading = useStore(loading.store, (s) => s);\n" +
|
|
1892
|
+
" return isLoading ? null : <div>{query.value}</div>;\n" +
|
|
1893
|
+
"}\n",
|
|
1894
|
+
});
|
|
1895
|
+
const report = runAudit(SITE, fs);
|
|
1896
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1897
|
+
// loading is subscribed — no finding for it.
|
|
1898
|
+
// query.value is read without useStore(query.store) — one finding.
|
|
1899
|
+
expect(r.findings).toHaveLength(1);
|
|
1900
|
+
expect(r.findings[0].meta?.signalName).toBe("query");
|
|
1901
|
+
expect(r.findings[0].file).toBe("src/components/Search.tsx");
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
it("does NOT flag when the .tsx file calls useStore with the exact signal's store", () => {
|
|
1905
|
+
const fs = makeFs({
|
|
1906
|
+
"/site/src/sdk/state.ts":
|
|
1907
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1908
|
+
"export const count = signal(0);\n",
|
|
1909
|
+
"/site/src/components/Counter.tsx":
|
|
1910
|
+
'import { useStore } from "@tanstack/react-store";\n' +
|
|
1911
|
+
'import { count } from "~/sdk/state";\n' +
|
|
1912
|
+
"export default function Counter() {\n" +
|
|
1913
|
+
" const n = useStore(count.store, (s) => s);\n" +
|
|
1914
|
+
" return <div>{count.value}</div>;\n" + // .value present but store is subscribed
|
|
1915
|
+
"}\n",
|
|
1916
|
+
});
|
|
1917
|
+
const report = runAudit(SITE, fs);
|
|
1918
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1919
|
+
expect(r.findings).toEqual([]);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
it("does not produce duplicate findings when two source files declare the same signal name", () => {
|
|
1923
|
+
const fs = makeFs({
|
|
1924
|
+
"/site/src/sdk/useAutocomplete.ts":
|
|
1925
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1926
|
+
"export const loading = signal(false);\n",
|
|
1927
|
+
// A re-export file also declares the same name — realistic in partial migrations.
|
|
1928
|
+
"/site/src/re-exports.ts":
|
|
1929
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1930
|
+
"export const loading = signal(false);\n",
|
|
1931
|
+
"/site/src/components/Searchbar.tsx":
|
|
1932
|
+
'import { loading } from "~/sdk/useAutocomplete";\n' +
|
|
1933
|
+
"export default function Searchbar() {\n" +
|
|
1934
|
+
" return loading.value ? <Spinner /> : <div />;\n" +
|
|
1935
|
+
"}\n",
|
|
1936
|
+
});
|
|
1937
|
+
const report = runAudit(SITE, fs);
|
|
1938
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1939
|
+
// Only one finding despite two source files declaring "loading".
|
|
1940
|
+
expect(r.findings).toHaveLength(1);
|
|
1941
|
+
expect(r.findings[0].meta?.signalName).toBe("loading");
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
it("returns zero findings on a site with no signal declarations", () => {
|
|
1945
|
+
const fs = makeFs({
|
|
1946
|
+
"/site/src/components/Static.tsx":
|
|
1947
|
+
"export default function Static() { return <div>hello</div>; }\n",
|
|
1948
|
+
});
|
|
1949
|
+
const report = runAudit(SITE, fs);
|
|
1950
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1951
|
+
expect(r.findings).toEqual([]);
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
it("does NOT support auto-fix (correct replacement varies per case)", () => {
|
|
1955
|
+
const fs = makeFs({});
|
|
1956
|
+
const report = runAudit(SITE, fs);
|
|
1957
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1958
|
+
expect(r.supportsAutoFix).toBe(false);
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it("includes signalName and declaredIn in finding meta", () => {
|
|
1962
|
+
const fs = makeFs({
|
|
1963
|
+
"/site/src/sdk/signals.ts":
|
|
1964
|
+
'import { signal } from "@decocms/blocks/sdk/signal";\n' +
|
|
1965
|
+
"export const mySignal = signal(0);\n",
|
|
1966
|
+
"/site/src/components/Widget.tsx":
|
|
1967
|
+
'import { mySignal } from "~/sdk/signals";\n' +
|
|
1968
|
+
"export default function Widget() { return <div>{mySignal.value}</div>; }\n",
|
|
1969
|
+
});
|
|
1970
|
+
const report = runAudit(SITE, fs);
|
|
1971
|
+
const r = report.rules.find((r) => r.rule === "signal-value-reads")!;
|
|
1972
|
+
expect(r.findings).toHaveLength(1);
|
|
1973
|
+
expect(r.findings[0].meta?.signalName).toBe("mySignal");
|
|
1974
|
+
expect(r.findings[0].meta?.declaredIn).toBe("src/sdk/signals.ts");
|
|
1975
|
+
});
|
|
1976
|
+
});
|
|
@@ -121,9 +121,7 @@ import {
|
|
|
121
121
|
} from "@decocms/blocks-admin";
|
|
122
122
|
import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps-vtex/utils/proxy";
|
|
123
123
|
import { extractVtexContext } from "@decocms/apps-vtex/middleware";
|
|
124
|
-
import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects";
|
|
125
124
|
import { withABTesting } from "@decocms/blocks/sdk/abTesting";
|
|
126
|
-
import { loadBlocks } from "@decocms/blocks/cms";
|
|
127
125
|
|
|
128
126
|
// ---------------------------------------------------------------------------
|
|
129
127
|
// VTEX checkout proxy — configured via @decocms/apps-vtex factory
|
|
@@ -152,11 +150,6 @@ const CSP_DIRECTIVES = [
|
|
|
152
150
|
|
|
153
151
|
const serverEntry = createServerEntry({ fetch: handler.fetch });
|
|
154
152
|
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// CMS Redirects — loaded once at module level from .deco/blocks/
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
const cmsRedirects = loadRedirects(loadBlocks());
|
|
159
|
-
|
|
160
153
|
const MOBILE_RE = /mobile|android|iphone/i;
|
|
161
154
|
|
|
162
155
|
const decoWorker = createDecoWorkerEntry(serverEntry, {
|
|
@@ -205,17 +198,6 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
|
|
|
205
198
|
|
|
206
199
|
const abTestedWorker = withABTesting(decoWorker, {
|
|
207
200
|
kvBinding: "SITES_KV",
|
|
208
|
-
preHandler: (request, url) => {
|
|
209
|
-
const redirect = matchRedirect(url.pathname, cmsRedirects);
|
|
210
|
-
if (redirect) {
|
|
211
|
-
const target = url.search ? \`\${redirect.to}\${url.search}\` : redirect.to;
|
|
212
|
-
return new Response(null, {
|
|
213
|
-
status: redirect.status,
|
|
214
|
-
headers: { Location: target },
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
return null;
|
|
218
|
-
},
|
|
219
201
|
shouldBypassAB: (_request, url) => {
|
|
220
202
|
if (url.pathname === "/login" || url.pathname === "/login/" ||
|
|
221
203
|
url.pathname === "/logout" || url.pathname === "/logout/") return false;
|