├── .changeset
├── README.md
├── config.json
└── lovely-pandas-design.md
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── README.md
├── cra.gif
├── devtools.png
├── next.gif
├── props.png
├── vite.gif
└── workflows
│ └── release.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── apps
├── cra
│ ├── .env.local
│ ├── .gitignore
│ ├── .prettierrc
│ ├── README.md
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── logo192.png
│ │ ├── logo512.png
│ │ ├── manifest.json
│ │ └── robots.txt
│ └── src
│ │ ├── App.css
│ │ ├── App.js
│ │ ├── App.test.js
│ │ ├── index.css
│ │ ├── index.js
│ │ ├── logo.svg
│ │ ├── reportWebVitals.js
│ │ └── setupTests.js
├── next
│ ├── .env.local
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── api
│ │ │ └── hello.ts
│ │ └── index.tsx
│ ├── public
│ │ ├── favicon.ico
│ │ └── vercel.svg
│ ├── styles
│ │ ├── Home.module.css
│ │ └── globals.css
│ └── tsconfig.json
└── remix
│ ├── .dockerignore
│ ├── .env.example
│ ├── .eslintrc.js
│ ├── .github
│ └── workflows
│ │ └── deploy.yml
│ ├── .gitignore
│ ├── .gitpod.Dockerfile
│ ├── .gitpod.yml
│ ├── .prettierignore
│ ├── Dockerfile
│ ├── README.md
│ ├── app
│ ├── db.server.ts
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── models
│ │ ├── note.server.ts
│ │ └── user.server.ts
│ ├── root.tsx
│ ├── routes
│ │ ├── healthcheck.tsx
│ │ ├── index.tsx
│ │ ├── join.tsx
│ │ ├── login.tsx
│ │ ├── logout.tsx
│ │ ├── notes.tsx
│ │ └── notes
│ │ │ ├── $noteId.tsx
│ │ │ ├── index.tsx
│ │ │ └── new.tsx
│ ├── session.server.ts
│ ├── tailwind.css
│ ├── utils.test.ts
│ └── utils.ts
│ ├── cypress.json
│ ├── cypress
│ ├── .eslintrc.js
│ ├── e2e
│ │ └── smoke.ts
│ ├── fixtures
│ │ └── example.json
│ ├── plugins
│ │ └── index.ts
│ ├── support
│ │ ├── commands.ts
│ │ ├── create-user.ts
│ │ ├── delete-user.ts
│ │ └── index.ts
│ └── tsconfig.json
│ ├── fly.toml
│ ├── mocks
│ ├── README.md
│ ├── index.js
│ └── start.ts
│ ├── package.json
│ ├── postcss.config.js
│ ├── prisma
│ ├── migrations
│ │ ├── 20220307190657_init
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ ├── schema.prisma
│ └── seed.ts
│ ├── public
│ └── favicon.ico
│ ├── remix.config.js
│ ├── remix.env.d.ts
│ ├── remix.init
│ ├── index.js
│ ├── package-lock.json
│ └── package.json
│ ├── start.sh
│ ├── tailwind.config.ts
│ ├── test
│ └── setup-test-env.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── package.json
├── packages
└── click-to-react-component
│ ├── .eslintrc.json
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── jsconfig.json
│ ├── package.json
│ └── src
│ ├── ClickToComponent.js
│ ├── ContextMenu.js
│ ├── getDisplayNameFromReactInstance.js
│ ├── getPathToSource.js
│ ├── getPropsForInstance.js
│ ├── getReactInstanceForElement.js
│ ├── getReactInstancesForElement.js
│ ├── getSourceForElement.js
│ ├── getSourceForInstance.js
│ ├── getUrl.js
│ ├── index.js
│ └── types.d.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": ["cra-app", "next-app", "remix-app"]
11 | }
12 |
--------------------------------------------------------------------------------
/.changeset/lovely-pandas-design.md:
--------------------------------------------------------------------------------
1 | ---
2 | 'click-to-react-component': patch
3 | ---
4 |
5 | Improve exports to support import fallback
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/README.md:
--------------------------------------------------------------------------------
1 | ../packages/click-to-react-component/README.md
--------------------------------------------------------------------------------
/.github/cra.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/.github/cra.gif
--------------------------------------------------------------------------------
/.github/devtools.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/.github/devtools.png
--------------------------------------------------------------------------------
/.github/next.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/.github/next.gif
--------------------------------------------------------------------------------
/.github/props.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/.github/props.png
--------------------------------------------------------------------------------
/.github/vite.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/.github/vite.gif
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout Repo
16 | uses: actions/checkout@v3
17 | with:
18 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
19 | fetch-depth: 0
20 |
21 | - name: Setup Node.js
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: lts/*
25 |
26 | - name: Cache pnpm modules
27 | uses: actions/cache@v3
28 | with:
29 | path: ~/.pnpm-store
30 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
31 | restore-keys: |
32 | ${{ runner.os }}-
33 |
34 | - uses: pnpm/action-setup@v2
35 | with:
36 | run_install: true
37 | version: latest
38 |
39 | - name: Create Release Pull Request or Publish to npm
40 | id: changesets
41 | uses: changesets/action@v1
42 | with:
43 | # This expects you to have a script called release which does a build for your packages and calls changeset publish
44 | publish: pnpm release
45 | env:
46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .turbo
3 | .idea
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
15 |
16 | # Runtime data
17 | pids
18 | *.pid
19 | *.seed
20 | *.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 | lib-cov
24 |
25 | # Coverage directory used by tools like istanbul
26 | coverage
27 | *.lcov
28 |
29 | # nyc test coverage
30 | .nyc_output
31 |
32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
33 | .grunt
34 |
35 | # Bower dependency directory (https://bower.io/)
36 | bower_components
37 |
38 | # node-waf configuration
39 | .lock-wscript
40 |
41 | # Compiled binary addons (https://nodejs.org/api/addons.html)
42 | build/Release
43 |
44 | # Dependency directories
45 | node_modules/
46 | jspm_packages/
47 |
48 | # TypeScript v1 declaration files
49 | typings/
50 |
51 | # TypeScript cache
52 | *.tsbuildinfo
53 |
54 | # Optional npm cache directory
55 | .npm
56 |
57 | # Optional eslint cache
58 | .eslintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variables file
76 | .env
77 | .env.test
78 |
79 | # parcel-bundler cache (https://parceljs.org/)
80 | .cache
81 |
82 | # Next.js build output
83 | .next
84 |
85 | # Nuxt.js build / generate output
86 | .nuxt
87 | dist
88 |
89 | # Gatsby files
90 | .cache/
91 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
92 | # https://nextjs.org/blog/next-9-1#public-directory-support
93 | # public
94 |
95 | # vuepress build output
96 | .vuepress/dist
97 |
98 | # Serverless directories
99 | .serverless/
100 |
101 | # FuseBox cache
102 | .fusebox/
103 |
104 | # DynamoDB Local files
105 | .dynamodb/
106 |
107 | # TernJS port file
108 | .tern-port
109 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "js/ts.implicitProjectConfig.checkJs": true
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Eric Clemmons
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/cra/.env.local:
--------------------------------------------------------------------------------
1 | REACT_APP_CTC_EDITOR=vscode-insiders
--------------------------------------------------------------------------------
/apps/cra/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | # Do uncomment this before pushing to git repos, as app secrets might get public
17 | # .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/apps/cra/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/apps/cra/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
13 |
14 | The page will reload when you make changes.\
15 | You may also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!**
35 |
36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
39 |
40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
63 |
64 | ### Deployment
65 |
66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
67 |
68 | ### `npm run build` fails to minify
69 |
70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
71 |
--------------------------------------------------------------------------------
/apps/cra/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cra-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.16.4",
7 | "@testing-library/react": "^13.1.1",
8 | "@testing-library/user-event": "^13.5.0",
9 | "react": "^18.0.0",
10 | "react-dom": "^18.0.0",
11 | "react-scripts": "5.0.1",
12 | "web-vitals": "^2.1.4"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "eslintConfig": {
21 | "extends": [
22 | "react-app",
23 | "react-app/jest"
24 | ]
25 | },
26 | "browserslist": {
27 | "production": [
28 | ">0.2%",
29 | "not dead",
30 | "not op_mini all"
31 | ],
32 | "development": [
33 | "last 1 chrome version",
34 | "last 1 firefox version",
35 | "last 1 safari version"
36 | ]
37 | },
38 | "devDependencies": {
39 | "click-to-react-component": "workspace:^1.1.2"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/apps/cra/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/apps/cra/public/favicon.ico
--------------------------------------------------------------------------------
/apps/cra/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/apps/cra/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/apps/cra/public/logo192.png
--------------------------------------------------------------------------------
/apps/cra/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/apps/cra/public/logo512.png
--------------------------------------------------------------------------------
/apps/cra/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/apps/cra/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/apps/cra/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | background-color: #282c34;
18 | min-height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | align-items: center;
22 | justify-content: center;
23 | font-size: calc(10px + 2vmin);
24 | color: white;
25 | }
26 |
27 | .App-link {
28 | color: #61dafb;
29 | }
30 |
31 | @keyframes App-logo-spin {
32 | from {
33 | transform: rotate(0deg);
34 | }
35 | to {
36 | transform: rotate(360deg);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/apps/cra/src/App.js:
--------------------------------------------------------------------------------
1 | import logo from './logo.svg';
2 | import './App.css';
3 |
4 | function App() {
5 | return (
6 |
22 | );
23 | }
24 |
25 | export default App;
26 |
--------------------------------------------------------------------------------
/apps/cra/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/apps/cra/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/apps/cra/src/index.js:
--------------------------------------------------------------------------------
1 | import { ClickToComponent } from 'click-to-react-component';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom/client';
4 | import './index.css';
5 | import App from './App';
6 | import reportWebVitals from './reportWebVitals';
7 |
8 | const root = ReactDOM.createRoot(document.getElementById('root'));
9 | root.render(
10 |
11 |
12 |
13 |
14 | );
15 |
16 | // If you want to start measuring performance in your app, pass a function
17 | // to log results (for example: reportWebVitals(console.log))
18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
19 | reportWebVitals();
20 |
--------------------------------------------------------------------------------
/apps/cra/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/cra/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/apps/cra/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/apps/next/.env.local:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CTC_EDITOR=cursor
--------------------------------------------------------------------------------
/apps/next/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/next/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | # Do uncomment this before pushing to git repos, as app secrets might get public
29 | # .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # typescript
38 | *.tsbuildinfo
39 |
--------------------------------------------------------------------------------
/apps/next/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/apps/next/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/next/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | module.exports = {
3 | reactStrictMode: true,
4 | }
5 |
--------------------------------------------------------------------------------
/apps/next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "next-app",
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "lint": "next lint"
9 | },
10 | "dependencies": {
11 | "next": "*",
12 | "react": "*",
13 | "react-dom": "*"
14 | },
15 | "devDependencies": {
16 | "@types/node": "17.0.23",
17 | "@types/react": "18.0.3",
18 | "click-to-react-component": "workspace:^1.1.2",
19 | "eslint": "8.13.0",
20 | "eslint-config-next": "12.1.5",
21 | "typescript": "4.6.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/apps/next/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { ClickToComponent } from 'click-to-react-component'
2 | import type { AppProps } from 'next/app'
3 |
4 | import '../styles/globals.css'
5 |
6 | function MyApp({ Component, pageProps }: AppProps) {
7 | return (
8 | <>
9 |
10 |
11 | >
12 | )
13 | }
14 |
15 | export default MyApp
16 |
--------------------------------------------------------------------------------
/apps/next/pages/api/hello.ts:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 | import type { NextApiRequest, NextApiResponse } from 'next'
3 |
4 | type Data = {
5 | name: string
6 | }
7 |
8 | export default function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse
11 | ) {
12 | res.status(200).json({ name: 'John Doe' })
13 | }
14 |
--------------------------------------------------------------------------------
/apps/next/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { NextPage } from 'next'
2 | import Head from 'next/head'
3 | import Image from 'next/image'
4 | import styles from '../styles/Home.module.css'
5 |
6 | const Home: NextPage = () => {
7 | return (
8 |
9 |
10 |
Create Next App
11 |
12 |
13 |
14 |
15 |
16 |
17 | Welcome to Next.js!
18 |
19 |
20 |
21 | Get started by editing{' '}
22 | pages/index.tsx
23 |
24 |
25 |
54 |
55 |
56 |
68 |
69 | )
70 | }
71 |
72 | export default Home
73 |
--------------------------------------------------------------------------------
/apps/next/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/apps/next/public/favicon.ico
--------------------------------------------------------------------------------
/apps/next/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/next/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .title {
43 | margin: 0;
44 | line-height: 1.15;
45 | font-size: 4rem;
46 | }
47 |
48 | .title,
49 | .description {
50 | text-align: center;
51 | }
52 |
53 | .description {
54 | margin: 4rem 0;
55 | line-height: 1.5;
56 | font-size: 1.5rem;
57 | }
58 |
59 | .code {
60 | background: #fafafa;
61 | border-radius: 5px;
62 | padding: 0.75rem;
63 | font-size: 1.1rem;
64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
65 | Bitstream Vera Sans Mono, Courier New, monospace;
66 | }
67 |
68 | .grid {
69 | display: flex;
70 | align-items: center;
71 | justify-content: center;
72 | flex-wrap: wrap;
73 | max-width: 800px;
74 | }
75 |
76 | .card {
77 | margin: 1rem;
78 | padding: 1.5rem;
79 | text-align: left;
80 | color: inherit;
81 | text-decoration: none;
82 | border: 1px solid #eaeaea;
83 | border-radius: 10px;
84 | transition: color 0.15s ease, border-color 0.15s ease;
85 | max-width: 300px;
86 | }
87 |
88 | .card:hover,
89 | .card:focus,
90 | .card:active {
91 | color: #0070f3;
92 | border-color: #0070f3;
93 | }
94 |
95 | .card h2 {
96 | margin: 0 0 1rem 0;
97 | font-size: 1.5rem;
98 | }
99 |
100 | .card p {
101 | margin: 0;
102 | font-size: 1.25rem;
103 | line-height: 1.5;
104 | }
105 |
106 | .logo {
107 | height: 1em;
108 | margin-left: 0.5rem;
109 | }
110 |
111 | @media (max-width: 600px) {
112 | .grid {
113 | width: 100%;
114 | flex-direction: column;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/apps/next/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": false,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true
17 | },
18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
19 | "exclude": ["node_modules"]
20 | }
21 |
--------------------------------------------------------------------------------
/apps/remix/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | *.log
3 | .DS_Store
4 | .env
5 | /.cache
6 | /public/build
7 | /build
8 |
--------------------------------------------------------------------------------
/apps/remix/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="file:./data.db?connection_limit=1"
2 | SESSION_SECRET="super-duper-s3cret"
3 |
--------------------------------------------------------------------------------
/apps/remix/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@types/eslint').Linter.BaseConfig}
3 | */
4 | module.exports = {
5 | extends: [
6 | "@remix-run/eslint-config",
7 | "@remix-run/eslint-config/node",
8 | "@remix-run/eslint-config/jest-testing-library",
9 | "prettier",
10 | ],
11 | // we're using vitest which has a very similar API to jest
12 | // (so the linting plugins work nicely), but it we have to explicitly
13 | // set the jest version.
14 | settings: {
15 | jest: {
16 | version: 27,
17 | },
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/apps/remix/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: 🚀 Deploy
2 | on:
3 | push:
4 | branches:
5 | - main
6 | - dev
7 | pull_request: {}
8 |
9 | jobs:
10 | lint:
11 | name: ⬣ ESLint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: 🛑 Cancel Previous Runs
15 | uses: styfle/cancel-workflow-action@0.9.1
16 |
17 | - name: ⬇️ Checkout repo
18 | uses: actions/checkout@v3
19 |
20 | - name: ⎔ Setup node
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: 16
24 |
25 | - name: 📥 Download deps
26 | uses: bahmutov/npm-install@v1
27 |
28 | - name: 🔬 Lint
29 | run: npm run lint
30 |
31 | typecheck:
32 | name: ʦ TypeScript
33 | runs-on: ubuntu-latest
34 | steps:
35 | - name: 🛑 Cancel Previous Runs
36 | uses: styfle/cancel-workflow-action@0.9.1
37 |
38 | - name: ⬇️ Checkout repo
39 | uses: actions/checkout@v3
40 |
41 | - name: ⎔ Setup node
42 | uses: actions/setup-node@v3
43 | with:
44 | node-version: 16
45 |
46 | - name: 📥 Download deps
47 | uses: bahmutov/npm-install@v1
48 |
49 | - name: 🔎 Type check
50 | run: npm run typecheck --if-present
51 |
52 | vitest:
53 | name: ⚡ Vitest
54 | runs-on: ubuntu-latest
55 | steps:
56 | - name: 🛑 Cancel Previous Runs
57 | uses: styfle/cancel-workflow-action@0.9.1
58 |
59 | - name: ⬇️ Checkout repo
60 | uses: actions/checkout@v3
61 |
62 | - name: ⎔ Setup node
63 | uses: actions/setup-node@v3
64 | with:
65 | node-version: 16
66 |
67 | - name: 📥 Download deps
68 | uses: bahmutov/npm-install@v1
69 |
70 | - name: ⚡ Run vitest
71 | run: npm run test -- --coverage
72 |
73 | cypress:
74 | name: ⚫️ Cypress
75 | runs-on: ubuntu-latest
76 | steps:
77 | - name: 🛑 Cancel Previous Runs
78 | uses: styfle/cancel-workflow-action@0.9.1
79 |
80 | - name: ⬇️ Checkout repo
81 | uses: actions/checkout@v3
82 |
83 | - name: 🏄 Copy test env vars
84 | run: cp .env.example .env
85 |
86 | - name: ⎔ Setup node
87 | uses: actions/setup-node@v3
88 | with:
89 | node-version: 16
90 |
91 | - name: 📥 Download deps
92 | uses: bahmutov/npm-install@v1
93 |
94 | - name: 🛠 Setup Database
95 | run: npx prisma migrate reset --force
96 |
97 | - name: ⚙️ Build
98 | run: npm run build
99 |
100 | - name: 🌳 Cypress run
101 | uses: cypress-io/github-action@v3
102 | with:
103 | start: npm run start:mocks
104 | wait-on: "http://localhost:8811"
105 | env:
106 | PORT: "8811"
107 |
108 | build:
109 | name: 🐳 Build
110 | # only build/deploy main branch on pushes
111 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
112 | runs-on: ubuntu-latest
113 | steps:
114 | - name: 🛑 Cancel Previous Runs
115 | uses: styfle/cancel-workflow-action@0.9.1
116 |
117 | - name: ⬇️ Checkout repo
118 | uses: actions/checkout@v3
119 |
120 | - name: 👀 Read app name
121 | uses: SebRollen/toml-action@v1.0.0
122 | id: app_name
123 | with:
124 | file: "fly.toml"
125 | field: "app"
126 |
127 | - name: 🐳 Set up Docker Buildx
128 | uses: docker/setup-buildx-action@v1
129 |
130 | # Setup cache
131 | - name: ⚡️ Cache Docker layers
132 | uses: actions/cache@v2
133 | with:
134 | path: /tmp/.buildx-cache
135 | key: ${{ runner.os }}-buildx-${{ github.sha }}
136 | restore-keys: |
137 | ${{ runner.os }}-buildx-
138 |
139 | - name: 🔑 Fly Registry Auth
140 | uses: docker/login-action@v1
141 | with:
142 | registry: registry.fly.io
143 | username: x
144 | password: ${{ secrets.FLY_API_TOKEN }}
145 |
146 | - name: 🐳 Docker build
147 | uses: docker/build-push-action@v2
148 | with:
149 | context: .
150 | push: true
151 | tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}
152 | build-args: |
153 | COMMIT_SHA=${{ github.sha }}
154 | cache-from: type=local,src=/tmp/.buildx-cache
155 | cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
156 |
157 | # This ugly bit is necessary if you don't want your cache to grow forever
158 | # till it hits GitHub's limit of 5GB.
159 | # Temp fix
160 | # https://github.com/docker/build-push-action/issues/252
161 | # https://github.com/moby/buildkit/issues/1896
162 | - name: 🚚 Move cache
163 | run: |
164 | rm -rf /tmp/.buildx-cache
165 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache
166 |
167 | deploy:
168 | name: 🚀 Deploy
169 | runs-on: ubuntu-latest
170 | needs: [lint, typecheck, vitest, cypress, build]
171 | # only build/deploy main branch on pushes
172 | if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
173 |
174 | steps:
175 | - name: 🛑 Cancel Previous Runs
176 | uses: styfle/cancel-workflow-action@0.9.1
177 |
178 | - name: ⬇️ Checkout repo
179 | uses: actions/checkout@v3
180 |
181 | - name: 👀 Read app name
182 | uses: SebRollen/toml-action@v1.0.0
183 | id: app_name
184 | with:
185 | file: "fly.toml"
186 | field: "app"
187 |
188 | - name: 🚀 Deploy Staging
189 | if: ${{ github.ref == 'refs/heads/dev' }}
190 | uses: superfly/flyctl-actions@1.3
191 | with:
192 | args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}"
193 | env:
194 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
195 |
196 | - name: 🚀 Deploy Production
197 | if: ${{ github.ref == 'refs/heads/main' }}
198 | uses: superfly/flyctl-actions@1.3
199 | with:
200 | args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}"
201 | env:
202 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
203 |
--------------------------------------------------------------------------------
/apps/remix/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | .env
6 |
7 | /cypress/screenshots
8 | /cypress/videos
9 | /prisma/data.db
10 | /prisma/data.db-journal
11 |
12 | /app/styles/tailwind.css
13 |
--------------------------------------------------------------------------------
/apps/remix/.gitpod.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM gitpod/workspace-full
2 |
3 | # Install Fly
4 | RUN curl -L https://fly.io/install.sh | sh
5 | ENV FLYCTL_INSTALL="/home/gitpod/.fly"
6 | ENV PATH="$FLYCTL_INSTALL/bin:$PATH"
7 |
8 | # Install GitHub CLI
9 | RUN brew install gh
10 |
--------------------------------------------------------------------------------
/apps/remix/.gitpod.yml:
--------------------------------------------------------------------------------
1 | # https://www.gitpod.io/docs/config-gitpod-file
2 |
3 | image:
4 | file: .gitpod.Dockerfile
5 |
6 | ports:
7 | - port: 3000
8 | onOpen: notify
9 |
10 | tasks:
11 | - name: Restore .env file
12 | command: |
13 | if [ -f .env ]; then
14 | # If this workspace already has a .env, don't override it
15 | # Local changes survive a workspace being opened and closed
16 | # but they will not persist between separate workspaces for the same repo
17 |
18 | echo "Found .env in workspace"
19 | else
20 | # There is no .env
21 | if [ ! -n "${ENV}" ]; then
22 | # There is no $ENV from a previous workspace
23 | # Default to the example .env
24 | echo "Setting example .env"
25 |
26 | cp .env.example .env
27 | else
28 | # After making changes to .env, run this line to persist it to $ENV
29 | # eval $(gp env -e ENV="$(base64 .env | tr -d '\n')")
30 | #
31 | # Environment variables set this way are shared between all your workspaces for this repo
32 | # The lines below will read $ENV and print a .env file
33 |
34 | echo "Restoring .env from Gitpod"
35 |
36 | echo "${ENV}" | base64 -d | tee .env > /dev/null
37 | fi
38 | fi
39 |
40 | - init: npm install
41 | command: npm run setup && npm run dev
42 |
43 | vscode:
44 | extensions:
45 | - ms-azuretools.vscode-docker
46 | - esbenp.prettier-vscode
47 | - dbaeumer.vscode-eslint
48 | - bradlc.vscode-tailwindcss
49 |
--------------------------------------------------------------------------------
/apps/remix/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /build
4 | /public/build
5 | .env
6 |
7 | /app/styles/tailwind.css
8 |
--------------------------------------------------------------------------------
/apps/remix/Dockerfile:
--------------------------------------------------------------------------------
1 | # base node image
2 | FROM node:16-bullseye-slim as base
3 |
4 | # set for base and all layer that inherit from it
5 | ENV NODE_ENV production
6 |
7 | # Install openssl for Prisma
8 | RUN apt-get update && apt-get install -y openssl sqlite3
9 |
10 | # Install all node_modules, including dev dependencies
11 | FROM base as deps
12 |
13 | WORKDIR /myapp
14 |
15 | ADD package.json package-lock.json ./
16 | RUN npm install --production=false
17 |
18 | # Setup production node_modules
19 | FROM base as production-deps
20 |
21 | WORKDIR /myapp
22 |
23 | COPY --from=deps /myapp/node_modules /myapp/node_modules
24 | ADD package.json package-lock.json ./
25 | RUN npm prune --production
26 |
27 | # Build the app
28 | FROM base as build
29 |
30 | WORKDIR /myapp
31 |
32 | COPY --from=deps /myapp/node_modules /myapp/node_modules
33 |
34 | ADD prisma .
35 | RUN npx prisma generate
36 |
37 | ADD . .
38 | RUN npm run build
39 |
40 | # Finally, build the production image with minimal footprint
41 | FROM base
42 |
43 | ENV DATABASE_URL=file:/data/sqlite.db
44 | ENV PORT="8080"
45 | ENV NODE_ENV="production"
46 |
47 | # add shortcut for connecting to database CLI
48 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
49 |
50 | WORKDIR /myapp
51 |
52 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules
53 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
54 |
55 | COPY --from=build /myapp/build /myapp/build
56 | COPY --from=build /myapp/public /myapp/public
57 | ADD . .
58 |
59 | CMD ["npm", "start"]
60 |
--------------------------------------------------------------------------------
/apps/remix/README.md:
--------------------------------------------------------------------------------
1 | # Remix Indie Stack
2 |
3 | 
4 |
5 | Learn more about [Remix Stacks](https://remix.run/stacks).
6 |
7 | ```
8 | npx create-remix --template remix-run/indie-stack
9 | ```
10 |
11 | ## What's in the stack
12 |
13 | - [Fly app deployment](https://fly.io) with [Docker](https://www.docker.com/)
14 | - Production-ready [SQLite Database](https://sqlite.org)
15 | - Healthcheck endpoint for [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks)
16 | - [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments
17 | - Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage)
18 | - Database ORM with [Prisma](https://prisma.io)
19 | - Styling with [Tailwind](https://tailwindcss.com/)
20 | - End-to-end testing with [Cypress](https://cypress.io)
21 | - Local third party request mocking with [MSW](https://mswjs.io)
22 | - Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com)
23 | - Code formatting with [Prettier](https://prettier.io)
24 | - Linting with [ESLint](https://eslint.org)
25 | - Static Types with [TypeScript](https://typescriptlang.org)
26 |
27 | Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own.
28 |
29 | ## Quickstart
30 |
31 | Click this button to create a [Gitpod](https://gitpod.io) workspace with the project set up and Fly pre-installed
32 |
33 | [](https://gitpod.io/from-referrer/)
34 |
35 | ## Development
36 |
37 | - Initial setup: _If you just generated this project, this step has been done for you._
38 |
39 | ```sh
40 | npm run setup
41 | ```
42 |
43 | - Start dev server:
44 |
45 | ```sh
46 | npm run dev
47 | ```
48 |
49 | This starts your app in development mode, rebuilding assets on file changes.
50 |
51 | The database seed script creates a new user with some data you can use to get started:
52 |
53 | - Email: `rachel@remix.run`
54 | - Password: `racheliscool`
55 |
56 | ### Relevant code:
57 |
58 | This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Prisma and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes.
59 |
60 | - creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts)
61 | - user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts)
62 | - creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts)
63 |
64 | ## Deployment
65 |
66 | This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments.
67 |
68 | Prior to your first deployment, you'll need to do a few things:
69 |
70 | - [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/)
71 |
72 | - Sign up and log in to Fly
73 |
74 | ```sh
75 | fly auth signup
76 | ```
77 |
78 | > **Note:** If you have more than one Fly account, ensure that you are signed into the same account in the Fly CLI as you are in the browser. In your terminal, run `fly auth whoami` and ensure the email matches the Fly account signed into the browser.
79 |
80 | - Create two apps on Fly, one for staging and one for production:
81 |
82 | ```sh
83 | fly create remix-5961
84 | fly create remix-5961-staging
85 | ```
86 |
87 | - Initialize Git.
88 |
89 | ```sh
90 | git init
91 | ```
92 |
93 | - Create a new [GitHub Repository](https://repo.new), and then add it as the remote for your project. **Do not push your app yet!**
94 |
95 | ```sh
96 | git remote add origin
97 | ```
98 |
99 | - Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user settings on Fly and create a new [token](https://web.fly.io/user/personal_access_tokens/new), then add it to [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) with the name `FLY_API_TOKEN`.
100 |
101 | - Add a `SESSION_SECRET` to your fly app secrets, to do this you can run the following commands:
102 |
103 | ```sh
104 | fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app remix-5961
105 | fly secrets set SESSION_SECRET=$(openssl rand -hex 32) --app remix-5961-staging
106 | ```
107 |
108 | If you don't have openssl installed, you can also use [1password](https://1password.com/generate-password) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret.
109 |
110 | - Create a persistent volume for the sqlite database for both your staging and production environments. Run the following:
111 |
112 | ```sh
113 | fly volumes create data --size 1 --app remix-5961
114 | fly volumes create data --size 1 --app remix-5961-staging
115 | ```
116 |
117 | Now that everything is set up you can commit and push your changes to your repo. Every commit to your `main` branch will trigger a deployment to your production environment, and every commit to your `dev` branch will trigger a deployment to your staging environment.
118 |
119 | ### Connecting to your database
120 |
121 | The sqlite database lives at `/data/sqlite.db` in your deployed application. You can connect to the live database by running `fly ssh console -C database-cli`.
122 |
123 | ### Getting Help with Deployment
124 |
125 | If you run into any issues deploying to Fly, make sure you've followed all of the steps above and if you have, then post as many details about your deployment (including your app name) to [the Fly support community](https://community.fly.io). They're normally pretty responsive over there and hopefully can help resolve any of your deployment issues and questions.
126 |
127 | ## GitHub Actions
128 |
129 | We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging.
130 |
131 | ## Testing
132 |
133 | ### Cypress
134 |
135 | We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes.
136 |
137 | We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically.
138 |
139 | To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above.
140 |
141 | We have a utility for testing authenticated features without having to go through the login flow:
142 |
143 | ```ts
144 | cy.login();
145 | // you are now logged in as a new user
146 | ```
147 |
148 | We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file:
149 |
150 | ```ts
151 | afterEach(() => {
152 | cy.cleanupUser();
153 | });
154 | ```
155 |
156 | That way, we can keep your local db clean and keep your tests isolated from one another.
157 |
158 | ### Vitest
159 |
160 | For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom).
161 |
162 | ### Type Checking
163 |
164 | This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`.
165 |
166 | ### Linting
167 |
168 | This project uses ESLint for linting. That is configured in `.eslintrc.js`.
169 |
170 | ### Formatting
171 |
172 | We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project.
173 |
--------------------------------------------------------------------------------
/apps/remix/app/db.server.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | let prisma: PrismaClient;
4 |
5 | declare global {
6 | var __db__: PrismaClient;
7 | }
8 |
9 | // this is needed because in development we don't want to restart
10 | // the server with every change, but we want to make sure we don't
11 | // create a new connection to the DB with every change either.
12 | // in production we'll have a single connection to the DB.
13 | if (process.env.NODE_ENV === "production") {
14 | prisma = new PrismaClient();
15 | } else {
16 | if (!global.__db__) {
17 | global.__db__ = new PrismaClient();
18 | }
19 | prisma = global.__db__;
20 | prisma.$connect();
21 | }
22 |
23 | export { prisma };
24 |
--------------------------------------------------------------------------------
/apps/remix/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from "@remix-run/react";
2 | import { hydrate } from "react-dom";
3 |
4 | hydrate(, document);
5 |
--------------------------------------------------------------------------------
/apps/remix/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type { EntryContext } from "@remix-run/node";
2 | import { RemixServer } from "@remix-run/react";
3 | import { renderToString } from "react-dom/server";
4 |
5 | export default function handleRequest(
6 | request: Request,
7 | responseStatusCode: number,
8 | responseHeaders: Headers,
9 | remixContext: EntryContext
10 | ) {
11 | const markup = renderToString(
12 |
13 | );
14 |
15 | responseHeaders.set("Content-Type", "text/html");
16 |
17 | return new Response("" + markup, {
18 | status: responseStatusCode,
19 | headers: responseHeaders,
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/apps/remix/app/models/note.server.ts:
--------------------------------------------------------------------------------
1 | import type { User, Note } from "@prisma/client";
2 |
3 | import { prisma } from "~/db.server";
4 |
5 | export type { Note } from "@prisma/client";
6 |
7 | export function getNote({
8 | id,
9 | userId,
10 | }: Pick & {
11 | userId: User["id"];
12 | }) {
13 | return prisma.note.findFirst({
14 | where: { id, userId },
15 | });
16 | }
17 |
18 | export function getNoteListItems({ userId }: { userId: User["id"] }) {
19 | return prisma.note.findMany({
20 | where: { userId },
21 | select: { id: true, title: true },
22 | orderBy: { updatedAt: "desc" },
23 | });
24 | }
25 |
26 | export function createNote({
27 | body,
28 | title,
29 | userId,
30 | }: Pick & {
31 | userId: User["id"];
32 | }) {
33 | return prisma.note.create({
34 | data: {
35 | title,
36 | body,
37 | user: {
38 | connect: {
39 | id: userId,
40 | },
41 | },
42 | },
43 | });
44 | }
45 |
46 | export function deleteNote({
47 | id,
48 | userId,
49 | }: Pick & { userId: User["id"] }) {
50 | return prisma.note.deleteMany({
51 | where: { id, userId },
52 | });
53 | }
54 |
--------------------------------------------------------------------------------
/apps/remix/app/models/user.server.ts:
--------------------------------------------------------------------------------
1 | import type { Password, User } from "@prisma/client";
2 | import bcrypt from "bcryptjs";
3 |
4 | import { prisma } from "~/db.server";
5 |
6 | export type { User } from "@prisma/client";
7 |
8 | export async function getUserById(id: User["id"]) {
9 | return prisma.user.findUnique({ where: { id } });
10 | }
11 |
12 | export async function getUserByEmail(email: User["email"]) {
13 | return prisma.user.findUnique({ where: { email } });
14 | }
15 |
16 | export async function createUser(email: User["email"], password: string) {
17 | const hashedPassword = await bcrypt.hash(password, 10);
18 |
19 | return prisma.user.create({
20 | data: {
21 | email,
22 | password: {
23 | create: {
24 | hash: hashedPassword,
25 | },
26 | },
27 | },
28 | });
29 | }
30 |
31 | export async function deleteUserByEmail(email: User["email"]) {
32 | return prisma.user.delete({ where: { email } });
33 | }
34 |
35 | export async function verifyLogin(
36 | email: User["email"],
37 | password: Password["hash"]
38 | ) {
39 | const userWithPassword = await prisma.user.findUnique({
40 | where: { email },
41 | include: {
42 | password: true,
43 | },
44 | });
45 |
46 | if (!userWithPassword || !userWithPassword.password) {
47 | return null;
48 | }
49 |
50 | const isValid = await bcrypt.compare(
51 | password,
52 | userWithPassword.password.hash
53 | );
54 |
55 | if (!isValid) {
56 | return null;
57 | }
58 |
59 | const { password: _password, ...userWithoutPassword } = userWithPassword;
60 |
61 | return userWithoutPassword;
62 | }
63 |
--------------------------------------------------------------------------------
/apps/remix/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { cssBundleHref } from "@remix-run/css-bundle";
2 | import type { LinksFunction, LoaderArgs } from "@remix-run/node";
3 | import { ClickToComponent } from "click-to-react-component";
4 | import { json } from "@remix-run/node";
5 | import {
6 | Links,
7 | LiveReload,
8 | Meta,
9 | Outlet,
10 | Scripts,
11 | ScrollRestoration,
12 | useLoaderData,
13 | } from "@remix-run/react";
14 |
15 | import { getUser } from "~/session.server";
16 | import stylesheet from "~/tailwind.css";
17 |
18 | export const links: LinksFunction = () => [
19 | { rel: "stylesheet", href: stylesheet },
20 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
21 | ];
22 |
23 | export const loader = async ({ request }: LoaderArgs) => {
24 | return json({
25 | user: await getUser(request),
26 | projectDir: process.env.NODE_ENV === 'production' ? undefined : process.cwd(),
27 | });
28 | };
29 |
30 | export default function App() {
31 | const { projectDir } = useLoaderData();
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | `${projectDir}/${path}`} />
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/healthcheck.tsx:
--------------------------------------------------------------------------------
1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks
2 | import type { LoaderFunction } from "@remix-run/node";
3 |
4 | import { prisma } from "~/db.server";
5 |
6 | export const loader: LoaderFunction = async ({ request }) => {
7 | const host =
8 | request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
9 |
10 | try {
11 | const url = new URL("/", `http://${host}`);
12 | // if we can connect to the database and make a simple query
13 | // and make a HEAD request to ourselves, then we're good.
14 | await Promise.all([
15 | prisma.user.count(),
16 | fetch(url.toString(), { method: "HEAD" }).then((r) => {
17 | if (!r.ok) return Promise.reject(r);
18 | }),
19 | ]);
20 | return new Response("OK");
21 | } catch (error: unknown) {
22 | console.log("healthcheck ❌", { error });
23 | return new Response("ERROR", { status: 500 });
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 |
3 | import { useOptionalUser } from "~/utils";
4 |
5 | export default function Index() {
6 | const user = useOptionalUser();
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |

18 |
19 |
20 |
21 |
22 |
23 | Indie Stack
24 |
25 |
26 |
27 | Check the README.md file for instructions on how to get this
28 | project deployed.
29 |
30 |
31 | {user ? (
32 |
36 | View Notes for {user.email}
37 |
38 | ) : (
39 |
40 |
44 | Sign up
45 |
46 |
50 | Log In
51 |
52 |
53 | )}
54 |
55 |
56 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | {[
69 | {
70 | src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg",
71 | alt: "Fly.io",
72 | href: "https://fly.io",
73 | },
74 | {
75 | src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg",
76 | alt: "SQLite",
77 | href: "https://sqlite.org",
78 | },
79 | {
80 | src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg",
81 | alt: "Prisma",
82 | href: "https://prisma.io",
83 | },
84 | {
85 | src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg",
86 | alt: "Tailwind",
87 | href: "https://tailwindcss.com",
88 | },
89 | {
90 | src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg",
91 | alt: "Cypress",
92 | href: "https://www.cypress.io",
93 | },
94 | {
95 | src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg",
96 | alt: "MSW",
97 | href: "https://mswjs.io",
98 | },
99 | {
100 | src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg",
101 | alt: "Vitest",
102 | href: "https://vitest.dev",
103 | },
104 | {
105 | src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png",
106 | alt: "Testing Library",
107 | href: "https://testing-library.com",
108 | },
109 | {
110 | src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg",
111 | alt: "Prettier",
112 | href: "https://prettier.io",
113 | },
114 | {
115 | src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg",
116 | alt: "ESLint",
117 | href: "https://eslint.org",
118 | },
119 | {
120 | src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg",
121 | alt: "TypeScript",
122 | href: "https://typescriptlang.org",
123 | },
124 | ].map((img) => (
125 |
130 |
131 |
132 | ))}
133 |
134 |
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/join.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | ActionFunction,
3 | LoaderFunction,
4 | MetaFunction,
5 | } from "@remix-run/node";
6 | import { json, redirect } from "@remix-run/node";
7 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
8 | import * as React from "react";
9 |
10 | import { getUserId, createUserSession } from "~/session.server";
11 |
12 | import { createUser, getUserByEmail } from "~/models/user.server";
13 | import { safeRedirect, validateEmail } from "~/utils";
14 |
15 | export const loader: LoaderFunction = async ({ request }) => {
16 | const userId = await getUserId(request);
17 | if (userId) return redirect("/");
18 | return json({});
19 | };
20 |
21 | interface ActionData {
22 | errors: {
23 | email?: string;
24 | password?: string;
25 | };
26 | }
27 |
28 | export const action: ActionFunction = async ({ request }) => {
29 | const formData = await request.formData();
30 | const email = formData.get("email");
31 | const password = formData.get("password");
32 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/");
33 |
34 | if (!validateEmail(email)) {
35 | return json(
36 | { errors: { email: "Email is invalid" } },
37 | { status: 400 }
38 | );
39 | }
40 |
41 | if (typeof password !== "string") {
42 | return json(
43 | { errors: { password: "Password is required" } },
44 | { status: 400 }
45 | );
46 | }
47 |
48 | if (password.length < 8) {
49 | return json(
50 | { errors: { password: "Password is too short" } },
51 | { status: 400 }
52 | );
53 | }
54 |
55 | const existingUser = await getUserByEmail(email);
56 | if (existingUser) {
57 | return json(
58 | { errors: { email: "A user already exists with this email" } },
59 | { status: 400 }
60 | );
61 | }
62 |
63 | const user = await createUser(email, password);
64 |
65 | return createUserSession({
66 | request,
67 | userId: user.id,
68 | remember: false,
69 | redirectTo,
70 | });
71 | };
72 |
73 | export const meta: MetaFunction = () => {
74 | return {
75 | title: "Sign Up",
76 | };
77 | };
78 |
79 | export default function Join() {
80 | const [searchParams] = useSearchParams();
81 | const redirectTo = searchParams.get("redirectTo") ?? undefined;
82 | const actionData = useActionData() as ActionData;
83 | const emailRef = React.useRef(null);
84 | const passwordRef = React.useRef(null);
85 |
86 | React.useEffect(() => {
87 | if (actionData?.errors?.email) {
88 | emailRef.current?.focus();
89 | } else if (actionData?.errors?.password) {
90 | passwordRef.current?.focus();
91 | }
92 | }, [actionData]);
93 |
94 | return (
95 |
176 | );
177 | }
178 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | ActionFunction,
3 | LoaderFunction,
4 | MetaFunction,
5 | } from "@remix-run/node";
6 | import { json, redirect } from "@remix-run/node";
7 | import { Form, Link, useActionData, useSearchParams } from "@remix-run/react";
8 | import * as React from "react";
9 |
10 | import { createUserSession, getUserId } from "~/session.server";
11 | import { verifyLogin } from "~/models/user.server";
12 | import { safeRedirect, validateEmail } from "~/utils";
13 |
14 | export const loader: LoaderFunction = async ({ request }) => {
15 | const userId = await getUserId(request);
16 | if (userId) return redirect("/");
17 | return json({});
18 | };
19 |
20 | interface ActionData {
21 | errors?: {
22 | email?: string;
23 | password?: string;
24 | };
25 | }
26 |
27 | export const action: ActionFunction = async ({ request }) => {
28 | const formData = await request.formData();
29 | const email = formData.get("email");
30 | const password = formData.get("password");
31 | const redirectTo = safeRedirect(formData.get("redirectTo"), "/notes");
32 | const remember = formData.get("remember");
33 |
34 | if (!validateEmail(email)) {
35 | return json(
36 | { errors: { email: "Email is invalid" } },
37 | { status: 400 }
38 | );
39 | }
40 |
41 | if (typeof password !== "string") {
42 | return json(
43 | { errors: { password: "Password is required" } },
44 | { status: 400 }
45 | );
46 | }
47 |
48 | if (password.length < 8) {
49 | return json(
50 | { errors: { password: "Password is too short" } },
51 | { status: 400 }
52 | );
53 | }
54 |
55 | const user = await verifyLogin(email, password);
56 |
57 | if (!user) {
58 | return json(
59 | { errors: { email: "Invalid email or password" } },
60 | { status: 400 }
61 | );
62 | }
63 |
64 | return createUserSession({
65 | request,
66 | userId: user.id,
67 | remember: remember === "on" ? true : false,
68 | redirectTo,
69 | });
70 | };
71 |
72 | export const meta: MetaFunction = () => {
73 | return {
74 | title: "Login",
75 | };
76 | };
77 |
78 | export default function LoginPage() {
79 | const [searchParams] = useSearchParams();
80 | const redirectTo = searchParams.get("redirectTo") || "/notes";
81 | const actionData = useActionData() as ActionData;
82 | const emailRef = React.useRef(null);
83 | const passwordRef = React.useRef(null);
84 |
85 | React.useEffect(() => {
86 | if (actionData?.errors?.email) {
87 | emailRef.current?.focus();
88 | } else if (actionData?.errors?.password) {
89 | passwordRef.current?.focus();
90 | }
91 | }, [actionData]);
92 |
93 | return (
94 |
189 | );
190 | }
191 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/logout.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node";
2 | import { redirect } from "@remix-run/node";
3 |
4 | import { logout } from "~/session.server";
5 |
6 | export const action: ActionFunction = async ({ request }) => {
7 | return logout(request);
8 | };
9 |
10 | export const loader: LoaderFunction = async () => {
11 | return redirect("/");
12 | };
13 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/notes.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunction } from "@remix-run/node";
2 | import { json } from "@remix-run/node";
3 | import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react";
4 |
5 | import { requireUserId } from "~/session.server";
6 | import { useUser } from "~/utils";
7 | import { getNoteListItems } from "~/models/note.server";
8 |
9 | type LoaderData = {
10 | noteListItems: Awaited>;
11 | };
12 |
13 | export const loader: LoaderFunction = async ({ request }) => {
14 | const userId = await requireUserId(request);
15 | const noteListItems = await getNoteListItems({ userId });
16 | return json({ noteListItems });
17 | };
18 |
19 | export default function NotesPage() {
20 | const data = useLoaderData() as LoaderData;
21 | const user = useUser();
22 |
23 | return (
24 |
25 |
26 |
27 | Notes
28 |
29 | {user.email}
30 |
38 |
39 |
40 |
41 |
42 |
43 | + New Note
44 |
45 |
46 |
47 |
48 | {data.noteListItems.length === 0 ? (
49 |
No notes yet
50 | ) : (
51 |
52 | {data.noteListItems.map((note) => (
53 | -
54 |
56 | `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}`
57 | }
58 | to={note.id}
59 | >
60 | 📝 {note.title}
61 |
62 |
63 | ))}
64 |
65 | )}
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/notes/$noteId.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunction, LoaderFunction } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { Form, useCatch, useLoaderData } from "@remix-run/react";
4 | import invariant from "tiny-invariant";
5 |
6 | import type { Note } from "~/models/note.server";
7 | import { deleteNote } from "~/models/note.server";
8 | import { getNote } from "~/models/note.server";
9 | import { requireUserId } from "~/session.server";
10 |
11 | type LoaderData = {
12 | note: Note;
13 | };
14 |
15 | export const loader: LoaderFunction = async ({ request, params }) => {
16 | const userId = await requireUserId(request);
17 | invariant(params.noteId, "noteId not found");
18 |
19 | const note = await getNote({ userId, id: params.noteId });
20 | if (!note) {
21 | throw new Response("Not Found", { status: 404 });
22 | }
23 | return json({ note });
24 | };
25 |
26 | export const action: ActionFunction = async ({ request, params }) => {
27 | const userId = await requireUserId(request);
28 | invariant(params.noteId, "noteId not found");
29 |
30 | await deleteNote({ userId, id: params.noteId });
31 |
32 | return redirect("/notes");
33 | };
34 |
35 | export default function NoteDetailsPage() {
36 | const data = useLoaderData() as LoaderData;
37 |
38 | return (
39 |
40 |
{data.note.title}
41 |
{data.note.body}
42 |
43 |
51 |
52 | );
53 | }
54 |
55 | export function ErrorBoundary({ error }: { error: Error }) {
56 | console.error(error);
57 |
58 | return An unexpected error occurred: {error.message}
;
59 | }
60 |
61 | export function CatchBoundary() {
62 | const caught = useCatch();
63 |
64 | if (caught.status === 404) {
65 | return Note not found
;
66 | }
67 |
68 | throw new Error(`Unexpected caught response with status: ${caught.status}`);
69 | }
70 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/notes/index.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 |
3 | export default function NoteIndexPage() {
4 | return (
5 |
6 | No note selected. Select a note on the left, or{" "}
7 |
8 | create a new note.
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/apps/remix/app/routes/notes/new.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunction } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { Form, useActionData } from "@remix-run/react";
4 | import * as React from "react";
5 |
6 | import { createNote } from "~/models/note.server";
7 | import { requireUserId } from "~/session.server";
8 |
9 | type ActionData = {
10 | errors?: {
11 | title?: string;
12 | body?: string;
13 | };
14 | };
15 |
16 | export const action: ActionFunction = async ({ request }) => {
17 | const userId = await requireUserId(request);
18 |
19 | const formData = await request.formData();
20 | const title = formData.get("title");
21 | const body = formData.get("body");
22 |
23 | if (typeof title !== "string" || title.length === 0) {
24 | return json(
25 | { errors: { title: "Title is required" } },
26 | { status: 400 }
27 | );
28 | }
29 |
30 | if (typeof body !== "string" || body.length === 0) {
31 | return json(
32 | { errors: { body: "Body is required" } },
33 | { status: 400 }
34 | );
35 | }
36 |
37 | const note = await createNote({ title, body, userId });
38 |
39 | return redirect(`/notes/${note.id}`);
40 | };
41 |
42 | export default function NewNotePage() {
43 | const actionData = useActionData() as ActionData;
44 | const titleRef = React.useRef(null);
45 | const bodyRef = React.useRef(null);
46 |
47 | React.useEffect(() => {
48 | if (actionData?.errors?.title) {
49 | titleRef.current?.focus();
50 | } else if (actionData?.errors?.body) {
51 | bodyRef.current?.focus();
52 | }
53 | }, [actionData]);
54 |
55 | return (
56 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/apps/remix/app/session.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage, redirect } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 |
4 | import type { User } from "~/models/user.server";
5 | import { getUserById } from "~/models/user.server";
6 |
7 | invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
8 |
9 | export const sessionStorage = createCookieSessionStorage({
10 | cookie: {
11 | name: "__session",
12 | httpOnly: true,
13 | path: "/",
14 | sameSite: "lax",
15 | secrets: [process.env.SESSION_SECRET],
16 | secure: process.env.NODE_ENV === "production",
17 | },
18 | });
19 |
20 | const USER_SESSION_KEY = "userId";
21 |
22 | export async function getSession(request: Request) {
23 | const cookie = request.headers.get("Cookie");
24 | return sessionStorage.getSession(cookie);
25 | }
26 |
27 | export async function getUserId(
28 | request: Request
29 | ): Promise {
30 | const session = await getSession(request);
31 | const userId = session.get(USER_SESSION_KEY);
32 | return userId;
33 | }
34 |
35 | export async function getUser(request: Request) {
36 | const userId = await getUserId(request);
37 | if (userId === undefined) return null;
38 |
39 | const user = await getUserById(userId);
40 | if (user) return user;
41 |
42 | throw await logout(request);
43 | }
44 |
45 | export async function requireUserId(
46 | request: Request,
47 | redirectTo: string = new URL(request.url).pathname
48 | ) {
49 | const userId = await getUserId(request);
50 | if (!userId) {
51 | const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
52 | throw redirect(`/login?${searchParams}`);
53 | }
54 | return userId;
55 | }
56 |
57 | export async function requireUser(request: Request) {
58 | const userId = await requireUserId(request);
59 |
60 | const user = await getUserById(userId);
61 | if (user) return user;
62 |
63 | throw await logout(request);
64 | }
65 |
66 | export async function createUserSession({
67 | request,
68 | userId,
69 | remember,
70 | redirectTo,
71 | }: {
72 | request: Request;
73 | userId: string;
74 | remember: boolean;
75 | redirectTo: string;
76 | }) {
77 | const session = await getSession(request);
78 | session.set(USER_SESSION_KEY, userId);
79 | return redirect(redirectTo, {
80 | headers: {
81 | "Set-Cookie": await sessionStorage.commitSession(session, {
82 | maxAge: remember
83 | ? 60 * 60 * 24 * 7 // 7 days
84 | : undefined,
85 | }),
86 | },
87 | });
88 | }
89 |
90 | export async function logout(request: Request) {
91 | const session = await getSession(request);
92 | return redirect("/", {
93 | headers: {
94 | "Set-Cookie": await sessionStorage.destroySession(session),
95 | },
96 | });
97 | }
98 |
--------------------------------------------------------------------------------
/apps/remix/app/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/apps/remix/app/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { validateEmail } from "./utils";
2 |
3 | test("validateEmail returns false for non-emails", () => {
4 | expect(validateEmail(undefined)).toBe(false);
5 | expect(validateEmail(null)).toBe(false);
6 | expect(validateEmail("")).toBe(false);
7 | expect(validateEmail("not-an-email")).toBe(false);
8 | expect(validateEmail("n@")).toBe(false);
9 | });
10 |
11 | test("validateEmail returns true for emails", () => {
12 | expect(validateEmail("kody@example.com")).toBe(true);
13 | });
14 |
--------------------------------------------------------------------------------
/apps/remix/app/utils.ts:
--------------------------------------------------------------------------------
1 | import { useMatches } from "@remix-run/react";
2 | import { useMemo } from "react";
3 |
4 | import type { User } from "~/models/user.server";
5 |
6 | const DEFAULT_REDIRECT = "/";
7 |
8 | /**
9 | * This should be used any time the redirect path is user-provided
10 | * (Like the query string on our login/signup pages). This avoids
11 | * open-redirect vulnerabilities.
12 | * @param {string} to The redirect destination
13 | * @param {string} defaultRedirect The redirect to use if the to is unsafe.
14 | */
15 | export function safeRedirect(
16 | to: FormDataEntryValue | string | null | undefined,
17 | defaultRedirect: string = DEFAULT_REDIRECT
18 | ) {
19 | if (!to || typeof to !== "string") {
20 | return defaultRedirect;
21 | }
22 |
23 | if (!to.startsWith("/") || to.startsWith("//")) {
24 | return defaultRedirect;
25 | }
26 |
27 | return to;
28 | }
29 |
30 | /**
31 | * This base hook is used in other hooks to quickly search for specific data
32 | * across all loader data using useMatches.
33 | * @param {string} id The route id
34 | * @returns {JSON|undefined} The router data or undefined if not found
35 | */
36 | export function useMatchesData(
37 | id: string
38 | ): Record | undefined {
39 | const matchingRoutes = useMatches();
40 | const route = useMemo(
41 | () => matchingRoutes.find((route) => route.id === id),
42 | [matchingRoutes, id]
43 | );
44 | return route?.data;
45 | }
46 |
47 | function isUser(user: any): user is User {
48 | return user && typeof user === "object" && typeof user.email === "string";
49 | }
50 |
51 | export function useOptionalUser(): User | undefined {
52 | const data = useMatchesData("root");
53 | if (!data || !isUser(data.user)) {
54 | return undefined;
55 | }
56 | return data.user;
57 | }
58 |
59 | export function useUser(): User {
60 | const maybeUser = useOptionalUser();
61 | if (!maybeUser) {
62 | throw new Error(
63 | "No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
64 | );
65 | }
66 | return maybeUser;
67 | }
68 |
69 | export function validateEmail(email: unknown): email is string {
70 | return typeof email === "string" && email.length > 3 && email.includes("@");
71 | }
72 |
--------------------------------------------------------------------------------
/apps/remix/cypress.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/apps/remix/cypress/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parserOptions: {
3 | tsconfigRootDir: __dirname,
4 | project: "./tsconfig.json",
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/remix/cypress/e2e/smoke.ts:
--------------------------------------------------------------------------------
1 | import faker from "@faker-js/faker";
2 |
3 | describe("smoke tests", () => {
4 | afterEach(() => {
5 | cy.cleanupUser();
6 | });
7 |
8 | it("should allow you to register and login", () => {
9 | const loginForm = {
10 | email: `${faker.internet.userName()}@example.com`,
11 | password: faker.internet.password(),
12 | };
13 | cy.then(() => ({ email: loginForm.email })).as("user");
14 |
15 | cy.visit("/");
16 | cy.findByRole("link", { name: /sign up/i }).click();
17 |
18 | cy.findByRole("textbox", { name: /email/i }).type(loginForm.email);
19 | cy.findByLabelText(/password/i).type(loginForm.password);
20 | cy.findByRole("button", { name: /create account/i }).click();
21 |
22 | cy.findByRole("link", { name: /notes/i }).click();
23 | cy.findByRole("button", { name: /logout/i }).click();
24 | cy.findByRole("link", { name: /log in/i });
25 | });
26 |
27 | it("should allow you to make a note", () => {
28 | const testNote = {
29 | title: faker.lorem.words(1),
30 | body: faker.lorem.sentences(1),
31 | };
32 | cy.login();
33 | cy.visit("/");
34 |
35 | cy.findByRole("link", { name: /notes/i }).click();
36 | cy.findByText("No notes yet");
37 |
38 | cy.findByRole("link", { name: /\+ new note/i }).click();
39 |
40 | cy.findByRole("textbox", { name: /title/i }).type(testNote.title);
41 | cy.findByRole("textbox", { name: /body/i }).type(testNote.body);
42 | cy.findByRole("button", { name: /save/i }).click();
43 |
44 | cy.findByRole("button", { name: /delete/i }).click();
45 |
46 | cy.findByText("No notes yet");
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/apps/remix/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/apps/remix/cypress/plugins/index.ts:
--------------------------------------------------------------------------------
1 | module.exports = (
2 | on: Cypress.PluginEvents,
3 | config: Cypress.PluginConfigOptions
4 | ) => {
5 | const isDev = config.watchForFileChanges;
6 | const port = process.env.PORT ?? (isDev ? "3000" : "8811");
7 | const configOverrides: Partial = {
8 | baseUrl: `http://localhost:${port}`,
9 | integrationFolder: "cypress/e2e",
10 | video: !process.env.CI,
11 | screenshotOnRunFailure: !process.env.CI,
12 | };
13 | Object.assign(config, configOverrides);
14 |
15 | // To use this:
16 | // cy.task('log', whateverYouWantInTheTerminal)
17 | on("task", {
18 | log(message) {
19 | console.log(message);
20 | return null;
21 | },
22 | });
23 |
24 | return config;
25 | };
26 |
--------------------------------------------------------------------------------
/apps/remix/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | import faker from "@faker-js/faker";
2 |
3 | declare global {
4 | namespace Cypress {
5 | interface Chainable {
6 | /**
7 | * Logs in with a random user. Yields the user and adds an alias to the user
8 | *
9 | * @returns {typeof login}
10 | * @memberof Chainable
11 | * @example
12 | * cy.login()
13 | * @example
14 | * cy.login({ email: 'whatever@example.com' })
15 | */
16 | login: typeof login;
17 |
18 | /**
19 | * Deletes the current @user
20 | *
21 | * @returns {typeof cleanupUser}
22 | * @memberof Chainable
23 | * @example
24 | * cy.cleanupUser()
25 | * @example
26 | * cy.cleanupUser({ email: 'whatever@example.com' })
27 | */
28 | cleanupUser: typeof cleanupUser;
29 | }
30 | }
31 | }
32 |
33 | function login({
34 | email = faker.internet.email(undefined, undefined, "example.com"),
35 | }: {
36 | email?: string;
37 | } = {}) {
38 | cy.then(() => ({ email })).as("user");
39 | cy.exec(
40 | `npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts "${email}"`
41 | ).then(({ stdout }) => {
42 | const cookieValue = stdout
43 | .replace(/.*(?.*)<\/cookie>.*/s, "$")
44 | .trim();
45 | cy.setCookie("__session", cookieValue);
46 | });
47 | return cy.get("@user");
48 | }
49 |
50 | function cleanupUser({ email }: { email?: string } = {}) {
51 | if (email) {
52 | deleteUserByEmail(email);
53 | } else {
54 | cy.get("@user").then((user) => {
55 | const email = (user as { email?: string }).email;
56 | if (email) {
57 | deleteUserByEmail(email);
58 | }
59 | });
60 | }
61 | cy.clearCookie("__session");
62 | }
63 |
64 | function deleteUserByEmail(email: string) {
65 | cy.exec(
66 | `npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts "${email}"`
67 | );
68 | cy.clearCookie("__session");
69 | }
70 |
71 | Cypress.Commands.add("login", login);
72 | Cypress.Commands.add("cleanupUser", cleanupUser);
73 |
74 | /*
75 | eslint
76 | @typescript-eslint/no-namespace: "off",
77 | */
78 |
--------------------------------------------------------------------------------
/apps/remix/cypress/support/create-user.ts:
--------------------------------------------------------------------------------
1 | // Use this to create a new user and login with that user
2 | // Simply call this with:
3 | // npx ts-node --require tsconfig-paths/register ./cypress/support/create-user.ts username@example.com
4 | // and it will log out the cookie value you can use to interact with the server
5 | // as that new user.
6 |
7 | import { parse } from "cookie";
8 | import { installGlobals } from "@remix-run/node/globals";
9 | import { createUserSession } from "~/session.server";
10 | import { createUser } from "~/models/user.server";
11 |
12 | installGlobals();
13 |
14 | async function createAndLogin(email: string) {
15 | if (!email) {
16 | throw new Error("email required for login");
17 | }
18 | if (!email.endsWith("@example.com")) {
19 | throw new Error("All test emails must end in @example.com");
20 | }
21 |
22 | const user = await createUser(email, "myreallystrongpassword");
23 |
24 | const response = await createUserSession({
25 | request: new Request(""),
26 | userId: user.id,
27 | remember: false,
28 | redirectTo: "/",
29 | });
30 |
31 | const cookieValue = response.headers.get("Set-Cookie");
32 | if (!cookieValue) {
33 | throw new Error("Cookie missing from createUserSession response");
34 | }
35 | const parsedCookie = parse(cookieValue);
36 | // we log it like this so our cypress command can parse it out and set it as
37 | // the cookie value.
38 | console.log(
39 | `
40 |
41 | ${parsedCookie.__session}
42 |
43 | `.trim()
44 | );
45 | }
46 |
47 | createAndLogin(process.argv[2]);
48 |
--------------------------------------------------------------------------------
/apps/remix/cypress/support/delete-user.ts:
--------------------------------------------------------------------------------
1 | // Use this to delete a user by their email
2 | // Simply call this with:
3 | // npx ts-node --require tsconfig-paths/register ./cypress/support/delete-user.ts username@example.com
4 | // and that user will get deleted
5 |
6 | import { installGlobals } from "@remix-run/node/globals";
7 | import { prisma } from "~/db.server";
8 |
9 | installGlobals();
10 |
11 | async function deleteUser(email: string) {
12 | if (!email) {
13 | throw new Error("email required for login");
14 | }
15 | if (!email.endsWith("@example.com")) {
16 | throw new Error("All test emails must end in @example.com");
17 | }
18 |
19 | await prisma.user.delete({ where: { email } });
20 | }
21 |
22 | deleteUser(process.argv[2]);
23 |
--------------------------------------------------------------------------------
/apps/remix/cypress/support/index.ts:
--------------------------------------------------------------------------------
1 | import "@testing-library/cypress/add-commands";
2 | import "./commands";
3 |
--------------------------------------------------------------------------------
/apps/remix/cypress/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [
3 | "../node_modules/@types/jest",
4 | "../node_modules/@testing-library/jest-dom"
5 | ],
6 | "include": [
7 | "./index.ts",
8 | "e2e/**/*",
9 | "plugins/**/*",
10 | "support/**/*",
11 | "../node_modules/cypress",
12 | "../node_modules/@testing-library/cypress"
13 | ],
14 | "compilerOptions": {
15 | "baseUrl": ".",
16 | "noEmit": true,
17 | "types": ["node", "cypress", "@testing-library/cypress"],
18 | "esModuleInterop": true,
19 | "jsx": "react",
20 | "moduleResolution": "node",
21 | "target": "es2019",
22 | "strict": true,
23 | "skipLibCheck": true,
24 | "resolveJsonModule": true,
25 | "typeRoots": ["../types", "../node_modules/@types"],
26 |
27 | "paths": {
28 | "~/*": ["../app/*"]
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/remix/fly.toml:
--------------------------------------------------------------------------------
1 | app = "remix-5961"
2 | kill_signal = "SIGINT"
3 | kill_timeout = 5
4 | processes = [ ]
5 |
6 | [experimental]
7 | allowed_public_ports = [ ]
8 | auto_rollback = true
9 | cmd = "start.sh"
10 | entrypoint = "sh"
11 |
12 | [mounts]
13 | source = "data"
14 | destination = "/data"
15 |
16 | [[services]]
17 | internal_port = 8_080
18 | processes = [ "app" ]
19 | protocol = "tcp"
20 | script_checks = [ ]
21 |
22 | [services.concurrency]
23 | hard_limit = 25
24 | soft_limit = 20
25 | type = "connections"
26 |
27 | [[services.ports]]
28 | handlers = [ "http" ]
29 | port = 80
30 | force_https = true
31 |
32 | [[services.ports]]
33 | handlers = [ "tls", "http" ]
34 | port = 443
35 |
36 | [[services.tcp_checks]]
37 | grace_period = "1s"
38 | interval = "15s"
39 | restart_limit = 0
40 | timeout = "2s"
41 |
42 | [[services.http_checks]]
43 | interval = 10_000
44 | grace_period = "5s"
45 | method = "get"
46 | path = "/healthcheck"
47 | protocol = "http"
48 | timeout = 2_000
49 | tls_skip_verify = false
50 | headers = { }
51 |
--------------------------------------------------------------------------------
/apps/remix/mocks/README.md:
--------------------------------------------------------------------------------
1 | # Mocks
2 |
3 | Use this to mock any third party HTTP resources that you don't have running locally and want to have mocked for local development as well as tests.
4 |
5 | Learn more about how to use this at [mswjs.io](https://mswjs.io/)
6 |
7 | For an extensive example, see the [source code for kentcdodds.com](https://github.com/kentcdodds/kentcdodds.com/blob/main/mocks/start.ts)
8 |
--------------------------------------------------------------------------------
/apps/remix/mocks/index.js:
--------------------------------------------------------------------------------
1 | require("tsconfig-paths/register");
2 | require("ts-node").register({ transpileOnly: true });
3 | require("./start");
4 |
--------------------------------------------------------------------------------
/apps/remix/mocks/start.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from "msw/node";
2 |
3 | import "~/utils";
4 |
5 | const server = setupServer();
6 |
7 | server.listen({ onUnhandledRequest: "bypass" });
8 | console.info("🔶 Mock server running");
9 |
10 | process.once("SIGINT", () => server.close());
11 | process.once("SIGTERM", () => server.close());
12 |
--------------------------------------------------------------------------------
/apps/remix/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-app",
3 | "private": true,
4 | "description": "",
5 | "license": "",
6 | "sideEffects": false,
7 | "scripts": {
8 | "postinstall": "remix setup node",
9 | "build": "run-s build:*",
10 | "build:css": "npm run generate:css -- --minify",
11 | "build:remix": "remix build",
12 | "dev": "run-p dev:*",
13 | "dev:css": "npm run generate:css -- --watch",
14 | "dev:remix": "cross-env NODE_ENV=development binode --require ./mocks -- @remix-run/dev:remix dev",
15 | "format": "prettier --write .",
16 | "generate:css": "tailwindcss -o ./app/styles/tailwind.css",
17 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
18 | "setup": "prisma migrate dev && prisma db seed",
19 | "start": "remix-serve build",
20 | "start:mocks": "binode --require ./mocks -- @remix-run/serve:remix-serve build",
21 | "test": "vitest",
22 | "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"cypress open\"",
23 | "pretest:e2e:run": "npm run build",
24 | "test:e2e:run": "cross-env PORT=8811 start-server-and-test start:mocks http://localhost:8811 \"cypress run\"",
25 | "typecheck": "tsc -b && tsc -b cypress",
26 | "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run"
27 | },
28 | "prettier": {},
29 | "eslintIgnore": [
30 | "/node_modules",
31 | "/build",
32 | "/public/build"
33 | ],
34 | "dependencies": {
35 | "@prisma/client": "^3.11.1",
36 | "@remix-run/node": "^1.4.1",
37 | "@remix-run/react": "^1.4.1",
38 | "@remix-run/serve": "^1.4.1",
39 | "@remix-run/server-runtime": "^1.4.1",
40 | "bcryptjs": "^2.4.3",
41 | "click-to-react-component": "workspace:^1.1.2",
42 | "react": "^17.0.2",
43 | "react-dom": "^17.0.2",
44 | "tiny-invariant": "^1.2.0"
45 | },
46 | "devDependencies": {
47 | "@faker-js/faker": "^6.1.1",
48 | "@remix-run/dev": "^1.4.1",
49 | "@remix-run/eslint-config": "^1.4.1",
50 | "@testing-library/cypress": "^8.0.2",
51 | "@testing-library/dom": "^8.12.0",
52 | "@testing-library/jest-dom": "^5.16.3",
53 | "@testing-library/react": "^12.1.4",
54 | "@testing-library/user-event": "^14.0.4",
55 | "@types/bcryptjs": "^2.4.2",
56 | "@types/eslint": "^8.4.1",
57 | "@types/node": "^17.0.23",
58 | "@types/react": "^17.0.43",
59 | "@types/react-dom": "^17.0.14",
60 | "@vitejs/plugin-react": "^1.3.0",
61 | "autoprefixer": "^10.4.4",
62 | "binode": "^1.0.5",
63 | "c8": "^7.11.0",
64 | "cross-env": "^7.0.3",
65 | "cypress": "^9.5.3",
66 | "eslint": "^8.12.0",
67 | "eslint-config-prettier": "^8.5.0",
68 | "happy-dom": "^2.55.0",
69 | "msw": "^0.39.2",
70 | "npm-run-all": "^4.1.5",
71 | "postcss": "^8.4.12",
72 | "prettier": "2.6.1",
73 | "prettier-plugin-tailwindcss": "^0.1.8",
74 | "prisma": "^3.11.1",
75 | "start-server-and-test": "^1.14.0",
76 | "tailwindcss": "^3.0.23",
77 | "ts-node": "^10.7.0",
78 | "tsconfig-paths": "^3.14.1",
79 | "typescript": "^4.6.3",
80 | "vite": "^2.9.1",
81 | "vite-tsconfig-paths": "^3.4.1",
82 | "vitest": "^0.8.2"
83 | },
84 | "engines": {
85 | "node": ">=14"
86 | },
87 | "prisma": {
88 | "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/apps/remix/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/remix/prisma/migrations/20220307190657_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" TEXT NOT NULL PRIMARY KEY,
4 | "email" TEXT NOT NULL,
5 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
6 | "updatedAt" DATETIME NOT NULL
7 | );
8 |
9 | -- CreateTable
10 | CREATE TABLE "Password" (
11 | "hash" TEXT NOT NULL,
12 | "userId" TEXT NOT NULL,
13 | CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
14 | );
15 |
16 | -- CreateTable
17 | CREATE TABLE "Note" (
18 | "id" TEXT NOT NULL PRIMARY KEY,
19 | "title" TEXT NOT NULL,
20 | "body" TEXT NOT NULL,
21 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
22 | "updatedAt" DATETIME NOT NULL,
23 | "userId" TEXT NOT NULL,
24 | CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
25 | );
26 |
27 | -- CreateIndex
28 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
29 |
30 | -- CreateIndex
31 | CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
32 |
--------------------------------------------------------------------------------
/apps/remix/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "sqlite"
--------------------------------------------------------------------------------
/apps/remix/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "sqlite"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model User {
11 | id String @id @default(cuid())
12 | email String @unique
13 |
14 | createdAt DateTime @default(now())
15 | updatedAt DateTime @updatedAt
16 |
17 | password Password?
18 | notes Note[]
19 | }
20 |
21 | model Password {
22 | hash String
23 |
24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
25 | userId String @unique
26 | }
27 |
28 | model Note {
29 | id String @id @default(cuid())
30 | title String
31 | body String
32 |
33 | createdAt DateTime @default(now())
34 | updatedAt DateTime @updatedAt
35 |
36 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
37 | userId String
38 | }
39 |
--------------------------------------------------------------------------------
/apps/remix/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | import bcrypt from "bcryptjs";
3 |
4 | const prisma = new PrismaClient();
5 |
6 | async function seed() {
7 | const email = "rachel@remix.run";
8 |
9 | // cleanup the existing database
10 | await prisma.user.delete({ where: { email } }).catch(() => {
11 | // no worries if it doesn't exist yet
12 | });
13 |
14 | const hashedPassword = await bcrypt.hash("racheliscool", 10);
15 |
16 | const user = await prisma.user.create({
17 | data: {
18 | email,
19 | password: {
20 | create: {
21 | hash: hashedPassword,
22 | },
23 | },
24 | },
25 | });
26 |
27 | await prisma.note.create({
28 | data: {
29 | title: "My first note",
30 | body: "Hello, world!",
31 | userId: user.id,
32 | },
33 | });
34 |
35 | await prisma.note.create({
36 | data: {
37 | title: "My second note",
38 | body: "Hello, world!",
39 | userId: user.id,
40 | },
41 | });
42 |
43 | console.log(`Database has been seeded. 🌱`);
44 | }
45 |
46 | seed()
47 | .catch((e) => {
48 | console.error(e);
49 | process.exit(1);
50 | })
51 | .finally(async () => {
52 | await prisma.$disconnect();
53 | });
54 |
--------------------------------------------------------------------------------
/apps/remix/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ericclemmons/click-to-component/ba280452494d4163a97eeaa83be35349e3d27a65/apps/remix/public/favicon.ico
--------------------------------------------------------------------------------
/apps/remix/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev').AppConfig}
3 | */
4 | module.exports = {
5 | cacheDirectory: "./node_modules/.cache/remix",
6 | ignoredRouteFiles: [".*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"],
7 | serverDependenciesToBundle: ["click-to-react-component"],
8 | };
9 |
--------------------------------------------------------------------------------
/apps/remix/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/apps/remix/remix.init/index.js:
--------------------------------------------------------------------------------
1 | const { execSync } = require("child_process");
2 | const crypto = require("crypto");
3 | const fs = require("fs/promises");
4 | const path = require("path");
5 | const inquirer = require("inquirer");
6 |
7 | const toml = require("@iarna/toml");
8 | const sort = require("sort-package-json");
9 |
10 | function escapeRegExp(string) {
11 | // $& means the whole matched string
12 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13 | }
14 |
15 | function getRandomString(length) {
16 | return crypto.randomBytes(length).toString("hex");
17 | }
18 |
19 | async function main({ rootDirectory }) {
20 | const README_PATH = path.join(rootDirectory, "README.md");
21 | const FLY_TOML_PATH = path.join(rootDirectory, "fly.toml");
22 | const EXAMPLE_ENV_PATH = path.join(rootDirectory, ".env.example");
23 | const ENV_PATH = path.join(rootDirectory, ".env");
24 | const PACKAGE_JSON_PATH = path.join(rootDirectory, "package.json");
25 |
26 | const REPLACER = "indie-stack-template";
27 |
28 | const DIR_NAME = path.basename(rootDirectory);
29 | const SUFFIX = getRandomString(2);
30 |
31 | const APP_NAME = (DIR_NAME + "-" + SUFFIX)
32 | // get rid of anything that's not allowed in an app name
33 | .replace(/[^a-zA-Z0-9-_]/g, "-");
34 |
35 | const [prodContent, readme, env, packageJson] = await Promise.all([
36 | fs.readFile(FLY_TOML_PATH, "utf-8"),
37 | fs.readFile(README_PATH, "utf-8"),
38 | fs.readFile(EXAMPLE_ENV_PATH, "utf-8"),
39 | fs.readFile(PACKAGE_JSON_PATH, "utf-8"),
40 | fs.rm(path.join(rootDirectory, ".github/ISSUE_TEMPLATE"), {
41 | recursive: true,
42 | }),
43 | fs.rm(path.join(rootDirectory, ".github/PULL_REQUEST_TEMPLATE.md")),
44 | ]);
45 |
46 | const newEnv = env.replace(
47 | /^SESSION_SECRET=.*$/m,
48 | `SESSION_SECRET="${getRandomString(16)}"`
49 | );
50 |
51 | const prodToml = toml.parse(prodContent);
52 | prodToml.app = prodToml.app.replace(REPLACER, APP_NAME);
53 |
54 | const newReadme = readme.replace(
55 | new RegExp(escapeRegExp(REPLACER), "g"),
56 | APP_NAME
57 | );
58 |
59 | const newPackageJson =
60 | JSON.stringify(
61 | sort({ ...JSON.parse(packageJson), name: APP_NAME }),
62 | null,
63 | 2
64 | ) + "\n";
65 |
66 | await Promise.all([
67 | fs.writeFile(FLY_TOML_PATH, toml.stringify(prodToml)),
68 | fs.writeFile(README_PATH, newReadme),
69 | fs.writeFile(ENV_PATH, newEnv),
70 | fs.writeFile(PACKAGE_JSON_PATH, newPackageJson),
71 | ]);
72 |
73 | execSync(`npm run setup`, { stdio: "inherit", cwd: rootDirectory });
74 |
75 | // TODO: There is currently an issue with the test cleanup script that results
76 | // in an error when running Cypress in some cases. Add this question back
77 | // when this is fixed.
78 | // await askSetupQuestions({ rootDirectory }).catch((error) => {
79 | // if (error.isTtyError) {
80 | // // Prompt couldn't be rendered in the current environment
81 | // } else {
82 | // throw error;
83 | // }
84 | // });
85 |
86 | console.log(
87 | `Setup is complete. You're now ready to rock and roll 🤘
88 |
89 | Start development with \`npm run dev\`
90 | `.trim()
91 | );
92 | }
93 |
94 | async function askSetupQuestions({ rootDirectory }) {
95 | const answers = await inquirer.prompt([
96 | {
97 | name: "validate",
98 | type: "confirm",
99 | default: false,
100 | message:
101 | "Do you want to run the build/tests/etc to verify things are setup properly?",
102 | },
103 | ]);
104 |
105 | if (answers.validate) {
106 | console.log(
107 | `Running the validate script to make sure everything was set up properly`
108 | );
109 | execSync(`npm run validate`, { stdio: "inherit", cwd: rootDirectory });
110 | }
111 | }
112 |
113 | module.exports = main;
114 |
--------------------------------------------------------------------------------
/apps/remix/remix.init/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix.init",
3 | "private": true,
4 | "main": "index.js",
5 | "license": "MIT",
6 | "dependencies": {
7 | "@iarna/toml": "^2.2.5",
8 | "inquirer": "^8.2.2",
9 | "sort-package-json": "^1.55.0"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/apps/remix/start.sh:
--------------------------------------------------------------------------------
1 | # This file is how Fly starts the server (configured in fly.toml). Before starting
2 | # the server though, we need to run any prisma migrations that haven't yet been
3 | # run, which is why this file exists in the first place.
4 | # Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
5 |
6 | #!/bin/sh
7 |
8 | set -ex
9 | npx prisma migrate deploy
10 | npm run start
11 |
--------------------------------------------------------------------------------
/apps/remix/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: ["./app/**/*.{js,jsx,ts,tsx}"],
5 | theme: {
6 | extend: {},
7 | },
8 | plugins: [],
9 | } satisfies Config;
10 |
--------------------------------------------------------------------------------
/apps/remix/test/setup-test-env.ts:
--------------------------------------------------------------------------------
1 | import { installGlobals } from "@remix-run/node/globals";
2 | import "@testing-library/jest-dom/extend-expect";
3 |
4 | installGlobals();
5 |
--------------------------------------------------------------------------------
/apps/remix/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["./cypress"],
3 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
4 | "compilerOptions": {
5 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
6 | "types": ["vitest/globals"],
7 | "isolatedModules": true,
8 | "esModuleInterop": true,
9 | "jsx": "react-jsx",
10 | "module": "CommonJS",
11 | "moduleResolution": "node",
12 | "resolveJsonModule": true,
13 | "target": "ES2019",
14 | "strict": true,
15 | "baseUrl": ".",
16 | "paths": {
17 | "~/*": ["./app/*"]
18 | },
19 | "skipLibCheck": true,
20 | "noEmit": true,
21 | "allowJs": true,
22 | "forceConsistentCasingInFileNames": true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/apps/remix/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | import { defineConfig } from "vite";
5 | import react from "@vitejs/plugin-react";
6 | import tsconfigPaths from "vite-tsconfig-paths";
7 |
8 | export default defineConfig({
9 | plugins: [react(), tsconfigPaths()],
10 | test: {
11 | globals: true,
12 | environment: "happy-dom",
13 | setupFiles: ["./test/setup-test-env.ts"],
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "main": "index.js",
4 | "scripts": {
5 | "build": "turbo run build --filter=click-to-*-component",
6 | "env": "env",
7 | "dev": "turbo run dev --parallel",
8 | "lint": "turbo run lint --filter=click-to-*-component",
9 | "prerelease": "pnpm run build",
10 | "release": "changeset publish"
11 | },
12 | "devDependencies": {
13 | "@changesets/cli": "^2.23.0",
14 | "eslint": "8.13.0",
15 | "turbo": "^1.2.16"
16 | },
17 | "packageManager": "pnpm@6.32.3"
18 | }
19 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # click-to-react-component
2 |
3 | ## 1.1.2
4 |
5 | ### Patch Changes
6 |
7 | - 32bd930: feat: Fix lots of bugs with getting source
8 |
9 | This works with newer versions of React, too. But, importantly, the algorithm to get the `source` is way more accurate.
10 |
11 | ## 1.1.1
12 |
13 | ### Patch Changes
14 |
15 | - 06bff87: Fix TypeScript error "Could not find a declaration file for module"
16 |
17 | ## 1.1.0
18 |
19 | ### Minor Changes
20 |
21 | - 9246a89: Add remix/esbuild support by checking the \_debugOwner.\_debugSource
22 |
23 | ## 1.0.10
24 |
25 | ### Patch Changes
26 |
27 | - 1bd62f1: Change outline style to accessible colors:
28 |
29 | >
30 |
31 | - c7cccdb: Prevent bundle from being included in prod build
32 | - b2dbad4: Fix "Cannot find module './src/ClickToComponent' or its corresponding type declarations."
33 |
34 | ## 1.0.9
35 |
36 | ### Patch Changes
37 |
38 | - 3c81f0b: Fixes `data-click-to-component` attribute staying on body indefinitely
39 |
40 | ## 1.0.8
41 |
42 | ### Patch Changes
43 |
44 | - 7661be0: Set floating-ui to max z-index
45 |
46 | ## 1.0.7
47 |
48 | ### Patch Changes
49 |
50 | - 3ae6fcf: Show menu only on Option+Right-click
51 |
52 | ## 1.0.6
53 |
54 | ### Patch Changes
55 |
56 | - 66606f2: - Fix file references
57 | - Fix types references
58 | - Add `lint` script to `click-to-react-component`
59 |
60 | ## 1.0.5
61 |
62 | ### Patch Changes
63 |
64 | - 5cad12c: Update README with demos
65 | - 10fc042: Return `null` instead of `undefined` to fix React warning
66 | - 361b076: Only publish `src` files
67 |
68 | ## 1.0.4
69 |
70 | ### Patch Changes
71 |
72 | - e31bda9: Add package.json#exports
73 |
74 | ## 1.0.3
75 |
76 | ### Patch Changes
77 |
78 | - 6f4d7a3: Move README to packages/click-to-react-component so it gets published on NPM
79 |
80 | ## 1.0.2
81 |
82 | ### Patch Changes
83 |
84 | - 65faad29a82e5a772423bb83cd431de67705377e: Use "type": "module"
85 | - 10125adb511e3164ae7cdf9642864d5bf51503fc: Default `editor` to `vscode`
86 |
87 | ## 1.0.1
88 |
89 | ### Patch Changes
90 |
91 | - 8849558: Remove ClickToComponent from production builds
92 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/README.md:
--------------------------------------------------------------------------------
1 | #
2 |
3 | [](https://www.npmjs.com/package/click-to-react-component)
4 | [](https://github.com/ericclemmons/click-to-component/actions/workflows/release.yml)
5 |
6 | Option+Click a Component in the browser to **instantly** goto the source in your editor.
7 |
8 | 
9 |
10 | ## Features
11 |
12 | - Option+Click opens the immediate Component's source
13 | - Option+Right-click opens a context menu with the parent Components' `props`, `fileName`, `columnNumber`, and `lineNumber`
14 |
15 | > 
16 |
17 | - Works with frameworks like [Next.js](https://nextjs.org/),
18 | [Create React App](https://create-react-app.dev/),
19 | & [Vite](https://github.com/vitejs/vite/tree/main/packages/plugin-react)
20 | that use [@babel/plugin-transform-react-jsx-source](https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-source)
21 | - Supports `vscode` & `vscode-insiders` & `cursor` [URL handling](https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls)
22 | - Automatically **tree-shaken** from `production` builds
23 | - Keyboard navigation in context menu (e.g. ←, →, ⏎)
24 | - More context & faster than using React DevTools:
25 |
26 | > 
27 |
28 | ## Installation
29 |
30 |
31 | npm
32 |
33 | ```shell
34 | npm install click-to-react-component
35 | ```
36 |
37 |
38 |
39 |
40 | pnpm
41 |
42 | ```shell
43 | pnpm add click-to-react-component
44 | ```
45 |
46 |
47 |
48 |
49 | yarn
50 |
51 | ```shell
52 | yarn add click-to-react-component
53 | ```
54 |
55 |
56 |
57 | Even though `click-to-react-component` is added to `dependencies`, [tree-shaking](https://esbuild.github.io/api/#tree-shaking) will remove `click-to-react-component` from `production` builds.
58 |
59 | ## Usage
60 |
61 |
62 | Create React App
63 |
64 | [/src/index.js](https://github.com/ericclemmons/click-to-component/blob/main/apps/cra/src/index.js#L11)
65 |
66 | ```diff
67 | +import { ClickToComponent } from 'click-to-react-component';
68 | import React from 'react';
69 | import ReactDOM from 'react-dom/client';
70 | import './index.css';
71 | @@ -8,7 +7,6 @@ import reportWebVitals from './reportWebVitals';
72 | const root = ReactDOM.createRoot(document.getElementById('root'));
73 | root.render(
74 |
75 | +
76 |
77 |
78 | );
79 | ```
80 |
81 | > 
82 |
83 |
84 |
85 |
86 | Next.js
87 |
88 | [pages/\_app.tsx](https://github.com/ericclemmons/click-to-component/blob/main/apps/next/pages/_app.tsx#L8)
89 |
90 | ```diff
91 | +import { ClickToComponent } from 'click-to-react-component'
92 | import type { AppProps } from 'next/app'
93 | import '../styles/globals.css'
94 |
95 | function MyApp({ Component, pageProps }: AppProps) {
96 | return (
97 | <>
98 | +
99 |
100 | >
101 | )
102 | ```
103 |
104 | > 
105 |
106 |
107 |
108 |
109 | Vite
110 |
111 | ```diff
112 | +import { ClickToComponent } from "click-to-react-component";
113 | import React from "react";
114 | import ReactDOM from "react-dom/client";
115 | import App from "./App";
116 | import "./index.css";
117 |
118 | ReactDOM.createRoot(document.getElementById("root")!).render(
119 |
120 |
121 | +
122 |
123 | );
124 | ```
125 |
126 | > 
127 |
128 |
129 |
130 |
131 | Docusaurus
132 |
133 | npm install @babel/plugin-transform-react-jsx-source
134 |
135 | babel.config.js:
136 |
137 | ```js
138 | module.exports = {
139 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
140 | plugins: [
141 | ...(process.env.BABEL_ENV === 'development'
142 | ? ['@babel/plugin-transform-react-jsx-source']
143 | : []),
144 | ],
145 | };
146 | ```
147 |
148 | src/theme/Root.js:
149 |
150 | ```js
151 | import { ClickToComponent } from 'click-to-react-component';
152 | import React from 'react';
153 |
154 | // Default implementation, that you can customize
155 | export default function Root({ children }) {
156 | return (
157 | <>
158 |
159 | {children}
160 | >
161 | );
162 | }
163 | ```
164 |
165 |
166 |
167 | If [developing in container](https://github.com/ericclemmons/click-to-component/issues/58)?
168 |
169 | ### `editor`
170 |
171 | By default, clicking will default `editor` to [`vscode`](https://code.visualstudio.com/).
172 |
173 | If, like me, you use [`vscode-insiders`](https://code.visualstudio.com/insiders/), you can set `editor` explicitly:
174 |
175 | ```diff
176 | -
177 | +
178 | ```
179 |
180 | ## Run Locally
181 |
182 | Clone the project
183 |
184 | ```shell
185 | gh repo clone ericclemmons/click-to-component
186 | ```
187 |
188 | Go to the project directory
189 |
190 | ```shell
191 | cd click-to-component
192 | ```
193 |
194 | Install dependencies
195 |
196 | ```shell
197 | pnpm install
198 | ```
199 |
200 | Run one of the examples:
201 |
202 |
203 | Create React App
204 |
205 | ```shell
206 | cd apps/cra
207 | pnpm start
208 | ```
209 |
210 |
211 |
212 |
213 | Next.js
214 |
215 | ```shell
216 | cd apps/next
217 | pnpm dev
218 | ```
219 |
220 |
221 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "checkJs": true,
5 | "lib": ["ES2017", "DOM"],
6 | // https://github.com/microsoft/TypeScript/issues/23219#issuecomment-773555768
7 | "maxNodeModuleJsDepth": 0,
8 | "module": "ESNext",
9 | "moduleResolution": "node",
10 | "noEmit": true,
11 | "noImplicitThis": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "name": "click-to-react-component",
4 | "version": "1.1.2",
5 | "description": "Option+Click your React components in your browser to open the source file in VS Code",
6 | "exports": {
7 | "types": "./src/types.d.ts",
8 | "import": "./src/index.js",
9 | "default": "./src/index.js"
10 | },
11 | "types": "src/types.d.ts",
12 | "files": [
13 | "src"
14 | ],
15 | "scripts": {
16 | "build": "echo \"Fuck build tools\"",
17 | "dev": "pnpm run build",
18 | "lint": "tsc -p ./jsconfig.json",
19 | "test": "echo \"Error: no test specified\" && exit 1"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/ericclemmons/click-to-component.git"
24 | },
25 | "keywords": [
26 | "react",
27 | "vue",
28 | "dx",
29 | "vscode",
30 | "devtools"
31 | ],
32 | "author": "Eric Clemmons ",
33 | "license": "ISC",
34 | "bugs": {
35 | "url": "https://github.com/ericclemmons/click-to-component/issues"
36 | },
37 | "homepage": "https://github.com/ericclemmons/click-to-component#readme",
38 | "peerDependencies": {
39 | "react": ">=16.8.0"
40 | },
41 | "dependencies": {
42 | "@floating-ui/react-dom-interactions": "^0.3.1",
43 | "htm": "^3.1.0",
44 | "react-merge-refs": "^1.1.0"
45 | },
46 | "devDependencies": {
47 | "@types/node": "^22.8.6",
48 | "@types/react": "^18.3.12",
49 | "@types/react-reconciler": "^0.28.8",
50 | "eslint": "^8.0.0",
51 | "eslint-config-react-app": "^7.0.1"
52 | },
53 | "sideEffects": false
54 | }
55 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/ClickToComponent.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('./types').ClickToComponentProps} Props
3 | * @typedef {import('./types').Coords} Coords
4 | */
5 |
6 | import { FloatingPortal } from '@floating-ui/react-dom-interactions'
7 | import { html } from 'htm/react'
8 | import * as React from 'react'
9 |
10 | import { ContextMenu } from './ContextMenu.js'
11 | import { getPathToSource } from './getPathToSource.js'
12 | import { getReactInstancesForElement } from './getReactInstancesForElement.js'
13 | import { getSourceForInstance } from './getSourceForInstance.js'
14 | import { getUrl } from './getUrl.js'
15 |
16 | export const State = /** @type {const} */ ({
17 | IDLE: 'IDLE',
18 | HOVER: 'HOVER',
19 | SELECT: 'SELECT',
20 | })
21 |
22 | /**
23 | * @param {Props} props
24 | */
25 | export function ClickToComponent({ editor = 'vscode', pathModifier }) {
26 | const [state, setState] = React.useState(
27 | /** @type {State[keyof State]} */
28 | (State.IDLE)
29 | )
30 |
31 | const [target, setTarget] = React.useState(
32 | /** @type {HTMLElement | null} */
33 | (null)
34 | )
35 |
36 | const onClick = React.useCallback(
37 | function handleClick(
38 | /**
39 | * @type {MouseEvent}
40 | */
41 | event
42 | ) {
43 | if (state === State.HOVER && target instanceof HTMLElement) {
44 | const instance = getReactInstancesForElement(target).find((instance) =>
45 | getSourceForInstance(instance)
46 | )
47 |
48 | if (!instance) {
49 | return console.warn(
50 | 'Could not find React instance for element',
51 | target
52 | )
53 | }
54 |
55 | const source = getSourceForInstance(instance)
56 |
57 | if (!source) {
58 | return console.warn(
59 | 'Could not find source for React instance',
60 | instance
61 | )
62 | }
63 | const path = getPathToSource(source, pathModifier)
64 | const url = getUrl({
65 | editor,
66 | pathToSource: path,
67 | })
68 |
69 | event.preventDefault()
70 | window.location.assign(url)
71 |
72 | setState(State.IDLE)
73 | }
74 | },
75 | [editor, pathModifier, state, target]
76 | )
77 |
78 | const onClose = React.useCallback(
79 | function handleClose(returnValue) {
80 | if (returnValue) {
81 | const url = getUrl({
82 | editor,
83 | pathToSource: returnValue,
84 | })
85 |
86 | window.location.assign(url)
87 | }
88 |
89 | setState(State.IDLE)
90 | },
91 | [editor]
92 | )
93 |
94 | const onContextMenu = React.useCallback(
95 | function handleContextMenu(
96 | /**
97 | * @type {MouseEvent}
98 | */
99 | event
100 | ) {
101 | const { target } = event
102 |
103 | if (state === State.HOVER && target instanceof HTMLElement) {
104 | event.preventDefault()
105 |
106 | setState(State.SELECT)
107 | setTarget(target)
108 | }
109 | },
110 | [state]
111 | )
112 |
113 | const onKeyDown = React.useCallback(
114 | function handleKeyDown(
115 | /**
116 | * @type {KeyboardEvent}
117 | */
118 | event
119 | ) {
120 | switch (state) {
121 | case State.IDLE:
122 | if (event.altKey) setState(State.HOVER)
123 | break
124 |
125 | default:
126 | }
127 | },
128 | [state]
129 | )
130 |
131 | const onKeyUp = React.useCallback(
132 | function handleKeyUp(
133 | /**
134 | * @type {KeyboardEvent}
135 | */
136 | event
137 | ) {
138 | switch (state) {
139 | case State.HOVER:
140 | setState(State.IDLE)
141 | break
142 |
143 | default:
144 | }
145 | },
146 | [state]
147 | )
148 |
149 | const onMouseMove = React.useCallback(
150 | function handleMouseMove(
151 | /** @type {MouseEvent} */
152 | event
153 | ) {
154 | if (!(event.target instanceof HTMLElement)) {
155 | return
156 | }
157 |
158 | switch (state) {
159 | case State.IDLE:
160 | case State.HOVER:
161 | setTarget(event.target)
162 | break
163 |
164 | default:
165 | break
166 | }
167 | },
168 | [state]
169 | )
170 |
171 | const onBlur = React.useCallback(
172 | function handleBlur() {
173 | switch (state) {
174 | case State.HOVER:
175 | setState(State.IDLE)
176 | break
177 |
178 | default:
179 | }
180 | },
181 | [state]
182 | )
183 |
184 | React.useEffect(
185 | function toggleIndicator() {
186 | for (const element of Array.from(
187 | document.querySelectorAll('[data-click-to-component-target]')
188 | )) {
189 | if (element instanceof HTMLElement) {
190 | delete element.dataset.clickToComponentTarget
191 | }
192 | }
193 |
194 | if (state === State.IDLE) {
195 | delete window.document.body.dataset.clickToComponent
196 | if (target) {
197 | delete target.dataset.clickToComponentTarget
198 | }
199 | return
200 | }
201 |
202 | if (target instanceof HTMLElement) {
203 | window.document.body.dataset.clickToComponent = state
204 | target.dataset.clickToComponentTarget = state
205 | }
206 | },
207 | [state, target]
208 | )
209 |
210 | React.useEffect(
211 | function addEventListenersToWindow() {
212 | window.addEventListener('click', onClick, { capture: true })
213 | window.addEventListener('contextmenu', onContextMenu, { capture: true })
214 | window.addEventListener('keydown', onKeyDown)
215 | window.addEventListener('keyup', onKeyUp)
216 | window.addEventListener('mousemove', onMouseMove)
217 | window.addEventListener('blur', onBlur)
218 |
219 | return function removeEventListenersFromWindow() {
220 | window.removeEventListener('click', onClick, { capture: true })
221 | window.removeEventListener('contextmenu', onContextMenu, {
222 | capture: true,
223 | })
224 | window.removeEventListener('keydown', onKeyDown)
225 | window.removeEventListener('keyup', onKeyUp)
226 | window.removeEventListener('mousemove', onMouseMove)
227 | window.removeEventListener('blur', onBlur)
228 | }
229 | },
230 | [onClick, onContextMenu, onKeyDown, onKeyUp, onMouseMove, onBlur]
231 | )
232 |
233 | return html`
234 |
248 |
249 | <${FloatingPortal} key="click-to-component-portal">
250 | ${html`<${ContextMenu}
251 | key="click-to-component-contextmenu"
252 | onClose=${onClose}
253 | pathModifier=${pathModifier}
254 | />`}
255 | ${FloatingPortal}
256 | `
257 | }
258 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/ContextMenu.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react').DialogHTMLAttributes} DialogHTMLAttributes
3 | * @typedef {import('react').HTMLAttributes} HTMLAttributes
4 | * @typedef {import('react').MouseEvent} ReactMouseEvent}
5 | * @typedef {import('./types').ContextMenuProps} Props
6 | */
7 |
8 | import {
9 | arrow,
10 | autoUpdate,
11 | flip,
12 | FloatingFocusManager,
13 | FloatingOverlay,
14 | offset,
15 | shift,
16 | useDismiss,
17 | useFloating,
18 | useInteractions,
19 | useListNavigation,
20 | useRole,
21 | } from '@floating-ui/react-dom-interactions'
22 | import { html } from 'htm/react'
23 | import * as React from 'react'
24 | import mergeRefs from 'react-merge-refs'
25 |
26 | import { getDisplayNameForInstance } from './getDisplayNameFromReactInstance.js'
27 | import { getPathToSource } from './getPathToSource.js'
28 | import { getPropsForInstance } from './getPropsForInstance.js'
29 | import { getReactInstancesForElement } from './getReactInstancesForElement.js'
30 | import { getSourceForInstance } from './getSourceForInstance.js'
31 |
32 | // @ts-expect-error
33 | export const ContextMenu = React.forwardRef((props, ref) => {
34 | // @ts-expect-error
35 | const { onClose, pathModifier } = props
36 |
37 | const [target, setTarget] = React.useState(
38 | /** @type {HTMLElement | null} */
39 | (null)
40 | )
41 |
42 | const arrowRef = React.useRef(
43 | /** @type {HTMLElement | null} */
44 | (null)
45 | )
46 |
47 | const [activeIndex, setActiveIndex] = React.useState(
48 | /** @type {number | null} */
49 | (null)
50 | )
51 |
52 | const [open, setOpen] = React.useState(false)
53 |
54 | const listItemsRef = React.useRef(
55 | /** @type {Array} */
56 | ([])
57 | )
58 |
59 | const {
60 | x,
61 | y,
62 | reference,
63 | floating,
64 | strategy,
65 | refs,
66 | update,
67 | context,
68 | placement,
69 | middlewareData: { arrow: { x: arrowX, y: arrowY } = {} },
70 | } = useFloating({
71 | open,
72 | onOpenChange(open) {
73 | setOpen(open)
74 |
75 | if (!open) onClose?.()
76 | },
77 | middleware: [
78 | offset({ mainAxis: 5, alignmentAxis: 4 }),
79 | flip(),
80 | shift(),
81 | arrow({ element: arrowRef }),
82 | ],
83 | placement: 'right',
84 | })
85 |
86 | const { getFloatingProps, getItemProps } = useInteractions([
87 | useRole(context, { role: 'menu' }),
88 | useDismiss(context),
89 | useListNavigation(context, {
90 | listRef: listItemsRef,
91 | activeIndex,
92 | onNavigate: setActiveIndex,
93 | focusItemOnOpen: false,
94 | }),
95 | ])
96 |
97 | React.useEffect(() => {
98 | if (open && refs.reference.current && refs.floating.current) {
99 | return autoUpdate(refs.reference.current, refs.floating.current, update)
100 | }
101 | }, [open, update, refs.reference, refs.floating])
102 |
103 | const mergedReferenceRef = React.useMemo(
104 | () => mergeRefs([ref, reference]),
105 | [reference, ref]
106 | )
107 |
108 | React.useEffect(() => {
109 | function onContextMenu(
110 | /** @type {MouseEvent} */
111 | e
112 | ) {
113 | if (!e.altKey) {
114 | return
115 | }
116 |
117 | e.preventDefault()
118 | mergedReferenceRef({
119 | getBoundingClientRect() {
120 | return {
121 | x: e.clientX,
122 | y: e.clientY,
123 | width: 0,
124 | height: 0,
125 | top: e.clientY,
126 | right: e.clientX,
127 | bottom: e.clientY,
128 | left: e.clientX,
129 | }
130 | },
131 | })
132 |
133 | setOpen(true)
134 |
135 | if (e.target instanceof HTMLElement) setTarget(e.target)
136 | }
137 |
138 | document.addEventListener('contextmenu', onContextMenu)
139 | return () => {
140 | document.removeEventListener('contextmenu', onContextMenu)
141 | }
142 | }, [mergedReferenceRef])
143 |
144 | React.useLayoutEffect(() => {
145 | if (open) {
146 | refs.floating.current?.focus()
147 | }
148 | }, [open, refs.floating])
149 |
150 | React.useLayoutEffect(() => {
151 | if (!arrowRef.current) return
152 |
153 | const staticSide = {
154 | top: 'bottom',
155 | right: 'left',
156 | bottom: 'top',
157 | left: 'right',
158 | }[placement.split('-')[0]]
159 |
160 | Object.assign(arrowRef.current.style, {
161 | display: 'block',
162 | left: arrowX != null ? `${arrowX}px` : '',
163 | top: arrowY != null ? `${arrowY}px` : '',
164 | right: '',
165 | bottom: '',
166 | [staticSide]: '-4px',
167 | })
168 | }, [arrowX, arrowY, placement])
169 |
170 | if (!target) {
171 | return null
172 | }
173 |
174 | const instances = getReactInstancesForElement(target).filter((instance) =>
175 | getSourceForInstance(instance)
176 | )
177 |
178 | return html`
179 |
299 |
300 | ${open &&
301 | html`
302 | <${FloatingOverlay} key="click-to-component-overlay" lockScroll>
303 | <${FloatingFocusManager} context=${context}>
304 |
371 | ${FloatingFocusManager}>
372 | ${FloatingOverlay}>
373 | `}
374 | `
375 | })
376 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getDisplayNameFromReactInstance.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react-reconciler').Fiber} Fiber
3 | * @param {Fiber} instance
4 | */
5 | export function getDisplayNameForInstance(instance) {
6 | const { elementType, tag } = instance
7 |
8 | // https://github.com/facebook/react/blob/7c8e5e7ab8bb63de911637892392c5efd8ce1d0f/packages/react-reconciler/src/ReactWorkTags.js
9 | switch (tag) {
10 | case 0: // FunctionComponent
11 | case 1: // ClassComponent
12 | return (
13 | elementType.displayName || elementType.name || 'Anonymous Component'
14 | )
15 |
16 | case 3:
17 | return 'HostRoot'
18 |
19 | case 4:
20 | return 'HostPortal'
21 |
22 | case 5: // HostComponent:
23 | return elementType
24 |
25 | case 6: // HostText:
26 | return 'String'
27 |
28 | case 7: // Fragment
29 | return 'React.Fragment'
30 |
31 | case 8:
32 | return 'Mode'
33 |
34 | case 9: // ContextConsumer
35 | return 'Context.Consumer'
36 |
37 | case 10: // ContextProvider
38 | return 'Context.Provider'
39 |
40 | case 11: // ForwardRef
41 | return 'React.forwardRef'
42 |
43 | case 12:
44 | return 'Profiler'
45 |
46 | case 13:
47 | return 'SuspenseComponent'
48 |
49 | case 14:
50 | return 'MemoComponent'
51 |
52 | case 15: // SimpleMemoComponent
53 | // Attempt to get name from wrapped component
54 | return elementType.type.name ?? 'MemoComponent'
55 |
56 | case 16: // LazyComponent
57 | return 'React.lazy'
58 |
59 | case 17:
60 | return 'IncompleteClassComponent'
61 |
62 | case 18:
63 | return 'DehydratedFragment'
64 |
65 | case 19:
66 | return 'SuspenseListComponent'
67 |
68 | case 21:
69 | return 'ScopeComponent'
70 |
71 | case 22:
72 | return 'OffscreenComponent'
73 |
74 | case 23:
75 | return 'LegacyHiddenComponent'
76 |
77 | case 24:
78 | return 'CacheComponent'
79 |
80 | // @ts-expect-error Type '25' is not comparable to type 'WorkTag'.ts(2678)
81 | case 25:
82 | return 'TracingMarkerComponent'
83 |
84 | // @ts-expect-error Type '26' is not comparable to type 'WorkTag'.ts(2678)
85 | case 26:
86 | return 'HostHoistable'
87 |
88 | // @ts-expect-error Type '27' is not comparable to type 'WorkTag'.ts(2678)
89 | case 27:
90 | return 'HostSingleton'
91 |
92 | // @ts-expect-error Type '28' is not comparable to type 'WorkTag'.ts(2678)
93 | case 28:
94 | return 'IncompleteFunctionComponent'
95 |
96 | // @ts-expect-error Type '29' is not comparable to type 'WorkTag'.ts(2678)
97 | case 29:
98 | return 'Throw'
99 |
100 | default:
101 | console.warn(`Unrecognized React Fiber tag: ${tag}`, instance)
102 | return 'Unknown Component'
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getPathToSource.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react-reconciler').Source} Source
3 | * @typedef {import('./types').PathModifier} PathModifier
4 | */
5 |
6 | /**
7 | * @param {Source} source
8 | * @param {PathModifier} pathModifier
9 | */
10 | export function getPathToSource(source, pathModifier) {
11 | const {
12 | // It _does_ exist!
13 | // @ts-ignore Property 'columnNumber' does not exist on type 'Source'.ts(2339)
14 | columnNumber = 1,
15 | fileName,
16 | lineNumber = 1,
17 | } = source
18 |
19 | let path = `${fileName}:${lineNumber}:${columnNumber}`
20 | if (pathModifier) {
21 | path = pathModifier(path)
22 | }
23 |
24 | return path
25 | }
26 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getPropsForInstance.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react-reconciler').Fiber} Fiber
3 | */
4 |
5 | /**
6 | * @param {Fiber} instance
7 | */
8 |
9 | export function getPropsForInstance(instance) {
10 | /**
11 | * @type {Fiber['pendingProps']}
12 | */
13 | const props = {}
14 |
15 | Object.entries(instance.memoizedProps).forEach(([key, value]) => {
16 | const type = typeof value
17 |
18 | // Ignore some values, even if they're scalar, because they're not unique enough
19 | if (['key'].includes(key) || value === instance.type.defaultProps?.[key]) {
20 | return
21 | }
22 |
23 | // Scalar values
24 | if (
25 | ['string', 'number', 'boolean', 'symbol'].includes(type) ||
26 | value instanceof String ||
27 | value instanceof Number ||
28 | value instanceof Boolean ||
29 | value instanceof Symbol
30 | ) {
31 | props[key] = value
32 | }
33 | })
34 |
35 | return props
36 | }
37 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getReactInstanceForElement.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {HTMLElement} element
3 | */
4 | export function getReactInstanceForElement(element) {
5 | // Prefer React DevTools, which has direct access to `react-dom` for mapping `element` <=> Fiber
6 | if ('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window) {
7 | // @ts-expect-error - TS2339 - Property '__REACT_DEVTOOLS_GLOBAL_HOOK__' does not exist on type 'Window & typeof globalThis'.
8 | const { renderers } = window.__REACT_DEVTOOLS_GLOBAL_HOOK__
9 |
10 | for (const renderer of renderers.values()) {
11 | try {
12 | const fiber = renderer.findFiberByHostInstance(element)
13 |
14 | if (fiber) {
15 | return fiber
16 | }
17 | } catch (e) {
18 | // If React is mid-render, references to previous nodes may disappear during the click events
19 | // (This is especially true for interactive elements, like menus)
20 | }
21 | }
22 | }
23 |
24 | if ('_reactRootContainer' in element) {
25 | // @ts-expect-error - TS2339 - Property '_reactRootContainer' does not exist on type 'HTMLElement'.
26 | return element._reactRootContainer._internalRoot.current.child
27 | }
28 |
29 | // eslint-disable-next-line guard-for-in
30 | for (const key in element) {
31 | // Pre-Fiber access React internals
32 | if (key.startsWith('__reactInternalInstance$')) {
33 | return element[key]
34 | }
35 |
36 | // Fiber access to React internals
37 | if (key.startsWith('__reactFiber')) {
38 | return element[key]
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getReactInstancesForElement.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react-reconciler').Fiber} Fiber
3 | */
4 |
5 | import { getReactInstanceForElement } from './getReactInstanceForElement.js'
6 |
7 | export function getReactInstancesForElement(
8 | /** @type {HTMLElement} */
9 | element
10 | ) {
11 | /** @type {Set} */
12 | const instances = new Set()
13 | let instance = getReactInstanceForElement(element)
14 |
15 | while (instance) {
16 | instances.add(instance)
17 |
18 | instance = instance._debugOwner
19 | }
20 |
21 | return Array.from(instances)
22 | }
23 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getSourceForElement.js:
--------------------------------------------------------------------------------
1 | import { getReactInstanceForElement } from './getReactInstanceForElement'
2 | import { getSourceForInstance } from './getSourceForInstance'
3 |
4 | /**
5 | * @typedef {import('react-reconciler').Fiber} Fiber
6 | */
7 |
8 | export function getSourceForElement(
9 | /**
10 | * @type {HTMLElement}
11 | */
12 | element
13 | ) {
14 | const instance = getReactInstanceForElement(element)
15 | const source = getSourceForInstance(instance)
16 |
17 | if (source) return source
18 |
19 | const fallbackSource = getFirstParentElementWithSource(element)
20 | return fallbackSource
21 | }
22 |
23 | function getFirstParentElementWithSource(element) {
24 | const parentElement = element.parentElement
25 | if (parentElement === null) {
26 | console.warn("Couldn't find a React instance for the element", element)
27 | throw new Error('No parent found for element')
28 | }
29 |
30 | const instance = getReactInstanceForElement(parentElement)
31 | const source = getSourceForInstance(instance)
32 |
33 | if (source) return source
34 | else return getFirstParentElementWithSource(element)
35 | }
36 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getSourceForInstance.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {import('react-reconciler').Fiber} Fiber
3 | * @typedef {import('react-reconciler').Source} Source
4 | */
5 |
6 | /**
7 | * @param {Fiber} instance
8 | */
9 | export function getSourceForInstance(instance) {
10 | if (!instance._debugSource) {
11 | return
12 | }
13 |
14 | const {
15 | // It _does_ exist!
16 | // @ts-ignore Property 'columnNumber' does not exist on type 'Source'.ts(2339)
17 | columnNumber = 1,
18 | fileName,
19 | lineNumber = 1,
20 | } = instance._debugSource
21 |
22 | return { columnNumber, fileName, lineNumber }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/getUrl.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {Object} param
3 | * @param {string} param.editor
4 | * @param {string} param.pathToSource
5 | */
6 | export function getUrl({ editor, pathToSource }) {
7 | // Fix https://github.com/microsoft/vscode/issues/197319
8 | if (pathToSource[0] === '/') {
9 | return `${editor}://file${pathToSource}`
10 | }
11 |
12 | return `${editor}://file/${pathToSource}`
13 | }
14 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/index.js:
--------------------------------------------------------------------------------
1 | import { ClickToComponent as Component } from './ClickToComponent.js'
2 |
3 | export const ClickToComponent =
4 | process.env.NODE_ENV === 'development' ? Component : () => null
5 |
--------------------------------------------------------------------------------
/packages/click-to-react-component/src/types.d.ts:
--------------------------------------------------------------------------------
1 | export { ClickToComponent } from './ClickToComponent'
2 |
3 | export type Editor = 'vscode' | 'vscode-insiders' | 'cursor' | string
4 |
5 | export type PathModifier = (path: string) => string
6 |
7 | export type ClickToComponentProps = {
8 | editor?: Editor
9 | pathModifier?: PathModifier
10 | }
11 |
12 | export type Coords = [MouseEvent['pageX'], MouseEvent['pageY']]
13 |
14 | export type Target = HTMLElement
15 |
16 | export type ContextMenuProps = {
17 | onClose?: () => void
18 | pathModifier?: PathModifier
19 | }
20 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "baseBranch": "origin/main",
4 | "pipeline": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": ["dist/**", ".next/**"]
8 | },
9 | "lint": {
10 | "outputs": []
11 | },
12 | "dev": {
13 | "cache": false
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------