@akinon/projectzero 2.0.20-rc.0 → 2.0.21-beta.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/CHANGELOG.md +4 -4
- package/app-template/.env.example +0 -1
- package/app-template/AGENTS.md +0 -8
- package/app-template/CHANGELOG.md +64 -73
- package/app-template/README.md +1 -25
- package/app-template/next.config.mjs +1 -7
- package/app-template/package.json +41 -41
- package/app-template/src/hooks/index.ts +0 -2
- package/app-template/src/plugins.js +0 -1
- package/app-template/src/proxy.ts +1 -2
- package/app-template/src/views/header/search/index.tsx +5 -13
- package/app-template/src/views/product/slider.tsx +38 -85
- package/codemods/fix-login-form-action/index.js +77 -0
- package/codemods/fix-login-form-action/transform.js +43 -0
- package/codemods/fix-template-currenturl/index.js +85 -0
- package/codemods/fix-template-currenturl/transform.js +91 -0
- package/codemods/upgrade-to-2/index.js +166 -50
- package/commands/plugins.ts +0 -4
- package/dist/commands/plugins.js +0 -4
- package/package.json +1 -1
- package/app-template/src/app/global-error.tsx +0 -22
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix-login-form-action
|
|
3
|
+
*
|
|
4
|
+
* Rewrites the login form's `action="/api/auth/signin/credentials"` attribute
|
|
5
|
+
* in src/views/login/index.tsx to `action={() => {}}`.
|
|
6
|
+
*
|
|
7
|
+
* Why: under React 19 the form `action` prop is a function-action signal —
|
|
8
|
+
* passing one tells React itself to call event.preventDefault() before any
|
|
9
|
+
* listener runs. With the string URL it used to have, a hydration race
|
|
10
|
+
* (e.g. searchParams CSR bailout) would let the browser perform a native
|
|
11
|
+
* submit to the auth endpoint before the JS onSubmit listener attached.
|
|
12
|
+
* On the credentials login form that meant POSTing email and password as
|
|
13
|
+
* form-encoded body. The no-op function neutralizes that fallback while
|
|
14
|
+
* keeping the existing JS onSubmit as the real submit path.
|
|
15
|
+
*
|
|
16
|
+
* Brands without the attribute (or without src/views/login/index.tsx) are
|
|
17
|
+
* clean no-ops.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node codemods/fix-login-form-action/index.js [--dry-run]
|
|
21
|
+
* npx @akinon/projectzero codemod --codemod=fix-login-form-action [--dry-run]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const jscodeshift = require('jscodeshift/src/Runner');
|
|
27
|
+
|
|
28
|
+
function log(msg) {
|
|
29
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
30
|
+
console.log(`[${ts}] ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const transform = () => {
|
|
34
|
+
const cwd = path.resolve(process.cwd());
|
|
35
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
36
|
+
const transformPath = path.join(__dirname, 'transform.js');
|
|
37
|
+
const target = path.join(cwd, 'src', 'views', 'login', 'index.tsx');
|
|
38
|
+
|
|
39
|
+
if (!fs.existsSync(target)) {
|
|
40
|
+
log(
|
|
41
|
+
`fix-login-form-action: no src/views/login/index.tsx at ${cwd}, skipping`
|
|
42
|
+
);
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
log(
|
|
47
|
+
`fix-login-form-action: scanning ${target}${dryRun ? ' (dry-run)' : ''}`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return jscodeshift
|
|
51
|
+
.run(transformPath, [target], {
|
|
52
|
+
verbose: 0,
|
|
53
|
+
dry: dryRun,
|
|
54
|
+
print: dryRun,
|
|
55
|
+
extensions: 'tsx',
|
|
56
|
+
parser: 'tsx',
|
|
57
|
+
ignorePattern: '**/node_modules/**',
|
|
58
|
+
silent: true
|
|
59
|
+
})
|
|
60
|
+
.then((res) => {
|
|
61
|
+
log(
|
|
62
|
+
`fix-login-form-action: ${res.ok} ok, ${res.nochange} unchanged, ${res.skip} skipped, ${res.error} errors`
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
module.exports = { transform };
|
|
68
|
+
|
|
69
|
+
if (require.main === module) {
|
|
70
|
+
const result = transform();
|
|
71
|
+
if (result && typeof result.then === 'function') {
|
|
72
|
+
result.catch((err) => {
|
|
73
|
+
console.error(err);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jscodeshift transform for fix-login-form-action.
|
|
3
|
+
*
|
|
4
|
+
* Replaces every JSXAttribute named `action` whose value is the string
|
|
5
|
+
* "/api/auth/signin/credentials" with a no-op arrow function expression
|
|
6
|
+
* (`action={() => {}}`), and attaches an explanatory leading comment block.
|
|
7
|
+
*
|
|
8
|
+
* Returns null (no source change) when no matching attribute is found.
|
|
9
|
+
*/
|
|
10
|
+
module.exports = function transform(fileInfo, api) {
|
|
11
|
+
const j = api.jscodeshift;
|
|
12
|
+
const root = j(fileInfo.source);
|
|
13
|
+
let changed = false;
|
|
14
|
+
|
|
15
|
+
root.find(j.JSXAttribute, { name: { name: 'action' } }).forEach((p) => {
|
|
16
|
+
const v = p.node.value;
|
|
17
|
+
if (!v) return;
|
|
18
|
+
|
|
19
|
+
// String values can show up as either Literal or StringLiteral depending
|
|
20
|
+
// on the parser version. Match both.
|
|
21
|
+
const isString = v.type === 'Literal' || v.type === 'StringLiteral';
|
|
22
|
+
if (!isString || v.value !== '/api/auth/signin/credentials') return;
|
|
23
|
+
|
|
24
|
+
const arrow = j.arrowFunctionExpression([], j.blockStatement([]));
|
|
25
|
+
p.node.value = j.jsxExpressionContainer(arrow);
|
|
26
|
+
|
|
27
|
+
// Leading block comment explains why the action is a no-op. Attaching
|
|
28
|
+
// to the attribute keeps the comment adjacent in the printed output.
|
|
29
|
+
const comment = j.commentBlock(
|
|
30
|
+
' React 19 belt-and-suspenders: passing a function to `action` makes\n' +
|
|
31
|
+
' React itself call preventDefault on the native submit before any\n' +
|
|
32
|
+
' listener runs, so a hydration race cannot leak credentials into\n' +
|
|
33
|
+
' the URL bar. onSubmit still drives the actual signIn flow. ',
|
|
34
|
+
true,
|
|
35
|
+
false
|
|
36
|
+
);
|
|
37
|
+
p.node.comments = [comment];
|
|
38
|
+
|
|
39
|
+
changed = true;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return changed ? root.toSource({ quote: 'single' }) : null;
|
|
43
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix-template-currenturl
|
|
3
|
+
*
|
|
4
|
+
* Rewrites the JSON-LD canonical url construction in
|
|
5
|
+
* src/app/** /template.tsx from
|
|
6
|
+
*
|
|
7
|
+
* const currentUrl = URL + pathname + searchParams;
|
|
8
|
+
*
|
|
9
|
+
* to
|
|
10
|
+
*
|
|
11
|
+
* const currentUrl = `${URL}${pathname}`;
|
|
12
|
+
*
|
|
13
|
+
* Why: in the Next 16 / React 19 model `searchParams` is a
|
|
14
|
+
* ReadonlyURLSearchParams that string-coerces differently on the server
|
|
15
|
+
* and the client. The mismatched output lands inside a JSON-LD
|
|
16
|
+
* <script dangerouslySetInnerHTML> tag, breaks hydration of the entire
|
|
17
|
+
* route subtree, and races sibling client component hydration — auth
|
|
18
|
+
* forms in particular have shipped credentials into the URL as native
|
|
19
|
+
* GETs because their onSubmit listener was not yet attached when the
|
|
20
|
+
* user clicked submit.
|
|
21
|
+
*
|
|
22
|
+
* Handles both base forms observed across brands:
|
|
23
|
+
* const currentUrl = URL + pathname + searchParams;
|
|
24
|
+
* const currentUrl = process.env.NEXT_PUBLIC_URL + pathname + searchParams;
|
|
25
|
+
*
|
|
26
|
+
* Files outside src/app/** /template.tsx are skipped. Files that already
|
|
27
|
+
* use the template-literal form are no-ops.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node codemods/fix-template-currenturl/index.js [--dry-run]
|
|
31
|
+
* npx @akinon/projectzero codemod --codemod=fix-template-currenturl [--dry-run]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const jscodeshift = require('jscodeshift/src/Runner');
|
|
37
|
+
|
|
38
|
+
function log(msg) {
|
|
39
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
40
|
+
console.log(`[${ts}] ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const transform = () => {
|
|
44
|
+
const cwd = path.resolve(process.cwd());
|
|
45
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
46
|
+
const transformPath = path.join(__dirname, 'transform.js');
|
|
47
|
+
const srcPath = path.join(cwd, 'src', 'app');
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(srcPath)) {
|
|
50
|
+
log(`fix-template-currenturl: no src/app at ${cwd}, skipping`);
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
log(
|
|
55
|
+
`fix-template-currenturl: scanning ${srcPath}${dryRun ? ' (dry-run)' : ''}`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return jscodeshift
|
|
59
|
+
.run(transformPath, [srcPath], {
|
|
60
|
+
verbose: 0,
|
|
61
|
+
dry: dryRun,
|
|
62
|
+
print: dryRun,
|
|
63
|
+
extensions: 'tsx',
|
|
64
|
+
parser: 'tsx',
|
|
65
|
+
ignorePattern: '**/node_modules/**',
|
|
66
|
+
silent: true
|
|
67
|
+
})
|
|
68
|
+
.then((res) => {
|
|
69
|
+
log(
|
|
70
|
+
`fix-template-currenturl: ${res.ok} ok, ${res.nochange} unchanged, ${res.skip} skipped, ${res.error} errors`
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
module.exports = { transform };
|
|
76
|
+
|
|
77
|
+
if (require.main === module) {
|
|
78
|
+
const result = transform();
|
|
79
|
+
if (result && typeof result.then === 'function') {
|
|
80
|
+
result.catch((err) => {
|
|
81
|
+
console.error(err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jscodeshift transform for fix-template-currenturl.
|
|
3
|
+
*
|
|
4
|
+
* Matches:
|
|
5
|
+
* const currentUrl = (URL | process.env.NEXT_PUBLIC_URL) + pathname + searchParams;
|
|
6
|
+
*
|
|
7
|
+
* Replaces the right-hand side with the template literal
|
|
8
|
+
* `${X}${pathname}`
|
|
9
|
+
*
|
|
10
|
+
* Only runs against files whose path ends in `/app/**\/template.tsx`. Returns
|
|
11
|
+
* null (no source change) when no match is found in the file or the
|
|
12
|
+
* declarator already has a non-binary initializer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function isUrlBase(node) {
|
|
16
|
+
if (!node) return false;
|
|
17
|
+
if (node.type === 'Identifier' && node.name === 'URL') return true;
|
|
18
|
+
if (
|
|
19
|
+
node.type === 'MemberExpression' &&
|
|
20
|
+
node.object &&
|
|
21
|
+
node.object.type === 'MemberExpression' &&
|
|
22
|
+
node.object.object &&
|
|
23
|
+
node.object.object.type === 'Identifier' &&
|
|
24
|
+
node.object.object.name === 'process' &&
|
|
25
|
+
node.object.property &&
|
|
26
|
+
node.object.property.type === 'Identifier' &&
|
|
27
|
+
node.object.property.name === 'env' &&
|
|
28
|
+
node.property &&
|
|
29
|
+
node.property.type === 'Identifier' &&
|
|
30
|
+
node.property.name === 'NEXT_PUBLIC_URL'
|
|
31
|
+
) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function flattenAdd(node, out) {
|
|
38
|
+
if (
|
|
39
|
+
node &&
|
|
40
|
+
node.type === 'BinaryExpression' &&
|
|
41
|
+
node.operator === '+'
|
|
42
|
+
) {
|
|
43
|
+
flattenAdd(node.left, out);
|
|
44
|
+
flattenAdd(node.right, out);
|
|
45
|
+
} else {
|
|
46
|
+
out.push(node);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = function transform(fileInfo, api) {
|
|
51
|
+
// Restrict to template.tsx under any nested app/ segment. Brand routing
|
|
52
|
+
// depth varies (e.g. [commerce]/[locale]/[currency] vs deeper member-type
|
|
53
|
+
// splits) but the file name is constant.
|
|
54
|
+
if (!/[\\/]app[\\/].*template\.tsx$/.test(fileInfo.path)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const j = api.jscodeshift;
|
|
59
|
+
const root = j(fileInfo.source);
|
|
60
|
+
let changed = false;
|
|
61
|
+
|
|
62
|
+
root
|
|
63
|
+
.find(j.VariableDeclarator, { id: { name: 'currentUrl' } })
|
|
64
|
+
.forEach((p) => {
|
|
65
|
+
const init = p.node.init;
|
|
66
|
+
if (
|
|
67
|
+
!init ||
|
|
68
|
+
init.type !== 'BinaryExpression' ||
|
|
69
|
+
init.operator !== '+'
|
|
70
|
+
) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parts = [];
|
|
75
|
+
flattenAdd(init, parts);
|
|
76
|
+
if (parts.length !== 3) return;
|
|
77
|
+
|
|
78
|
+
const [base, mid, tail] = parts;
|
|
79
|
+
if (mid.type !== 'Identifier' || mid.name !== 'pathname') return;
|
|
80
|
+
if (tail.type !== 'Identifier' || tail.name !== 'searchParams') return;
|
|
81
|
+
if (!isUrlBase(base)) return;
|
|
82
|
+
|
|
83
|
+
// Build: `${base}${pathname}`
|
|
84
|
+
const empty = () => j.templateElement({ raw: '', cooked: '' }, false);
|
|
85
|
+
const tail2 = j.templateElement({ raw: '', cooked: '' }, true);
|
|
86
|
+
p.node.init = j.templateLiteral([empty(), empty(), tail2], [base, mid]);
|
|
87
|
+
changed = true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return changed ? root.toSource({ quote: 'single' }) : null;
|
|
91
|
+
};
|