mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-27 11:49:14 +00:00
Compare commits
35 Commits
@vercel/ne
...
@vercel/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43e413ba5 | ||
|
|
0945d24cbe | ||
|
|
a8ecf40d6f | ||
|
|
2995781a58 | ||
|
|
e2096c268d | ||
|
|
663d8e6437 | ||
|
|
153ca440fa | ||
|
|
c5e0cb4812 | ||
|
|
5bf1fe4c74 | ||
|
|
011f836aa7 | ||
|
|
ec107d7c91 | ||
|
|
0d27ae3b1a | ||
|
|
08da4b9c92 | ||
|
|
877f09ff5c | ||
|
|
5cca9b6c5c | ||
|
|
4af242af86 | ||
|
|
0cbdae1411 | ||
|
|
85dd667781 | ||
|
|
7d3dda7341 | ||
|
|
2144d0b2a9 | ||
|
|
976e6aedf9 | ||
|
|
6328751e14 | ||
|
|
8cb49a5136 | ||
|
|
3fa4f344cc | ||
|
|
27610896ed | ||
|
|
b9dae36e37 | ||
|
|
1537ff9c38 | ||
|
|
7e0317775f | ||
|
|
2dd27976b3 | ||
|
|
25e2b7efba | ||
|
|
3d23d1270c | ||
|
|
fde40e731a | ||
|
|
f353527421 | ||
|
|
c1cdfb3e75 | ||
|
|
fc413707d0 |
4
.github/DISCUSSION_TEMPLATE/general.yml
vendored
4
.github/DISCUSSION_TEMPLATE/general.yml
vendored
@@ -2,7 +2,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
|
||||
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
|
||||
|
||||
If you post in this repository seeking help with other Vercel tools and features, it may be missed by our support team. For help with topics other than the CLI and Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
6
.github/DISCUSSION_TEMPLATE/help.yml
vendored
6
.github/DISCUSSION_TEMPLATE/help.yml
vendored
@@ -2,9 +2,11 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions/categories/help)
|
||||
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
|
||||
|
||||
If you post in this repository seeking help with other Vercel tools and features, it may be missed by our support team. For help with topics other than the CLI and Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
label: Question
|
||||
validations:
|
||||
required: true
|
||||
|
||||
4
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
4
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
@@ -2,7 +2,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions/categories/ideas)
|
||||
**Note**: This category is intended for sharing ideas related to Vercel CLI or Runtimes.
|
||||
|
||||
Please visit the [Vercel Community](https://github.com/orgs/vercel/discussions) to share ideas for other Vercel tools and features.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Idea
|
||||
|
||||
10
.github/DISCUSSION_TEMPLATE/polls.yml
vendored
10
.github/DISCUSSION_TEMPLATE/polls.yml
vendored
@@ -1,10 +0,0 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
validations:
|
||||
required: true
|
||||
@@ -2,7 +2,9 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: For discussions not related to Vercel CLI or Runtimes, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
|
||||
**Note**: This category is intended for discussions related to Vercel CLI or Runtimes.
|
||||
|
||||
For topics related to other Vercel features, please visit the [Vercel Community](https://github.com/orgs/vercel/discussions).
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
5
.github/DISCUSSION_TEMPLATE/temporary.yml
vendored
5
.github/DISCUSSION_TEMPLATE/temporary.yml
vendored
@@ -1,5 +0,0 @@
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Note**: This category should not be used for new discussions. Please visit the [Vercel Community](https://github.com/orgs/vercel/discussions)
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -81,11 +81,11 @@ jobs:
|
||||
run: echo | openssl s_client -showcerts -servername 'api.vercel.com' -connect 76.76.21.21:443
|
||||
|
||||
- name: Build ${{matrix.packageName}} and all its dependencies
|
||||
run: node utils/gen.js && node_modules/.bin/turbo run build --cache-dir=".turbo" --scope=${{matrix.packageName}} --include-dependencies --no-deps
|
||||
run: node utils/gen.js && node_modules/.bin/turbo run build --cache-dir=".turbo" --log-order=stream --scope=${{matrix.packageName}} --include-dependencies --no-deps
|
||||
env:
|
||||
FORCE_COLOR: '1'
|
||||
- name: Test ${{matrix.packageName}}
|
||||
run: node utils/gen.js && node_modules/.bin/turbo run test --cache-dir=".turbo" --scope=${{matrix.packageName}} --no-deps -- ${{ join(matrix.testPaths, ' ') }}
|
||||
run: node utils/gen.js && node_modules/.bin/turbo run test --cache-dir=".turbo" --log-order=stream --scope=${{matrix.packageName}} --no-deps -- ${{ join(matrix.testPaths, ' ') }}
|
||||
shell: bash
|
||||
env:
|
||||
VERCEL_CLI_VERSION: ${{ needs.setup.outputs.dplUrl }}/tarballs/vercel.tgz
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(25%, auto));
|
||||
width: var(--max-width);
|
||||
max-width: 100%;
|
||||
width: var(--max-width);
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
365
examples/nextjs/package-lock.json
generated
365
examples/nextjs/package-lock.json
generated
@@ -8,9 +8,9 @@
|
||||
"name": "nextjs",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-next": "13.4.12",
|
||||
"next": "13.4.12",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-next": "13.4.13",
|
||||
"next": "13.4.13",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
@@ -49,17 +49,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz",
|
||||
"integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==",
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz",
|
||||
"integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==",
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz",
|
||||
"integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz",
|
||||
"integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
@@ -79,9 +79,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz",
|
||||
"integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==",
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz",
|
||||
"integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
@@ -117,22 +117,22 @@
|
||||
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.12.tgz",
|
||||
"integrity": "sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ=="
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.13.tgz",
|
||||
"integrity": "sha512-fwz2QgVg08v7ZL7KmbQBLF2PubR/6zQdKBgmHEl3BCyWTEDsAQEijjw2gbFhI1tcKfLdOOJUXntz5vZ4S0Polg=="
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.12.tgz",
|
||||
"integrity": "sha512-6rhK9CdxEgj/j1qvXIyLTWEaeFv7zOK8yJMulz3Owel0uek0U9MJCGzmKgYxM3aAUBo3gKeywCZKyQnJKto60A==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.4.13.tgz",
|
||||
"integrity": "sha512-RpZeXlPxQ9FLeYN84XHDqRN20XxmVNclYCraLYdifRsmibtcWUWdwE/ANp2C8kgesFRsvwfsw6eOkYNl9sLJ3A==",
|
||||
"dependencies": {
|
||||
"glob": "7.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz",
|
||||
"integrity": "sha512-deUrbCXTMZ6ZhbOoloqecnUeNpUOupi8SE2tx4jPfNS9uyUR9zK4iXBvH65opVcA/9F5I/p8vDXSYbUlbmBjZg==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.13.tgz",
|
||||
"integrity": "sha512-ZptVhHjzUuivnXMNCJ6lER33HN7lC+rZ01z+PM10Ows21NHFYMvGhi5iXkGtBDk6VmtzsbqnAjnx4Oz5um0FjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -145,9 +145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.12.tgz",
|
||||
"integrity": "sha512-WRvH7RxgRHlC1yb5oG0ZLx8F7uci9AivM5/HGGv9ZyG2Als8Ij64GC3d+mQ5sJhWjusyU6T6V1WKTUoTmOB0zQ==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.13.tgz",
|
||||
"integrity": "sha512-t9nTiWCLApw8W4G1kqJyYP7y6/7lyal3PftmRturIxAIBlZss9wrtVN8nci50StDHmIlIDxfguYIEGVr9DbFTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -160,9 +160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.12.tgz",
|
||||
"integrity": "sha512-YEKracAWuxp54tKiAvvq73PUs9lok57cc8meYRibTWe/VdPB2vLgkTVWFcw31YDuRXdEhdX0fWS6Q+ESBhnEig==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.13.tgz",
|
||||
"integrity": "sha512-xEHUqC8eqR5DHe8SOmMnDU1K3ggrJ28uIKltrQAwqFSSSmzjnN/XMocZkcVhuncuxYrpbri0iMQstRyRVdQVWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -175,9 +175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.12.tgz",
|
||||
"integrity": "sha512-LhJR7/RAjdHJ2Isl2pgc/JaoxNk0KtBgkVpiDJPVExVWA1c6gzY57+3zWuxuyWzTG+fhLZo2Y80pLXgIJv7g3g==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.13.tgz",
|
||||
"integrity": "sha512-sNf3MnLAm8rquSSAoeD9nVcdaDeRYOeey4stOWOyWIgbBDtP+C93amSgH/LPTDoUV7gNiU6f+ghepTjTjRgIUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -190,9 +190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.12.tgz",
|
||||
"integrity": "sha512-1DWLL/B9nBNiQRng+1aqs3OaZcxC16Nf+mOnpcrZZSdyKHek3WQh6j/fkbukObgNGwmCoVevLUa/p3UFTTqgqg==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.13.tgz",
|
||||
"integrity": "sha512-WhcRaJJSHyx9OWmKjjz+OWHumiPZWRqmM/09Bt7Up4UqUJFFhGExeztR4trtv3rflvULatu9IH/nTV8fUUgaMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -205,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.12.tgz",
|
||||
"integrity": "sha512-kEAJmgYFhp0VL+eRWmUkVxLVunn7oL9Mdue/FS8yzRBVj7Z0AnIrHpTIeIUl1bbdQq1VaoOztnKicAjfkLTRCQ==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.13.tgz",
|
||||
"integrity": "sha512-+Y4LLhOWWZQIDKVwr2R17lq2KSN0F1c30QVgGIWfnjjHpH8nrIWHEndhqYU+iFuW8It78CiJjQKTw4f51HD7jA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -220,9 +220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.12.tgz",
|
||||
"integrity": "sha512-GMLuL/loR6yIIRTnPRY6UGbLL9MBdw2anxkOnANxvLvsml4F0HNIgvnU3Ej4BjbqMTNjD4hcPFdlEow4XHPdZA==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.13.tgz",
|
||||
"integrity": "sha512-rWurdOR20uxjfqd1X9vDAgv0Jb26KjyL8akF9CBeFqX8rVaBAnW/Wf6A2gYEwyYY4Bai3T7p1kro6DFrsvBAAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -235,9 +235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-ia32-msvc": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.12.tgz",
|
||||
"integrity": "sha512-PhgNqN2Vnkm7XaMdRmmX0ZSwZXQAtamBVSa9A/V1dfKQCV1rjIZeiy/dbBnVYGdj63ANfsOR/30XpxP71W0eww==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.13.tgz",
|
||||
"integrity": "sha512-E8bSPwRuY5ibJ3CzLQmJEt8qaWrPYuUTwnrwygPUEWoLzD5YRx9SD37oXRdU81TgGwDzCxpl7z5Nqlfk50xAog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -250,9 +250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.12.tgz",
|
||||
"integrity": "sha512-Z+56e/Ljt0bUs+T+jPjhFyxYBcdY2RIq9ELFU+qAMQMteHo7ymbV7CKmlcX59RI9C4YzN8PgMgLyAoi916b5HA==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.13.tgz",
|
||||
"integrity": "sha512-4KlyC6jWRubPnppgfYsNTPeWfGCxtWLh5vaOAW/kdzAk9widqho8Qb5S4K2vHmal1tsURi7Onk2MMCV1phvyqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -334,24 +334,25 @@
|
||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
|
||||
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.2.1.tgz",
|
||||
"integrity": "sha512-Ld+uL1kYFU8e6btqBFpsHkwQ35rw30IWpdQxgOqOh4NfxSDH6uCkah1ks8R/RgQqI5hHPXMaLy9fbFseIe+dIg==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "5.62.0",
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/typescript-estree": "5.62.0",
|
||||
"@typescript-eslint/scope-manager": "6.2.1",
|
||||
"@typescript-eslint/types": "6.2.1",
|
||||
"@typescript-eslint/typescript-estree": "6.2.1",
|
||||
"@typescript-eslint/visitor-keys": "6.2.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -360,15 +361,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
|
||||
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.2.1.tgz",
|
||||
"integrity": "sha512-UCqBF9WFqv64xNsIEPfBtenbfodPXsJ3nPAr55mGPkQIkiQvgoWNo+astj9ZUfJfVKiYgAZDMnM6dIpsxUMp3Q==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/visitor-keys": "5.62.0"
|
||||
"@typescript-eslint/types": "6.2.1",
|
||||
"@typescript-eslint/visitor-keys": "6.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -376,11 +377,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
|
||||
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.2.1.tgz",
|
||||
"integrity": "sha512-528bGcoelrpw+sETlyM91k51Arl2ajbNT9L4JwoXE2dvRe1yd8Q64E4OL7vHYw31mlnVsf+BeeLyAZUEQtqahQ==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -388,20 +389,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
|
||||
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.2.1.tgz",
|
||||
"integrity": "sha512-G+UJeQx9AKBHRQBpmvr8T/3K5bJa485eu+4tQBxFq0KoT22+jJyzo1B50JDT9QdC1DEmWQfdKsa8ybiNWYsi0Q==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"@typescript-eslint/visitor-keys": "5.62.0",
|
||||
"@typescript-eslint/types": "6.2.1",
|
||||
"@typescript-eslint/visitor-keys": "6.2.1",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
"semver": "^7.3.7",
|
||||
"tsutils": "^3.21.0"
|
||||
"semver": "^7.5.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -414,15 +415,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
|
||||
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.2.1.tgz",
|
||||
"integrity": "sha512-iTN6w3k2JEZ7cyVdZJTVJx2Lv7t6zFA8DCrJEHD2mwfc16AEvvBWVhbFh34XyG2NORCd0viIgQY1+u7kPI0WpA==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
"@typescript-eslint/types": "6.2.1",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -536,6 +537,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/array.prototype.findlastindex": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.2.tgz",
|
||||
"integrity": "sha512-tb5thFFlUcp7NdNF6/MpDk/1r/4awWG1FIz3YqDf+/zJSTezBb+/5WViH41obXULHVpDzoiCLpJ/ZO9YbJMsdw==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.1.4",
|
||||
"es-abstract": "^1.20.4",
|
||||
"es-shim-unscopables": "^1.0.0",
|
||||
"get-intrinsic": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/array.prototype.flat": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz",
|
||||
@@ -723,9 +742,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001517",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
|
||||
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
|
||||
"version": "1.0.30001519",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz",
|
||||
"integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1027,26 +1046,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.45.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz",
|
||||
"integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==",
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz",
|
||||
"integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.4.0",
|
||||
"@eslint/eslintrc": "^2.1.0",
|
||||
"@eslint/js": "8.44.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.1",
|
||||
"@eslint/js": "^8.46.0",
|
||||
"@humanwhocodes/config-array": "^0.11.10",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"ajv": "^6.10.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"debug": "^4.3.2",
|
||||
"doctrine": "^3.0.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^7.2.0",
|
||||
"eslint-visitor-keys": "^3.4.1",
|
||||
"espree": "^9.6.0",
|
||||
"eslint-scope": "^7.2.2",
|
||||
"eslint-visitor-keys": "^3.4.2",
|
||||
"espree": "^9.6.1",
|
||||
"esquery": "^1.4.2",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1080,13 +1099,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.12.tgz",
|
||||
"integrity": "sha512-ZF0r5vxKaVazyZH/37Au/XItiG7qUOBw+HaH3PeyXltIMwXorsn6bdrl0Nn9N5v5v9spc+6GM2ryjugbjF6X2g==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.4.13.tgz",
|
||||
"integrity": "sha512-EXAh5h1yG/YTNa5YdskzaSZncBjKjvFe2zclMCi2KXyTsXha22wB6MPs/U7idB6a2qjpBdbZcruQY1TWjfNMZw==",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "13.4.12",
|
||||
"@next/eslint-plugin-next": "13.4.13",
|
||||
"@rushstack/eslint-patch": "^1.1.3",
|
||||
"@typescript-eslint/parser": "^5.42.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
@@ -1105,13 +1124,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz",
|
||||
"integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==",
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.8.tgz",
|
||||
"integrity": "sha512-tEe+Pok22qIGaK3KoMP+N96GVDS66B/zreoVVmiavLvRUEmGRtvb4B8wO9jwnb8d2lvHtrkhZ7UD73dWBVnf/Q==",
|
||||
"dependencies": {
|
||||
"debug": "^3.2.7",
|
||||
"is-core-module": "^2.11.0",
|
||||
"resolve": "^1.22.1"
|
||||
"is-core-module": "^2.13.0",
|
||||
"resolve": "^1.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node/node_modules/debug": {
|
||||
@@ -1201,25 +1220,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import": {
|
||||
"version": "2.27.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz",
|
||||
"integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==",
|
||||
"version": "2.28.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz",
|
||||
"integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==",
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.6",
|
||||
"array.prototype.findlastindex": "^1.2.2",
|
||||
"array.prototype.flat": "^1.3.1",
|
||||
"array.prototype.flatmap": "^1.3.1",
|
||||
"debug": "^3.2.7",
|
||||
"doctrine": "^2.1.0",
|
||||
"eslint-import-resolver-node": "^0.3.7",
|
||||
"eslint-module-utils": "^2.7.4",
|
||||
"eslint-module-utils": "^2.8.0",
|
||||
"has": "^1.0.3",
|
||||
"is-core-module": "^2.11.0",
|
||||
"is-core-module": "^2.12.1",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^3.1.2",
|
||||
"object.fromentries": "^2.0.6",
|
||||
"object.groupby": "^1.0.0",
|
||||
"object.values": "^1.1.6",
|
||||
"resolve": "^1.22.1",
|
||||
"semver": "^6.3.0",
|
||||
"tsconfig-paths": "^3.14.1"
|
||||
"resolve": "^1.22.3",
|
||||
"semver": "^6.3.1",
|
||||
"tsconfig-paths": "^3.14.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -1293,9 +1315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react": {
|
||||
"version": "7.33.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.0.tgz",
|
||||
"integrity": "sha512-qewL/8P34WkY8jAqdQxsiL82pDUeT7nhs8IsuXgfgnsEloKCT4miAV9N9kGtx7/KM9NH/NCGUE7Edt9iGxLXFw==",
|
||||
"version": "7.33.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz",
|
||||
"integrity": "sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==",
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.6",
|
||||
"array.prototype.flatmap": "^1.3.1",
|
||||
@@ -1367,9 +1389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz",
|
||||
"integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==",
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
@@ -1382,9 +1404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
|
||||
"integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
|
||||
"integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
@@ -1447,9 +1469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz",
|
||||
"integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz",
|
||||
"integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.3",
|
||||
"get-stream": "^6.0.1",
|
||||
@@ -1474,9 +1496,9 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz",
|
||||
"integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
@@ -1959,9 +1981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz",
|
||||
"integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==",
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz",
|
||||
"integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==",
|
||||
"dependencies": {
|
||||
"has": "^1.0.3"
|
||||
},
|
||||
@@ -2237,9 +2259,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz",
|
||||
"integrity": "sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==",
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.6",
|
||||
"array.prototype.flat": "^1.3.1",
|
||||
@@ -2399,11 +2421,11 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "13.4.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-13.4.12.tgz",
|
||||
"integrity": "sha512-eHfnru9x6NRmTMcjQp6Nz0J4XH9OubmzOa7CkWL+AUrUxpibub3vWwttjduu9No16dug1kq04hiUUpo7J3m3Xw==",
|
||||
"version": "13.4.13",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-13.4.13.tgz",
|
||||
"integrity": "sha512-A3YVbVDNeXLhWsZ8Nf6IkxmNlmTNz0yVg186NJ97tGZqPDdPzTrHotJ+A1cuJm2XfuWPrKOUZILl5iBQkIf8Jw==",
|
||||
"dependencies": {
|
||||
"@next/env": "13.4.12",
|
||||
"@next/env": "13.4.13",
|
||||
"@swc/helpers": "0.5.1",
|
||||
"busboy": "1.6.0",
|
||||
"caniuse-lite": "^1.0.30001406",
|
||||
@@ -2419,19 +2441,18 @@
|
||||
"node": ">=16.8.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "13.4.12",
|
||||
"@next/swc-darwin-x64": "13.4.12",
|
||||
"@next/swc-linux-arm64-gnu": "13.4.12",
|
||||
"@next/swc-linux-arm64-musl": "13.4.12",
|
||||
"@next/swc-linux-x64-gnu": "13.4.12",
|
||||
"@next/swc-linux-x64-musl": "13.4.12",
|
||||
"@next/swc-win32-arm64-msvc": "13.4.12",
|
||||
"@next/swc-win32-ia32-msvc": "13.4.12",
|
||||
"@next/swc-win32-x64-msvc": "13.4.12"
|
||||
"@next/swc-darwin-arm64": "13.4.13",
|
||||
"@next/swc-darwin-x64": "13.4.13",
|
||||
"@next/swc-linux-arm64-gnu": "13.4.13",
|
||||
"@next/swc-linux-arm64-musl": "13.4.13",
|
||||
"@next/swc-linux-x64-gnu": "13.4.13",
|
||||
"@next/swc-linux-x64-musl": "13.4.13",
|
||||
"@next/swc-win32-arm64-msvc": "13.4.13",
|
||||
"@next/swc-win32-ia32-msvc": "13.4.13",
|
||||
"@next/swc-win32-x64-msvc": "13.4.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"fibers": ">= 3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.3.0"
|
||||
@@ -2440,9 +2461,6 @@
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"fibers": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -2543,6 +2561,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object.groupby": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.0.tgz",
|
||||
"integrity": "sha512-70MWG6NfRH9GnbZOikuhPPYzpUpof9iW2J9E4dW7FXTqPNb6rllE6u39SKwwiNh8lCwX3DDb5OgcKGiEBrTTyw==",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"define-properties": "^1.2.0",
|
||||
"es-abstract": "^1.21.2",
|
||||
"get-intrinsic": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/object.hasown": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz",
|
||||
@@ -2836,11 +2865,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.2",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz",
|
||||
"integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==",
|
||||
"version": "1.22.4",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
|
||||
"integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==",
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.11.0",
|
||||
"is-core-module": "^2.13.0",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
@@ -3316,6 +3345,17 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.1.tgz",
|
||||
"integrity": "sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==",
|
||||
"engines": {
|
||||
"node": ">=16.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz",
|
||||
@@ -3328,28 +3368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
|
||||
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
|
||||
},
|
||||
"node_modules/tsutils": {
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
|
||||
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
|
||||
"dependencies": {
|
||||
"tslib": "^1.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tsutils/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz",
|
||||
"integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig=="
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "8.45.0",
|
||||
"eslint-config-next": "13.4.12",
|
||||
"next": "13.4.12",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-next": "13.4.13",
|
||||
"next": "13.4.13",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
}
|
||||
|
||||
@@ -45,4 +45,4 @@ Locally preview production build:
|
||||
npm run preview
|
||||
```
|
||||
|
||||
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.
|
||||
Checkout the [deployment documentation](https://nuxt.com/docs/getting-started/deployment#presets) for more information.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @vercel-internals/types
|
||||
|
||||
## 1.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
|
||||
- @vercel/build-utils@6.8.3
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@vercel-internals/types",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"types": "index.d.ts",
|
||||
"main": "index.d.ts",
|
||||
"dependencies": {
|
||||
"@types/node": "14.14.31",
|
||||
"@vercel-internals/constants": "1.0.4",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/routing-utils": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"source-map-support": "0.5.12",
|
||||
"ts-eager": "2.0.2",
|
||||
"ts-jest": "29.1.0",
|
||||
"turbo": "1.10.9",
|
||||
"turbo": "1.10.12",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @vercel/build-utils
|
||||
|
||||
## 6.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix `getPrefixedEnvVars()` to handle `VERCEL_BRANCH_URL` ([#10315](https://github.com/vercel/vercel/pull/10315))
|
||||
|
||||
## 6.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/build-utils",
|
||||
"version": "6.8.2",
|
||||
"version": "6.8.3",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.js",
|
||||
|
||||
@@ -14,7 +14,12 @@ export function getPrefixedEnvVars({
|
||||
envs: Envs;
|
||||
}): Envs {
|
||||
const vercelSystemEnvPrefix = 'VERCEL_';
|
||||
const allowed = ['VERCEL_URL', 'VERCEL_ENV', 'VERCEL_REGION'];
|
||||
const allowed = [
|
||||
'VERCEL_URL',
|
||||
'VERCEL_ENV',
|
||||
'VERCEL_REGION',
|
||||
'VERCEL_BRANCH_URL',
|
||||
];
|
||||
const newEnvs: Envs = {};
|
||||
if (envPrefix && envs.VERCEL_URL) {
|
||||
Object.keys(envs)
|
||||
|
||||
@@ -13,6 +13,8 @@ describe('Test `getPrefixedEnvVars()`', () => {
|
||||
envs: {
|
||||
VERCEL: '1',
|
||||
VERCEL_URL: 'example.vercel.sh',
|
||||
VERCEL_ENV: 'production',
|
||||
VERCEL_BRANCH_URL: 'example-git-main-acme.vercel.app',
|
||||
USER_ENV_VAR_NOT_VERCEL: 'example.com',
|
||||
VERCEL_ARTIFACTS_TOKEN: 'abc123',
|
||||
FOO: 'bar',
|
||||
@@ -20,6 +22,8 @@ describe('Test `getPrefixedEnvVars()`', () => {
|
||||
},
|
||||
want: {
|
||||
NEXT_PUBLIC_VERCEL_URL: 'example.vercel.sh',
|
||||
NEXT_PUBLIC_VERCEL_ENV: 'production',
|
||||
NEXT_PUBLIC_VERCEL_BRANCH_URL: 'example-git-main-acme.vercel.app',
|
||||
TURBO_CI_VENDOR_ENV_KEY: 'NEXT_PUBLIC_VERCEL_',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# vercel
|
||||
|
||||
## 31.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Be looser in tests with mock server urls ([#10300](https://github.com/vercel/vercel/pull/10300))
|
||||
|
||||
- Handle calls for deployment aliases when mocking deployments ([#10303](https://github.com/vercel/vercel/pull/10303))
|
||||
|
||||
- Remove unused code ([#10309](https://github.com/vercel/vercel/pull/10309))
|
||||
|
||||
- Updated dependencies [[`5bf1fe4c7`](https://github.com/vercel/vercel/commit/5bf1fe4c743f6be3f7d5a24447ea5b083a68dc67), [`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6), [`08da4b9c9`](https://github.com/vercel/vercel/commit/08da4b9c923501d9d28eb6e3f26f4605fee83042), [`0945d24cb`](https://github.com/vercel/vercel/commit/0945d24cbe901ca3f0eedd011251ad499c72d472)]:
|
||||
- @vercel/next@3.9.4
|
||||
- @vercel/build-utils@6.8.3
|
||||
- @vercel/remix-builder@1.10.0
|
||||
- @vercel/node@2.15.9
|
||||
- @vercel/static-build@1.3.45
|
||||
|
||||
## 31.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Migrate list command to new structure ([#10284](https://github.com/vercel/vercel/pull/10284))
|
||||
|
||||
- Migrate whoami command to new structure ([#10266](https://github.com/vercel/vercel/pull/10266))
|
||||
|
||||
- Migrate logs command to new structure ([#10281](https://github.com/vercel/vercel/pull/10281))
|
||||
|
||||
- Migrate login command to new structure ([#10283](https://github.com/vercel/vercel/pull/10283))
|
||||
|
||||
- Migrate pull command to new structure ([#10280](https://github.com/vercel/vercel/pull/10280))
|
||||
|
||||
- Migrate logout command to new structure ([#10282](https://github.com/vercel/vercel/pull/10282))
|
||||
|
||||
- Migrate build command to new structure ([#10286](https://github.com/vercel/vercel/pull/10286))
|
||||
|
||||
- Migrate inspect command to new structure ([#10277](https://github.com/vercel/vercel/pull/10277))
|
||||
|
||||
- Migrate redeploy command to new structure ([#10279](https://github.com/vercel/vercel/pull/10279))
|
||||
|
||||
- Migrate link command to new structure ([#10285](https://github.com/vercel/vercel/pull/10285))
|
||||
|
||||
- Update spacing of --help output for CLI ([#10287](https://github.com/vercel/vercel/pull/10287))
|
||||
|
||||
- Updated dependencies [[`4af242af8`](https://github.com/vercel/vercel/commit/4af242af8633e58b6a9bf920564416da3ef22ad4), [`0cbdae141`](https://github.com/vercel/vercel/commit/0cbdae1411aa7936ff7dfe551919ca5e56cd6e98), [`85dd66778`](https://github.com/vercel/vercel/commit/85dd667781693539d753d587566e53964bbe189d)]:
|
||||
- @vercel/node@2.15.8
|
||||
- @vercel/remix-builder@1.9.1
|
||||
- @vercel/static-build@1.3.44
|
||||
|
||||
## 31.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Migrate bisect command to new structure ([#10276](https://github.com/vercel/vercel/pull/10276))
|
||||
|
||||
- Migrate remove command to new structure ([#10268](https://github.com/vercel/vercel/pull/10268))
|
||||
|
||||
- Updated dependencies [[`fc413707d`](https://github.com/vercel/vercel/commit/fc413707d017e234d5013b761d885f65f9b981bc)]:
|
||||
- @vercel/node@2.15.7
|
||||
- @vercel/static-build@1.3.43
|
||||
|
||||
## 31.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vercel",
|
||||
"version": "31.2.0",
|
||||
"version": "31.2.3",
|
||||
"preferGlobal": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "The command-line interface for Vercel",
|
||||
@@ -31,16 +31,16 @@
|
||||
"node": ">= 14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/go": "2.5.1",
|
||||
"@vercel/hydrogen": "0.0.64",
|
||||
"@vercel/next": "3.9.3",
|
||||
"@vercel/node": "2.15.6",
|
||||
"@vercel/next": "3.9.4",
|
||||
"@vercel/node": "2.15.9",
|
||||
"@vercel/python": "3.1.60",
|
||||
"@vercel/redwood": "1.1.15",
|
||||
"@vercel/remix-builder": "1.9.0",
|
||||
"@vercel/remix-builder": "1.10.0",
|
||||
"@vercel/ruby": "1.3.76",
|
||||
"@vercel/static-build": "1.3.42"
|
||||
"@vercel/static-build": "1.3.45"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alex_neo/jest-expect-message": "1.0.5",
|
||||
@@ -86,8 +86,8 @@
|
||||
"@types/yauzl-promise": "2.1.0",
|
||||
"@vercel-internals/constants": "1.0.4",
|
||||
"@vercel-internals/get-package-json": "1.0.0",
|
||||
"@vercel-internals/types": "1.0.5",
|
||||
"@vercel/client": "12.6.5",
|
||||
"@vercel-internals/types": "1.0.6",
|
||||
"@vercel/client": "12.6.6",
|
||||
"@vercel/error-utils": "1.0.10",
|
||||
"@vercel/frameworks": "1.5.0",
|
||||
"@vercel/fs-detectors": "4.1.1",
|
||||
@@ -157,7 +157,6 @@
|
||||
"semver": "5.5.0",
|
||||
"serve-handler": "6.1.1",
|
||||
"strip-ansi": "6.0.1",
|
||||
"stripe": "5.1.0",
|
||||
"supports-hyperlinks": "3.0.0",
|
||||
"tar-fs": "1.16.3",
|
||||
"test-listen": "1.1.0",
|
||||
|
||||
69
packages/cli/src/commands/bisect/command.ts
Normal file
69
packages/cli/src/commands/bisect/command.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const bisectCommand: Command = {
|
||||
name: 'bisect',
|
||||
description: 'Bisect the current project interactively.',
|
||||
arguments: [],
|
||||
options: [
|
||||
{
|
||||
name: 'bad',
|
||||
description: 'Known bad URL',
|
||||
argument: 'URL',
|
||||
shorthand: 'b',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'good',
|
||||
description: 'Known good URL',
|
||||
argument: 'URL',
|
||||
shorthand: 'g',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'open',
|
||||
description: 'Automatically open each URL in the browser',
|
||||
argument: 'URL',
|
||||
shorthand: 'o',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'path',
|
||||
description: 'Subpath of the deployment URL to test',
|
||||
argument: 'URL',
|
||||
shorthand: 'p',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'run',
|
||||
description: 'Test script to run for each deployment',
|
||||
argument: 'URL',
|
||||
shorthand: 'r',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Bisect the current project interactively',
|
||||
value: `${getPkgName()} bisect`,
|
||||
},
|
||||
{
|
||||
name: 'Bisect with a known bad deployment',
|
||||
value: `${getPkgName()} bisect --bad example-310pce9i0.vercel.app`,
|
||||
},
|
||||
{
|
||||
name: 'Automated bisect with a run script',
|
||||
value: `${getPkgName()} bisect --run ./test.sh`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -8,53 +8,19 @@ import { URLSearchParams, parse } from 'url';
|
||||
import box from '../../util/output/box';
|
||||
import formatDate from '../../util/format-date';
|
||||
import link from '../../util/output/link';
|
||||
import logo from '../../util/output/logo';
|
||||
import getArgs from '../../util/get-args';
|
||||
import Client from '../../util/client';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
import { Deployment } from '@vercel-internals/types';
|
||||
import { normalizeURL } from '../../util/bisect/normalize-url';
|
||||
import getScope from '../../util/get-scope';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import { help } from '../help';
|
||||
import { bisectCommand } from './command';
|
||||
|
||||
interface Deployments {
|
||||
deployments: Deployment[];
|
||||
}
|
||||
|
||||
const pkgName = getPkgName();
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${pkgName} bisect`)} [options]
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-b, --bad Known bad URL
|
||||
-g, --good Known good URL
|
||||
-o, --open Automatically open each URL in the browser
|
||||
-p, --path Subpath of the deployment URL to test
|
||||
-r, --run Test script to run for each deployment
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Bisect the current project interactively
|
||||
|
||||
${chalk.cyan(`$ ${pkgName} bisect`)}
|
||||
|
||||
${chalk.gray('–')} Bisect with a known bad deployment
|
||||
|
||||
${chalk.cyan(`$ ${pkgName} bisect --bad example-310pce9i0.vercel.app`)}
|
||||
|
||||
${chalk.gray('–')} Automated bisect with a run script
|
||||
|
||||
${chalk.cyan(`$ ${pkgName} bisect --run ./test.sh`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client): Promise<number> {
|
||||
export default async function bisect(client: Client): Promise<number> {
|
||||
const { output } = client;
|
||||
const scope = await getScope(client);
|
||||
const { contextName } = scope;
|
||||
@@ -73,7 +39,7 @@ export default async function main(client: Client): Promise<number> {
|
||||
});
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(bisectCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
|
||||
46
packages/cli/src/commands/build/command.ts
Normal file
46
packages/cli/src/commands/build/command.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const buildCommand: Command = {
|
||||
name: 'build',
|
||||
description: 'Build the project.',
|
||||
arguments: [],
|
||||
options: [
|
||||
{
|
||||
name: 'prod',
|
||||
description: 'Build a production deployment',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'output',
|
||||
description: 'Directory where built assets should be written to',
|
||||
shorthand: null,
|
||||
argument: 'PATH',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'yes',
|
||||
description:
|
||||
'Skip the confirmation prompt about pulling environment variables and project settings when not found locally',
|
||||
shorthand: 'y',
|
||||
type: 'boolean',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Build the project',
|
||||
value: `${getPkgName()} build`,
|
||||
},
|
||||
{
|
||||
name: 'Build the project in a specific directory',
|
||||
value: `${getPkgName()} build --cwd ./path-to-project`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -38,35 +38,37 @@ import {
|
||||
import { fileNameSymbol } from '@vercel/client';
|
||||
import type { VercelConfig } from '@vercel/client';
|
||||
|
||||
import pull from './pull';
|
||||
import { staticFiles as getFiles } from '../util/get-files';
|
||||
import Client from '../util/client';
|
||||
import getArgs from '../util/get-args';
|
||||
import cmd from '../util/output/cmd';
|
||||
import * as cli from '../util/pkg-name';
|
||||
import cliPkg from '../util/pkg';
|
||||
import readJSONFile from '../util/read-json-file';
|
||||
import { CantParseJSONFile } from '../util/errors-ts';
|
||||
import pull from '../pull';
|
||||
import { staticFiles as getFiles } from '../../util/get-files';
|
||||
import Client from '../../util/client';
|
||||
import getArgs from '../../util/get-args';
|
||||
import cmd from '../../util/output/cmd';
|
||||
import * as cli from '../../util/pkg-name';
|
||||
import cliPkg from '../../util/pkg';
|
||||
import readJSONFile from '../../util/read-json-file';
|
||||
import { CantParseJSONFile } from '../../util/errors-ts';
|
||||
import {
|
||||
pickOverrides,
|
||||
ProjectLinkAndSettings,
|
||||
readProjectSettings,
|
||||
} from '../util/projects/project-settings';
|
||||
import { getProjectLink, VERCEL_DIR } from '../util/projects/link';
|
||||
import confirm from '../util/input/confirm';
|
||||
import { emoji, prependEmoji } from '../util/emoji';
|
||||
import stamp from '../util/output/stamp';
|
||||
} from '../../util/projects/project-settings';
|
||||
import { getProjectLink, VERCEL_DIR } from '../../util/projects/link';
|
||||
import confirm from '../../util/input/confirm';
|
||||
import { emoji, prependEmoji } from '../../util/emoji';
|
||||
import stamp from '../../util/output/stamp';
|
||||
import {
|
||||
OUTPUT_DIR,
|
||||
PathOverride,
|
||||
writeBuildResult,
|
||||
} from '../util/build/write-build-result';
|
||||
import { importBuilders } from '../util/build/import-builders';
|
||||
import { initCorepack, cleanupCorepack } from '../util/build/corepack';
|
||||
import { sortBuilders } from '../util/build/sort-builders';
|
||||
import { toEnumerableError } from '../util/error';
|
||||
import { validateConfig } from '../util/validate-config';
|
||||
import { setMonorepoDefaultSettings } from '../util/build/monorepo';
|
||||
} from '../../util/build/write-build-result';
|
||||
import { importBuilders } from '../../util/build/import-builders';
|
||||
import { initCorepack, cleanupCorepack } from '../../util/build/corepack';
|
||||
import { sortBuilders } from '../../util/build/sort-builders';
|
||||
import { toEnumerableError } from '../../util/error';
|
||||
import { validateConfig } from '../../util/validate-config';
|
||||
import { setMonorepoDefaultSettings } from '../../util/build/monorepo';
|
||||
import { help } from '../help';
|
||||
import { buildCommand } from './command';
|
||||
|
||||
type BuildResult = BuildResultV2 | BuildResultV3;
|
||||
|
||||
@@ -103,35 +105,6 @@ export interface BuildsManifest {
|
||||
builds?: SerializedBuilder[];
|
||||
}
|
||||
|
||||
const help = () => {
|
||||
return console.log(`
|
||||
${chalk.bold(`${cli.logo} ${cli.name} build`)}
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
--cwd [path] The current working directory
|
||||
--output [path] Directory where built assets should be written to
|
||||
--prod Build a production deployment
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-y, --yes Skip the confirmation prompt about pulling environment variables and project settings when not found locally
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('-')} Build the project
|
||||
|
||||
${chalk.cyan(`$ ${cli.name} build`)}
|
||||
${chalk.cyan(`$ ${cli.name} build --cwd ./path-to-project`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client): Promise<number> {
|
||||
let { cwd } = client;
|
||||
const { output } = client;
|
||||
@@ -162,7 +135,7 @@ export default async function main(client: Client): Promise<number> {
|
||||
});
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(buildCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -132,8 +132,8 @@ export function lineToString(line: string[]) {
|
||||
return string;
|
||||
}
|
||||
|
||||
export function outputArrayToString(outputArray: string[]) {
|
||||
return outputArray.join(NEWLINE);
|
||||
export function outputArrayToString(outputArray: (string | null)[]) {
|
||||
return outputArray.filter(line => line !== null).join(NEWLINE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +142,12 @@ export function outputArrayToString(outputArray: string[]) {
|
||||
* @returns
|
||||
*/
|
||||
export function buildCommandSynopsisLine(command: Command) {
|
||||
const line: string[] = [LOGO, chalk.bold(NAME), chalk.bold(command.name)];
|
||||
const line: string[] = [
|
||||
INDENT,
|
||||
LOGO,
|
||||
chalk.bold(NAME),
|
||||
chalk.bold(command.name),
|
||||
];
|
||||
if (command.arguments.length > 0) {
|
||||
for (const argument of command.arguments) {
|
||||
line.push(argument.required ? argument.name : `[${argument.name}]`);
|
||||
@@ -151,6 +156,8 @@ export function buildCommandSynopsisLine(command: Command) {
|
||||
if (command.options.length > 0) {
|
||||
line.push('[options]');
|
||||
}
|
||||
|
||||
line.push(NEWLINE);
|
||||
return lineToString(line);
|
||||
}
|
||||
|
||||
@@ -165,11 +172,11 @@ export function buildCommandOptionLines(
|
||||
);
|
||||
|
||||
if (commandOptions.length === 0) {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize output array with header and empty line
|
||||
const outputArray: string[] = [`${chalk.dim(sectionTitle)}:`, ''];
|
||||
const outputArray: string[] = [`${INDENT}${chalk.dim(sectionTitle)}:`, ''];
|
||||
|
||||
// Start building option lines
|
||||
const optionLines: string[][] = [];
|
||||
@@ -181,7 +188,7 @@ export function buildCommandOptionLines(
|
||||
let maxLineStartLength = 0;
|
||||
// Iterate over options and create the "start" of each option (e.g. ` -b, --build-env <key=value>`)
|
||||
for (const option of commandOptions) {
|
||||
const startLine: string[] = [INDENT];
|
||||
const startLine: string[] = [INDENT, INDENT, INDENT];
|
||||
if (option.shorthand) {
|
||||
startLine.push(`-${option.shorthand},`);
|
||||
}
|
||||
@@ -249,12 +256,11 @@ export function buildCommandOptionLines(
|
||||
}
|
||||
}
|
||||
|
||||
// return the entire list of options as a single string after delete the last '\n' added to the option list
|
||||
return outputArrayToString(outputArray);
|
||||
return `${outputArrayToString(outputArray)}${NEWLINE}`;
|
||||
}
|
||||
|
||||
export function buildCommandExampleLines(command: Command) {
|
||||
const outputArray: string[] = [chalk.dim(`Examples:`), ''];
|
||||
const outputArray: string[] = [`${INDENT}${chalk.dim('Examples:')}`, ''];
|
||||
for (const example of command.examples) {
|
||||
const nameLine: string[] = [INDENT];
|
||||
nameLine.push(chalk.gray('-'));
|
||||
@@ -273,12 +279,15 @@ export function buildCommandExampleLines(command: Command) {
|
||||
}
|
||||
outputArray.push('');
|
||||
}
|
||||
// delete the last newline added after examples iteration
|
||||
outputArray.splice(-1);
|
||||
|
||||
return outputArrayToString(outputArray);
|
||||
}
|
||||
|
||||
function buildDescriptionLine(command: Command) {
|
||||
const line: string[] = [INDENT, command.description, NEWLINE];
|
||||
return lineToString(line);
|
||||
}
|
||||
|
||||
interface BuildHelpOutputOptions {
|
||||
columns: number;
|
||||
}
|
||||
@@ -287,15 +296,12 @@ export function buildHelpOutput(
|
||||
command: Command,
|
||||
options: BuildHelpOutputOptions
|
||||
) {
|
||||
const outputArray: string[] = [
|
||||
const outputArray: (string | null)[] = [
|
||||
'',
|
||||
buildCommandSynopsisLine(command),
|
||||
'',
|
||||
command.description,
|
||||
'',
|
||||
buildDescriptionLine(command),
|
||||
buildCommandOptionLines(command.options, options, 'Options'),
|
||||
'',
|
||||
buildCommandOptionLines(globalCommandOptions, options, 'Global Options'),
|
||||
'',
|
||||
buildCommandExampleLines(command),
|
||||
'',
|
||||
];
|
||||
|
||||
50
packages/cli/src/commands/inspect/command.ts
Normal file
50
packages/cli/src/commands/inspect/command.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const inspectCommand: Command = {
|
||||
name: 'inspect',
|
||||
description: 'Show information about a deployment.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'url',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'timeout',
|
||||
description: 'Time to wait for deployment completion [3m]',
|
||||
argument: 'TIME',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'wait',
|
||||
description: 'Blocks until deployment completes',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Get information about a deployment by its unique URL',
|
||||
value: `${getPkgName()} inspect my-deployment-ji2fjij2.vercel.app`,
|
||||
},
|
||||
{
|
||||
name: 'Get information about the deployment an alias points to',
|
||||
value: `${getPkgName()} inspect my-deployment.vercel.app`,
|
||||
},
|
||||
{
|
||||
name: 'Get information about a deployment by piping in the URL',
|
||||
value: `echo my-deployment.vercel.app | ${getPkgName()} inspect`,
|
||||
},
|
||||
{
|
||||
name: 'Wait up to 90 seconds for deployment to complete',
|
||||
value: `${getPkgName()} inspect my-deployment.vercel.app --wait --timeout 90s`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,71 +1,27 @@
|
||||
import chalk from 'chalk';
|
||||
import getArgs from '../util/get-args';
|
||||
import buildsList from '../util/output/builds';
|
||||
import routesList from '../util/output/routes';
|
||||
import indent from '../util/output/indent';
|
||||
import logo from '../util/output/logo';
|
||||
import elapsed from '../util/output/elapsed';
|
||||
import { handleError } from '../util/error';
|
||||
import getScope from '../util/get-scope';
|
||||
import { getPkgName, getCommandName } from '../util/pkg-name';
|
||||
import Client from '../util/client';
|
||||
import getDeployment from '../util/get-deployment';
|
||||
import getArgs from '../../util/get-args';
|
||||
import buildsList from '../../util/output/builds';
|
||||
import routesList from '../../util/output/routes';
|
||||
import indent from '../../util/output/indent';
|
||||
import elapsed from '../../util/output/elapsed';
|
||||
import { handleError } from '../../util/error';
|
||||
import getScope from '../../util/get-scope';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import Client from '../../util/client';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import type { Build, Deployment } from '@vercel-internals/types';
|
||||
import title from 'title';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import { URL } from 'url';
|
||||
import readStandardInput from '../util/input/read-standard-input';
|
||||
import sleep from '../util/sleep';
|
||||
import readStandardInput from '../../util/input/read-standard-input';
|
||||
import sleep from '../../util/sleep';
|
||||
import ms from 'ms';
|
||||
import { isDeploying } from '../util/deploy/is-deploying';
|
||||
import { isDeploying } from '../../util/deploy/is-deploying';
|
||||
import { help } from '../help';
|
||||
import { inspectCommand } from './command';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} inspect`)} <url>
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-S, --scope Set a custom scope
|
||||
--timeout=${chalk.bold.underline(
|
||||
'TIME'
|
||||
)} Time to wait for deployment completion [3m]
|
||||
--wait Blocks until deployment completes
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Get information about a deployment by its unique URL
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} inspect my-deployment-ji2fjij2.vercel.app`)}
|
||||
|
||||
${chalk.gray('-')} Get information about the deployment an alias points to
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} inspect my-deployment.vercel.app`)}
|
||||
|
||||
${chalk.gray('-')} Get information about a deployment by piping in the URL
|
||||
|
||||
${chalk.cyan(`$ echo my-deployment.vercel.app | ${getPkgName()} inspect`)}
|
||||
|
||||
${chalk.gray('-')} Wait up to 90 seconds for deployment to complete
|
||||
|
||||
${chalk.cyan(
|
||||
`$ ${getPkgName()} inspect my-deployment.vercel.app --wait --timeout 90s`
|
||||
)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client) {
|
||||
export default async function inspect(client: Client) {
|
||||
const { output } = client;
|
||||
let argv;
|
||||
|
||||
try {
|
||||
@@ -79,7 +35,7 @@ export default async function main(client: Client) {
|
||||
}
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(inspectCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
@@ -99,7 +55,7 @@ export default async function main(client: Client) {
|
||||
|
||||
if (!deploymentIdOrHost) {
|
||||
error(`${getCommandName('inspect <url>')} expects exactly one argument`);
|
||||
help();
|
||||
output.print(help(inspectCommand, { columns: client.stderr.columns }));
|
||||
return 1;
|
||||
}
|
||||
|
||||
54
packages/cli/src/commands/link/command.ts
Normal file
54
packages/cli/src/commands/link/command.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const linkCommand: Command = {
|
||||
name: 'link',
|
||||
description: 'Link a local directory to a Vercel Project.',
|
||||
arguments: [],
|
||||
options: [
|
||||
{
|
||||
name: 'repo',
|
||||
description: 'Link multiple projects based on Git repository (alpha)',
|
||||
shorthand: 'r',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'project',
|
||||
description: 'Specify a project name',
|
||||
shorthand: 'p',
|
||||
argument: 'NAME',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'yes',
|
||||
description:
|
||||
'Skip questions when setting up new project using default scope and settings',
|
||||
shorthand: 'y',
|
||||
type: 'boolean',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Link current directory to a Vercel Project',
|
||||
value: `${getPkgName()} link`,
|
||||
},
|
||||
{
|
||||
name: 'Link current directory with default options and skip questions',
|
||||
value: `${getPkgName()} link --yes`,
|
||||
},
|
||||
{
|
||||
name: 'Link a specific directory to a Vercel Project',
|
||||
value: `${getPkgName()} link --cwd /path/to/project`,
|
||||
},
|
||||
{
|
||||
name: 'Link to the current Git repository, allowing for multiple Vercel Projects to be linked simultaneously (useful for monorepos)',
|
||||
value: `${getPkgName()} link --repo`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,62 +1,12 @@
|
||||
import chalk from 'chalk';
|
||||
import Client from '../../util/client';
|
||||
import getArgs from '../../util/get-args';
|
||||
import logo from '../../util/output/logo';
|
||||
import cmd from '../../util/output/cmd';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
import { ensureLink } from '../../util/link/ensure-link';
|
||||
import { ensureRepoLink } from '../../util/link/repo';
|
||||
import { help } from '../help';
|
||||
import { linkCommand } from './command';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} link`)} [options]
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-r, --repo Link multiple projects based on Git repository (alpha)
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-p ${chalk.bold.underline('NAME')}, --project=${chalk.bold.underline(
|
||||
'NAME'
|
||||
)} Project name
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Link current directory to a Vercel Project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} link`)}
|
||||
|
||||
${chalk.gray(
|
||||
'–'
|
||||
)} Link current directory with default options and skip questions
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} link --yes`)}
|
||||
|
||||
${chalk.gray('–')} Link a specific directory to a Vercel Project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} link --cwd /path/to/project`)}
|
||||
|
||||
${chalk.gray('–')} ${chalk.yellow(
|
||||
'(alpha)'
|
||||
)} Link to the current Git repository, allowing for multiple
|
||||
Vercel Projects to be linked simultaneously (useful for monorepos)
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} link --repo`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client) {
|
||||
export default async function link(client: Client) {
|
||||
const argv = getArgs(client.argv.slice(2), {
|
||||
'--yes': Boolean,
|
||||
'-y': '--yes',
|
||||
@@ -71,7 +21,7 @@ export default async function main(client: Client) {
|
||||
});
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
client.output.print(help(linkCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
|
||||
61
packages/cli/src/commands/list/command.ts
Normal file
61
packages/cli/src/commands/list/command.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const listCommand: Command = {
|
||||
name: 'list',
|
||||
description: 'List app deployments for an app.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'app',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'meta',
|
||||
description:
|
||||
'Filter deployments by metadata (e.g.: `-m KEY=value`). Can appear many times.',
|
||||
argument: 'KEY=value',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
name: 'environment',
|
||||
description: '',
|
||||
argument: 'production|preview',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'next',
|
||||
description: 'Show next page of results',
|
||||
argument: 'MS',
|
||||
shorthand: 'n',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'List all deployments for the currently linked project',
|
||||
value: `${getPkgName()} list`,
|
||||
},
|
||||
{
|
||||
name: 'List all deployments for the project `my-app` in the team of the currently linked project',
|
||||
value: `${getPkgName()} list my-app`,
|
||||
},
|
||||
{
|
||||
name: 'Filter deployments by metadata',
|
||||
value: `${getPkgName()} list -m key1=value1 -m key2=value2`,
|
||||
},
|
||||
{
|
||||
name: 'Paginate deployments for a project, where `1584722256178` is the time in milliseconds since the UNIX epoch',
|
||||
value: `${getPkgName()} list my-app --next 1584722256178`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2,78 +2,27 @@ import chalk from 'chalk';
|
||||
import ms from 'ms';
|
||||
import table from 'text-table';
|
||||
import title from 'title';
|
||||
import Now from '../util';
|
||||
import getArgs from '../util/get-args';
|
||||
import { handleError } from '../util/error';
|
||||
import logo from '../util/output/logo';
|
||||
import elapsed from '../util/output/elapsed';
|
||||
import strlen from '../util/strlen';
|
||||
import toHost from '../util/to-host';
|
||||
import parseMeta from '../util/parse-meta';
|
||||
import { isValidName } from '../util/is-valid-name';
|
||||
import getCommandFlags from '../util/get-command-flags';
|
||||
import { getPkgName, getCommandName } from '../util/pkg-name';
|
||||
import Client from '../util/client';
|
||||
import Now from '../../util';
|
||||
import getArgs from '../../util/get-args';
|
||||
import { handleError } from '../../util/error';
|
||||
import elapsed from '../../util/output/elapsed';
|
||||
import strlen from '../../util/strlen';
|
||||
import toHost from '../../util/to-host';
|
||||
import parseMeta from '../../util/parse-meta';
|
||||
import { isValidName } from '../../util/is-valid-name';
|
||||
import getCommandFlags from '../../util/get-command-flags';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import Client from '../../util/client';
|
||||
import { Deployment } from '@vercel/client';
|
||||
import { getLinkedProject } from '../util/projects/link';
|
||||
import { ensureLink } from '../util/link/ensure-link';
|
||||
import getScope from '../util/get-scope';
|
||||
import { isAPIError } from '../util/errors-ts';
|
||||
import { getLinkedProject } from '../../util/projects/link';
|
||||
import { ensureLink } from '../../util/link/ensure-link';
|
||||
import getScope from '../../util/get-scope';
|
||||
import { isAPIError } from '../../util/errors-ts';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import { help } from '../help';
|
||||
import { listCommand } from './command';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} list`)} [app]
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-S, --scope Set a custom scope
|
||||
-m, --meta Filter deployments by metadata (e.g.: ${chalk.dim(
|
||||
'`-m KEY=value`'
|
||||
)}). Can appear many times.
|
||||
--environment=${chalk.bold.underline(
|
||||
'ENVIRONMENT'
|
||||
)} Filter by environment (production|preview)
|
||||
-N, --next Show next page of results
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} List all deployments for the currently linked project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls`)}
|
||||
|
||||
${chalk.gray('–')} List all deployments for the project ${chalk.dim(
|
||||
'`my-app`'
|
||||
)} in the team of the currently linked project
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls my-app`)}
|
||||
|
||||
${chalk.gray('–')} Filter deployments by metadata
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls -m key1=value1 -m key2=value2`)}
|
||||
|
||||
${chalk.gray('–')} Paginate deployments for a project, where ${chalk.dim(
|
||||
'`1584722256178`'
|
||||
)} is the time in milliseconds since the UNIX epoch.
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} ls my-app --next 1584722256178`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client) {
|
||||
export default async function list(client: Client) {
|
||||
let argv;
|
||||
|
||||
try {
|
||||
@@ -111,7 +60,7 @@ export default async function main(client: Client) {
|
||||
}
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(listCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
49
packages/cli/src/commands/login/command.ts
Normal file
49
packages/cli/src/commands/login/command.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const loginCommand: Command = {
|
||||
name: 'login',
|
||||
description: 'Authenticate using your email or team id.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'email or team id',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'github',
|
||||
description: 'Log in with GitHub',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'oob',
|
||||
description: 'Log in with "out of band" authentication',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Log into the Vercel platform',
|
||||
value: `${getPkgName()} login`,
|
||||
},
|
||||
{
|
||||
name: 'Log in using a specific email address',
|
||||
value: `${getPkgName()} login username@example.com`,
|
||||
},
|
||||
{
|
||||
name: 'Log in using a specific team "slug" for SAML Single Sign-On',
|
||||
value: `${getPkgName()} login acme`,
|
||||
},
|
||||
{
|
||||
name: 'Log in using GitHub in "out-of-band" mode',
|
||||
value: `${getPkgName()} login --github --oob`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,55 +1,24 @@
|
||||
import { validate as validateEmail } from 'email-validator';
|
||||
import chalk from 'chalk';
|
||||
import hp from '../util/humanize-path';
|
||||
import getArgs from '../util/get-args';
|
||||
import logo from '../util/output/logo';
|
||||
import prompt from '../util/login/prompt';
|
||||
import doSamlLogin from '../util/login/saml';
|
||||
import doEmailLogin from '../util/login/email';
|
||||
import doGithubLogin from '../util/login/github';
|
||||
import doGitlabLogin from '../util/login/gitlab';
|
||||
import doBitbucketLogin from '../util/login/bitbucket';
|
||||
import { prependEmoji, emoji } from '../util/emoji';
|
||||
import { getCommandName, getPkgName } from '../util/pkg-name';
|
||||
import getGlobalPathConfig from '../util/config/global-path';
|
||||
import { writeToAuthConfigFile, writeToConfigFile } from '../util/config/files';
|
||||
import Client from '../util/client';
|
||||
import { LoginResult } from '../util/login/types';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} login`)} <email or team>
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
--no-color No color mode [off]
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Log into the Vercel platform
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} login`)}
|
||||
|
||||
${chalk.gray('–')} Log in using a specific email address
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} login john@doe.com`)}
|
||||
|
||||
${chalk.gray('–')} Log in using a specific team "slug" for SAML Single Sign-On
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} login acme`)}
|
||||
|
||||
${chalk.gray('–')} Log in using GitHub in "out-of-band" mode
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} login --github --oob`)}
|
||||
`);
|
||||
};
|
||||
import hp from '../../util/humanize-path';
|
||||
import getArgs from '../../util/get-args';
|
||||
import prompt from '../../util/login/prompt';
|
||||
import doSamlLogin from '../../util/login/saml';
|
||||
import doEmailLogin from '../../util/login/email';
|
||||
import doGithubLogin from '../../util/login/github';
|
||||
import doGitlabLogin from '../../util/login/gitlab';
|
||||
import doBitbucketLogin from '../../util/login/bitbucket';
|
||||
import { prependEmoji, emoji } from '../../util/emoji';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import getGlobalPathConfig from '../../util/config/global-path';
|
||||
import {
|
||||
writeToAuthConfigFile,
|
||||
writeToConfigFile,
|
||||
} from '../../util/config/files';
|
||||
import Client from '../../util/client';
|
||||
import { LoginResult } from '../../util/login/types';
|
||||
import { help } from '../help';
|
||||
import { loginCommand } from './command';
|
||||
|
||||
export default async function login(client: Client): Promise<number> {
|
||||
const { output } = client;
|
||||
@@ -62,7 +31,7 @@ export default async function login(client: Client): Promise<number> {
|
||||
});
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(loginCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
15
packages/cli/src/commands/logout/command.ts
Normal file
15
packages/cli/src/commands/logout/command.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const logoutCommand: Command = {
|
||||
name: 'logout',
|
||||
description: 'Logout the current authenticated user or team.',
|
||||
arguments: [],
|
||||
options: [],
|
||||
examples: [
|
||||
{
|
||||
name: 'Logout from the CLI',
|
||||
value: `${getPkgName()} logout`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,37 +1,19 @@
|
||||
import chalk from 'chalk';
|
||||
import logo from '../util/output/logo';
|
||||
import { handleError } from '../util/error';
|
||||
import { writeToConfigFile, writeToAuthConfigFile } from '../util/config/files';
|
||||
import getArgs from '../util/get-args';
|
||||
import Client from '../util/client';
|
||||
import { getCommandName, getPkgName } from '../util/pkg-name';
|
||||
import { isAPIError } from '../util/errors-ts';
|
||||
import { handleError } from '../../util/error';
|
||||
import {
|
||||
writeToConfigFile,
|
||||
writeToAuthConfigFile,
|
||||
} from '../../util/config/files';
|
||||
import getArgs from '../../util/get-args';
|
||||
import Client from '../../util/client';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import { isAPIError } from '../../util/errors-ts';
|
||||
import { errorToString } from '@vercel/error-utils';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} logout`)}
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Logout from the CLI:
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} logout`)}
|
||||
`);
|
||||
};
|
||||
import { help } from '../help';
|
||||
import { logoutCommand } from './command';
|
||||
|
||||
export default async function main(client: Client): Promise<number> {
|
||||
let argv;
|
||||
const { authConfig, config, output } = client;
|
||||
|
||||
try {
|
||||
argv = getArgs(client.argv.slice(2), {
|
||||
@@ -44,12 +26,10 @@ export default async function main(client: Client): Promise<number> {
|
||||
}
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
output.print(help(logoutCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { authConfig, config, output } = client;
|
||||
|
||||
if (!authConfig.token) {
|
||||
output.note(
|
||||
`Not currently logged in, so ${getCommandName('logout')} did nothing`
|
||||
66
packages/cli/src/commands/logs/command.ts
Normal file
66
packages/cli/src/commands/logs/command.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const logsCommand: Command = {
|
||||
name: 'logs',
|
||||
description: 'Display logs for a specific deployment.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'url|deploymentId',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'follow',
|
||||
shorthand: 'f',
|
||||
description: 'Wait for additional data [off]',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
shorthand: 'n',
|
||||
description: 'Number of log entries [100]',
|
||||
argument: 'NUMBER',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'since',
|
||||
shorthand: null,
|
||||
description: 'Only return logs after date (ISO 8601)',
|
||||
argument: 'SINCE',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'until',
|
||||
shorthand: null,
|
||||
description:
|
||||
'Only return logs before date (ISO 8601), ignored when used with --follow',
|
||||
argument: 'UNTIL',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'output',
|
||||
shorthand: 'o',
|
||||
description: `Specify the output format (short|raw) [short]`,
|
||||
argument: 'MODE',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Print the logs for the deployment DEPLOYMENT_ID',
|
||||
value: `${getPkgName()} logs DEPLOYMENT_ID`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,60 +1,15 @@
|
||||
import chalk from 'chalk';
|
||||
import logo from '../util/output/logo';
|
||||
import elapsed from '../util/output/elapsed';
|
||||
import { maybeURL, normalizeURL } from '../util/url';
|
||||
import printEvents, { DeploymentEvent } from '../util/events';
|
||||
import getScope from '../util/get-scope';
|
||||
import { getPkgName } from '../util/pkg-name';
|
||||
import getArgs from '../util/get-args';
|
||||
import Client from '../util/client';
|
||||
import getDeployment from '../util/get-deployment';
|
||||
import elapsed from '../../util/output/elapsed';
|
||||
import { maybeURL, normalizeURL } from '../../util/url';
|
||||
import printEvents, { DeploymentEvent } from '../../util/events';
|
||||
import getScope from '../../util/get-scope';
|
||||
import getArgs from '../../util/get-args';
|
||||
import Client from '../../util/client';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import { help } from '../help';
|
||||
import { logsCommand } from './command';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} logs`)} <url|deploymentId>
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-f, --follow Wait for additional data [off]
|
||||
-n ${chalk.bold.underline(
|
||||
'NUMBER'
|
||||
)} Number of logs [100]
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
--since=${chalk.bold.underline(
|
||||
'SINCE'
|
||||
)} Only return logs after date (ISO 8601)
|
||||
--until=${chalk.bold.underline(
|
||||
'UNTIL'
|
||||
)} Only return logs before date (ISO 8601), ignored for ${'`-f`'}
|
||||
-S, --scope Set a custom scope
|
||||
-o ${chalk.bold.underline('MODE')}, --output=${chalk.bold.underline(
|
||||
'MODE'
|
||||
)} Specify the output format (${Object.keys(logPrinters).join(
|
||||
'|'
|
||||
)}) [short]
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Print the logs for the deployment ${chalk.dim(
|
||||
'`deploymentId`'
|
||||
)}
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} logs deploymentId`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client) {
|
||||
export default async function logs(client: Client) {
|
||||
let head;
|
||||
let limit;
|
||||
let follow;
|
||||
@@ -76,14 +31,13 @@ export default async function main(client: Client) {
|
||||
|
||||
argv._ = argv._.slice(1);
|
||||
deploymentIdOrURL = argv._[0];
|
||||
const { output } = client;
|
||||
|
||||
if (argv['--help'] || !deploymentIdOrURL || deploymentIdOrURL === 'help') {
|
||||
help();
|
||||
output.print(help(logsCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { output } = client;
|
||||
|
||||
try {
|
||||
since = argv['--since'] ? toTimestamp(argv['--since']) : 0;
|
||||
} catch (err) {
|
||||
53
packages/cli/src/commands/pull/command.ts
Normal file
53
packages/cli/src/commands/pull/command.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
import { getEnvTargetPlaceholder } from '../../util/env/env-target';
|
||||
|
||||
export const pullCommand: Command = {
|
||||
name: 'pull',
|
||||
description:
|
||||
'Pull latest environment variables and project settings from Vercel. ',
|
||||
arguments: [
|
||||
{
|
||||
name: 'project-path',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'environment',
|
||||
description: 'Deployment environment [development]',
|
||||
argument: 'environment',
|
||||
shorthand: null,
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'yes',
|
||||
description:
|
||||
'Skip questions when setting up new project using default scope and settings',
|
||||
shorthand: 'y',
|
||||
type: 'string',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Pull the latest Environment Variables and Project Settings from the cloud',
|
||||
value: `${getPkgName()} pull`,
|
||||
},
|
||||
{
|
||||
name: 'Pull the latest Environment Variables and Project Settings from the cloud targeting a directory',
|
||||
value: `${getPkgName()} pull ./path-to-project`,
|
||||
},
|
||||
{
|
||||
name: 'Pull for a specific environment',
|
||||
value: `${getPkgName()} pull --environment=${getEnvTargetPlaceholder()}`,
|
||||
},
|
||||
{
|
||||
name: 'If you want to download environment variables to a specific file, use `vercel env pull` instead',
|
||||
value: `${getPkgName()} env pull`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,66 +1,26 @@
|
||||
import chalk from 'chalk';
|
||||
import { join } from 'path';
|
||||
import Client from '../util/client';
|
||||
import Client from '../../util/client';
|
||||
import type {
|
||||
Project,
|
||||
ProjectEnvTarget,
|
||||
ProjectLinked,
|
||||
} from '@vercel-internals/types';
|
||||
import { emoji, prependEmoji } from '../util/emoji';
|
||||
import getArgs from '../util/get-args';
|
||||
import logo from '../util/output/logo';
|
||||
import stamp from '../util/output/stamp';
|
||||
import { getPkgName } from '../util/pkg-name';
|
||||
import { VERCEL_DIR, VERCEL_DIR_PROJECT } from '../util/projects/link';
|
||||
import { writeProjectSettings } from '../util/projects/project-settings';
|
||||
import envPull from './env/pull';
|
||||
import { emoji, prependEmoji } from '../../util/emoji';
|
||||
import getArgs from '../../util/get-args';
|
||||
import stamp from '../../util/output/stamp';
|
||||
import { VERCEL_DIR, VERCEL_DIR_PROJECT } from '../../util/projects/link';
|
||||
import { writeProjectSettings } from '../../util/projects/project-settings';
|
||||
import envPull from '../env/pull';
|
||||
import {
|
||||
isValidEnvTarget,
|
||||
getEnvTargetPlaceholder,
|
||||
} from '../util/env/env-target';
|
||||
import { ensureLink } from '../util/link/ensure-link';
|
||||
import humanizePath from '../util/humanize-path';
|
||||
} from '../../util/env/env-target';
|
||||
import { ensureLink } from '../../util/link/ensure-link';
|
||||
import humanizePath from '../../util/humanize-path';
|
||||
|
||||
const help = () => {
|
||||
return console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} pull`)} [project-path]
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--git-branch Specify the Git branch to pull specific Environment Variables for
|
||||
--no-color No color mode [off]
|
||||
--environment [environment] Deployment environment [development]
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray(
|
||||
'–'
|
||||
)} Pull the latest Environment Variables and Project Settings from the cloud
|
||||
and stores them in \`.vercel/.env.\${target}.local\` and \`.vercel/project.json\` respectively.
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} pull`)}
|
||||
${chalk.cyan(`$ ${getPkgName()} pull ./path-to-project`)}
|
||||
|
||||
${chalk.gray('–')} Pull for a specific environment
|
||||
|
||||
${chalk.cyan(
|
||||
`$ ${getPkgName()} pull --environment=${getEnvTargetPlaceholder()}`
|
||||
)}
|
||||
|
||||
${chalk.gray(
|
||||
'If you want to download environment variables to a specific file, use `vercel env pull` instead.'
|
||||
)}
|
||||
`);
|
||||
};
|
||||
import { help } from '../help';
|
||||
import { pullCommand } from './command';
|
||||
|
||||
function processArgs(client: Client) {
|
||||
return getArgs(client.argv.slice(2), {
|
||||
@@ -77,7 +37,7 @@ function parseArgs(client: Client) {
|
||||
const argv = processArgs(client);
|
||||
|
||||
if (argv['--help']) {
|
||||
help();
|
||||
client.output.print(help(pullCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
33
packages/cli/src/commands/redeploy/command.ts
Normal file
33
packages/cli/src/commands/redeploy/command.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const redeployCommand: Command = {
|
||||
name: 'redeploy',
|
||||
description: 'Rebuild and deploy a previous deployment.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'deploymentId|deploymentName',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'no-wait',
|
||||
shorthand: null,
|
||||
description: "Don't wait for the redeploy to finish",
|
||||
type: 'boolean',
|
||||
deprecated: false,
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Rebuild and deploy an existing deployment using id or url',
|
||||
value: `${getPkgName()} redeploy my-deployment.vercel.app`,
|
||||
},
|
||||
{
|
||||
name: 'Write Deployment URL to a file',
|
||||
value: `${getPkgName()} redeploy my-deployment.vercel.app > deployment-url.txt`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,66 +1,29 @@
|
||||
import chalk from 'chalk';
|
||||
import { checkDeploymentStatus } from '@vercel/client';
|
||||
import type Client from '../util/client';
|
||||
import { emoji, prependEmoji } from '../util/emoji';
|
||||
import getArgs from '../util/get-args';
|
||||
import { getCommandName, getPkgName } from '../util/pkg-name';
|
||||
import { getDeploymentByIdOrURL } from '../util/deploy/get-deployment-by-id-or-url';
|
||||
import getScope from '../util/get-scope';
|
||||
import handleError from '../util/handle-error';
|
||||
import type Client from '../../util/client';
|
||||
import { emoji, prependEmoji } from '../../util/emoji';
|
||||
import getArgs from '../../util/get-args';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import { getDeploymentByIdOrURL } from '../../util/deploy/get-deployment-by-id-or-url';
|
||||
import getScope from '../../util/get-scope';
|
||||
import handleError from '../../util/handle-error';
|
||||
import { isErrnoException } from '@vercel/error-utils';
|
||||
import logo from '../util/output/logo';
|
||||
import Now from '../util';
|
||||
import { printDeploymentStatus } from '../util/deploy/print-deployment-status';
|
||||
import stamp from '../util/output/stamp';
|
||||
import ua from '../util/ua';
|
||||
import Now from '../../util';
|
||||
import { printDeploymentStatus } from '../../util/deploy/print-deployment-status';
|
||||
import stamp from '../../util/output/stamp';
|
||||
import ua from '../../util/ua';
|
||||
import type { VercelClientOptions } from '@vercel/client';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(
|
||||
`${logo} ${getPkgName()} redeploy`
|
||||
)} [deploymentId|deploymentName]
|
||||
|
||||
Rebuild and deploy a previous deployment.
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
--no-wait Don't wait for the redeploy to finish
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-y, --yes Skip questions when setting up new project using default scope and settings
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Rebuild and deploy an existing deployment using id or url
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} redeploy my-deployment.vercel.app`)}
|
||||
|
||||
${chalk.gray('–')} Write Deployment URL to a file
|
||||
|
||||
${chalk.cyan(
|
||||
`$ ${getPkgName()} redeploy my-deployment.vercel.app > deployment-url.txt`
|
||||
)}
|
||||
`);
|
||||
};
|
||||
import { help } from '../help';
|
||||
import { redeployCommand } from './command';
|
||||
|
||||
/**
|
||||
* `vc redeploy` command
|
||||
* @param {Client} client
|
||||
* @returns {Promise<number>} Resolves an exit code; 0 on success
|
||||
*/
|
||||
export default async (client: Client): Promise<number> => {
|
||||
export default async function redeploy(client: Client): Promise<number> {
|
||||
let argv;
|
||||
const { output } = client;
|
||||
try {
|
||||
argv = getArgs(client.argv.slice(2), {
|
||||
'--no-wait': Boolean,
|
||||
@@ -73,11 +36,10 @@ export default async (client: Client): Promise<number> => {
|
||||
}
|
||||
|
||||
if (argv['--help'] || argv._[0] === 'help') {
|
||||
help();
|
||||
output.print(help(redeployCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { output } = client;
|
||||
const deployIdOrUrl = argv._[1];
|
||||
if (!deployIdOrUrl) {
|
||||
output.error(
|
||||
@@ -201,4 +163,4 @@ export default async (client: Client): Promise<number> => {
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
45
packages/cli/src/commands/remove/command.ts
Normal file
45
packages/cli/src/commands/remove/command.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const removeCommand: Command = {
|
||||
name: 'remove',
|
||||
description: 'Remove a deployment by name or id.',
|
||||
arguments: [
|
||||
{
|
||||
name: '...deploymentId|deploymentName',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: 'yes',
|
||||
shorthand: 'y',
|
||||
type: 'boolean',
|
||||
deprecated: false,
|
||||
description: 'Skip confirmation',
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
name: 'safe',
|
||||
shorthand: 's',
|
||||
type: 'boolean',
|
||||
deprecated: false,
|
||||
description: 'Skip deployments with an active alias',
|
||||
multi: false,
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
{
|
||||
name: 'Remove a deployment identified by `deploymentId`',
|
||||
value: `${getPkgName()} remove my-app`,
|
||||
},
|
||||
{
|
||||
name: 'Remove all deployments with name `my-app`',
|
||||
value: `${getPkgName()} remove deploymentId`,
|
||||
},
|
||||
{
|
||||
name: 'Remove two deployments with IDs `eyWt6zuSdeus` and `uWHoA9RQ1d1o`',
|
||||
value: `${getPkgName()} remove eyWt6zuSdeus uWHoA9RQ1d1o`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2,74 +2,31 @@ import chalk from 'chalk';
|
||||
import ms from 'ms';
|
||||
import plural from 'pluralize';
|
||||
import table from 'text-table';
|
||||
import Now from '../util';
|
||||
import getAliases from '../util/alias/get-aliases';
|
||||
import logo from '../util/output/logo';
|
||||
import elapsed from '../util/output/elapsed';
|
||||
import { normalizeURL } from '../util/url';
|
||||
import getScope from '../util/get-scope';
|
||||
import { isValidName } from '../util/is-valid-name';
|
||||
import removeProject from '../util/projects/remove-project';
|
||||
import getProjectByIdOrName from '../util/projects/get-project-by-id-or-name';
|
||||
import getDeployment from '../util/get-deployment';
|
||||
import getDeploymentsByProjectId from '../util/deploy/get-deployments-by-project-id';
|
||||
import { getPkgName, getCommandName } from '../util/pkg-name';
|
||||
import getArgs from '../util/get-args';
|
||||
import handleError from '../util/handle-error';
|
||||
import type Client from '../util/client';
|
||||
import { Output } from '../util/output';
|
||||
import Now from '../../util';
|
||||
import getAliases from '../../util/alias/get-aliases';
|
||||
import elapsed from '../../util/output/elapsed';
|
||||
import { normalizeURL } from '../../util/url';
|
||||
import getScope from '../../util/get-scope';
|
||||
import { isValidName } from '../../util/is-valid-name';
|
||||
import removeProject from '../../util/projects/remove-project';
|
||||
import getProjectByIdOrName from '../../util/projects/get-project-by-id-or-name';
|
||||
import getDeployment from '../../util/get-deployment';
|
||||
import getDeploymentsByProjectId from '../../util/deploy/get-deployments-by-project-id';
|
||||
import { getCommandName } from '../../util/pkg-name';
|
||||
import getArgs from '../../util/get-args';
|
||||
import handleError from '../../util/handle-error';
|
||||
import type Client from '../../util/client';
|
||||
import { Output } from '../../util/output';
|
||||
import { Alias, Deployment, Project } from '@vercel-internals/types';
|
||||
import { NowError } from '../util/now-error';
|
||||
import { NowError } from '../../util/now-error';
|
||||
import { help } from '../help';
|
||||
import { removeCommand } from './command';
|
||||
|
||||
type DeploymentWithAliases = Deployment & {
|
||||
aliases: Alias[];
|
||||
};
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(
|
||||
`${logo} ${getPkgName()} remove`
|
||||
)} [...deploymentId|deploymentName]
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
-y, --yes Skip confirmation
|
||||
-s, --safe Skip deployments with an active alias
|
||||
-S, --scope Set a custom scope
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Remove a deployment identified by ${chalk.dim(
|
||||
'`deploymentId`'
|
||||
)}
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} rm deploymentId`)}
|
||||
|
||||
${chalk.gray('–')} Remove all deployments with name ${chalk.dim('`my-app`')}
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} rm my-app`)}
|
||||
|
||||
${chalk.gray('–')} Remove two deployments with IDs ${chalk.dim(
|
||||
'`eyWt6zuSdeus`'
|
||||
)} and ${chalk.dim('`uWHoA9RQ1d1o`')}
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} rm eyWt6zuSdeus uWHoA9RQ1d1o`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async function main(client: Client) {
|
||||
export default async function remove(client: Client) {
|
||||
let argv;
|
||||
|
||||
try {
|
||||
@@ -98,13 +55,13 @@ export default async function main(client: Client) {
|
||||
const { success, error, log } = output;
|
||||
|
||||
if (argv['--help'] || ids[0] === 'help') {
|
||||
help();
|
||||
output.print(help(removeCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (ids.length < 1) {
|
||||
error(`${getCommandName('rm')} expects at least one argument`);
|
||||
help();
|
||||
output.print(help(removeCommand, { columns: client.stderr.columns }));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import logo from '../util/output/logo';
|
||||
import getScope from '../util/get-scope';
|
||||
import { getPkgName } from '../util/pkg-name';
|
||||
import getArgs from '../util/get-args';
|
||||
import Client from '../util/client';
|
||||
|
||||
const help = () => {
|
||||
console.log(`
|
||||
${chalk.bold(`${logo} ${getPkgName()} whoami`)}
|
||||
|
||||
${chalk.dim('Options:')}
|
||||
|
||||
-h, --help Output usage information
|
||||
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
|
||||
'FILE'
|
||||
)} Path to the local ${'`vercel.json`'} file
|
||||
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
|
||||
'DIR'
|
||||
)} Path to the global ${'`.vercel`'} directory
|
||||
-d, --debug Debug mode [off]
|
||||
--no-color No color mode [off]
|
||||
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
|
||||
'TOKEN'
|
||||
)} Login token
|
||||
|
||||
${chalk.dim('Examples:')}
|
||||
|
||||
${chalk.gray('–')} Shows the username of the currently logged in user
|
||||
|
||||
${chalk.cyan(`$ ${getPkgName()} whoami`)}
|
||||
`);
|
||||
};
|
||||
|
||||
export default async (client: Client): Promise<number> => {
|
||||
const { output } = client;
|
||||
const argv = getArgs(client.argv.slice(2), {});
|
||||
argv._ = argv._.slice(1);
|
||||
|
||||
if (argv['--help'] || argv._[0] === 'help') {
|
||||
help();
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { contextName } = await getScope(client, { getTeam: false });
|
||||
|
||||
if (client.stdout.isTTY) {
|
||||
output.log(contextName);
|
||||
} else {
|
||||
// If stdout is not a TTY, then only print the username
|
||||
// to support piping the output to another file / exe
|
||||
client.stdout.write(`${contextName}\n`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
15
packages/cli/src/commands/whoami/command.ts
Normal file
15
packages/cli/src/commands/whoami/command.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Command } from '../help';
|
||||
import { getPkgName } from '../../util/pkg-name';
|
||||
|
||||
export const whoamiCommand: Command = {
|
||||
name: 'whoami',
|
||||
description: 'Shows the username of the currently logged in user.',
|
||||
arguments: [],
|
||||
options: [],
|
||||
examples: [
|
||||
{
|
||||
name: 'Shows the username of the currently logged in user',
|
||||
value: `${getPkgName()} whoami`,
|
||||
},
|
||||
],
|
||||
};
|
||||
29
packages/cli/src/commands/whoami/index.ts
Normal file
29
packages/cli/src/commands/whoami/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { help } from '../help';
|
||||
import { whoamiCommand } from './command';
|
||||
|
||||
import getScope from '../../util/get-scope';
|
||||
import getArgs from '../../util/get-args';
|
||||
import Client from '../../util/client';
|
||||
|
||||
export default async function whoami(client: Client): Promise<number> {
|
||||
const { output } = client;
|
||||
const argv = getArgs(client.argv.slice(2), {});
|
||||
argv._ = argv._.slice(1);
|
||||
|
||||
if (argv['--help'] || argv._[0] === 'help') {
|
||||
output.print(help(whoamiCommand, { columns: client.stderr.columns }));
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { contextName } = await getScope(client, { getTeam: false });
|
||||
|
||||
if (client.stdout.isTTY) {
|
||||
output.log(contextName);
|
||||
} else {
|
||||
// If stdout is not a TTY, then only print the username
|
||||
// to support piping the output to another file / exe
|
||||
client.stdout.write(`${contextName}\n`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import stripeFactory from 'stripe';
|
||||
import Now from '.';
|
||||
|
||||
const stripe = stripeFactory('pk_live_alyEi3lN0kSwbdevK0nrGwTw');
|
||||
|
||||
export default class CreditCards extends Now {
|
||||
async ls() {
|
||||
const res = await this._fetch('/stripe/sources/');
|
||||
const body = await res.json();
|
||||
|
||||
if (res.status !== 200) {
|
||||
const e = new Error(body.error.message);
|
||||
e.code = body.error.code;
|
||||
throw e;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async setDefault(source) {
|
||||
await this._fetch('/stripe/sources/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
source,
|
||||
makeDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async rm(source) {
|
||||
await this._fetch(`/stripe/sources/`, {
|
||||
method: 'DELETE',
|
||||
body: { source },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async add(card) {
|
||||
if (!card.expDate) {
|
||||
throw new Error(`Please define an expiration date for your card`);
|
||||
}
|
||||
|
||||
const expDateParts = card.expDate.split(' / ');
|
||||
|
||||
card = {
|
||||
name: card.name,
|
||||
number: card.cardNumber,
|
||||
cvc: card.ccv,
|
||||
};
|
||||
|
||||
card.exp_month = expDateParts[0];
|
||||
card.exp_year = expDateParts[1];
|
||||
|
||||
try {
|
||||
const token = (await stripe.tokens.create({ card })).id;
|
||||
|
||||
const res = await this._fetch('/stripe/sources/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
source: token,
|
||||
},
|
||||
});
|
||||
|
||||
const { source, error } = await res.json();
|
||||
|
||||
if (source && source.id) {
|
||||
return {
|
||||
last4: source.last4,
|
||||
};
|
||||
} else if (error && error.message) {
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(err.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const { createGzip } = require('zlib');
|
||||
|
||||
module.exports = (_req, resp) => {
|
||||
resp.setHeader('content-encoding', 'gzip');
|
||||
|
||||
const gzip = createGzip();
|
||||
gzip.pipe(resp);
|
||||
gzip.end('Hello World!');
|
||||
};
|
||||
@@ -143,6 +143,13 @@ test(
|
||||
})
|
||||
);
|
||||
|
||||
test(
|
||||
'[vercel dev] 43-compress-encoding',
|
||||
testFixtureStdio('43-compress-encoding', async (testPath: any) => {
|
||||
await testPath(200, '/api', 'Hello World!');
|
||||
})
|
||||
);
|
||||
|
||||
test(
|
||||
'[vercel dev] Middleware that returns a 200 response',
|
||||
testFixtureStdio('middleware-response', async (testPath: any) => {
|
||||
|
||||
@@ -170,6 +170,14 @@ function setupDeploymentEndpoints(): void {
|
||||
res.json({ builds });
|
||||
});
|
||||
|
||||
client.scenario.get('/:version/deployments/:id/aliases', (req, res) => {
|
||||
const limit = parseInt(req.query.limit);
|
||||
res.json({
|
||||
aliases: [],
|
||||
pagination: { count: limit, total: limit, page: 1, pages: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
function handleGetDeployments(req: Request, res: Response) {
|
||||
const currentDeployments = Array.from(deployments.values()).sort(
|
||||
(a: Deployment, b: Deployment) => {
|
||||
|
||||
@@ -144,7 +144,7 @@ export const defaultProject: Project = {
|
||||
*/
|
||||
export function useUnknownProject() {
|
||||
let project: Project;
|
||||
client.scenario.get(`/v8/projects/:projectNameOrId`, (_req, res) => {
|
||||
client.scenario.get(`/:version/projects/:projectNameOrId`, (_req, res) => {
|
||||
res.status(404).send();
|
||||
});
|
||||
client.scenario.post(`/:version/projects`, (req, res) => {
|
||||
|
||||
@@ -1,131 +1,164 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`help command help output snapshots column width 40 1`] = `
|
||||
"▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
"
|
||||
▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
|
||||
[2mOptions[22m:
|
||||
[2mOptions[22m:
|
||||
|
||||
--archive Compress
|
||||
the
|
||||
deployment
|
||||
code into
|
||||
a file
|
||||
before
|
||||
uploading
|
||||
it
|
||||
-b, --build-env <key=value> Specify
|
||||
environment
|
||||
variables
|
||||
during
|
||||
build-time
|
||||
(e.g. \`-b
|
||||
KEY1=value1
|
||||
-b
|
||||
KEY2=value2\`)
|
||||
-e, --env <key=value> Specify
|
||||
environment
|
||||
variables
|
||||
during
|
||||
run-time
|
||||
(e.g. \`-e
|
||||
KEY1=value1
|
||||
-e
|
||||
KEY2=value2\`)
|
||||
-f, --force Force a
|
||||
new
|
||||
deployment
|
||||
even if
|
||||
nothing
|
||||
has
|
||||
changed
|
||||
-m, --meta <key=value> Specify
|
||||
metadata
|
||||
for the
|
||||
deployment
|
||||
(e.g. \`-m
|
||||
KEY1=value1
|
||||
-m
|
||||
KEY2=value2\`)
|
||||
--no-wait Don't
|
||||
wait for
|
||||
the
|
||||
deployment
|
||||
to finish
|
||||
--prebuilt Use in
|
||||
combination
|
||||
with \`vc
|
||||
build\`.
|
||||
Deploy an
|
||||
existing
|
||||
build
|
||||
--prod Create a
|
||||
production
|
||||
deployment
|
||||
-p, --public
|
||||
Deployment
|
||||
is public
|
||||
(\`/_src\`)
|
||||
is
|
||||
exposed)
|
||||
--regions Set
|
||||
default
|
||||
regions
|
||||
to enable
|
||||
the
|
||||
deployment
|
||||
on
|
||||
--with-cache Retain
|
||||
build
|
||||
cache
|
||||
when
|
||||
using
|
||||
"--force"
|
||||
-y, --yes Use
|
||||
default
|
||||
options
|
||||
to skip
|
||||
all
|
||||
prompts
|
||||
--archive
|
||||
Compress
|
||||
the
|
||||
deployment
|
||||
code
|
||||
into
|
||||
a
|
||||
file
|
||||
before
|
||||
uploading
|
||||
it
|
||||
-b, --build-env <key=value>
|
||||
Specify
|
||||
environment
|
||||
variables
|
||||
during
|
||||
build-time
|
||||
(e.g.
|
||||
\`-b
|
||||
KEY1=value1
|
||||
-b
|
||||
KEY2=value2\`)
|
||||
-e, --env <key=value>
|
||||
Specify
|
||||
environment
|
||||
variables
|
||||
during
|
||||
run-time
|
||||
(e.g.
|
||||
\`-e
|
||||
KEY1=value1
|
||||
-e
|
||||
KEY2=value2\`)
|
||||
-f, --force
|
||||
Force
|
||||
a new
|
||||
deployment
|
||||
even
|
||||
if
|
||||
nothing
|
||||
has
|
||||
changed
|
||||
-m, --meta <key=value>
|
||||
Specify
|
||||
metadata
|
||||
for
|
||||
the
|
||||
deployment
|
||||
(e.g.
|
||||
\`-m
|
||||
KEY1=value1
|
||||
-m
|
||||
KEY2=value2\`)
|
||||
--no-wait
|
||||
Don't
|
||||
wait
|
||||
for
|
||||
the
|
||||
deployment
|
||||
to
|
||||
finish
|
||||
--prebuilt Use
|
||||
in
|
||||
combination
|
||||
with
|
||||
\`vc
|
||||
build\`.
|
||||
Deploy
|
||||
an
|
||||
existing
|
||||
build
|
||||
--prod
|
||||
Create
|
||||
a
|
||||
production
|
||||
deployment
|
||||
-p, --public
|
||||
Deployment
|
||||
is
|
||||
public
|
||||
(\`/_src\`)
|
||||
is
|
||||
exposed)
|
||||
--regions Set
|
||||
default
|
||||
regions
|
||||
to
|
||||
enable
|
||||
the
|
||||
deployment
|
||||
on
|
||||
--with-cache
|
||||
Retain
|
||||
build
|
||||
cache
|
||||
when
|
||||
using
|
||||
"--force"
|
||||
-y, --yes Use
|
||||
default
|
||||
options
|
||||
to
|
||||
skip
|
||||
all
|
||||
prompts
|
||||
|
||||
[2mGlobal Options[22m:
|
||||
[2mGlobal Options[22m:
|
||||
|
||||
--cwd <DIR> Sets the
|
||||
current
|
||||
working
|
||||
directory
|
||||
for a
|
||||
single run
|
||||
of a
|
||||
command
|
||||
-d, --debug Debug mode
|
||||
(default
|
||||
off)
|
||||
-Q, --global-config <DIR> Path to the
|
||||
global
|
||||
\`.vercel\`
|
||||
directory
|
||||
-h, --help Output
|
||||
usage
|
||||
information
|
||||
-A, --local-config <FILE> Path to the
|
||||
local
|
||||
\`vercel.json\`
|
||||
file
|
||||
--no-color No color
|
||||
mode
|
||||
(default
|
||||
off)
|
||||
-S, --scope Set a
|
||||
custom
|
||||
scope
|
||||
-t, --token <TOKEN> Login token
|
||||
-v, --version Output the
|
||||
version
|
||||
number
|
||||
--cwd <DIR> Sets
|
||||
the
|
||||
current
|
||||
working
|
||||
directory
|
||||
for a
|
||||
single
|
||||
run of
|
||||
a
|
||||
command
|
||||
-d, --debug Debug
|
||||
mode
|
||||
(default
|
||||
off)
|
||||
-Q, --global-config <DIR> Path to
|
||||
the
|
||||
global
|
||||
\`.vercel\`
|
||||
directory
|
||||
-h, --help Output
|
||||
usage
|
||||
information
|
||||
-A, --local-config <FILE> Path to
|
||||
the
|
||||
local
|
||||
\`vercel.json\`
|
||||
file
|
||||
--no-color No
|
||||
color
|
||||
mode
|
||||
(default
|
||||
off)
|
||||
-S, --scope Set a
|
||||
custom
|
||||
scope
|
||||
-t, --token <TOKEN> Login
|
||||
token
|
||||
-v, --version Output
|
||||
the
|
||||
version
|
||||
number
|
||||
|
||||
[2mExamples:[22m
|
||||
[2mExamples:[22m
|
||||
|
||||
[90m-[39m Deploy the current directory
|
||||
|
||||
@@ -147,49 +180,53 @@ Deploy your project to Vercel. The \`deploy\` command is the default command for
|
||||
[90m-[39m Write Deployment URL to a file
|
||||
|
||||
[36m$ vercel > deployment-url.txt[39m
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`help command help output snapshots column width 80 1`] = `
|
||||
"▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
"
|
||||
▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
|
||||
[2mOptions[22m:
|
||||
[2mOptions[22m:
|
||||
|
||||
--archive Compress the deployment code into a file before
|
||||
uploading it
|
||||
-b, --build-env <key=value> Specify environment variables during build-time
|
||||
(e.g. \`-b KEY1=value1 -b KEY2=value2\`)
|
||||
-e, --env <key=value> Specify environment variables during run-time
|
||||
(e.g. \`-e KEY1=value1 -e KEY2=value2\`)
|
||||
-f, --force Force a new deployment even if nothing has
|
||||
changed
|
||||
-m, --meta <key=value> Specify metadata for the deployment (e.g. \`-m
|
||||
KEY1=value1 -m KEY2=value2\`)
|
||||
--no-wait Don't wait for the deployment to finish
|
||||
--prebuilt Use in combination with \`vc build\`. Deploy an
|
||||
existing build
|
||||
--prod Create a production deployment
|
||||
-p, --public Deployment is public (\`/_src\`) is exposed)
|
||||
--regions Set default regions to enable the deployment on
|
||||
--with-cache Retain build cache when using "--force"
|
||||
-y, --yes Use default options to skip all prompts
|
||||
--archive Compress the deployment code into a file
|
||||
before uploading it
|
||||
-b, --build-env <key=value> Specify environment variables during
|
||||
build-time (e.g. \`-b KEY1=value1 -b
|
||||
KEY2=value2\`)
|
||||
-e, --env <key=value> Specify environment variables during run-time
|
||||
(e.g. \`-e KEY1=value1 -e KEY2=value2\`)
|
||||
-f, --force Force a new deployment even if nothing has
|
||||
changed
|
||||
-m, --meta <key=value> Specify metadata for the deployment (e.g. \`-m
|
||||
KEY1=value1 -m KEY2=value2\`)
|
||||
--no-wait Don't wait for the deployment to finish
|
||||
--prebuilt Use in combination with \`vc build\`. Deploy an
|
||||
existing build
|
||||
--prod Create a production deployment
|
||||
-p, --public Deployment is public (\`/_src\`) is exposed)
|
||||
--regions Set default regions to enable the deployment
|
||||
on
|
||||
--with-cache Retain build cache when using "--force"
|
||||
-y, --yes Use default options to skip all prompts
|
||||
|
||||
[2mGlobal Options[22m:
|
||||
[2mGlobal Options[22m:
|
||||
|
||||
--cwd <DIR> Sets the current working directory for a single run
|
||||
of a command
|
||||
-d, --debug Debug mode (default off)
|
||||
-Q, --global-config <DIR> Path to the global \`.vercel\` directory
|
||||
-h, --help Output usage information
|
||||
-A, --local-config <FILE> Path to the local \`vercel.json\` file
|
||||
--no-color No color mode (default off)
|
||||
-S, --scope Set a custom scope
|
||||
-t, --token <TOKEN> Login token
|
||||
-v, --version Output the version number
|
||||
--cwd <DIR> Sets the current working directory for a single
|
||||
run of a command
|
||||
-d, --debug Debug mode (default off)
|
||||
-Q, --global-config <DIR> Path to the global \`.vercel\` directory
|
||||
-h, --help Output usage information
|
||||
-A, --local-config <FILE> Path to the local \`vercel.json\` file
|
||||
--no-color No color mode (default off)
|
||||
-S, --scope Set a custom scope
|
||||
-t, --token <TOKEN> Login token
|
||||
-v, --version Output the version number
|
||||
|
||||
[2mExamples:[22m
|
||||
[2mExamples:[22m
|
||||
|
||||
[90m-[39m Deploy the current directory
|
||||
|
||||
@@ -211,42 +248,45 @@ Deploy your project to Vercel. The \`deploy\` command is the default command for
|
||||
[90m-[39m Write Deployment URL to a file
|
||||
|
||||
[36m$ vercel > deployment-url.txt[39m
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`help command help output snapshots column width 120 1`] = `
|
||||
"▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
"
|
||||
▲ [1mvercel[22m [1mdeploy[22m [project-path] [options]
|
||||
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
Deploy your project to Vercel. The \`deploy\` command is the default command for the Vercel CLI, and can be omitted (\`vc deploy my-app\` equals \`vc my-app\`).
|
||||
|
||||
[2mOptions[22m:
|
||||
[2mOptions[22m:
|
||||
|
||||
--archive Compress the deployment code into a file before uploading it
|
||||
-b, --build-env <key=value> Specify environment variables during build-time (e.g. \`-b KEY1=value1 -b KEY2=value2\`)
|
||||
-e, --env <key=value> Specify environment variables during run-time (e.g. \`-e KEY1=value1 -e KEY2=value2\`)
|
||||
-f, --force Force a new deployment even if nothing has changed
|
||||
-m, --meta <key=value> Specify metadata for the deployment (e.g. \`-m KEY1=value1 -m KEY2=value2\`)
|
||||
--no-wait Don't wait for the deployment to finish
|
||||
--prebuilt Use in combination with \`vc build\`. Deploy an existing build
|
||||
--prod Create a production deployment
|
||||
-p, --public Deployment is public (\`/_src\`) is exposed)
|
||||
--regions Set default regions to enable the deployment on
|
||||
--with-cache Retain build cache when using "--force"
|
||||
-y, --yes Use default options to skip all prompts
|
||||
--archive Compress the deployment code into a file before uploading it
|
||||
-b, --build-env <key=value> Specify environment variables during build-time (e.g. \`-b KEY1=value1 -b
|
||||
KEY2=value2\`)
|
||||
-e, --env <key=value> Specify environment variables during run-time (e.g. \`-e KEY1=value1 -e KEY2=value2\`)
|
||||
-f, --force Force a new deployment even if nothing has changed
|
||||
-m, --meta <key=value> Specify metadata for the deployment (e.g. \`-m KEY1=value1 -m KEY2=value2\`)
|
||||
--no-wait Don't wait for the deployment to finish
|
||||
--prebuilt Use in combination with \`vc build\`. Deploy an existing build
|
||||
--prod Create a production deployment
|
||||
-p, --public Deployment is public (\`/_src\`) is exposed)
|
||||
--regions Set default regions to enable the deployment on
|
||||
--with-cache Retain build cache when using "--force"
|
||||
-y, --yes Use default options to skip all prompts
|
||||
|
||||
[2mGlobal Options[22m:
|
||||
[2mGlobal Options[22m:
|
||||
|
||||
--cwd <DIR> Sets the current working directory for a single run of a command
|
||||
-d, --debug Debug mode (default off)
|
||||
-Q, --global-config <DIR> Path to the global \`.vercel\` directory
|
||||
-h, --help Output usage information
|
||||
-A, --local-config <FILE> Path to the local \`vercel.json\` file
|
||||
--no-color No color mode (default off)
|
||||
-S, --scope Set a custom scope
|
||||
-t, --token <TOKEN> Login token
|
||||
-v, --version Output the version number
|
||||
--cwd <DIR> Sets the current working directory for a single run of a command
|
||||
-d, --debug Debug mode (default off)
|
||||
-Q, --global-config <DIR> Path to the global \`.vercel\` directory
|
||||
-h, --help Output usage information
|
||||
-A, --local-config <FILE> Path to the local \`vercel.json\` file
|
||||
--no-color No color mode (default off)
|
||||
-S, --scope Set a custom scope
|
||||
-t, --token <TOKEN> Login token
|
||||
-v, --version Output the version number
|
||||
|
||||
[2mExamples:[22m
|
||||
[2mExamples:[22m
|
||||
|
||||
[90m-[39m Deploy the current directory
|
||||
|
||||
@@ -268,5 +308,6 @@ Deploy your project to Vercel. The \`deploy\` command is the default command for
|
||||
[90m-[39m Write Deployment URL to a file
|
||||
|
||||
[36m$ vercel > deployment-url.txt[39m
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
59
packages/cli/test/unit/commands/remove.test.ts
Normal file
59
packages/cli/test/unit/commands/remove.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { client } from '../../mocks/client';
|
||||
import {
|
||||
defaultProject,
|
||||
useProject,
|
||||
useUnknownProject,
|
||||
} from '../../mocks/project';
|
||||
import remove from '../../../src/commands/remove';
|
||||
import { useDeployment } from '../../mocks/deployment';
|
||||
import { useUser } from '../../mocks/user';
|
||||
|
||||
describe('remove', () => {
|
||||
it('should error if missing deployment url', async () => {
|
||||
client.setArgv('remove');
|
||||
const exitCodePromise = remove(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
'Error: `vercel rm` expects at least one argument'
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('should error without calling API for invalid names', async () => {
|
||||
const badDeployName = '/#';
|
||||
client.setArgv('remove', badDeployName);
|
||||
const exitCodePromise = remove(client);
|
||||
|
||||
await expect(client.stderr).toOutput(
|
||||
`Error: The provided argument "${badDeployName}" is not a valid deployment or project`
|
||||
);
|
||||
await expect(exitCodePromise).resolves.toEqual(1);
|
||||
});
|
||||
|
||||
it('calls API to delete a project ', async () => {
|
||||
let deleteAPIWasCalled = false;
|
||||
const user = useUser();
|
||||
|
||||
const project = useProject({
|
||||
...defaultProject,
|
||||
id: '123',
|
||||
});
|
||||
|
||||
useUnknownProject();
|
||||
|
||||
const deployment = useDeployment({
|
||||
creator: user,
|
||||
project,
|
||||
});
|
||||
|
||||
client.scenario.delete('/now/deployments/:id', (req, res) => {
|
||||
deleteAPIWasCalled = true;
|
||||
res.json({});
|
||||
});
|
||||
|
||||
client.setArgv('remove', deployment.url, '--yes');
|
||||
await remove(client);
|
||||
|
||||
expect(deleteAPIWasCalled);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
# @vercel/client
|
||||
|
||||
## 12.6.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
|
||||
- @vercel/build-utils@6.8.3
|
||||
|
||||
## 12.6.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/client",
|
||||
"version": "12.6.5",
|
||||
"version": "12.6.6",
|
||||
"main": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"homepage": "https://vercel.com",
|
||||
@@ -35,7 +35,7 @@
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/routing-utils": "2.2.1",
|
||||
"@zeit/fetch": "5.2.0",
|
||||
"async-retry": "1.2.3",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/minimatch": "3.0.5",
|
||||
"@types/node": "14.18.33",
|
||||
"@types/semver": "7.3.10",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"typescript": "4.9.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @vercel/gatsby-plugin-vercel-builder
|
||||
|
||||
## 1.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
|
||||
- @vercel/build-utils@6.8.3
|
||||
- @vercel/node@2.15.9
|
||||
|
||||
## 1.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4af242af8`](https://github.com/vercel/vercel/commit/4af242af8633e58b6a9bf920564416da3ef22ad4), [`85dd66778`](https://github.com/vercel/vercel/commit/85dd667781693539d753d587566e53964bbe189d)]:
|
||||
- @vercel/node@2.15.8
|
||||
|
||||
## 1.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`fc413707d`](https://github.com/vercel/vercel/commit/fc413707d017e234d5013b761d885f65f9b981bc)]:
|
||||
- @vercel/node@2.15.7
|
||||
|
||||
## 1.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/gatsby-plugin-vercel-builder",
|
||||
"version": "1.3.14",
|
||||
"version": "1.3.17",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -20,8 +20,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.25.24",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/node": "2.15.6",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/node": "2.15.9",
|
||||
"@vercel/routing-utils": "2.2.1",
|
||||
"esbuild": "0.14.47",
|
||||
"etag": "1.8.1",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/node-fetch": "^2.3.0",
|
||||
"@types/tar": "^4.0.0",
|
||||
"@types/yauzl-promise": "2.1.0",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/ncc": "0.24.0",
|
||||
"async-retry": "1.3.1",
|
||||
"execa": "^1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "27.5.1",
|
||||
"@types/node": "14.18.33",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/static-config": "2.0.17",
|
||||
"execa": "3.2.0",
|
||||
"fs-extra": "11.1.0",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @vercel/next
|
||||
|
||||
## 3.9.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Preserve sourceMappingURL comments in template literals ([#10275](https://github.com/vercel/vercel/pull/10275))
|
||||
|
||||
## 3.9.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/next",
|
||||
"version": "3.9.3",
|
||||
"version": "3.9.4",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./dist/index",
|
||||
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/next-js",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/semver": "6.0.0",
|
||||
"@types/text-table": "0.2.1",
|
||||
"@types/webpack-sources": "3.2.0",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/nft": "0.22.5",
|
||||
"@vercel/routing-utils": "2.2.1",
|
||||
"async-sema": "3.0.1",
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function fileToSource(
|
||||
fullFilePath?: string
|
||||
): Promise<Source> {
|
||||
const sourcemap = await getSourceMap(content, fullFilePath);
|
||||
const cleanContent = convertSourceMap.removeComments(content);
|
||||
const cleanContent = removeInlinedSourceMap(content);
|
||||
return sourcemap
|
||||
? new SourceMapSource(cleanContent, sourceName, sourcemap)
|
||||
: new OriginalSource(cleanContent, sourceName);
|
||||
@@ -99,3 +99,42 @@ export function stringifySourceMap(
|
||||
delete obj.sourcesContent;
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
// Based on https://github.com/thlorenz/convert-source-map/blob/f1ed815b4edacfa9c3c5552dd342e71a3cffbb0a/index.js#L4 (MIT license)
|
||||
// Groups: 1: media type, 2: MIME type, 3: charset, 4: encoding, 5: data.
|
||||
const SOURCE_MAP_COMMENT_REGEX =
|
||||
/^\s*?\/[/*][@#]\s+?sourceMappingURL=data:(((?:application|text)\/json)(?:;charset=([^;,]+?)?)?)?(?:;(base64))?,(.*?)$/gm;
|
||||
|
||||
function isValidSourceMapData(encoding: string, data: string): boolean {
|
||||
if (encoding !== 'base64') {
|
||||
// Unknown encoding. I think the comment is short (e.g. URL) if it's not
|
||||
// base64 encoded, so let's keep it to be safe.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove any spaces and "*/" of the source map comment. They should not be
|
||||
// considered as a part of the source map data.
|
||||
data = data.replace(/\s/g, '').replace(/\*\//g, '');
|
||||
|
||||
// If it's an invalid base64 string, it must be a sourceMappingURL
|
||||
// inside a template literal like the follwoing.
|
||||
// https://github.com/webpack-contrib/style-loader/blob/16e401b17a39544d5c8ca47c9032f02e2b60d8f5/src/runtime/styleDomAPI.js#L35C1-L40C1
|
||||
return /^[a-zA-Z0-9+=/]+$/.test(data);
|
||||
}
|
||||
|
||||
/*
|
||||
* Removes sourceMappingURL comments from a string.
|
||||
*/
|
||||
export function removeInlinedSourceMap(source: string): string {
|
||||
for (const m of source.matchAll(SOURCE_MAP_COMMENT_REGEX)) {
|
||||
// Check if it's certainly a sourceMappingURL in a comment, not a part
|
||||
// of JavaScript code (e.g. template literal).
|
||||
if (!isValidSourceMapData(m[4], m[5])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
source = source.replace(m[0], '');
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
104
packages/next/test/unit/sourcemapped.test.ts
Normal file
104
packages/next/test/unit/sourcemapped.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { removeInlinedSourceMap } from '../../src/sourcemapped';
|
||||
|
||||
describe('removeInlinedSourceMap', () => {
|
||||
it('removes inlined source map', () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */
|
||||
`)
|
||||
).toEqual(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
`);
|
||||
});
|
||||
|
||||
it('removes multiple inlined source maps', () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */
|
||||
/*# sourceMappingURL=data:application/json;base64,cdefAB+/== */
|
||||
`)
|
||||
).toEqual(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
`);
|
||||
});
|
||||
|
||||
it('preserves non-base64 source map comments', () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=script.min.js.map
|
||||
`)
|
||||
).toEqual(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
//# sourceMappingURL=script.min.js.map
|
||||
`);
|
||||
});
|
||||
|
||||
it('preserves source map comments in the middle', () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
function foo() {
|
||||
console.log('/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */')
|
||||
}
|
||||
`)
|
||||
).toEqual(`
|
||||
function foo() {
|
||||
console.log('/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */')
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it(`doesn't remove sourceMappingURL inside string literal`, () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
css += \`
|
||||
/*# sourceMappingURL=data:application/json;base64,\${btoa(
|
||||
unescape(encodeURIComponent(JSON.stringify(sourceMap)))
|
||||
)} */\`
|
||||
`)
|
||||
).toEqual(`
|
||||
css += \`
|
||||
/*# sourceMappingURL=data:application/json;base64,\${btoa(
|
||||
unescape(encodeURIComponent(JSON.stringify(sourceMap)))
|
||||
)} */\`
|
||||
`);
|
||||
});
|
||||
|
||||
// Assuming that our bundler doesn't generate source maps like this, multiple comments
|
||||
// may indicate that it's part of the original source code, not generated by the bundler.
|
||||
it(`doesn't remove if there's another comment in the same line`, () => {
|
||||
expect(
|
||||
removeInlinedSourceMap(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */ /* second comment */
|
||||
`)
|
||||
).toEqual(`
|
||||
function foo() {
|
||||
return 1;
|
||||
}
|
||||
/*# sourceMappingURL=data:application/json;base64,abcdabcd12341234 */ /* second comment */
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,26 @@
|
||||
# @vercel/node
|
||||
|
||||
## 2.15.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
|
||||
- @vercel/build-utils@6.8.3
|
||||
|
||||
## 2.15.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Move `@types/content-type` to dev dependency ([#10292](https://github.com/vercel/vercel/pull/10292))
|
||||
|
||||
- fix: compress condition ([#10288](https://github.com/vercel/vercel/pull/10288))
|
||||
|
||||
## 2.15.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: move content-type as dependency ([#10274](https://github.com/vercel/vercel/pull/10274))
|
||||
|
||||
## 2.15.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/node",
|
||||
"version": "2.15.6",
|
||||
"version": "2.15.9",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./dist/index",
|
||||
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/node-js",
|
||||
@@ -24,10 +24,11 @@
|
||||
"@edge-runtime/vm": "3.0.1",
|
||||
"@types/node": "14.18.33",
|
||||
"@types/node-fetch": "2.6.3",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/error-utils": "1.0.10",
|
||||
"@vercel/static-config": "2.0.17",
|
||||
"async-listen": "3.0.0",
|
||||
"content-type": "1.0.5",
|
||||
"edge-runtime": "2.4.3",
|
||||
"esbuild": "0.14.47",
|
||||
"exit-hook": "2.2.1",
|
||||
@@ -42,14 +43,13 @@
|
||||
"@babel/plugin-transform-modules-commonjs": "7.5.0",
|
||||
"@tootallnate/once": "1.1.2",
|
||||
"@types/aws-lambda": "8.10.19",
|
||||
"@types/content-type": "1.1.3",
|
||||
"@types/content-type": "1.1.5",
|
||||
"@types/cookie": "0.3.3",
|
||||
"@types/etag": "1.8.0",
|
||||
"@types/jest": "29.5.0",
|
||||
"@types/test-listen": "1.1.0",
|
||||
"@vercel/ncc": "0.24.0",
|
||||
"@vercel/nft": "0.22.5",
|
||||
"content-type": "1.0.4",
|
||||
"cookie": "0.4.0",
|
||||
"cross-env": "7.0.3",
|
||||
"etag": "1.8.1",
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function createServerlessEventHandler(
|
||||
// @ts-expect-error
|
||||
const response = await fetch(url, {
|
||||
body: await serializeBody(request),
|
||||
compress: isStreaming,
|
||||
compress: !isStreaming,
|
||||
headers: {
|
||||
...request.headers,
|
||||
host: request.headers['x-forwarded-host'],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/execa": "^0.9.0",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/node": "14.18.33",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/ncc": "0.24.0",
|
||||
"execa": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/aws-lambda": "8.10.19",
|
||||
"@types/node": "14.18.33",
|
||||
"@types/semver": "6.0.0",
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"execa": "3.2.0",
|
||||
"fs-extra": "11.1.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @vercel/remix-builder
|
||||
|
||||
## 1.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add initial support for Hydrogen v2 ([#10305](https://github.com/vercel/vercel/pull/10305))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Update `@remix-run/dev` fork to v1.19.2 ([#10299](https://github.com/vercel/vercel/pull/10299))
|
||||
|
||||
- Updated dependencies [[`a8ecf40d6`](https://github.com/vercel/vercel/commit/a8ecf40d6f50e2fc8b13b02c8ef50b3dcafad3a6)]:
|
||||
- @vercel/build-utils@6.8.3
|
||||
|
||||
## 1.9.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Disable root workspace check in pnpm and yarn when adding deps ([#10291](https://github.com/vercel/vercel/pull/10291))
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vercel/remix-builder",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./dist/index.js",
|
||||
"homepage": "https://vercel.com/docs",
|
||||
@@ -20,7 +20,7 @@
|
||||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@vercel/build-utils": "6.8.2",
|
||||
"@vercel/build-utils": "6.8.3",
|
||||
"@vercel/nft": "0.22.5",
|
||||
"@vercel/static-config": "2.0.17",
|
||||
"path-to-regexp": "6.2.1",
|
||||
@@ -28,7 +28,7 @@
|
||||
"ts-morph": "12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.1",
|
||||
"@remix-run/dev": "npm:@vercel/remix-run-dev@1.19.2",
|
||||
"@types/jest": "27.5.1",
|
||||
"@types/node": "14.18.33",
|
||||
"@types/semver": "7.3.13"
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ensureResolvable,
|
||||
isESM,
|
||||
} from './utils';
|
||||
import { patchHydrogenServer } from './hydrogen';
|
||||
|
||||
interface ServerBundle {
|
||||
serverBuildPath: string;
|
||||
@@ -140,6 +141,10 @@ export const build: BuildV2 = async ({
|
||||
await runNpmInstall(entrypointFsDirname, [], spawnOpts, meta, nodeVersion);
|
||||
}
|
||||
|
||||
const isHydrogen2 =
|
||||
pkg.dependencies?.['@shopify/remix-oxygen'] ||
|
||||
pkg.devDependencies?.['@shopify/remix-oxygen'];
|
||||
|
||||
// Determine the version of Remix based on the `@remix-run/dev`
|
||||
// package version.
|
||||
const remixRunDevPath = await ensureResolvable(
|
||||
@@ -162,7 +167,9 @@ export const build: BuildV2 = async ({
|
||||
|
||||
const depsToAdd: string[] = [];
|
||||
|
||||
if (remixRunDevPkg.name !== '@vercel/remix-run-dev') {
|
||||
// Override the official `@remix-run/dev` package with the
|
||||
// Vercel fork, which supports the `serverBundles` config
|
||||
if (!isHydrogen2 && remixRunDevPkg.name !== '@vercel/remix-run-dev') {
|
||||
const remixDevForkVersion = resolveSemverMinMax(
|
||||
REMIX_RUN_DEV_MIN_VERSION,
|
||||
REMIX_RUN_DEV_MAX_VERSION,
|
||||
@@ -216,6 +223,8 @@ export const build: BuildV2 = async ({
|
||||
}
|
||||
|
||||
let remixConfigWrapped = false;
|
||||
let serverEntryPointAbs: string | undefined;
|
||||
let originalServerEntryPoint: string | undefined;
|
||||
const remixConfigPath = findConfig(entrypointFsDirname, 'remix.config');
|
||||
const renamedRemixConfigPath = remixConfigPath
|
||||
? `${remixConfigPath}.original${extname(remixConfigPath)}`
|
||||
@@ -232,7 +241,13 @@ export const build: BuildV2 = async ({
|
||||
const staticConfigsMap = new Map<ConfigRoute, BaseFunctionConfig | null>();
|
||||
for (const route of remixRoutes) {
|
||||
const routePath = join(remixConfig.appDirectory, route.file);
|
||||
const staticConfig = getConfig(project, routePath);
|
||||
let staticConfig = getConfig(project, routePath);
|
||||
if (staticConfig && isHydrogen2) {
|
||||
console.log(
|
||||
'WARN: `export const config` is currently not supported for Hydrogen v2 apps'
|
||||
);
|
||||
staticConfig = null;
|
||||
}
|
||||
staticConfigsMap.set(route, staticConfig);
|
||||
}
|
||||
|
||||
@@ -240,7 +255,8 @@ export const build: BuildV2 = async ({
|
||||
const config = getResolvedRouteConfig(
|
||||
route,
|
||||
remixConfig.routes,
|
||||
staticConfigsMap
|
||||
staticConfigsMap,
|
||||
isHydrogen2
|
||||
);
|
||||
resolvedConfigsMap.set(route, config);
|
||||
}
|
||||
@@ -269,7 +285,12 @@ export const build: BuildV2 = async ({
|
||||
([hash, routes]) => {
|
||||
const runtime = resolvedConfigsMap.get(routes[0])?.runtime ?? 'nodejs';
|
||||
return {
|
||||
serverBuildPath: `build/build-${runtime}-${hash}.js`,
|
||||
serverBuildPath: isHydrogen2
|
||||
? relative(entrypointFsDirname, remixConfig.serverBuildPath)
|
||||
: `${relative(
|
||||
entrypointFsDirname,
|
||||
dirname(remixConfig.serverBuildPath)
|
||||
)}/build-${runtime}-${hash}.js`,
|
||||
routes: routes.map(r => r.id),
|
||||
};
|
||||
}
|
||||
@@ -277,7 +298,7 @@ export const build: BuildV2 = async ({
|
||||
|
||||
// We need to patch the `remix.config.js` file to force some values necessary
|
||||
// for a build that works on either Node.js or the Edge runtime
|
||||
if (remixConfigPath && renamedRemixConfigPath) {
|
||||
if (!isHydrogen2 && remixConfigPath && renamedRemixConfigPath) {
|
||||
await fs.rename(remixConfigPath, renamedRemixConfigPath);
|
||||
|
||||
let patchedConfig: string;
|
||||
@@ -307,6 +328,32 @@ module.exports = config;`;
|
||||
remixConfigWrapped = true;
|
||||
}
|
||||
|
||||
// For Hydrogen v2, patch the `server.ts` file to be Vercel-compatible
|
||||
if (isHydrogen2) {
|
||||
if (remixConfig.serverEntryPoint) {
|
||||
serverEntryPointAbs = join(
|
||||
entrypointFsDirname,
|
||||
remixConfig.serverEntryPoint
|
||||
);
|
||||
originalServerEntryPoint = await fs.readFile(
|
||||
serverEntryPointAbs,
|
||||
'utf8'
|
||||
);
|
||||
const patchedServerEntryPoint = patchHydrogenServer(
|
||||
project,
|
||||
serverEntryPointAbs
|
||||
);
|
||||
if (patchedServerEntryPoint) {
|
||||
debug(
|
||||
`Patched Hydrogen server file: ${remixConfig.serverEntryPoint}`
|
||||
);
|
||||
await fs.writeFile(serverEntryPointAbs, patchedServerEntryPoint);
|
||||
}
|
||||
} else {
|
||||
console.log('WARN: No "server" field found in Remix config');
|
||||
}
|
||||
}
|
||||
|
||||
// Make `remix build` output production mode
|
||||
spawnOpts.env.NODE_ENV = 'production';
|
||||
|
||||
@@ -336,10 +383,28 @@ module.exports = config;`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const cleanupOps: Promise<void>[] = [];
|
||||
// Clean up our patched `remix.config.js` to be polite
|
||||
if (remixConfigWrapped && remixConfigPath && renamedRemixConfigPath) {
|
||||
await fs.rename(renamedRemixConfigPath, remixConfigPath);
|
||||
cleanupOps.push(
|
||||
fs
|
||||
.rename(renamedRemixConfigPath, remixConfigPath)
|
||||
.then(() =>
|
||||
debug(`Restored original "${basename(remixConfigPath)}" file`)
|
||||
)
|
||||
);
|
||||
}
|
||||
// Restore original server entrypoint if it was modified (for Hydrogen v2)
|
||||
if (serverEntryPointAbs && originalServerEntryPoint) {
|
||||
cleanupOps.push(
|
||||
fs
|
||||
.writeFile(serverEntryPointAbs, originalServerEntryPoint)
|
||||
.then(() =>
|
||||
debug(`Restored original "${basename(serverEntryPointAbs!)}" file`)
|
||||
)
|
||||
);
|
||||
}
|
||||
await Promise.all(cleanupOps);
|
||||
}
|
||||
|
||||
// This needs to happen before we run NFT to create the Node/Edge functions
|
||||
@@ -349,11 +414,21 @@ module.exports = config;`;
|
||||
repoRootPath,
|
||||
'@remix-run/server-runtime'
|
||||
),
|
||||
ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node'),
|
||||
!isHydrogen2
|
||||
? ensureResolvable(entrypointFsDirname, repoRootPath, '@remix-run/node')
|
||||
: null,
|
||||
]);
|
||||
|
||||
const staticDir = join(
|
||||
remixConfig.assetsBuildDirectory,
|
||||
...remixConfig.publicPath
|
||||
.replace(/^\/|\/$/g, '')
|
||||
.split('/')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(_ => '..')
|
||||
);
|
||||
const [staticFiles, ...functions] = await Promise.all([
|
||||
glob('**', join(entrypointFsDirname, 'public')),
|
||||
glob('**', staticDir),
|
||||
...serverBundles.map(bundle => {
|
||||
const firstRoute = remixConfig.routes[bundle.routes[0]];
|
||||
const config = resolvedConfigsMap.get(firstRoute) ?? {
|
||||
|
||||
106
packages/remix/src/hydrogen.ts
Normal file
106
packages/remix/src/hydrogen.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { basename } from 'path';
|
||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
||||
|
||||
/**
|
||||
* For Hydrogen v2, the `server.ts` file exports a signature like:
|
||||
*
|
||||
* ```
|
||||
* export default {
|
||||
* async fetch(
|
||||
* request: Request,
|
||||
* env: Env,
|
||||
* executionContext: ExecutionContext,
|
||||
* ): Promise<Response>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Here we parse the AST of that file so that we can:
|
||||
*
|
||||
* 1. Convert the signature to be compatible with Vercel Edge functions
|
||||
* (i.e. `export default (res: Response): Promise<Response>`).
|
||||
*
|
||||
* 2. Track usages of the `env` parameter which (which gets removed),
|
||||
* so that we can create that object based on `process.env`.
|
||||
*/
|
||||
export function patchHydrogenServer(
|
||||
project: Project,
|
||||
serverEntryPoint: string
|
||||
) {
|
||||
const sourceFile = project.addSourceFileAtPath(serverEntryPoint);
|
||||
const defaultExportSymbol = sourceFile.getDescendantsOfKind(
|
||||
SyntaxKind.ExportAssignment
|
||||
)[0];
|
||||
const envProperties: string[] = [];
|
||||
|
||||
if (!defaultExportSymbol) {
|
||||
console.log(
|
||||
`WARN: No default export found in "${basename(serverEntryPoint)}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectLiteral = defaultExportSymbol.getFirstChildByKind(
|
||||
SyntaxKind.ObjectLiteralExpression
|
||||
);
|
||||
if (!Node.isObjectLiteralExpression(objectLiteral)) {
|
||||
console.log(
|
||||
`WARN: Default export in "${basename(
|
||||
serverEntryPoint
|
||||
)}" does not conform to Oxygen syntax`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMethod = objectLiteral.getProperty('fetch');
|
||||
if (!fetchMethod || !Node.isMethodDeclaration(fetchMethod)) {
|
||||
console.log(
|
||||
`WARN: Default export in "${basename(
|
||||
serverEntryPoint
|
||||
)}" does not conform to Oxygen syntax`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parameters = fetchMethod.getParameters();
|
||||
|
||||
// Find usages of the env object within the fetch method
|
||||
const envParam = parameters[1];
|
||||
const envParamName = envParam.getName();
|
||||
if (envParam) {
|
||||
fetchMethod.forEachDescendant(node => {
|
||||
if (
|
||||
Node.isPropertyAccessExpression(node) &&
|
||||
node.getExpression().getText() === envParamName
|
||||
) {
|
||||
envProperties.push(node.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Vercel does not support the Web Cache API, so find
|
||||
// and replace `caches.open()` calls with `undefined`
|
||||
fetchMethod.forEachDescendant(node => {
|
||||
if (
|
||||
Node.isCallExpression(node) &&
|
||||
node.getExpression().getText() === 'caches.open'
|
||||
) {
|
||||
node.replaceWithText(`undefined /* ${node.getText()} */`);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove the 'env' parameter to match Vercel's Edge signature
|
||||
parameters.splice(1, 1);
|
||||
|
||||
// Construct the new function with the parameters and body of the original fetch method
|
||||
const newFunction = `export default async function(${parameters
|
||||
.map(p => p.getText())
|
||||
.join(', ')}) ${fetchMethod.getBody()!.getText()}`;
|
||||
defaultExportSymbol.replaceWithText(newFunction);
|
||||
|
||||
const envCode = `const env = { ${envProperties
|
||||
.map(name => `${name}: process.env.${name}`)
|
||||
.join(', ')} };`;
|
||||
|
||||
const updatedCodeString = sourceFile.getFullText();
|
||||
return `${envCode}\n${updatedCodeString}`;
|
||||
}
|
||||
@@ -78,7 +78,8 @@ function isEdgeRuntime(runtime: string): boolean {
|
||||
export function getResolvedRouteConfig(
|
||||
route: ConfigRoute,
|
||||
routes: RouteManifest,
|
||||
configs: Map<ConfigRoute, BaseFunctionConfig | null>
|
||||
configs: Map<ConfigRoute, BaseFunctionConfig | null>,
|
||||
isHydrogen2: boolean
|
||||
): ResolvedRouteConfig {
|
||||
let runtime: ResolvedRouteConfig['runtime'] | undefined;
|
||||
let regions: ResolvedRouteConfig['regions'];
|
||||
@@ -107,8 +108,8 @@ export function getResolvedRouteConfig(
|
||||
regions = Array.from(new Set(regions)).sort();
|
||||
}
|
||||
|
||||
if (runtime === 'edge') {
|
||||
return { runtime, regions };
|
||||
if (isHydrogen2 || runtime === 'edge') {
|
||||
return { runtime: 'edge', regions };
|
||||
}
|
||||
|
||||
if (regions && !Array.isArray(regions)) {
|
||||
@@ -274,6 +275,7 @@ export function addDependencies(
|
||||
debug(` - ${name}`);
|
||||
}
|
||||
const args: string[] = [];
|
||||
|
||||
if (cliType === 'npm' || cliType === 'pnpm') {
|
||||
args.push('install');
|
||||
if (opts.saveDev) {
|
||||
@@ -281,11 +283,19 @@ export function addDependencies(
|
||||
}
|
||||
} else {
|
||||
// 'yarn'
|
||||
args.push('add');
|
||||
args.push('add', '--ignore-workspace-root-check');
|
||||
if (opts.saveDev) {
|
||||
args.push('--dev');
|
||||
}
|
||||
}
|
||||
|
||||
// Don't fail if pnpm is being run at the workspace root
|
||||
if (cliType === 'pnpm' && opts.cwd) {
|
||||
if (existsSync(join(opts.cwd, 'pnpm-workspace.yaml'))) {
|
||||
args.push('--workspace-root');
|
||||
}
|
||||
}
|
||||
|
||||
return spawnAsync(cliType, args.concat(names), opts);
|
||||
}
|
||||
|
||||
|
||||
9
packages/remix/test/fixtures/10-hydrogen-2/.gitignore
vendored
Normal file
9
packages/remix/test/fixtures/10-hydrogen-2/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
/.cache
|
||||
/build
|
||||
/dist
|
||||
/public/build
|
||||
/.mf
|
||||
.env
|
||||
.shopify
|
||||
.vercel
|
||||
1
packages/remix/test/fixtures/10-hydrogen-2/.graphqlrc.yml
vendored
Normal file
1
packages/remix/test/fixtures/10-hydrogen-2/.graphqlrc.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
schema: node_modules/@shopify/hydrogen-react/storefront.schema.json
|
||||
40
packages/remix/test/fixtures/10-hydrogen-2/README.md
vendored
Normal file
40
packages/remix/test/fixtures/10-hydrogen-2/README.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Hydrogen template: Skeleton
|
||||
|
||||
Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get started with Hydrogen.
|
||||
|
||||
[Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen)
|
||||
[Get familiar with Remix](https://remix.run/docs/en/v1)
|
||||
|
||||
## What's included
|
||||
|
||||
- Remix
|
||||
- Hydrogen
|
||||
- Oxygen
|
||||
- Shopify CLI
|
||||
- ESLint
|
||||
- Prettier
|
||||
- GraphQL generator
|
||||
- TypeScript and JavaScript flavors
|
||||
- Minimal setup of components and routes
|
||||
|
||||
## Getting started
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Node.js version 16.14.0 or higher
|
||||
|
||||
```bash
|
||||
npm create @shopify/hydrogen@latest
|
||||
```
|
||||
|
||||
## Building for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
47
packages/remix/test/fixtures/10-hydrogen-2/app/components/Aside.tsx
vendored
Normal file
47
packages/remix/test/fixtures/10-hydrogen-2/app/components/Aside.tsx
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* A side bar component with Overlay that works without JavaScript.
|
||||
* @example
|
||||
* ```ts
|
||||
* <Aside id="search-aside" heading="SEARCH">`
|
||||
* <input type="search" />
|
||||
* ...
|
||||
* </Aside>
|
||||
* ```
|
||||
*/
|
||||
export function Aside({
|
||||
children,
|
||||
heading,
|
||||
id = 'aside',
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
heading: React.ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div aria-modal className="overlay" id={id} role="dialog">
|
||||
<button
|
||||
className="close-outside"
|
||||
onClick={() => {
|
||||
history.go(-1);
|
||||
window.location.hash = '';
|
||||
}}
|
||||
/>
|
||||
<aside>
|
||||
<header>
|
||||
<h3>{heading}</h3>
|
||||
<CloseAside />
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseAside() {
|
||||
return (
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-is-valid */
|
||||
<a className="close" href="#" onChange={() => history.go(-1)}>
|
||||
×
|
||||
</a>
|
||||
);
|
||||
}
|
||||
340
packages/remix/test/fixtures/10-hydrogen-2/app/components/Cart.tsx
vendored
Normal file
340
packages/remix/test/fixtures/10-hydrogen-2/app/components/Cart.tsx
vendored
Normal file
@@ -0,0 +1,340 @@
|
||||
import {CartForm, Image, Money} from '@shopify/hydrogen';
|
||||
import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import {Link} from '@remix-run/react';
|
||||
import type {CartApiQueryFragment} from 'storefrontapi.generated';
|
||||
import {useVariantUrl} from '~/utils';
|
||||
|
||||
type CartLine = CartApiQueryFragment['lines']['nodes'][0];
|
||||
|
||||
type CartMainProps = {
|
||||
cart: CartApiQueryFragment | null;
|
||||
layout: 'page' | 'aside';
|
||||
};
|
||||
|
||||
export function CartMain({layout, cart}: CartMainProps) {
|
||||
const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
|
||||
const withDiscount =
|
||||
cart &&
|
||||
Boolean(cart.discountCodes.filter((code) => code.applicable).length);
|
||||
const className = `cart-main ${withDiscount ? 'with-discount' : ''}`;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CartEmpty hidden={linesCount} layout={layout} />
|
||||
<CartDetails cart={cart} layout={layout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDetails({layout, cart}: CartMainProps) {
|
||||
const cartHasItems = !!cart && cart.totalQuantity > 0;
|
||||
|
||||
return (
|
||||
<div className="cart-details">
|
||||
<CartLines lines={cart?.lines} layout={layout} />
|
||||
{cartHasItems && (
|
||||
<CartSummary cost={cart.cost} layout={layout}>
|
||||
<CartDiscounts discountCodes={cart.discountCodes} />
|
||||
<CartCheckoutActions checkoutUrl={cart.checkoutUrl} />
|
||||
</CartSummary>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLines({
|
||||
lines,
|
||||
layout,
|
||||
}: {
|
||||
layout: CartMainProps['layout'];
|
||||
lines: CartApiQueryFragment['lines'] | undefined;
|
||||
}) {
|
||||
if (!lines) return null;
|
||||
|
||||
return (
|
||||
<div aria-labelledby="cart-lines">
|
||||
<ul>
|
||||
{lines.nodes.map((line) => (
|
||||
<CartLineItem key={line.id} line={line} layout={layout} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineItem({
|
||||
layout,
|
||||
line,
|
||||
}: {
|
||||
layout: CartMainProps['layout'];
|
||||
line: CartLine;
|
||||
}) {
|
||||
const {id, merchandise} = line;
|
||||
const {product, title, image, selectedOptions} = merchandise;
|
||||
const lineItemUrl = useVariantUrl(product.handle, selectedOptions);
|
||||
|
||||
return (
|
||||
<li key={id} className="cart-line">
|
||||
{image && (
|
||||
<Image
|
||||
alt={title}
|
||||
aspectRatio="1/1"
|
||||
data={image}
|
||||
height={100}
|
||||
loading="lazy"
|
||||
width={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Link
|
||||
prefetch="intent"
|
||||
to={lineItemUrl}
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
// close the drawer
|
||||
window.location.href = lineItemUrl;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<strong>{product.title}</strong>
|
||||
</p>
|
||||
</Link>
|
||||
<CartLinePrice line={line} as="span" />
|
||||
<ul>
|
||||
{selectedOptions.map((option) => (
|
||||
<li key={option.name}>
|
||||
<small>
|
||||
{option.name}: {option.value}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<CartLineQuantity line={line} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartCheckoutActions({checkoutUrl}: {checkoutUrl: string}) {
|
||||
if (!checkoutUrl) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a href={checkoutUrl} target="_self">
|
||||
<p>Continue to Checkout →</p>
|
||||
</a>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartSummary({
|
||||
cost,
|
||||
layout,
|
||||
children = null,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
cost: CartApiQueryFragment['cost'];
|
||||
layout: CartMainProps['layout'];
|
||||
}) {
|
||||
const className =
|
||||
layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
|
||||
|
||||
return (
|
||||
<div aria-labelledby="cart-summary" className={className}>
|
||||
<h4>Totals</h4>
|
||||
<dl className="cart-subtotal">
|
||||
<dt>Subtotal</dt>
|
||||
<dd>
|
||||
{cost?.subtotalAmount?.amount ? (
|
||||
<Money data={cost?.subtotalAmount} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineRemoveButton({lineIds}: {lineIds: string[]}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesRemove}
|
||||
inputs={{lineIds}}
|
||||
>
|
||||
<button type="submit">Remove</button>
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineQuantity({line}: {line: CartLine}) {
|
||||
if (!line || typeof line?.quantity === 'undefined') return null;
|
||||
const {id: lineId, quantity} = line;
|
||||
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
|
||||
const nextQuantity = Number((quantity + 1).toFixed(0));
|
||||
|
||||
return (
|
||||
<div className="cart-line-quantiy">
|
||||
<small>Quantity: {quantity} </small>
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
|
||||
<button
|
||||
aria-label="Decrease quantity"
|
||||
disabled={quantity <= 1}
|
||||
name="decrease-quantity"
|
||||
value={prevQuantity}
|
||||
>
|
||||
<span>− </span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
|
||||
<button
|
||||
aria-label="Increase quantity"
|
||||
name="increase-quantity"
|
||||
value={nextQuantity}
|
||||
>
|
||||
<span>+</span>
|
||||
</button>
|
||||
</CartLineUpdateButton>
|
||||
|
||||
<CartLineRemoveButton lineIds={[lineId]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLinePrice({
|
||||
line,
|
||||
priceType = 'regular',
|
||||
...passthroughProps
|
||||
}: {
|
||||
line: CartLine;
|
||||
priceType?: 'regular' | 'compareAt';
|
||||
[key: string]: any;
|
||||
}) {
|
||||
if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) return null;
|
||||
|
||||
const moneyV2 =
|
||||
priceType === 'regular'
|
||||
? line.cost.totalAmount
|
||||
: line.cost.compareAtAmountPerQuantity;
|
||||
|
||||
if (moneyV2 == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Money withoutTrailingZeros {...passthroughProps} data={moneyV2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CartEmpty({
|
||||
hidden = false,
|
||||
layout = 'aside',
|
||||
}: {
|
||||
hidden: boolean;
|
||||
layout?: CartMainProps['layout'];
|
||||
}) {
|
||||
return (
|
||||
<div hidden={hidden}>
|
||||
<br />
|
||||
<p>
|
||||
Looks like you haven’t added anything yet, let’s get you
|
||||
started!
|
||||
</p>
|
||||
<br />
|
||||
<Link
|
||||
to="/collections"
|
||||
onClick={() => {
|
||||
if (layout === 'aside') {
|
||||
window.location.href = '/collections';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue shopping →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CartDiscounts({
|
||||
discountCodes,
|
||||
}: {
|
||||
discountCodes: CartApiQueryFragment['discountCodes'];
|
||||
}) {
|
||||
const codes: string[] =
|
||||
discountCodes
|
||||
?.filter((discount) => discount.applicable)
|
||||
?.map(({code}) => code) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Have existing discount, display it with a remove option */}
|
||||
<dl hidden={!codes.length}>
|
||||
<div>
|
||||
<dt>Discount(s)</dt>
|
||||
<UpdateDiscountForm>
|
||||
<div className="cart-discount">
|
||||
<code>{codes?.join(', ')}</code>
|
||||
|
||||
<button>Remove</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Show an input to apply a discount */}
|
||||
<UpdateDiscountForm discountCodes={codes}>
|
||||
<div>
|
||||
<input type="text" name="discountCode" placeholder="Discount code" />
|
||||
|
||||
<button type="submit">Apply</button>
|
||||
</div>
|
||||
</UpdateDiscountForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDiscountForm({
|
||||
discountCodes,
|
||||
children,
|
||||
}: {
|
||||
discountCodes?: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.DiscountCodesUpdate}
|
||||
inputs={{
|
||||
discountCodes: discountCodes || [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CartLineUpdateButton({
|
||||
children,
|
||||
lines,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
lines: CartLineUpdateInput[];
|
||||
}) {
|
||||
return (
|
||||
<CartForm
|
||||
route="/cart"
|
||||
action={CartForm.ACTIONS.LinesUpdate}
|
||||
inputs={{lines}}
|
||||
>
|
||||
{children}
|
||||
</CartForm>
|
||||
);
|
||||
}
|
||||
99
packages/remix/test/fixtures/10-hydrogen-2/app/components/Footer.tsx
vendored
Normal file
99
packages/remix/test/fixtures/10-hydrogen-2/app/components/Footer.tsx
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
import {useMatches, NavLink} from '@remix-run/react';
|
||||
import type {FooterQuery} from 'storefrontapi.generated';
|
||||
|
||||
export function Footer({menu}: FooterQuery) {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<FooterMenu menu={menu} />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterMenu({menu}: Pick<FooterQuery, 'menu'>) {
|
||||
const [root] = useMatches();
|
||||
const publicStoreDomain = root?.data?.publicStoreDomain;
|
||||
return (
|
||||
<nav className="footer-menu" role="navigation">
|
||||
{(menu || FALLBACK_FOOTER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
const isExternal = !url.startsWith('/');
|
||||
return isExternal ? (
|
||||
<a href={url} key={item.id} rel="noopener noreferrer" target="_blank">
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<NavLink
|
||||
end
|
||||
key={item.id}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const FALLBACK_FOOTER_MENU = {
|
||||
id: 'gid://shopify/Menu/199655620664',
|
||||
items: [
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633060920',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358046264',
|
||||
tags: [],
|
||||
title: 'Privacy Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/privacy-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633093688',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358013496',
|
||||
tags: [],
|
||||
title: 'Refund Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/refund-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633126456',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358111800',
|
||||
tags: [],
|
||||
title: 'Shipping Policy',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/shipping-policy',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461633159224',
|
||||
resourceId: 'gid://shopify/ShopPolicy/23358079032',
|
||||
tags: [],
|
||||
title: 'Terms of Service',
|
||||
type: 'SHOP_POLICY',
|
||||
url: '/policies/terms-of-service',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function activeLinkStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'white',
|
||||
};
|
||||
}
|
||||
178
packages/remix/test/fixtures/10-hydrogen-2/app/components/Header.tsx
vendored
Normal file
178
packages/remix/test/fixtures/10-hydrogen-2/app/components/Header.tsx
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
import {Await, NavLink, useMatches} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {LayoutProps} from './Layout';
|
||||
|
||||
type HeaderProps = Pick<LayoutProps, 'header' | 'cart' | 'isLoggedIn'>;
|
||||
|
||||
type Viewport = 'desktop' | 'mobile';
|
||||
|
||||
export function Header({header, isLoggedIn, cart}: HeaderProps) {
|
||||
const {shop, menu} = header;
|
||||
return (
|
||||
<header className="header">
|
||||
<NavLink prefetch="intent" to="/" style={activeLinkStyle} end>
|
||||
<strong>{shop.name}</strong>
|
||||
</NavLink>
|
||||
<HeaderMenu menu={menu} viewport="desktop" />
|
||||
<HeaderCtas isLoggedIn={isLoggedIn} cart={cart} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderMenu({
|
||||
menu,
|
||||
viewport,
|
||||
}: {
|
||||
menu: HeaderProps['header']['menu'];
|
||||
viewport: Viewport;
|
||||
}) {
|
||||
const [root] = useMatches();
|
||||
const publicStoreDomain = root?.data?.publicStoreDomain;
|
||||
const className = `header-menu-${viewport}`;
|
||||
|
||||
function closeAside(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (viewport === 'mobile') {
|
||||
event.preventDefault();
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={className} role="navigation">
|
||||
{viewport === 'mobile' && (
|
||||
<NavLink
|
||||
end
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to="/"
|
||||
>
|
||||
Home
|
||||
</NavLink>
|
||||
)}
|
||||
{(menu || FALLBACK_HEADER_MENU).items.map((item) => {
|
||||
if (!item.url) return null;
|
||||
|
||||
// if the url is internal, we strip the domain
|
||||
const url =
|
||||
item.url.includes('myshopify.com') ||
|
||||
item.url.includes(publicStoreDomain)
|
||||
? new URL(item.url).pathname
|
||||
: item.url;
|
||||
return (
|
||||
<NavLink
|
||||
className="header-menu-item"
|
||||
end
|
||||
key={item.id}
|
||||
onClick={closeAside}
|
||||
prefetch="intent"
|
||||
style={activeLinkStyle}
|
||||
to={url}
|
||||
>
|
||||
{item.title}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCtas({
|
||||
isLoggedIn,
|
||||
cart,
|
||||
}: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
|
||||
return (
|
||||
<nav className="header-ctas" role="navigation">
|
||||
<HeaderMenuMobileToggle />
|
||||
<NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
|
||||
{isLoggedIn ? 'Account' : 'Sign in'}
|
||||
</NavLink>
|
||||
<SearchToggle />
|
||||
<CartToggle cart={cart} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderMenuMobileToggle() {
|
||||
return (
|
||||
<a className="header-menu-mobile-toggle" href="#mobile-menu-aside">
|
||||
<h3>☰</h3>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchToggle() {
|
||||
return <a href="#search-aside">Search</a>;
|
||||
}
|
||||
|
||||
function CartBadge({count}: {count: number}) {
|
||||
return <a href="#cart-aside">Cart {count}</a>;
|
||||
}
|
||||
|
||||
function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
|
||||
return (
|
||||
<Suspense fallback={<CartBadge count={0} />}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
if (!cart) return <CartBadge count={0} />;
|
||||
return <CartBadge count={cart.totalQuantity || 0} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const FALLBACK_HEADER_MENU = {
|
||||
id: 'gid://shopify/Menu/199655587896',
|
||||
items: [
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609500728',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Collections',
|
||||
type: 'HTTP',
|
||||
url: '/collections',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609533496',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Blog',
|
||||
type: 'HTTP',
|
||||
url: '/blogs/journal',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609566264',
|
||||
resourceId: null,
|
||||
tags: [],
|
||||
title: 'Policies',
|
||||
type: 'HTTP',
|
||||
url: '/policies',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'gid://shopify/MenuItem/461609599032',
|
||||
resourceId: 'gid://shopify/Page/92591030328',
|
||||
tags: [],
|
||||
title: 'About',
|
||||
type: 'PAGE',
|
||||
url: '/pages/about',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function activeLinkStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'black',
|
||||
};
|
||||
}
|
||||
95
packages/remix/test/fixtures/10-hydrogen-2/app/components/Layout.tsx
vendored
Normal file
95
packages/remix/test/fixtures/10-hydrogen-2/app/components/Layout.tsx
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
import {Await} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import type {
|
||||
CartApiQueryFragment,
|
||||
FooterQuery,
|
||||
HeaderQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
import {Aside} from '~/components/Aside';
|
||||
import {Footer} from '~/components/Footer';
|
||||
import {Header, HeaderMenu} from '~/components/Header';
|
||||
import {CartMain} from '~/components/Cart';
|
||||
import {
|
||||
PredictiveSearchForm,
|
||||
PredictiveSearchResults,
|
||||
} from '~/components/Search';
|
||||
|
||||
export type LayoutProps = {
|
||||
cart: Promise<CartApiQueryFragment | null>;
|
||||
children?: React.ReactNode;
|
||||
footer: Promise<FooterQuery>;
|
||||
header: HeaderQuery;
|
||||
isLoggedIn: boolean;
|
||||
};
|
||||
|
||||
export function Layout({
|
||||
cart,
|
||||
children = null,
|
||||
footer,
|
||||
header,
|
||||
isLoggedIn,
|
||||
}: LayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CartAside cart={cart} />
|
||||
<SearchAside />
|
||||
<MobileMenuAside menu={header.menu} />
|
||||
<Header header={header} cart={cart} isLoggedIn={isLoggedIn} />
|
||||
<main>{children}</main>
|
||||
<Suspense>
|
||||
<Await resolve={footer}>
|
||||
{(footer) => <Footer menu={footer.menu} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CartAside({cart}: {cart: LayoutProps['cart']}) {
|
||||
return (
|
||||
<Aside id="cart-aside" heading="CART">
|
||||
<Suspense fallback={<p>Loading cart ...</p>}>
|
||||
<Await resolve={cart}>
|
||||
{(cart) => {
|
||||
return <CartMain cart={cart} layout="aside" />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchAside() {
|
||||
return (
|
||||
<Aside id="search-aside" heading="SEARCH">
|
||||
<div className="predictive-search">
|
||||
<br />
|
||||
<PredictiveSearchForm>
|
||||
{({fetchResults, inputRef}) => (
|
||||
<div>
|
||||
<input
|
||||
name="q"
|
||||
onChange={fetchResults}
|
||||
onFocus={fetchResults}
|
||||
placeholder="Search"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</div>
|
||||
)}
|
||||
</PredictiveSearchForm>
|
||||
<PredictiveSearchResults />
|
||||
</div>
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenuAside({menu}: {menu: HeaderQuery['menu']}) {
|
||||
return (
|
||||
<Aside id="mobile-menu-aside" heading="MENU">
|
||||
<HeaderMenu menu={menu} viewport="mobile" />
|
||||
</Aside>
|
||||
);
|
||||
}
|
||||
480
packages/remix/test/fixtures/10-hydrogen-2/app/components/Search.tsx
vendored
Normal file
480
packages/remix/test/fixtures/10-hydrogen-2/app/components/Search.tsx
vendored
Normal file
@@ -0,0 +1,480 @@
|
||||
import {
|
||||
useParams,
|
||||
useFetcher,
|
||||
Link,
|
||||
Form,
|
||||
type FormProps,
|
||||
} from '@remix-run/react';
|
||||
import {Image, Money, Pagination} from '@shopify/hydrogen';
|
||||
import React, {useRef, useEffect} from 'react';
|
||||
import {useFetchers} from '@remix-run/react';
|
||||
|
||||
import type {
|
||||
PredictiveProductFragment,
|
||||
PredictiveCollectionFragment,
|
||||
PredictiveArticleFragment,
|
||||
SearchQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
type PredicticeSearchResultItemImage =
|
||||
| PredictiveCollectionFragment['image']
|
||||
| PredictiveArticleFragment['image']
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['image'];
|
||||
|
||||
type PredictiveSearchResultItemPrice =
|
||||
| PredictiveProductFragment['variants']['nodes'][0]['price'];
|
||||
|
||||
export type NormalizedPredictiveSearchResultItem = {
|
||||
__typename: string | undefined;
|
||||
handle: string;
|
||||
id: string;
|
||||
image?: PredicticeSearchResultItemImage;
|
||||
price?: PredictiveSearchResultItemPrice;
|
||||
styledTitle?: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type NormalizedPredictiveSearchResults = Array<
|
||||
| {type: 'queries'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'products'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'collections'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'pages'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
| {type: 'articles'; items: Array<NormalizedPredictiveSearchResultItem>}
|
||||
>;
|
||||
|
||||
export type NormalizedPredictiveSearch = {
|
||||
results: NormalizedPredictiveSearchResults;
|
||||
totalResults: number;
|
||||
};
|
||||
|
||||
type FetchSearchResultsReturn = {
|
||||
searchResults: {
|
||||
results: SearchQuery | null;
|
||||
totalResults: number;
|
||||
};
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const NO_PREDICTIVE_SEARCH_RESULTS: NormalizedPredictiveSearchResults = [
|
||||
{type: 'queries', items: []},
|
||||
{type: 'products', items: []},
|
||||
{type: 'collections', items: []},
|
||||
{type: 'pages', items: []},
|
||||
{type: 'articles', items: []},
|
||||
];
|
||||
|
||||
export function SearchForm({searchTerm}: {searchTerm: string}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// focus the input when cmd+k is pressed
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'k' && event.metaKey) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form method="get">
|
||||
<input
|
||||
defaultValue={searchTerm}
|
||||
name="q"
|
||||
placeholder="Search…"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
/>
|
||||
|
||||
<button type="submit">Search</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
results,
|
||||
}: Pick<FetchSearchResultsReturn['searchResults'], 'results'>) {
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(results) as Array<keyof typeof results>;
|
||||
return (
|
||||
<div>
|
||||
{results &&
|
||||
keys.map((type) => {
|
||||
const resourceResults = results[type];
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Page') {
|
||||
const pageResults = resourceResults as SearchQuery['pages'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultPageGrid key="pages" pages={pageResults} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Product') {
|
||||
const productResults = resourceResults as SearchQuery['products'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultsProductsGrid
|
||||
key="products"
|
||||
products={productResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (resourceResults.nodes[0]?.__typename === 'Article') {
|
||||
const articleResults = resourceResults as SearchQuery['articles'];
|
||||
return resourceResults.nodes.length ? (
|
||||
<SearchResultArticleGrid
|
||||
key="articles"
|
||||
articles={articleResults}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultsProductsGrid({products}: Pick<SearchQuery, 'products'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h3>Products</h3>
|
||||
<Pagination connection={products}>
|
||||
{({nodes, isLoading, NextLink, PreviousLink}) => {
|
||||
const itemsMarkup = nodes.map((product) => (
|
||||
<div className="search-results-item" key={product.id}>
|
||||
<Link prefetch="intent" to={`/products/${product.handle}`}>
|
||||
<span>{product.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
</div>
|
||||
<div>
|
||||
{itemsMarkup}
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultPageGrid({pages}: Pick<SearchQuery, 'pages'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Pages</h2>
|
||||
<div>
|
||||
{pages?.nodes?.map((page) => (
|
||||
<div className="search-results-item" key={page.id}>
|
||||
<Link prefetch="intent" to={`/pages/${page.handle}`}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultArticleGrid({articles}: Pick<SearchQuery, 'articles'>) {
|
||||
return (
|
||||
<div className="search-result">
|
||||
<h2>Articles</h2>
|
||||
<div>
|
||||
{articles?.nodes?.map((article) => (
|
||||
<div className="search-results-item" key={article.id}>
|
||||
<Link prefetch="intent" to={`/blog/${article.handle}`}>
|
||||
{article.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoSearchResults() {
|
||||
return <p>No results, try a different search.</p>;
|
||||
}
|
||||
|
||||
type ChildrenRenderProps = {
|
||||
fetchResults: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
fetcher: ReturnType<typeof useFetcher<NormalizedPredictiveSearchResults>>;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
type SearchFromProps = {
|
||||
action?: FormProps['action'];
|
||||
method?: FormProps['method'];
|
||||
className?: string;
|
||||
children: (passedProps: ChildrenRenderProps) => React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search form component that posts search requests to the `/search` route
|
||||
**/
|
||||
export function PredictiveSearchForm({
|
||||
action,
|
||||
children,
|
||||
className = 'predictive-search-form',
|
||||
method = 'POST',
|
||||
...props
|
||||
}: SearchFromProps) {
|
||||
const params = useParams();
|
||||
const fetcher = useFetcher<NormalizedPredictiveSearchResults>();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
function fetchResults(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const searchAction = action ?? '/api/predictive-search';
|
||||
const localizedAction = params.locale
|
||||
? `/${params.locale}${searchAction}`
|
||||
: searchAction;
|
||||
const newSearchTerm = event.target.value || '';
|
||||
fetcher.submit(
|
||||
{q: newSearchTerm, limit: '6'},
|
||||
{method, action: localizedAction},
|
||||
);
|
||||
}
|
||||
|
||||
// ensure the passed input has a type of search, because SearchResults
|
||||
// will select the element based on the input
|
||||
useEffect(() => {
|
||||
inputRef?.current?.setAttribute('type', 'search');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
{...props}
|
||||
className={className}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!inputRef?.current || inputRef.current.value === '') {
|
||||
return;
|
||||
}
|
||||
inputRef.current.blur();
|
||||
}}
|
||||
>
|
||||
{children({fetchResults, inputRef, fetcher})}
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PredictiveSearchResults() {
|
||||
const {results, totalResults, searchInputRef, searchTerm} =
|
||||
usePredictiveSearch();
|
||||
|
||||
function goToSearchResult(event: React.MouseEvent<HTMLAnchorElement>) {
|
||||
if (!searchInputRef.current) return;
|
||||
searchInputRef.current.blur();
|
||||
searchInputRef.current.value = '';
|
||||
// close the aside
|
||||
window.location.href = event.currentTarget.href;
|
||||
}
|
||||
|
||||
if (!totalResults) {
|
||||
return <NoPredictiveSearchResults searchTerm={searchTerm} />;
|
||||
}
|
||||
return (
|
||||
<div className="predictive-search-results">
|
||||
<div>
|
||||
{results.map(({type, items}) => (
|
||||
<PredictiveSearchResult
|
||||
goToSearchResult={goToSearchResult}
|
||||
items={items}
|
||||
key={type}
|
||||
searchTerm={searchTerm}
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* view all results /search?q=term */}
|
||||
{searchTerm.current && (
|
||||
<Link onClick={goToSearchResult} to={`/search?q=${searchTerm.current}`}>
|
||||
<p>
|
||||
View all results for <q>{searchTerm.current}</q>
|
||||
→
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPredictiveSearchResults({
|
||||
searchTerm,
|
||||
}: {
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
}) {
|
||||
if (!searchTerm.current) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
No results found for <q>{searchTerm.current}</q>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultTypeProps = {
|
||||
goToSearchResult: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
items: NormalizedPredictiveSearchResultItem[];
|
||||
searchTerm: UseSearchReturn['searchTerm'];
|
||||
type: NormalizedPredictiveSearchResults[number]['type'];
|
||||
};
|
||||
|
||||
function PredictiveSearchResult({
|
||||
goToSearchResult,
|
||||
items,
|
||||
searchTerm,
|
||||
type,
|
||||
}: SearchResultTypeProps) {
|
||||
const isSuggestions = type === 'queries';
|
||||
const categoryUrl = `/search?q=${
|
||||
searchTerm.current
|
||||
}&type=${pluralToSingularSearchType(type)}`;
|
||||
|
||||
return (
|
||||
<div className="predictive-search-result" key={type}>
|
||||
<Link prefetch="intent" to={categoryUrl} onClick={goToSearchResult}>
|
||||
<h5>{isSuggestions ? 'Suggestions' : type}</h5>
|
||||
</Link>
|
||||
<ul>
|
||||
{items.map((item: NormalizedPredictiveSearchResultItem) => (
|
||||
<SearchResultItem
|
||||
goToSearchResult={goToSearchResult}
|
||||
item={item}
|
||||
key={item.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchResultItemProps = Pick<SearchResultTypeProps, 'goToSearchResult'> & {
|
||||
item: NormalizedPredictiveSearchResultItem;
|
||||
};
|
||||
|
||||
function SearchResultItem({goToSearchResult, item}: SearchResultItemProps) {
|
||||
return (
|
||||
<li className="predictive-search-result-item" key={item.id}>
|
||||
<Link onClick={goToSearchResult} to={item.url}>
|
||||
{item.image?.url && (
|
||||
<Image
|
||||
alt={item.image.altText ?? ''}
|
||||
src={item.image.url}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{item.styledTitle ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.styledTitle,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{item.title}</span>
|
||||
)}
|
||||
{item?.price && (
|
||||
<small>
|
||||
<Money data={item.price} />
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type UseSearchReturn = NormalizedPredictiveSearch & {
|
||||
searchInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
searchTerm: React.MutableRefObject<string>;
|
||||
};
|
||||
|
||||
function usePredictiveSearch(): UseSearchReturn {
|
||||
const fetchers = useFetchers();
|
||||
const searchTerm = useRef<string>('');
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const searchFetcher = fetchers.find((fetcher) => fetcher.data?.searchResults);
|
||||
|
||||
if (searchFetcher?.state === 'loading') {
|
||||
searchTerm.current = (searchFetcher.formData?.get('q') || '') as string;
|
||||
}
|
||||
|
||||
const search = (searchFetcher?.data?.searchResults || {
|
||||
results: NO_PREDICTIVE_SEARCH_RESULTS,
|
||||
totalResults: 0,
|
||||
}) as NormalizedPredictiveSearch;
|
||||
|
||||
// capture the search input element as a ref
|
||||
useEffect(() => {
|
||||
if (searchInputRef.current) return;
|
||||
searchInputRef.current = document.querySelector('input[type="search"]');
|
||||
}, []);
|
||||
|
||||
return {...search, searchInputRef, searchTerm};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a plural search type to a singular search type
|
||||
* @param type - The plural search type
|
||||
* @returns The singular search type
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* pluralToSingularSearchType('articles') // => 'ARTICLE'
|
||||
* pluralToSingularSearchType(['articles', 'products']) // => 'ARTICLE,PRODUCT'
|
||||
* ```
|
||||
*/
|
||||
function pluralToSingularSearchType(
|
||||
type:
|
||||
| NormalizedPredictiveSearchResults[number]['type']
|
||||
| Array<NormalizedPredictiveSearchResults[number]['type']>,
|
||||
) {
|
||||
const plural = {
|
||||
articles: 'ARTICLE',
|
||||
collections: 'COLLECTION',
|
||||
pages: 'PAGE',
|
||||
products: 'PRODUCT',
|
||||
queries: 'QUERY',
|
||||
};
|
||||
|
||||
if (typeof type === 'string') {
|
||||
return plural[type];
|
||||
}
|
||||
|
||||
return type.map((t) => plural[t]).join(',');
|
||||
}
|
||||
12
packages/remix/test/fixtures/10-hydrogen-2/app/entry.client.tsx
vendored
Normal file
12
packages/remix/test/fixtures/10-hydrogen-2/app/entry.client.tsx
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import {RemixBrowser} from '@remix-run/react';
|
||||
import {startTransition, StrictMode} from 'react';
|
||||
import {hydrateRoot} from 'react-dom/client';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
33
packages/remix/test/fixtures/10-hydrogen-2/app/entry.server.tsx
vendored
Normal file
33
packages/remix/test/fixtures/10-hydrogen-2/app/entry.server.tsx
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {EntryContext} from '@shopify/remix-oxygen';
|
||||
import {RemixServer} from '@remix-run/react';
|
||||
import isbot from 'isbot';
|
||||
import {renderToReadableStream} from 'react-dom/server';
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
) {
|
||||
const body = await renderToReadableStream(
|
||||
<RemixServer context={remixContext} url={request.url} />,
|
||||
{
|
||||
signal: request.signal,
|
||||
onError(error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
responseStatusCode = 500;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (isbot(request.headers.get('user-agent'))) {
|
||||
await body.allReady;
|
||||
}
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
return new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
245
packages/remix/test/fixtures/10-hydrogen-2/app/root.tsx
vendored
Normal file
245
packages/remix/test/fixtures/10-hydrogen-2/app/root.tsx
vendored
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
useMatches,
|
||||
useRouteError,
|
||||
} from '@remix-run/react';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
} from '@remix-run/react';
|
||||
import type {CustomerAccessToken} from '@shopify/hydrogen-react/storefront-api-types';
|
||||
import type {HydrogenSession} from '../server';
|
||||
import favicon from '../public/favicon.svg';
|
||||
import resetStyles from './styles/reset.css';
|
||||
import appStyles from './styles/app.css';
|
||||
import {Layout} from '~/components/Layout';
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{rel: 'stylesheet', href: resetStyles},
|
||||
{rel: 'stylesheet', href: appStyles},
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://cdn.shopify.com',
|
||||
},
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://shop.app',
|
||||
},
|
||||
{rel: 'icon', type: 'image/svg+xml', href: favicon},
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {storefront, session, cart} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN;
|
||||
|
||||
// validate the customer access token is valid
|
||||
const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
customerAccessToken,
|
||||
session,
|
||||
);
|
||||
|
||||
// defer the cart query by not awaiting it
|
||||
const cartPromise = cart.get();
|
||||
|
||||
// defer the footer query (below the fold)
|
||||
const footerPromise = storefront.query(FOOTER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
footerMenuHandle: 'footer', // Adjust to your footer menu handle
|
||||
},
|
||||
});
|
||||
|
||||
// await the header query (above the fold)
|
||||
const headerPromise = storefront.query(HEADER_QUERY, {
|
||||
cache: storefront.CacheLong(),
|
||||
variables: {
|
||||
headerMenuHandle: 'main-menu', // Adjust to your header menu handle
|
||||
},
|
||||
});
|
||||
|
||||
return defer(
|
||||
{
|
||||
cart: cartPromise,
|
||||
footer: footerPromise,
|
||||
header: await headerPromise,
|
||||
isLoggedIn,
|
||||
publicStoreDomain,
|
||||
},
|
||||
{headers},
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...data}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const [root] = useMatches();
|
||||
let errorMessage = 'Unknown error';
|
||||
let errorStatus = 500;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error?.data?.message ?? error.data;
|
||||
errorStatus = error.status;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<Layout {...root.data}>
|
||||
<div className="route-error">
|
||||
<h1>Oops</h1>
|
||||
<h2>{errorStatus}</h2>
|
||||
{errorMessage && (
|
||||
<fieldset>
|
||||
<pre>{errorMessage}</pre>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the customer access token and returns a boolean and headers
|
||||
* @see https://shopify.dev/docs/api/storefront/latest/objects/CustomerAccessToken
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* //
|
||||
* const {isLoggedIn, headers} = await validateCustomerAccessToken(
|
||||
* customerAccessToken,
|
||||
* session,
|
||||
* );
|
||||
* ```
|
||||
* */
|
||||
async function validateCustomerAccessToken(
|
||||
customerAccessToken: CustomerAccessToken,
|
||||
session: HydrogenSession,
|
||||
) {
|
||||
let isLoggedIn = false;
|
||||
const headers = new Headers();
|
||||
if (!customerAccessToken?.accessToken || !customerAccessToken?.expiresAt) {
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
const expiresAt = new Date(customerAccessToken.expiresAt);
|
||||
const dateNow = new Date();
|
||||
const customerAccessTokenExpired = expiresAt < dateNow;
|
||||
if (customerAccessTokenExpired) {
|
||||
session.unset('customerAccessToken');
|
||||
headers.append('Set-Cookie', await session.commit());
|
||||
} else {
|
||||
isLoggedIn = true;
|
||||
}
|
||||
|
||||
return {isLoggedIn, headers};
|
||||
}
|
||||
|
||||
const MENU_FRAGMENT = `#graphql
|
||||
fragment MenuItem on MenuItem {
|
||||
id
|
||||
resourceId
|
||||
tags
|
||||
title
|
||||
type
|
||||
url
|
||||
}
|
||||
fragment ChildMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
}
|
||||
fragment ParentMenuItem on MenuItem {
|
||||
...MenuItem
|
||||
items {
|
||||
...ChildMenuItem
|
||||
}
|
||||
}
|
||||
fragment Menu on Menu {
|
||||
id
|
||||
items {
|
||||
...ParentMenuItem
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const HEADER_QUERY = `#graphql
|
||||
fragment Shop on Shop {
|
||||
id
|
||||
name
|
||||
description
|
||||
primaryDomain {
|
||||
url
|
||||
}
|
||||
brand {
|
||||
logo {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
query Header(
|
||||
$country: CountryCode
|
||||
$headerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
shop {
|
||||
...Shop
|
||||
}
|
||||
menu(handle: $headerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
const FOOTER_QUERY = `#graphql
|
||||
query Footer(
|
||||
$country: CountryCode
|
||||
$footerMenuHandle: String!
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
menu(handle: $footerMenuHandle) {
|
||||
...Menu
|
||||
}
|
||||
}
|
||||
${MENU_FRAGMENT}
|
||||
` as const;
|
||||
7
packages/remix/test/fixtures/10-hydrogen-2/app/routes/$.tsx
vendored
Normal file
7
packages/remix/test/fixtures/10-hydrogen-2/app/routes/$.tsx
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({request}: LoaderArgs) {
|
||||
throw new Response(`${new URL(request.url).pathname} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
145
packages/remix/test/fixtures/10-hydrogen-2/app/routes/[robots.txt].tsx
vendored
Normal file
145
packages/remix/test/fixtures/10-hydrogen-2/app/routes/[robots.txt].tsx
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
import {type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
|
||||
import {parseGid} from '@shopify/hydrogen';
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const {shop} = await context.storefront.query(ROBOTS_QUERY);
|
||||
|
||||
const shopId = parseGid(shop.id).id;
|
||||
const body = robotsTxtData({url: url.origin, shopId});
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Oops</h1>
|
||||
<p>Status: {error.status}</p>
|
||||
<p>{error.data.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let errorMessage = 'Unknown error';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Uh oh ...</h1>
|
||||
<p>Something went wrong.</p>
|
||||
<pre>{errorMessage}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
|
||||
const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
|
||||
|
||||
return `
|
||||
User-agent: *
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
# Google adsbot ignores robots.txt unless specifically named!
|
||||
User-agent: adsbot-google
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
Disallow: /carts
|
||||
Disallow: /orders
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
|
||||
User-agent: Nutch
|
||||
Disallow: /
|
||||
|
||||
User-agent: AhrefsBot
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: AhrefsSiteAudit
|
||||
Crawl-delay: 10
|
||||
${generalDisallowRules({sitemapUrl, shopId})}
|
||||
|
||||
User-agent: MJ12bot
|
||||
Crawl-Delay: 10
|
||||
|
||||
User-agent: Pinterest
|
||||
Crawl-delay: 1
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function generates disallow rules that generally follow what Shopify's
|
||||
* Online Store has as defaults for their robots.txt
|
||||
*/
|
||||
function generalDisallowRules({
|
||||
shopId,
|
||||
sitemapUrl,
|
||||
}: {
|
||||
shopId?: string;
|
||||
sitemapUrl?: string;
|
||||
}) {
|
||||
return `Disallow: /admin
|
||||
Disallow: /cart
|
||||
Disallow: /orders
|
||||
Disallow: /checkouts/
|
||||
Disallow: /checkout
|
||||
${shopId ? `Disallow: /${shopId}/checkouts` : ''}
|
||||
${shopId ? `Disallow: /${shopId}/orders` : ''}
|
||||
Disallow: /carts
|
||||
Disallow: /account
|
||||
Disallow: /collections/*sort_by*
|
||||
Disallow: /*/collections/*sort_by*
|
||||
Disallow: /collections/*+*
|
||||
Disallow: /collections/*%2B*
|
||||
Disallow: /collections/*%2b*
|
||||
Disallow: /*/collections/*+*
|
||||
Disallow: /*/collections/*%2B*
|
||||
Disallow: /*/collections/*%2b*
|
||||
Disallow: */collections/*filter*&*filter*
|
||||
Disallow: /blogs/*+*
|
||||
Disallow: /blogs/*%2B*
|
||||
Disallow: /blogs/*%2b*
|
||||
Disallow: /*/blogs/*+*
|
||||
Disallow: /*/blogs/*%2B*
|
||||
Disallow: /*/blogs/*%2b*
|
||||
Disallow: /*?*oseid=*
|
||||
Disallow: /*preview_theme_id*
|
||||
Disallow: /*preview_script_id*
|
||||
Disallow: /policies/
|
||||
Disallow: /*/*?*ls=*&ls=*
|
||||
Disallow: /*/*?*ls%3D*%3Fls%3D*
|
||||
Disallow: /*/*?*ls%3d*%3fls%3d*
|
||||
Disallow: /search
|
||||
Allow: /search/
|
||||
Disallow: /search/?*
|
||||
Disallow: /apple-app-site-association
|
||||
Disallow: /.well-known/shopify/monorail
|
||||
${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
|
||||
}
|
||||
|
||||
const ROBOTS_QUERY = `#graphql
|
||||
query StoreRobots($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
shop {
|
||||
id
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
174
packages/remix/test/fixtures/10-hydrogen-2/app/routes/[sitemap.xml].tsx
vendored
Normal file
174
packages/remix/test/fixtures/10-hydrogen-2/app/routes/[sitemap.xml].tsx
vendored
Normal file
@@ -0,0 +1,174 @@
|
||||
import {flattenConnection} from '@shopify/hydrogen';
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {SitemapQuery} from 'storefrontapi.generated';
|
||||
|
||||
/**
|
||||
* the google limit is 50K, however, the storefront API
|
||||
* allows querying only 250 resources per pagination page
|
||||
*/
|
||||
const MAX_URLS = 250;
|
||||
|
||||
type Entry = {
|
||||
url: string;
|
||||
lastMod?: string;
|
||||
changeFreq?: string;
|
||||
image?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
caption?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function loader({request, context: {storefront}}: LoaderArgs) {
|
||||
const data = await storefront.query(SITEMAP_QUERY, {
|
||||
variables: {
|
||||
urlLimits: MAX_URLS,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Response('No data found', {status: 404});
|
||||
}
|
||||
|
||||
const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin});
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
|
||||
'Cache-Control': `max-age=${60 * 60 * 24}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function xmlEncode(string: string) {
|
||||
return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`);
|
||||
}
|
||||
|
||||
function generateSitemap({
|
||||
data,
|
||||
baseUrl,
|
||||
}: {
|
||||
data: SitemapQuery;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const products = flattenConnection(data.products)
|
||||
.filter((product) => product.onlineStoreUrl)
|
||||
.map((product) => {
|
||||
const url = `${baseUrl}/products/${xmlEncode(product.handle)}`;
|
||||
|
||||
const productEntry: Entry = {
|
||||
url,
|
||||
lastMod: product.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
|
||||
if (product.featuredImage?.url) {
|
||||
productEntry.image = {
|
||||
url: xmlEncode(product.featuredImage.url),
|
||||
};
|
||||
|
||||
if (product.title) {
|
||||
productEntry.image.title = xmlEncode(product.title);
|
||||
}
|
||||
|
||||
if (product.featuredImage.altText) {
|
||||
productEntry.image.caption = xmlEncode(product.featuredImage.altText);
|
||||
}
|
||||
}
|
||||
|
||||
return productEntry;
|
||||
});
|
||||
|
||||
const collections = flattenConnection(data.collections)
|
||||
.filter((collection) => collection.onlineStoreUrl)
|
||||
.map((collection) => {
|
||||
const url = `${baseUrl}/collections/${collection.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: collection.updatedAt,
|
||||
changeFreq: 'daily',
|
||||
};
|
||||
});
|
||||
|
||||
const pages = flattenConnection(data.pages)
|
||||
.filter((page) => page.onlineStoreUrl)
|
||||
.map((page) => {
|
||||
const url = `${baseUrl}/pages/${page.handle}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
lastMod: page.updatedAt,
|
||||
changeFreq: 'weekly',
|
||||
};
|
||||
});
|
||||
|
||||
const urls = [...products, ...collections, ...pages];
|
||||
|
||||
return `
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
||||
>
|
||||
${urls.map(renderUrlTag).join('')}
|
||||
</urlset>`;
|
||||
}
|
||||
|
||||
function renderUrlTag({url, lastMod, changeFreq, image}: Entry) {
|
||||
const imageTag = image
|
||||
? `<image:image>
|
||||
<image:loc>${image.url}</image:loc>
|
||||
<image:title>${image.title ?? ''}</image:title>
|
||||
<image:caption>${image.caption ?? ''}</image:caption>
|
||||
</image:image>`.trim()
|
||||
: '';
|
||||
|
||||
return `
|
||||
<url>
|
||||
<loc>${url}</loc>
|
||||
<lastmod>${lastMod}</lastmod>
|
||||
<changefreq>${changeFreq}</changefreq>
|
||||
${imageTag}
|
||||
</url>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
const SITEMAP_QUERY = `#graphql
|
||||
query Sitemap($urlLimits: Int, $language: LanguageCode)
|
||||
@inContext(language: $language) {
|
||||
products(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
title
|
||||
featuredImage {
|
||||
url
|
||||
altText
|
||||
}
|
||||
}
|
||||
}
|
||||
collections(
|
||||
first: $urlLimits
|
||||
query: "published_status:'online_store:visible'"
|
||||
) {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
pages(first: $urlLimits, query: "published_status:'published'") {
|
||||
nodes {
|
||||
updatedAt
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
145
packages/remix/test/fixtures/10-hydrogen-2/app/routes/_index.tsx
vendored
Normal file
145
packages/remix/test/fixtures/10-hydrogen-2/app/routes/_index.tsx
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
import type {V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {defer, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Await, useLoaderData, Link} from '@remix-run/react';
|
||||
import {Suspense} from 'react';
|
||||
import {Image, Money} from '@shopify/hydrogen';
|
||||
import type {
|
||||
FeaturedCollectionFragment,
|
||||
RecommendedProductsQuery,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Hydrogen | Home'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const {collections} = await storefront.query(FEATURED_COLLECTION_QUERY);
|
||||
const featuredCollection = collections.nodes[0];
|
||||
const recommendedProducts = storefront.query(RECOMMENDED_PRODUCTS_QUERY);
|
||||
|
||||
return defer({featuredCollection, recommendedProducts});
|
||||
}
|
||||
|
||||
export default function Homepage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="home">
|
||||
<FeaturedCollection collection={data.featuredCollection} />
|
||||
<RecommendedProducts products={data.recommendedProducts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedCollection({
|
||||
collection,
|
||||
}: {
|
||||
collection: FeaturedCollectionFragment;
|
||||
}) {
|
||||
const image = collection.image;
|
||||
return (
|
||||
<Link
|
||||
className="featured-collection"
|
||||
to={`/collections/${collection.handle}`}
|
||||
>
|
||||
{image && (
|
||||
<div className="featured-collection-image">
|
||||
<Image data={image} sizes="100vw" />
|
||||
</div>
|
||||
)}
|
||||
<h1>{collection.title}</h1>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendedProducts({
|
||||
products,
|
||||
}: {
|
||||
products: Promise<RecommendedProductsQuery>;
|
||||
}) {
|
||||
return (
|
||||
<div className="recommended-products">
|
||||
<h2>Recommended Products</h2>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Await resolve={products}>
|
||||
{({products}) => (
|
||||
<div className="recommended-products-grid">
|
||||
{products.nodes.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
className="recommended-product"
|
||||
to={`/products/${product.handle}`}
|
||||
>
|
||||
<Image
|
||||
data={product.images.nodes[0]}
|
||||
aspectRatio="1/1"
|
||||
sizes="(min-width: 45em) 20vw, 50vw"
|
||||
/>
|
||||
<h4>{product.title}</h4>
|
||||
<small>
|
||||
<Money data={product.priceRange.minVariantPrice} />
|
||||
</small>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FEATURED_COLLECTION_QUERY = `#graphql
|
||||
fragment FeaturedCollection on Collection {
|
||||
id
|
||||
title
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
handle
|
||||
}
|
||||
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...FeaturedCollection
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
|
||||
fragment RecommendedProduct on Product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
images(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
|
||||
@inContext(country: $country, language: $language) {
|
||||
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
|
||||
nodes {
|
||||
...RecommendedProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
9
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.$.tsx
vendored
Normal file
9
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.$.tsx
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {redirect} from '@shopify/remix-oxygen';
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return redirect('/account/login');
|
||||
}
|
||||
563
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.addresses.tsx
vendored
Normal file
563
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.addresses.tsx
vendored
Normal file
@@ -0,0 +1,563 @@
|
||||
import type {MailingAddressInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {AddressFragment, CustomerFragment} from 'storefrontapi.generated';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
addressId?: string | null;
|
||||
createdAddress?: AddressFragment;
|
||||
defaultAddress?: string | null;
|
||||
deletedAddress?: string | null;
|
||||
error: Record<AddressFragment['id'], string> | null;
|
||||
updatedAddress?: AddressFragment;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Addresses'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const {session} = context;
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {storefront, session} = context;
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
|
||||
const addressId = form.has('addressId')
|
||||
? String(form.get('addressId'))
|
||||
: null;
|
||||
if (!addressId) {
|
||||
throw new Error('You must provide an address id.');
|
||||
}
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: {[addressId]: 'Unauthorized'}}, {status: 401});
|
||||
}
|
||||
const {accessToken} = customerAccessToken;
|
||||
|
||||
const defaultAddress = form.has('defaultAddress')
|
||||
? String(form.get('defaultAddress')) === 'on'
|
||||
: null;
|
||||
const address: MailingAddressInput = {};
|
||||
const keys: (keyof MailingAddressInput)[] = [
|
||||
'address1',
|
||||
'address2',
|
||||
'city',
|
||||
'company',
|
||||
'country',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phone',
|
||||
'province',
|
||||
'zip',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = form.get(key);
|
||||
if (typeof value === 'string') {
|
||||
address[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'POST': {
|
||||
// handle new address creation
|
||||
try {
|
||||
const {customerAddressCreate} = await storefront.mutate(
|
||||
CREATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, address},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressCreate?.customerUserErrors?.length) {
|
||||
const error = customerAddressCreate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const createdAddress = customerAddressCreate?.customerAddress;
|
||||
if (!createdAddress?.id) {
|
||||
throw new Error(
|
||||
'Expected customer address to be created, but the id is missing',
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const createdAddressId = decodeURIComponent(createdAddress.id);
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: createdAddressId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, createdAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'PUT': {
|
||||
// handle address updates
|
||||
try {
|
||||
const {customerAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
address,
|
||||
customerAccessToken: accessToken,
|
||||
id: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updatedAddress = customerAddressUpdate?.customerAddress;
|
||||
|
||||
if (customerAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (defaultAddress) {
|
||||
const {customerDefaultAddressUpdate} = await storefront.mutate(
|
||||
UPDATE_DEFAULT_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
customerAccessToken: accessToken,
|
||||
addressId: decodeURIComponent(addressId),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerDefaultAddressUpdate?.customerUserErrors?.length) {
|
||||
const error = customerDefaultAddressUpdate.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return json({error: null, updatedAddress, defaultAddress});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
// handles address deletion
|
||||
try {
|
||||
const {customerAddressDelete} = await storefront.mutate(
|
||||
DELETE_ADDRESS_MUTATION,
|
||||
{
|
||||
variables: {customerAccessToken: accessToken, id: addressId},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerAddressDelete?.customerUserErrors?.length) {
|
||||
const error = customerAddressDelete.customerUserErrors[0];
|
||||
throw new Error(error.message);
|
||||
}
|
||||
return json({error: null, deletedAddress: addressId});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: {[addressId]: error.message}}, {status: 400});
|
||||
}
|
||||
return json({error: {[addressId]: error}}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json(
|
||||
{error: {[addressId]: 'Method not allowed'}},
|
||||
{status: 405},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Addresses() {
|
||||
const {customer} = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {defaultAddress, addresses} = customer;
|
||||
|
||||
return (
|
||||
<div className="account-addresses">
|
||||
<h2>Addresses</h2>
|
||||
<br />
|
||||
{!addresses.nodes.length ? (
|
||||
<p>You have no addresses saved.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div>
|
||||
<legend>Create address</legend>
|
||||
<NewAddressForm />
|
||||
</div>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<ExistingAddresses
|
||||
addresses={addresses}
|
||||
defaultAddress={defaultAddress}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAddressForm() {
|
||||
const newAddress = {
|
||||
address1: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
company: '',
|
||||
country: '',
|
||||
firstName: '',
|
||||
id: 'new',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
zip: '',
|
||||
} as AddressFragment;
|
||||
|
||||
return (
|
||||
<AddressForm address={newAddress} defaultAddress={null}>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('POST') !== 'idle'}
|
||||
formMethod="POST"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('POST') !== 'idle' ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ExistingAddresses({
|
||||
addresses,
|
||||
defaultAddress,
|
||||
}: Pick<CustomerFragment, 'addresses' | 'defaultAddress'>) {
|
||||
return (
|
||||
<div>
|
||||
<legend>Existing addresses</legend>
|
||||
{addresses.nodes.map((address) => (
|
||||
<AddressForm
|
||||
key={address.id}
|
||||
address={address}
|
||||
defaultAddress={defaultAddress}
|
||||
>
|
||||
{({stateForMethod}) => (
|
||||
<div>
|
||||
<button
|
||||
disabled={stateForMethod('PUT') !== 'idle'}
|
||||
formMethod="PUT"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('PUT') !== 'idle' ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
disabled={stateForMethod('DELETE') !== 'idle'}
|
||||
formMethod="DELETE"
|
||||
type="submit"
|
||||
>
|
||||
{stateForMethod('DELETE') !== 'idle' ? 'Deleting' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</AddressForm>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressForm({
|
||||
address,
|
||||
defaultAddress,
|
||||
children,
|
||||
}: {
|
||||
children: (props: {
|
||||
stateForMethod: (
|
||||
method: 'PUT' | 'POST' | 'DELETE',
|
||||
) => ReturnType<typeof useNavigation>['state'];
|
||||
}) => React.ReactNode;
|
||||
defaultAddress: CustomerFragment['defaultAddress'];
|
||||
address: AddressFragment;
|
||||
}) {
|
||||
const {state, formMethod} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error?.[address.id];
|
||||
const isDefaultAddress = defaultAddress?.id === address.id;
|
||||
return (
|
||||
<Form id={address.id}>
|
||||
<fieldset>
|
||||
<input type="hidden" name="addressId" defaultValue={address.id} />
|
||||
<label htmlFor="firstName">First name*</label>
|
||||
<input
|
||||
aria-label="First name"
|
||||
autoComplete="given-name"
|
||||
defaultValue={address?.firstName ?? ''}
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
placeholder="First name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="lastName">Last name*</label>
|
||||
<input
|
||||
aria-label="Last name"
|
||||
autoComplete="family-name"
|
||||
defaultValue={address?.lastName ?? ''}
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
placeholder="Last name"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="company">Company</label>
|
||||
<input
|
||||
aria-label="Company"
|
||||
autoComplete="organization"
|
||||
defaultValue={address?.company ?? ''}
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Company"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address1">Address line*</label>
|
||||
<input
|
||||
aria-label="Address line 1"
|
||||
autoComplete="address-line1"
|
||||
defaultValue={address?.address1 ?? ''}
|
||||
id="address1"
|
||||
name="address1"
|
||||
placeholder="Address line 1*"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="address2">Address line 2</label>
|
||||
<input
|
||||
aria-label="Address line 2"
|
||||
autoComplete="address-line2"
|
||||
defaultValue={address?.address2 ?? ''}
|
||||
id="address2"
|
||||
name="address2"
|
||||
placeholder="Address line 2"
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="city">City*</label>
|
||||
<input
|
||||
aria-label="City"
|
||||
autoComplete="address-level2"
|
||||
defaultValue={address?.city ?? ''}
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="City"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="province">State / Province*</label>
|
||||
<input
|
||||
aria-label="State"
|
||||
autoComplete="address-level1"
|
||||
defaultValue={address?.province ?? ''}
|
||||
id="province"
|
||||
name="province"
|
||||
placeholder="State / Province"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="zip">Zip / Postal Code*</label>
|
||||
<input
|
||||
aria-label="Zip"
|
||||
autoComplete="postal-code"
|
||||
defaultValue={address?.zip ?? ''}
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="Zip / Postal Code"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="country">Country*</label>
|
||||
<input
|
||||
aria-label="Country"
|
||||
autoComplete="country-name"
|
||||
defaultValue={address?.country ?? ''}
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Country"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
<label htmlFor="phone">Phone</label>
|
||||
<input
|
||||
aria-label="Phone"
|
||||
autoComplete="tel"
|
||||
defaultValue={address?.phone ?? ''}
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+16135551111"
|
||||
pattern="^\+?[1-9]\d{3,14}$"
|
||||
type="tel"
|
||||
/>
|
||||
<div>
|
||||
<input
|
||||
defaultChecked={isDefaultAddress}
|
||||
id="defaultAddress"
|
||||
name="defaultAddress"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label htmlFor="defaultAddress">Set as default address</label>
|
||||
</div>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
{children({
|
||||
stateForMethod: (method) => (formMethod === method ? state : 'idle'),
|
||||
})}
|
||||
</fieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/2023-04/mutations/customeraddressupdate
|
||||
const UPDATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressUpdate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$id: ID!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressUpdate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
id: $id
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerAddressDelete
|
||||
const DELETE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressDelete(
|
||||
$customerAccessToken: String!,
|
||||
$id: ID!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
deletedCustomerAddressId
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerdefaultaddressupdate
|
||||
const UPDATE_DEFAULT_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerDefaultAddressUpdate(
|
||||
$addressId: ID!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerDefaultAddressUpdate(
|
||||
addressId: $addressId
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customer {
|
||||
defaultAddress {
|
||||
id
|
||||
}
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraddresscreate
|
||||
const CREATE_ADDRESS_MUTATION = `#graphql
|
||||
mutation customerAddressCreate(
|
||||
$address: MailingAddressInput!
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerAddressCreate(
|
||||
address: $address
|
||||
customerAccessToken: $customerAccessToken
|
||||
) {
|
||||
customerAddress {
|
||||
id
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
309
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.orders.$id.tsx
vendored
Normal file
309
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.orders.$id.tsx
vendored
Normal file
@@ -0,0 +1,309 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Link, useLoaderData, type V2_MetaFunction} from '@remix-run/react';
|
||||
import {Money, Image, flattenConnection} from '@shopify/hydrogen';
|
||||
import type {OrderLineItemFullFragment} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction<typeof loader> = ({data}) => {
|
||||
return [{title: `Order ${data?.order?.name}`}];
|
||||
};
|
||||
|
||||
export async function loader({params, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (!params.id) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
|
||||
const orderId = atob(params.id);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
const {order} = await storefront.query(CUSTOMER_ORDER_QUERY, {
|
||||
variables: {orderId},
|
||||
});
|
||||
|
||||
if (!order || !('lineItems' in order)) {
|
||||
throw new Response('Order not found', {status: 404});
|
||||
}
|
||||
|
||||
const lineItems = flattenConnection(order.lineItems);
|
||||
const discountApplications = flattenConnection(order.discountApplications);
|
||||
|
||||
const firstDiscount = discountApplications[0]?.value;
|
||||
|
||||
const discountValue =
|
||||
firstDiscount?.__typename === 'MoneyV2' && firstDiscount;
|
||||
|
||||
const discountPercentage =
|
||||
firstDiscount?.__typename === 'PricingPercentageValue' &&
|
||||
firstDiscount?.percentage;
|
||||
|
||||
return json({
|
||||
order,
|
||||
lineItems,
|
||||
discountValue,
|
||||
discountPercentage,
|
||||
});
|
||||
}
|
||||
|
||||
export default function OrderRoute() {
|
||||
const {order, lineItems, discountValue, discountPercentage} =
|
||||
useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div className="account-order">
|
||||
<h2>Order {order.name}</h2>
|
||||
<p>Placed on {new Date(order.processedAt!).toDateString()}</p>
|
||||
<br />
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Product</th>
|
||||
<th scope="col">Price</th>
|
||||
<th scope="col">Quantity</th>
|
||||
<th scope="col">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((lineItem, lineItemIndex) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<OrderLineRow key={lineItemIndex} lineItem={lineItem} />
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{((discountValue && discountValue.amount) ||
|
||||
discountPercentage) && (
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Discounts</p>
|
||||
</th>
|
||||
<td>
|
||||
{discountPercentage ? (
|
||||
<span>-{discountPercentage}% OFF</span>
|
||||
) : (
|
||||
discountValue && <Money data={discountValue!} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Subtotal</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.subtotalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Tax
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Tax</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalTaxV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" colSpan={3}>
|
||||
Total
|
||||
</th>
|
||||
<th scope="row">
|
||||
<p>Total</p>
|
||||
</th>
|
||||
<td>
|
||||
<Money data={order.totalPriceV2!} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div>
|
||||
<h3>Shipping Address</h3>
|
||||
{order?.shippingAddress ? (
|
||||
<address>
|
||||
<p>
|
||||
{order.shippingAddress.firstName &&
|
||||
order.shippingAddress.firstName + ' '}
|
||||
{order.shippingAddress.lastName}
|
||||
</p>
|
||||
{order?.shippingAddress?.formatted ? (
|
||||
order.shippingAddress.formatted.map((line: string) => (
|
||||
<p key={line}>{line}</p>
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</address>
|
||||
) : (
|
||||
<p>No shipping address defined</p>
|
||||
)}
|
||||
<h3>Status</h3>
|
||||
<div>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
<a target="_blank" href={order.statusUrl} rel="noreferrer">
|
||||
View Order Status →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderLineRow({lineItem}: {lineItem: OrderLineItemFullFragment}) {
|
||||
return (
|
||||
<tr key={lineItem.variant!.id}>
|
||||
<td>
|
||||
<div>
|
||||
<Link to={`/products/${lineItem.variant!.product!.handle}`}>
|
||||
{lineItem?.variant?.image && (
|
||||
<div>
|
||||
<Image data={lineItem.variant.image} width={96} height={96} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div>
|
||||
<p>{lineItem.title}</p>
|
||||
<small>{lineItem.variant!.title}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Money data={lineItem.variant!.price!} />
|
||||
</td>
|
||||
<td>{lineItem.quantity}</td>
|
||||
<td>
|
||||
<Money data={lineItem.discountedTotalPrice!} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/objects/Order
|
||||
const CUSTOMER_ORDER_QUERY = `#graphql
|
||||
fragment OrderMoney on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
fragment AddressFull on MailingAddress {
|
||||
address1
|
||||
address2
|
||||
city
|
||||
company
|
||||
country
|
||||
countryCodeV2
|
||||
firstName
|
||||
formatted
|
||||
id
|
||||
lastName
|
||||
name
|
||||
phone
|
||||
province
|
||||
provinceCode
|
||||
zip
|
||||
}
|
||||
fragment DiscountApplication on DiscountApplication {
|
||||
value {
|
||||
__typename
|
||||
... on MoneyV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
... on PricingPercentageValue {
|
||||
percentage
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment OrderLineProductVariant on ProductVariant {
|
||||
id
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
id
|
||||
width
|
||||
}
|
||||
price {
|
||||
...OrderMoney
|
||||
}
|
||||
product {
|
||||
handle
|
||||
}
|
||||
sku
|
||||
title
|
||||
}
|
||||
fragment OrderLineItemFull on OrderLineItem {
|
||||
title
|
||||
quantity
|
||||
discountAllocations {
|
||||
allocatedAmount {
|
||||
...OrderMoney
|
||||
}
|
||||
discountApplication {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
originalTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
discountedTotalPrice {
|
||||
...OrderMoney
|
||||
}
|
||||
variant {
|
||||
...OrderLineProductVariant
|
||||
}
|
||||
}
|
||||
fragment Order on Order {
|
||||
id
|
||||
name
|
||||
orderNumber
|
||||
statusUrl
|
||||
processedAt
|
||||
fulfillmentStatus
|
||||
totalTaxV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
totalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
subtotalPriceV2 {
|
||||
...OrderMoney
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFull
|
||||
}
|
||||
discountApplications(first: 100) {
|
||||
nodes {
|
||||
...DiscountApplication
|
||||
}
|
||||
}
|
||||
lineItems(first: 100) {
|
||||
nodes {
|
||||
...OrderLineItemFull
|
||||
}
|
||||
}
|
||||
}
|
||||
query Order(
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
$orderId: ID!
|
||||
) @inContext(country: $country, language: $language) {
|
||||
order: node(id: $orderId) {
|
||||
... on Order {
|
||||
...Order
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
196
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.orders._index.tsx
vendored
Normal file
196
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.orders._index.tsx
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
import {Link, useLoaderData} from '@remix-run/react';
|
||||
import {Money, Pagination, getPaginationVariables} from '@shopify/hydrogen';
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import type {
|
||||
CustomerOrdersFragment,
|
||||
OrderItemFragment,
|
||||
} from 'storefrontapi.generated';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Orders'}];
|
||||
};
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken?.accessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
try {
|
||||
const paginationVariables = getPaginationVariables(request, {
|
||||
pageBy: 20,
|
||||
});
|
||||
|
||||
const {customer} = await storefront.query(CUSTOMER_ORDERS_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
...paginationVariables,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json({customer});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Orders() {
|
||||
const {customer} = useLoaderData<{customer: CustomerOrdersFragment}>();
|
||||
const {orders, numberOfOrders} = customer;
|
||||
return (
|
||||
<div className="orders">
|
||||
<h2>
|
||||
Orders <small>({numberOfOrders})</small>
|
||||
</h2>
|
||||
<br />
|
||||
{orders.nodes.length ? <OrdersTable orders={orders} /> : <EmptyOrders />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersTable({orders}: Pick<CustomerOrdersFragment, 'orders'>) {
|
||||
return (
|
||||
<div className="acccount-orders">
|
||||
{orders?.nodes.length ? (
|
||||
<Pagination connection={orders}>
|
||||
{({nodes, isLoading, PreviousLink, NextLink}) => {
|
||||
return (
|
||||
<>
|
||||
<PreviousLink>
|
||||
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
|
||||
</PreviousLink>
|
||||
{nodes.map((order) => {
|
||||
return <OrderItem key={order.id} order={order} />;
|
||||
})}
|
||||
<NextLink>
|
||||
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
|
||||
</NextLink>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pagination>
|
||||
) : (
|
||||
<EmptyOrders />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyOrders() {
|
||||
return (
|
||||
<div>
|
||||
<p>You haven't placed any orders yet.</p>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/collections">Start Shopping →</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderItem({order}: {order: OrderItemFragment}) {
|
||||
return (
|
||||
<>
|
||||
<fieldset>
|
||||
<Link to={`/account/orders/${order.id}`}>
|
||||
<strong>#{order.orderNumber}</strong>
|
||||
</Link>
|
||||
<p>{new Date(order.processedAt).toDateString()}</p>
|
||||
<p>{order.financialStatus}</p>
|
||||
<p>{order.fulfillmentStatus}</p>
|
||||
<Money data={order.currentTotalPrice} />
|
||||
<Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
|
||||
</fieldset>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ORDER_ITEM_FRAGMENT = `#graphql
|
||||
fragment OrderItem on Order {
|
||||
currentTotalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
financialStatus
|
||||
fulfillmentStatus
|
||||
id
|
||||
lineItems(first: 10) {
|
||||
nodes {
|
||||
title
|
||||
variant {
|
||||
image {
|
||||
url
|
||||
altText
|
||||
height
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
orderNumber
|
||||
customerUrl
|
||||
statusUrl
|
||||
processedAt
|
||||
}
|
||||
` as const;
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment CustomerOrders on Customer {
|
||||
numberOfOrders
|
||||
orders(
|
||||
sortKey: PROCESSED_AT,
|
||||
reverse: true,
|
||||
first: $first,
|
||||
last: $last,
|
||||
before: $startCursor,
|
||||
after: $endCursor
|
||||
) {
|
||||
nodes {
|
||||
...OrderItem
|
||||
}
|
||||
pageInfo {
|
||||
hasPreviousPage
|
||||
hasNextPage
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${ORDER_ITEM_FRAGMENT}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_ORDERS_QUERY = `#graphql
|
||||
${CUSTOMER_FRAGMENT}
|
||||
query CustomerOrders(
|
||||
$country: CountryCode
|
||||
$customerAccessToken: String!
|
||||
$endCursor: String
|
||||
$first: Int
|
||||
$language: LanguageCode
|
||||
$last: Int
|
||||
$startCursor: String
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...CustomerOrders
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
289
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.profile.tsx
vendored
Normal file
289
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.profile.tsx
vendored
Normal file
@@ -0,0 +1,289 @@
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
import type {CustomerUpdateInput} from '@shopify/hydrogen/storefront-api-types';
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect, type V2_MetaFunction} from '@shopify/remix-oxygen';
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useOutletContext,
|
||||
} from '@remix-run/react';
|
||||
|
||||
export type ActionResponse = {
|
||||
error: string | null;
|
||||
customer: CustomerFragment | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Profile'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'PUT') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
if (!customerAccessToken) {
|
||||
return json({error: 'Unauthorized'}, {status: 401});
|
||||
}
|
||||
|
||||
try {
|
||||
const password = getPassword(form);
|
||||
const customer: CustomerUpdateInput = {};
|
||||
const validInputKeys = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
] as const;
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (!validInputKeys.includes(key as any)) {
|
||||
continue;
|
||||
}
|
||||
if (key === 'acceptsMarketing') {
|
||||
customer.acceptsMarketing = value === 'on';
|
||||
}
|
||||
if (typeof value === 'string' && value.length) {
|
||||
customer[key as (typeof validInputKeys)[number]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (password) {
|
||||
customer.password = password;
|
||||
}
|
||||
|
||||
// update customer and possibly password
|
||||
const updated = await storefront.mutate(CUSTOMER_UPDATE_MUTATION, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
customer,
|
||||
},
|
||||
});
|
||||
|
||||
// check for mutation errors
|
||||
if (updated.customerUpdate?.customerUserErrors?.length) {
|
||||
return json(
|
||||
{error: updated.customerUpdate?.customerUserErrors[0]},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
// update session with the updated access token
|
||||
if (updated.customerUpdate?.customerAccessToken?.accessToken) {
|
||||
session.set(
|
||||
'customerAccessToken',
|
||||
updated.customerUpdate?.customerAccessToken,
|
||||
);
|
||||
}
|
||||
|
||||
return json(
|
||||
{error: null, customer: updated.customerUpdate?.customer},
|
||||
{
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({error: error.message, customer: null}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountProfile() {
|
||||
const account = useOutletContext<{customer: CustomerFragment}>();
|
||||
const {state} = useNavigation();
|
||||
const action = useActionData<ActionResponse>();
|
||||
const customer = action?.customer ?? account?.customer;
|
||||
|
||||
return (
|
||||
<div className="account-profile">
|
||||
<h2>My profile</h2>
|
||||
<br />
|
||||
<Form method="PUT">
|
||||
<legend>Personal information</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="firstName">First name</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="First name"
|
||||
aria-label="First name"
|
||||
defaultValue={customer.firstName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="lastName">Last name</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Last name"
|
||||
aria-label="Last name"
|
||||
defaultValue={customer.lastName ?? ''}
|
||||
minLength={2}
|
||||
/>
|
||||
<label htmlFor="phone">Mobile</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="Mobile"
|
||||
aria-label="Mobile"
|
||||
defaultValue={customer.phone ?? ''}
|
||||
/>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
defaultValue={customer.email ?? ''}
|
||||
/>
|
||||
<div className="account-profile-marketing">
|
||||
<input
|
||||
id="acceptsMarketing"
|
||||
name="acceptsMarketing"
|
||||
type="checkbox"
|
||||
placeholder="Accept marketing"
|
||||
aria-label="Accept marketing"
|
||||
defaultChecked={customer.acceptsMarketing}
|
||||
/>
|
||||
<label htmlFor="acceptsMarketing">
|
||||
Subscribed to marketing communications
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<br />
|
||||
<legend>Change password (optional)</legend>
|
||||
<fieldset>
|
||||
<label htmlFor="currentPassword">Current password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
aria-label="Current password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPassword">New password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
aria-label="New password"
|
||||
minLength={8}
|
||||
/>
|
||||
|
||||
<label htmlFor="newPasswordConfirm">New password (confirm)</label>
|
||||
<input
|
||||
id="newPasswordConfirm"
|
||||
name="newPasswordConfirm"
|
||||
type="password"
|
||||
placeholder="New password (confirm)"
|
||||
aria-label="New password confirm"
|
||||
minLength={8}
|
||||
/>
|
||||
<small>Passwords must be at least 8 characters.</small>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit" disabled={state !== 'idle'}>
|
||||
{state !== 'idle' ? 'Updating' : 'Update'}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPassword(form: FormData): string | undefined {
|
||||
let password;
|
||||
const currentPassword = form.get('currentPassword');
|
||||
const newPassword = form.get('newPassword');
|
||||
const newPasswordConfirm = form.get('newPasswordConfirm');
|
||||
|
||||
let passwordError;
|
||||
if (newPassword && !currentPassword) {
|
||||
passwordError = new Error('Current password is required.');
|
||||
}
|
||||
|
||||
if (newPassword && newPassword !== newPasswordConfirm) {
|
||||
passwordError = new Error('New passwords must match.');
|
||||
}
|
||||
|
||||
if (newPassword && currentPassword && newPassword === currentPassword) {
|
||||
passwordError = new Error(
|
||||
'New password must be different than current password.',
|
||||
);
|
||||
}
|
||||
|
||||
if (passwordError) {
|
||||
throw passwordError;
|
||||
}
|
||||
|
||||
if (currentPassword && newPassword) {
|
||||
password = newPassword;
|
||||
} else {
|
||||
password = currentPassword;
|
||||
}
|
||||
|
||||
return String(password);
|
||||
}
|
||||
|
||||
const CUSTOMER_UPDATE_MUTATION = `#graphql
|
||||
# https://shopify.dev/docs/api/storefront/latest/mutations/customerUpdate
|
||||
mutation customerUpdate(
|
||||
$customerAccessToken: String!,
|
||||
$customer: CustomerUpdateInput!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(language: $language, country: $country) {
|
||||
customerUpdate(customerAccessToken: $customerAccessToken, customer: $customer) {
|
||||
customer {
|
||||
acceptsMarketing
|
||||
email
|
||||
firstName
|
||||
id
|
||||
lastName
|
||||
phone
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
203
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.tsx
vendored
Normal file
203
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account.tsx
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import type {CustomerFragment} from 'storefrontapi.generated';
|
||||
|
||||
export function shouldRevalidate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loader({request, context}: LoaderArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {pathname} = new URL(request.url);
|
||||
const customerAccessToken = await session.get('customerAccessToken');
|
||||
const isLoggedIn = Boolean(customerAccessToken?.accessToken);
|
||||
const isAccountHome = pathname === '/account' || pathname === '/account/';
|
||||
const isPrivateRoute =
|
||||
/^\/account\/(orders|orders\/.*|profile|addresses|addresses\/.*)$/.test(
|
||||
pathname,
|
||||
);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (isPrivateRoute || isAccountHome) {
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// public subroute such as /account/login...
|
||||
return json({
|
||||
isLoggedIn: false,
|
||||
isAccountHome,
|
||||
isPrivateRoute,
|
||||
customer: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// loggedIn, default redirect to the orders page
|
||||
if (isAccountHome) {
|
||||
return redirect('/account/orders');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const {customer} = await storefront.query(CUSTOMER_QUERY, {
|
||||
variables: {
|
||||
customerAccessToken: customerAccessToken.accessToken,
|
||||
country: storefront.i18n.country,
|
||||
language: storefront.i18n.language,
|
||||
},
|
||||
cache: storefront.CacheNone(),
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return json(
|
||||
{isLoggedIn, isPrivateRoute, isAccountHome, customer},
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('There was a problem loading account', error);
|
||||
session.unset('customerAccessToken');
|
||||
return redirect('/account/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Acccount() {
|
||||
const {customer, isPrivateRoute, isAccountHome} =
|
||||
useLoaderData<typeof loader>();
|
||||
|
||||
if (!isPrivateRoute && !isAccountHome) {
|
||||
return <Outlet context={{customer}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountLayout customer={customer as CustomerFragment}>
|
||||
<br />
|
||||
<br />
|
||||
<Outlet context={{customer}} />
|
||||
</AccountLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountLayout({
|
||||
customer,
|
||||
children,
|
||||
}: {
|
||||
customer: CustomerFragment;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const heading = customer
|
||||
? customer.firstName
|
||||
? `Welcome, ${customer.firstName}`
|
||||
: `Welcome to your account.`
|
||||
: 'Account Details';
|
||||
|
||||
return (
|
||||
<div className="account">
|
||||
<h1>{heading}</h1>
|
||||
<br />
|
||||
<AcccountMenu />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcccountMenu() {
|
||||
function isActiveStyle({
|
||||
isActive,
|
||||
isPending,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return {
|
||||
fontWeight: isActive ? 'bold' : '',
|
||||
color: isPending ? 'grey' : 'black',
|
||||
};
|
||||
}
|
||||
return (
|
||||
<nav role="navigation">
|
||||
<NavLink to="/account/orders" style={isActiveStyle}>
|
||||
Orders
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/profile" style={isActiveStyle}>
|
||||
Profile
|
||||
</NavLink>
|
||||
|
|
||||
<NavLink to="/account/addresses" style={isActiveStyle}>
|
||||
Addresses
|
||||
</NavLink>
|
||||
|
|
||||
<Logout />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Logout() {
|
||||
return (
|
||||
<Form className="account-logout" method="POST" action="/account/logout">
|
||||
<button type="submit">Sign out</button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment Customer on Customer {
|
||||
acceptsMarketing
|
||||
addresses(first: 6) {
|
||||
nodes {
|
||||
...Address
|
||||
}
|
||||
}
|
||||
defaultAddress {
|
||||
...Address
|
||||
}
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
numberOfOrders
|
||||
phone
|
||||
}
|
||||
fragment Address on MailingAddress {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
country
|
||||
province
|
||||
city
|
||||
zip
|
||||
phone
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/customer
|
||||
const CUSTOMER_QUERY = `#graphql
|
||||
query Customer(
|
||||
$customerAccessToken: String!
|
||||
$country: CountryCode
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customer(customerAccessToken: $customerAccessToken) {
|
||||
...Customer
|
||||
}
|
||||
}
|
||||
${CUSTOMER_FRAGMENT}
|
||||
` as const;
|
||||
157
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.activate.$id.$activationToken.tsx
vendored
Normal file
157
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.activate.$id.$activationToken.tsx
vendored
Normal file
@@ -0,0 +1,157 @@
|
||||
import type {ActionArgs, LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {json, redirect} from '@shopify/remix-oxygen';
|
||||
import {Form, useActionData, type V2_MetaFunction} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Activate Account'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context, params}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
const {id, activationToken} = params;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!id || !activationToken) {
|
||||
throw new Error('Missing token. The link you followed might be wrong.');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const password = form.has('password') ? String(form.get('password')) : null;
|
||||
const passwordConfirm = form.has('passwordConfirm')
|
||||
? String(form.get('passwordConfirm'))
|
||||
: null;
|
||||
|
||||
const validPasswords =
|
||||
password && passwordConfirm && password === passwordConfirm;
|
||||
|
||||
if (!validPasswords) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
const {customerActivate} = await storefront.mutate(
|
||||
CUSTOMER_ACTIVATE_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
id: `gid://shopify/Customer/${id}`,
|
||||
input: {
|
||||
password,
|
||||
activationToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (customerActivate?.customerUserErrors?.length) {
|
||||
throw new Error(customerActivate.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerActivate ?? {};
|
||||
if (!customerAccessToken) {
|
||||
throw new Error('Could not activate account.');
|
||||
}
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Activate() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
const error = action?.error ?? null;
|
||||
|
||||
return (
|
||||
<div className="account-activate">
|
||||
<h1>Activate Account.</h1>
|
||||
<p>Create your password to activate your account.</p>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="passwordConfirm">Re-enter password</label>
|
||||
<input
|
||||
id="passwordConfirm"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Re-enter password"
|
||||
aria-label="Re-enter password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button
|
||||
className="bg-primary text-contrast rounded py-2 px-4 focus:shadow-outline block w-full"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeractivate
|
||||
const CUSTOMER_ACTIVATE_MUTATION = `#graphql
|
||||
mutation customerActivate(
|
||||
$id: ID!,
|
||||
$input: CustomerActivateInput!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerActivate(id: $id, input: $input) {
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
143
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.login.tsx
vendored
Normal file
143
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.login.tsx
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type LoaderArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Login'}];
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
if (await context.session.get('customerAccessToken')) {
|
||||
return redirect('/account');
|
||||
}
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session, storefront} = context;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const email = String(form.has('email') ? form.get('email') : '');
|
||||
const password = String(form.has('password') ? form.get('password') : '');
|
||||
const validInputs = Boolean(email && password);
|
||||
|
||||
if (!validInputs) {
|
||||
throw new Error('Please provide both an email and a password.');
|
||||
}
|
||||
|
||||
const {customerAccessTokenCreate} = await storefront.mutate(
|
||||
LOGIN_MUTATION,
|
||||
{
|
||||
variables: {
|
||||
input: {email, password},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!customerAccessTokenCreate?.customerAccessToken?.accessToken) {
|
||||
throw new Error(customerAccessTokenCreate?.customerUserErrors[0].message);
|
||||
}
|
||||
|
||||
const {customerAccessToken} = customerAccessTokenCreate;
|
||||
session.set('customerAccessToken', customerAccessToken);
|
||||
|
||||
return redirect('/account', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message}, {status: 400});
|
||||
}
|
||||
return json({error}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const data = useActionData<ActionResponse>();
|
||||
const error = data?.error || null;
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h1>Sign in.</h1>
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
placeholder="Email address"
|
||||
aria-label="Email address"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
aria-label="Password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Sign in</button>
|
||||
</Form>
|
||||
<br />
|
||||
<div>
|
||||
<p>
|
||||
<Link to="/account/recover">Forgot password →</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/account/register">Register →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customeraccesstokencreate
|
||||
const LOGIN_MUTATION = `#graphql
|
||||
mutation login($input: CustomerAccessTokenCreateInput!) {
|
||||
customerAccessTokenCreate(input: $input) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
customerAccessToken {
|
||||
accessToken
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
33
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.logout.tsx
vendored
Normal file
33
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.logout.tsx
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
json,
|
||||
redirect,
|
||||
type ActionArgs,
|
||||
type V2_MetaFunction,
|
||||
} from '@shopify/remix-oxygen';
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{title: 'Logout'}];
|
||||
};
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/account/login');
|
||||
}
|
||||
|
||||
export async function action({request, context}: ActionArgs) {
|
||||
const {session} = context;
|
||||
session.unset('customerAccessToken');
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
return redirect('/', {
|
||||
headers: {
|
||||
'Set-Cookie': await session.commit(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function Logout() {
|
||||
return null;
|
||||
}
|
||||
124
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.recover.tsx
vendored
Normal file
124
packages/remix/test/fixtures/10-hydrogen-2/app/routes/account_.recover.tsx
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
import {json, redirect, type LoaderArgs} from '@shopify/remix-oxygen';
|
||||
import {Form, Link, useActionData} from '@remix-run/react';
|
||||
|
||||
type ActionResponse = {
|
||||
error?: string;
|
||||
resetRequested?: boolean;
|
||||
};
|
||||
|
||||
export async function loader({context}: LoaderArgs) {
|
||||
const customerAccessToken = await context.session.get('customerAccessToken');
|
||||
if (customerAccessToken) {
|
||||
return redirect('/account');
|
||||
}
|
||||
|
||||
return json({});
|
||||
}
|
||||
|
||||
export async function action({request, context}: LoaderArgs) {
|
||||
const {storefront} = context;
|
||||
const form = await request.formData();
|
||||
const email = form.has('email') ? String(form.get('email')) : null;
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
return json({error: 'Method not allowed'}, {status: 405});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!email) {
|
||||
throw new Error('Please provide an email.');
|
||||
}
|
||||
await storefront.mutate(CUSTOMER_RECOVER_MUTATION, {
|
||||
variables: {email},
|
||||
});
|
||||
|
||||
return json({resetRequested: true});
|
||||
} catch (error: unknown) {
|
||||
const resetRequested = false;
|
||||
if (error instanceof Error) {
|
||||
return json({error: error.message, resetRequested}, {status: 400});
|
||||
}
|
||||
return json({error, resetRequested}, {status: 400});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Recover() {
|
||||
const action = useActionData<ActionResponse>();
|
||||
|
||||
return (
|
||||
<div className="account-recover">
|
||||
<div>
|
||||
{action?.resetRequested ? (
|
||||
<>
|
||||
<h1>Request Sent.</h1>
|
||||
<p>
|
||||
If that email address is in our system, you will receive an email
|
||||
with instructions about how to reset your password in a few
|
||||
minutes.
|
||||
</p>
|
||||
<br />
|
||||
<Link to="/account/login">Return to Login</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>Forgot Password.</h1>
|
||||
<p>
|
||||
Enter the email address associated with your account to receive a
|
||||
link to reset your password.
|
||||
</p>
|
||||
<br />
|
||||
<Form method="POST">
|
||||
<fieldset>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
aria-label="Email address"
|
||||
autoComplete="email"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</fieldset>
|
||||
{action?.error ? (
|
||||
<p>
|
||||
<mark>
|
||||
<small>{action.error}</small>
|
||||
</mark>
|
||||
</p>
|
||||
) : (
|
||||
<br />
|
||||
)}
|
||||
<button type="submit">Request Reset Link</button>
|
||||
</Form>
|
||||
<div>
|
||||
<br />
|
||||
<p>
|
||||
<Link to="/account/login">Login →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/storefront/latest/mutations/customerrecover
|
||||
const CUSTOMER_RECOVER_MUTATION = `#graphql
|
||||
mutation customerRecover(
|
||||
$email: String!,
|
||||
$country: CountryCode,
|
||||
$language: LanguageCode
|
||||
) @inContext(country: $country, language: $language) {
|
||||
customerRecover(email: $email) {
|
||||
customerUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user