├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── toc.yml
├── .gitignore
├── LICENSE
├── README.md
├── babel.config.js
├── bin
└── cli.js
├── fixtures
├── README.md
└── basic
│ ├── react
│ ├── component.react
│ └── super
│ │ └── nexted
│ │ └── foo.react
│ └── src
│ ├── component.js
│ └── super
│ └── nexted
│ └── foo.js
├── jest.config.js
├── package-lock.json
├── package.json
├── src
└── compiler.ts
├── tests
├── README.md
├── __snapshots__
│ └── snapshots.test.ts.snap
├── binding-nested-obj
│ └── src.react
├── options-displayName
│ ├── options.json
│ └── src.react
├── snapshots.test.ts
├── state-simple
│ └── src.react
├── styles-static
│ └── src.react
└── temp
│ └── src.react
└── tsconfig.json
/.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/workflows/toc.yml:
--------------------------------------------------------------------------------
1 | on: push
2 | name: TOC Generator
3 | jobs:
4 | generateTOC:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: technote-space/toc-generator@v2
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # Snowpack dependency directory (https://snowpack.dev/)
45 | web_modules/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 | .parcel-cache
78 |
79 | # Next.js build output
80 | .next
81 | out
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 | dist
86 |
87 | # Gatsby files
88 | .cache/
89 | # Comment in the public line in if your project uses Gatsby and not Next.js
90 | # https://nextjs.org/blog/next-9-1#public-directory-support
91 | # public
92 |
93 | # vuepress build output
94 | .vuepress/dist
95 |
96 | # Serverless directories
97 | .serverless/
98 |
99 | # FuseBox cache
100 | .fusebox/
101 |
102 | # DynamoDB Local files
103 | .dynamodb/
104 |
105 | # TernJS port file
106 | .tern-port
107 |
108 | # Stores VSCode versions used for testing VSCode extensions
109 | .vscode-test
110 |
111 | # yarn v2
112 | .yarn/cache
113 | .yarn/unplugged
114 | .yarn/build-state.yml
115 | .yarn/install-state.gz
116 | .pnp.*
117 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License Copyright (c) 2020 sw-yx
2 |
3 | Permission is hereby granted, free of
4 | charge, to any person obtaining a copy of this software and associated
5 | documentation files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use, copy, modify, merge,
7 | publish, distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to the
9 | following conditions:
10 |
11 | The above copyright notice and this permission notice
12 | (including the next paragraph) shall be included in all copies or substantial
13 | portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Experimental React Single File Components
2 |
3 | Swyx's Experimental Proposal for bringing Single File Components to React. [Other proposals can be found here](https://github.com/react-sfc/react-sfc-proposal). The specific APIs are unstable for now and have already changed from what was shown at the React Rally talk!
4 |
5 | > :warning: This is an experiment/proof of concept, and is a solo endeavor not endorsed by the React team. There are legitimate design concerns raised (see Concerns section below). It may remain a toy unless other folks pick it up/help contribute/design/maintain it! [Let me know what your interest is and help spread the word](https://twitter.com/swyx).
6 |
7 | See philosophical discussion at React Rally 2020: https://www.youtube.com/watch?v=18F5v1diO_A
8 |
9 | ## Usage
10 |
11 | 2 ways use React SFCs in your app:
12 |
13 | ### As a CLI
14 |
15 | To gradually adopt this in **pre-existing** React projects - you can leave your project exactly as is and only write individual SFCs in a separate folder, without touching your bundler config at all.
16 |
17 | - `npm i react-sfc`
18 | - Create a `/react` *origin* folder in your project to watch and compile **from**.
19 | - We assume you have a destination `/src` *output* folder with the rest of your app, to compile **to**.
20 | - run `react-sfc watch` or `rsfc watch`.
21 | - Now you are free to create `/react/MyButton.react` files in that folder
22 |
23 | CLI Flags:
24 | - If you need to customize the names of the folders that you are compiling **from** and compiling **to**, you can pass CLI flags: `react-sfc watch -f MYORIGINFOLDER -t MYOUTPUTFOLDER`
25 | - By default, the CLI compiles `.react` files into `.js` files. If you need it to output `.tsx` files or other, you can pass the extension flag `--extension tsx` or `-e tsx`. *Note: the developer experience for this is not yet tested*.
26 |
27 | Other commands:
28 |
29 | - if you don't need a `watch` workflow, you can also do single runs with other commands (same CLI flags apply):
30 | - `react-sfc build` to build once
31 | - `react-sfc validate` to parse your origin folder without building, to check for errors
32 |
33 | ### As a Rollup plugin
34 |
35 | In a new or pre-existing React + Rollup project
36 |
37 | - Plugin: https://github.com/sw-yx/rollup-plugin-react-sfc
38 | - Demo: https://github.com/sw-yx/rollup-react-boilerplate
39 |
40 | ## Other ways
41 |
42 | TBD. need help to write a webpack plugin version of this.
43 |
44 | ---
45 |
46 | > Special note to readers: this package is deployed to `react-sfc` on npm right now - but i am not going to be selfish at all about this. if someone else comes along with a better impl i will give you the npm name and github org. Please come and take it.
47 |
48 | ## Table of Contents
49 |
50 |
51 |
52 | **Table of Contents**
53 |
54 | - [Design Goals](#design-goals)
55 | - [In 1 image](#in-1-image)
56 | - [Features implemented](#features-implemented)
57 | - [Basic Proposal](#basic-proposal)
58 | - [Advanced Opportunities](#advanced-opportunities)
59 | - [CSS in JS](#css-in-js)
60 | - [State](#state)
61 | - [Binding](#binding)
62 | - [GraphQL](#graphql)
63 | - [Dev Optimizations](#dev-optimizations)
64 | - [Why? I don't need this!](#why-i-dont-need-this)
65 | - [General principle: Loaders vs SFCs](#general-principle-loaders-vs-sfcs)
66 | - [Notable Concerns](#notable-concerns)
67 | - [Am I missing some obvious idea or some critical flaw?](#am-i-missing-some-obvious-idea-or-some-critical-flaw)
68 |
69 |
70 |
71 |
72 | ## Design Goals
73 |
74 | - Stay "Close to JavaScript" to benefit from existing tooling: syntax highlighting, autocomplete/autoimport, static exports, TypeScript
75 | - Have easy upgrade paths to go from a basic component to dynamic styles, or add state, or extract graphql dependencies
76 | - Reduce verbosity without sacrificing readability
77 |
78 | This probably means that a successful React SFC should be a superset of normal React: you should be able to rename any `.js` and `.jsx` file and it should "just work", before taking advantage of any new features.
79 |
80 | ## In 1 image
81 |
82 | 
83 |
84 |
85 |
86 | ## Features implemented
87 |
88 | - [x] Automatic react import
89 | - [x] mutable useState `_` syntax
90 | - [x] useStateWithLabel hook replaces useState to label in prod
91 | - [x] Dynamic CSS transform to styled-JSX
92 | - [x] set displayName if passed as compiler option
93 | - [x] `$value={$text}` binding for onChange
94 | - this works for nested properties eg `$value={$text.foo}`
95 |
96 | TODO:
97 |
98 | - [ ] JS and CSS sourcemaps
99 | - [ ] it does not properly work with `styled-jsx` in rollup - need [SUPER hacky shit](https://twitter.com/swyx/status/1290055528068952064) to work (see boilerplate's index.html)
100 | - [ ] useEffect dependency tracking
101 | - [ ] automatically extract text for i18n
102 | - [ ] nothing graphql related yet
103 | - [ ] optional `css` no-op function for syntax highlighting in JS
104 | - [ ] $value shorthand eg `$value`
105 | - [ ] $value generalized eg `$style`
106 | - [ ] handle multiple bindings
107 | - [ ] test for TSX support?
108 |
109 | open questions
110 |
111 | - what binding syntax is best?
112 | - considered `bind:value` but typescript does not like that
113 | - `$` prefix works but doesnt look coherent with the rest of RSFC format. using this for now
114 | - `_` prefix looks ugly? <- went with this one
115 |
116 | ## Basic Proposal
117 |
118 | Here is how we might write a React Single File Component:
119 |
120 | ```js
121 | let _count = 1
122 |
123 | export const STYLE = `
124 | div { /* scoped by default */
125 | background-color: ${_count > 4 ? "papayawhip" : "palegoldenrod"};
126 | }
127 | `
128 |
129 | export default () => {
130 | useEffect(() => console.log('rerendered'))
131 | return (
132 |
135 | )
136 | }
137 | ```
138 |
139 | The component name would be taken from the filename. Named exports would also be externally accessible.
140 |
141 | ## Advanced Opportunities
142 |
143 | These require more work done by the surrounding compiler/distribution, and offer a lot of room for innovation:
144 |
145 | ### CSS in JS
146 |
147 | We can switch nicely from no-runtime scoped styles to CSS-in-JS:
148 |
149 | ```js
150 | export const STYLE = props => `
151 | div {
152 | background-color: ${props.bgColor || 'papayawhip'};
153 | }
154 | `
155 | // etc
156 | ```
157 |
158 | In future we might offer a no-op `css` function that would make it easier for editor tooling to do CSS in JS syntax highlighting:
159 |
160 | ```js
161 | // NOT YET IMPLEMENTED
162 | export const STYLE = css`
163 | div { /* properly syntax highlighted */
164 | background-color: blue;
165 | }
166 | `
167 | ```
168 |
169 | ### State
170 |
171 | We can declare mutable state:
172 |
173 | ```js
174 | let _count = 0
175 |
176 | export const STYLE = `
177 | button {
178 | // scoped by default
179 | background-color: ${_count > 5 ? 'red' : 'papayawhip'};
180 | }
181 | `
182 |
183 | export default () => {
184 | return
185 | }
186 | ```
187 |
188 |
189 |
190 | and this is transformed to the appropriate React APIs.
191 |
192 |
193 |
194 | ```js
195 | export default const FILENAME = () => {
196 | const [_count, set_Count] = useState(0);
197 | return (
198 | <>
199 |
200 |
208 | >
209 | );
210 | };
211 | ```
212 |
213 |
214 |
215 |
216 | We can also do local two way binding to make forms a lot easier:
217 |
218 |
219 | ```js
220 | let data = {
221 | firstName: '',
222 | lastName: '',
223 | age: undefined,
224 | }
225 |
226 | function onSubmit(event) {
227 | event.preventDefault()
228 | fetch('/myendpoint, {
229 | method: 'POST',
230 | body: JSON.stringify(data)
231 | })
232 | }
233 |
234 | export default () => {
235 | return (
236 |
251 | )
252 | }
253 | ```
254 |
255 | ### Binding
256 |
257 | Local two way binding can be really nice.
258 |
259 |
260 | ```js
261 | let _text = 0
262 |
263 | export default () => {
264 | return
265 | }
266 | ```
267 |
268 | And this transpiles to the appropriate `onChange` handler and `value` attribute. It would also have to handle object access.
269 |
270 | Another feature from Vue and Svelte that is handy is class binding. JSX only offers className as a string. We could do better:
271 |
272 |
273 | ```js
274 | // NOT YET IMPLEMENTED
275 | let _foo = 0
276 | let _bar = 0
277 |
278 | export default () => {
279 | return
287 | }
288 | ```
289 |
290 | ### GraphQL
291 |
292 | The future of React is [Render-as-you-Fetch](https://reactjs.org/docs/concurrent-mode-suspense.html#approach-3-render-as-you-fetch-using-suspense) data, and being able to statically extract the data dependencies from the component (without rendering it) is important to avoid Data waterfalls:
293 |
294 | ```js
295 | // NOT YET IMPLEMENTED
296 | export const GRAPHQL = `
297 | query MYPOSTS {
298 | posts {
299 | title
300 | author
301 | }
302 | }
303 | `
304 | // NOT YET IMPLEMENTED
305 | export default function MYFILE (props, {data, status}) {
306 | if (typeof status === Error) return
Error {data.state.message}
307 | return (
308 |
309 | Posts:
310 | {status.isLoading() ?
Loading...
311 | : (
312 |
313 | {data.map((item, i) =>
{item}
)}
314 |
315 | )
316 | }
317 |
318 | )
319 | }
320 | }
321 | ```
322 |
323 | ### Dev Optimizations
324 |
325 | We can offer other compile time optimizations for React:
326 |
327 | - Named State Hooks
328 |
329 | Automatically insert `useDebugValue` for each `useState`:
330 |
331 | ```js
332 | function useStateWithLabel(initialValue, name) {
333 | const [value, setValue] = useState(initialValue);
334 | useDebugValue(`${name}: ${value}`);
335 | return [value, setValue];
336 | }
337 | ```
338 |
339 | - Auto optimized useEffect
340 |
341 | Automatically insert all dependencies when using `useAutoEffect`, exactly similar to https://github.com/yuchi/hooks.macro
342 |
343 | ## Why? I don't need this!
344 |
345 | That's right, you don't -need- it. SFCs are always sugar, just like JSX. You don't need it, but when it is enough of a community standard it makes things nicer for almost everyone. SFC's aren't a required part of Vue, but they are a welcome community norm.
346 |
347 | The goal isn't to evaluate this idea based on need. In my mind this will live or die based on how well it accomplishes two goals:
348 |
349 | - For beginners, provide a blessed structure in a chaotic world of anything-goes.
350 | - For experts, provide a nicer DX by encoding extremely common boilerplatey patterns in syntax.
351 |
352 | Any new file format starts with a handicap of not working with existing tooling e.g. syntax highlighting. So a successful React SFC effort will also need to have a plan for critical tooling.
353 |
354 | ## General principle: Loaders vs SFCs
355 |
356 | Stepping back from concrete examples to discuss how this might affect DX. In a sense, SFCs simply centralize what we already do with loaders. Instead of
357 |
358 | ```
359 | Component.jsx
360 | Component.scss
361 | Component.graphql
362 | ```
363 |
364 | we have
365 |
366 | ```js
367 | export const STYLE // etc
368 | export const GRAPHQL // etc
369 | export default () => // etc
370 | ```
371 |
372 | in a file. Why would we exchange file separation for a super long file? Although there are ways to mitigate this, it is not very appealing on its own.
373 |
374 | However, to the extent that the React SFC loader is a single entry point to webpack for all these different filetypes, we have the opportunity to simplify config, skip small amounts of boilerplate, and enforce some consistency with the single file format. Having fewer files causes less pollution of IDE file namespace, and makes it easier to set up these peripheral concerns around jsx (styling, data, tests, documentation, etc) incrementally without messing with creating/deleting files.
375 |
376 | ## Notable Concerns
377 |
378 | - "This is a sugar that makes things more complicated/confusing, not less. Like people aren't going to understand the boundaries of what is allowed here. Like if you can mutate the binding, can you mutate the value? Either way will cause confusion" - [source](https://twitter.com/buildsghost/status/1294355186538799105?s=20)
379 | - i need to think about this but i might end up agreeing
380 | - it might be possible to design around this
381 |
382 |
383 | ## Am I missing some obvious idea or some critical flaw?
384 |
385 | File an issue or PR or [tweet at me](https://twitter.com/swyx), lets chat.
386 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@babel/preset-typescript'
4 | ]
5 | }
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | // 👆 Used to tell Node.js that this is a CLI tool
3 |
4 | "use strict";
5 |
6 | const sade = require("sade");
7 | const prog = sade("react-sfc");
8 | const chalk = require("chalk");
9 | const CheapWatch = require("cheap-watch");
10 | const { Compiler } = require("../dist/compiler");
11 |
12 | const fs = require("fs");
13 | const path = require("path");
14 | const output = fs.readFileSync(path.join(__dirname, "../package.json"), "utf8");
15 |
16 | prog
17 | .version(output.version)
18 | .command("watch")
19 | .describe(
20 | "Transpile and watch a directory of .react files to .js and .css files (default from react to src). Ctrl+C to kill."
21 | )
22 | .example("watch")
23 | .option("--from, -f", "Source dir (default react)")
24 | .option("--to, -t", "Dist dir (default src)")
25 | .option("--extension, -e", "Filetype (default js)")
26 | .action(async (opts) => {
27 | const src = opts.from || "react";
28 | const dst = opts.to || "src";
29 | const outputExtension = opts.extension || "js"; // pass tsx for typescript?
30 | console.log(
31 | `> watching files from ${chalk.cyan(src)} to ${chalk.cyan(dst)}`
32 | );
33 |
34 | const watch = new CheapWatch({ dir: src });
35 | await watch.init();
36 | buildOnce({ paths: watch.paths, src, dst, outputExtension });
37 |
38 | watch.on("+", ({ path: _path, stats, isNew }) => {
39 | if (stats.isDirectory()) return;
40 | compileFile({ src, dst, semiPath: _path, outputExtension });
41 | const phrase = isNew ? "created" : "updated";
42 | console.log(`> ${phrase} ` + path.join(dst, _path));
43 | });
44 | watch.on("-", ({ path: _path, stats }) => {
45 | const p = path.join(dst, _path);
46 | fs.unlinkSync(p);
47 | console.log("> deleted " + p);
48 | });
49 | });
50 |
51 | prog
52 | .command("build")
53 | .describe(
54 | "Transpile only once a directory of .react files to .js and .css files (default from react to src). Ctrl+C to kill."
55 | )
56 | .example("build")
57 | .option("--from, -f", "Source dir (default react)")
58 | .option("--to, -t", "Dist dir (default src)")
59 | .option("--extension, -e", "Filetype (default js)")
60 | .action(async (opts) => {
61 | const src = opts.from || "react";
62 | const dst = opts.to || "src";
63 | const outputExtension = opts.extension || "js"; // pass tsx for typescript?
64 | console.log(
65 | `> building files from ${chalk.cyan(src)} to ${chalk.cyan(dst)}`
66 | );
67 |
68 | const watch = new CheapWatch({ dir: src, watch: false });
69 | await watch.init();
70 | buildOnce({ paths: watch.paths, src, dst, outputExtension });
71 | });
72 |
73 |
74 | /**
75 | *
76 | * CORE FUNCTIONALITY
77 | *
78 | */
79 |
80 | function buildOnce({ paths, src, dst, outputExtension }) {
81 | for (const [changedFileSemiPath, stats] of paths) {
82 | try {
83 | if (!stats.isDirectory()) {
84 | compileFile({ src, dst, semiPath: changedFileSemiPath, outputExtension });
85 | console.log("> output " + path.join(dst, changedFileSemiPath));
86 | // todo: check dst dir to delete files that are no longer in src?
87 | }
88 | } catch (err) {
89 | console.warn(
90 | chalk.yellow(
91 | `Unable to compile ${changedFileSemiPath}, please edit and save`
92 | )
93 | );
94 | console.error(err);
95 | }
96 | }
97 | }
98 |
99 | function compileFile({ src, dst, semiPath, outputExtension }) {
100 | if (!semiPath.endsWith(".react")) return;
101 | try {
102 | const changedFilePath = path.join(src, semiPath);
103 | const code = fs.readFileSync(changedFilePath, "utf8");
104 | const output = Compiler({ code });
105 | const pathWithNoReact =
106 | semiPath.slice(0, semiPath.length - 5) + outputExtension;
107 | const destinaFilePath = path.join(dst, pathWithNoReact);
108 | mkdirp(destinaFilePath);
109 | fs.writeFileSync(destinaFilePath, output.js.code);
110 | } catch (err) {
111 | console.warn(
112 | chalk.yellow(`Unable to compile ${semiPath}, please edit and save`)
113 | );
114 | console.error(err);
115 | }
116 | }
117 |
118 |
119 |
120 | prog.parse(process.argv);
121 |
122 |
123 | /**
124 | *
125 | * UTILS
126 | *
127 | */
128 |
129 | // stats: Stats {
130 | // dev: 16777220,
131 | // mode: 33188,
132 | // nlink: 1,
133 | // uid: 501,
134 | // gid: 20,
135 | // rdev: 0,
136 | // blksize: 4096,
137 | // ino: 14077282,
138 | // size: 37,
139 | // blocks: 8,
140 | // atimeMs: 1596930405579.274,
141 | // mtimeMs: 1596930404101.0032,
142 | // ctimeMs: 1596930404101.0032,
143 | // birthtimeMs: 1596930394717.8994,
144 | // atime: 2020-08-08T23:46:45.579Z,
145 | // mtime: 2020-08-08T23:46:44.101Z,
146 | // ctime: 2020-08-08T23:46:44.101Z,
147 | // birthtime: 2020-08-08T23:46:34.718Z
148 | // }
149 |
150 | function mkdirp(path) {
151 | path.split("/").reduce(function (prev, next) {
152 | if (!fs.existsSync(prev)) fs.mkdirSync(prev);
153 | return prev + "/" + next;
154 | });
155 | }
156 |
--------------------------------------------------------------------------------
/fixtures/README.md:
--------------------------------------------------------------------------------
1 | this folder is meant as a test for the cli version of `react-sfc`.
2 |
3 |
4 | as a contributor, here's what you do
5 |
6 | - stay at project root
7 | - `./bin/cli.js watch -f fixtures/basic/react -t fixtures/basic/src`
8 | - now you have a running react-sfc server
9 | - add files to `/fixtures/basic/react`
10 | - watch them get populated in `src`
--------------------------------------------------------------------------------
/fixtures/basic/react/component.react:
--------------------------------------------------------------------------------
1 | let _abc = 123
2 |
3 | export default () => {
4 | return
5 | }
--------------------------------------------------------------------------------
/fixtures/basic/react/super/nexted/foo.react:
--------------------------------------------------------------------------------
1 | let _sdlkj = {
2 | foo: 23
3 | }
4 |
5 | export default () => {
6 | return
7 | }
--------------------------------------------------------------------------------
/fixtures/basic/src/component.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => {
4 | let [_abc, set_abc] = use_abc_State(123)
5 | return
6 | }
7 | function use_abc_State(v) {
8 | const x = React.useState(v);
9 | React.useDebugValue('_abc: ' + x[0]); return x;
10 | }
--------------------------------------------------------------------------------
/fixtures/basic/src/super/nexted/foo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => {
4 | let [_sdlkj, set_sdlkj] = use_sdlkj_State({
5 | foo: 23
6 | })
7 | return {
9 | let temp = Object.assign({}, foo);
10 | temp._sdlkj = e.target.value;
11 | setfoo(temp);
12 | }}>
13 | }
14 | function use_sdlkj_State(v) {
15 | const x = React.useState(v);
16 | React.useDebugValue('_sdlkj: ' + x[0]); return x;
17 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sfc",
3 | "version": "0.1.1",
4 | "description": "Swyx's proposal for bringing Single File Components to React. [Other proposals can be found here](https://github.com/react-sfc/react-sfc-proposal).",
5 | "main": "dist/compiler.js",
6 | "bin": {
7 | "react-sfc": "./bin/cli.js",
8 | "rsfc": "./bin/cli.js"
9 | },
10 | "files": [
11 | "src",
12 | "dist",
13 | "README.md",
14 | "LICENSE"
15 | ],
16 | "directories": {
17 | "test": "tests"
18 | },
19 | "scripts": {
20 | "build": "tsc",
21 | "version": "auto-changelog -p --template keepachangelog && git add CHANGELOG.md",
22 | "prepublishOnly": "npm run build && git push && git push --tags && gh-release",
23 | "test": "jest --watch"
24 | },
25 | "repository": {
26 | "type": "git",
27 | "url": "git+https://github.com/react-sfc/react-sfc-swyx.git"
28 | },
29 | "keywords": [],
30 | "author": "swyx",
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/react-sfc/react-sfc-swyx/issues"
34 | },
35 | "homepage": "https://github.com/react-sfc/react-sfc-swyx#readme",
36 | "devDependencies": {
37 | "@babel/preset-typescript": "^7.10.4",
38 | "@types/estree": "0.0.45",
39 | "@types/jest": "^26.0.9",
40 | "acorn-jsx": "^5.2.0",
41 | "auto-changelog": "^2.2.0",
42 | "gh-release": "^3.5.0",
43 | "jest": "^26.2.2",
44 | "ts-jest": "^26.1.4",
45 | "typescript": "^3.9.7"
46 | },
47 | "dependencies": {
48 | "acorn": "^7.4.0",
49 | "chalk": "^4.1.0",
50 | "cheap-watch": "^1.0.2",
51 | "estree-walker": "^2.0.1",
52 | "magic-string": "^0.25.7",
53 | "sade": "^1.7.3"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/compiler.ts:
--------------------------------------------------------------------------------
1 | import { walk } from "estree-walker";
2 | const { Parser } = require("acorn");
3 | import MagicString from "magic-string";
4 | import {
5 | ImportDeclaration,
6 | TemplateLiteral,
7 | ExportNamedDeclaration,
8 | ArrowFunctionExpression,
9 | ExportDefaultDeclaration,
10 | FunctionDeclaration,
11 | ReturnStatement,
12 | VariableDeclaration,
13 | VariableDeclarator,
14 | AssignmentExpression,
15 | UpdateExpression,
16 | Identifier,
17 | Literal,
18 | MemberExpression,
19 | Expression,
20 | } from "estree";
21 |
22 | const hooks = [
23 | "useState",
24 | "useEffect",
25 | "useContext",
26 | "useReducer",
27 | "useCallback",
28 | "useMemo",
29 | "useRef",
30 | "useImperativeHandle",
31 | "useLayoutEffect",
32 | "useDebugValue",
33 | ];
34 |
35 | interface ASTNode {
36 | start: number;
37 | end: number;
38 | type: string;
39 | }
40 |
41 | interface JSXOpeningElement extends ASTNode {
42 | type: "JSXOpeningElement";
43 | attributes: JSXAttribute[];
44 | name: JSXIdentifier;
45 | selfClosing: boolean;
46 | }
47 | interface JSXClosingElement extends ASTNode {
48 | type: "JSXClosingElement";
49 | name: JSXIdentifier;
50 | }
51 | interface JSXElement extends ASTNode {
52 | type: "JSXElement";
53 | attributes: JSXAttribute[];
54 | openingElement: JSXOpeningElement;
55 | closingElement: JSXClosingElement;
56 | children: (JSXElement | JSXText)[];
57 | }
58 | interface JSXText extends ASTNode {
59 | type: "JSXText";
60 | value: string;
61 | raw: string;
62 | }
63 | interface JSXAttribute extends ASTNode {
64 | type: "JSXAttribute";
65 | name: JSXNamespacedName | JSXIdentifier;
66 | value: JSXExpressionContainer | Literal;
67 | }
68 | interface JSXIdentifier extends ASTNode {
69 | type: "JSXIdentifier";
70 | name: string;
71 | }
72 | interface JSXNamespacedName extends ASTNode {
73 | type: "JSXNamespacedName";
74 | namespace: JSXIdentifier;
75 | name: JSXIdentifier;
76 | }
77 | interface JSXExpressionContainer extends ASTNode {
78 | type: "JSXExpressionContainer";
79 | expression: Expression;
80 | }
81 |
82 | type CompilerOptions = {
83 | useStateWithLabel?: boolean;
84 | componentDisplayName?: string;
85 | };
86 |
87 | export function Compiler(args: {
88 | code: string;
89 | parser?: any; // TODO: parser type
90 | options?: CompilerOptions;
91 | }) {
92 | // console.log({acorn})
93 | function defaultacorn(x: string) {
94 | const MyParser = Parser.extend(require("acorn-jsx")());
95 | return MyParser.parse(x, {
96 | sourceType: "module",
97 | });
98 | }
99 | const parser = args.parser || defaultacorn;
100 | const ast: ASTNode = parser(args.code);
101 | let ms = new MagicString(args.code);
102 |
103 | const isProduction = false; // TODO: figure out how to get this from rollup
104 | // undocumented option - tbd if we actually want to let users configure
105 | // TODO: can make it dev-only, or maybe also useful in prod?
106 | const options = args.options || ({} as CompilerOptions);
107 | const componentDisplayName = options.componentDisplayName;
108 | const userWantsUSWL = options.useStateWithLabel || !isProduction;
109 |
110 | let STYLECONTENT, STYLEDECLARATION: any; // typescript is stupid about assignment inside an if, thinks this is a `never` or a `undefined`
111 | let STYLESTATICCSS: any; // just stubbing this out
112 |
113 | /** refactoring to exportDefaultNode.. remind me to delete */
114 | // let lastChildofDefault: any; // typescript is stupid about assignment inside an if, thinks this is a `never` or a `undefined`
115 | // let pos_HeadOfDefault: number;
116 |
117 | let exportDefaultNode: {
118 | lastChildofDefault?: any;
119 | pos_HeadOfDefault?: number;
120 | node?: ExportDefaultDeclaration & ASTNode;
121 | } = {};
122 | let stateMap = new Map();
123 | let assignmentsMap = new Map();
124 | let bindValuesMap = new Map();
125 | let isReactImported = false;
126 | let hasExportDefault = false; // ensure there is a default export
127 | walk(ast, {
128 | enter(node, parent, prop, index) {
129 | if (node.type === "ImportDeclaration") {
130 | let _node = node as ImportDeclaration;
131 | if (_node.source.value === "react") isReactImported = true;
132 | // TODO: check that the name React is actually defined!
133 | // most people will not run into this, but you could be nasty
134 | }
135 | if (node.type === "CallExpression") {
136 | let _node = node as any; // CallExpression would be nice but SimpleLiteral is annoying
137 | if (hooks.some((hook) => _node.callee.name === hook)) {
138 | ms.prependLeft(_node.callee.start, "React.");
139 | }
140 | }
141 |
142 | if (node.type === "ExportNamedDeclaration") {
143 | let _node = (node as ExportNamedDeclaration).declaration;
144 | if (
145 | _node &&
146 | _node.type === "VariableDeclaration" &&
147 | _node?.declarations[0].id.type === "Identifier"
148 | ) {
149 | if (_node?.declarations[0].id.name === "STYLE") {
150 | let loc = _node.declarations[0].init as (
151 | | TemplateLiteral
152 | | ArrowFunctionExpression
153 | ) &
154 | ASTNode; // klooge as i believe TemplateLiteral type is incomplete
155 | // TODO: check return type of ArrowFunctionExpression as well
156 |
157 | /**
158 | *
159 | * MVP of css static export feature
160 | *
161 | */
162 | if (loc.type === "TemplateLiteral" && loc.quasis.length === 1) {
163 | STYLESTATICCSS = loc.quasis[0].value.raw;
164 | // TODO: namespace to scope to component
165 | // TODO: take care of css nesting
166 | }
167 | /**
168 | *
169 | * end MVP of css static export feature
170 | *
171 | */
172 |
173 | if (loc) {
174 | STYLEDECLARATION = node as VariableDeclaration & ASTNode;
175 | STYLECONTENT = ms.slice(loc.start, loc.end);
176 | }
177 | // TODO - consider whether to handle Literal (just string)
178 | // TODO - there are bunch of other expression types we may someday need to support (unlikely)
179 | }
180 | }
181 | }
182 | if (node.type === "ExportDefaultDeclaration") {
183 | let _node = (node as ExportDefaultDeclaration).declaration as
184 | | FunctionDeclaration
185 | | ArrowFunctionExpression; // TODO: consider other expressions
186 | exportDefaultNode.node = node as ExportDefaultDeclaration & ASTNode;
187 | if (_node.body.type === "BlockStatement") {
188 | let RSArg = (_node.body.body.find(
189 | (x) => x.type === "ReturnStatement"
190 | ) as ReturnStatement)?.argument as any; // TODO: this type is missing JSXElement
191 | if (RSArg.type === "JSXElement") {
192 | exportDefaultNode.pos_HeadOfDefault = (_node.body as any).start + 1;
193 | exportDefaultNode.lastChildofDefault = RSArg.children.slice(-1)[0];
194 | hasExportDefault = true;
195 | // use start and end
196 | } else {
197 | throw new Error("not returning JSX in export default function"); // TODO: fix this?
198 | }
199 | }
200 | if (!hasExportDefault) {
201 | throw new Error(
202 | "a React SFC must export default a function component. None was detected. Pls file an issue if this is a mistake."
203 | );
204 | }
205 | }
206 | // usestate
207 | if (node.type === "VariableDeclaration") {
208 | let dec = (node as VariableDeclaration)
209 | .declarations[0] as VariableDeclarator & { init: ASTNode };
210 | if (dec.id.type === "Identifier" && dec.id.name.startsWith("_")) {
211 | stateMap.set(dec.id.name, {
212 | node, // for replacement
213 | value: ms.slice(dec.init.start, dec.init.end), // for use in templating
214 | });
215 | }
216 | }
217 |
218 | // SETSTATE
219 | if (node.type === "AssignmentExpression") {
220 | // todo: maybe only read assignmentexpressions if the LHS is in the stateMap
221 | let LHS = (node as AssignmentExpression).left;
222 | if (LHS.type === "Identifier" && LHS.name.startsWith("_")) {
223 | assignmentsMap.set(LHS.name, { node });
224 | }
225 | }
226 | if (node.type === "UpdateExpression") {
227 | // todo: maybe only read assignmentexpressions if the LHS is in the stateMap
228 | let ID = (node as UpdateExpression).argument as Identifier;
229 | if (ID.name.startsWith("_")) {
230 | assignmentsMap.set(ID.name, { node });
231 | }
232 | }
233 |
234 | // BINDING
235 | if (node.type === "JSXAttribute") {
236 | // // bind:value syntax - we may want to use this in future?
237 | // let _node = node as JSXAttribute;
238 | // if (
239 | // _node.name.type === "JSXNamespacedName" &&
240 | // _node.name.namespace.name === "bind"
241 | // ) {
242 | // let RHSobject, RHSname;
243 | // // TODO: in future - support RHS which is just a Literal? MAAAYBE, maybe not
244 | // if (_node.value.type === "JSXExpressionContainer") {
245 | // if (_node.value.expression.type === "Identifier") {
246 | // // RHS is just an identifier
247 | // RHSname = _node.value.expression.name;
248 | // } else if (_node.value.expression.type === "MemberExpression") {
249 | // // RHS is an object access
250 | // let exp = _node.value.expression as MemberExpression & ASTNode;
251 | // RHSobject = {
252 | // objectName:
253 | // (exp.object as Identifier).name ||
254 | // ((exp.object as MemberExpression).object as Identifier).name, // either its an identifier '$foo.bar` or a memberexpression `$foo.bar.baz`
255 | // fullAccessName: ms.slice(exp.start, exp.end),
256 | // };
257 | // } else {
258 | // throw new Error(
259 | // "warning - unrecognized RHS expression type in binding: " +
260 | // _node.value.expression.type +
261 | // ". We will probably do this wrong, pls report this along with your code"
262 | // );
263 | // }
264 | // }
265 |
266 | // bindValuesMap.set(
267 | // node, // to replace
268 | // {
269 | // // LHSname: _node.name.name.name.slice(1), // only tested to work for 'value'. remove the leading $
270 | // LHSname: _node.name.name.name, // only tested to work for 'value'. remove the leading $
271 | // RHSname,
272 | // RHSobject,
273 | // }
274 | // );
275 | // }
276 |
277 | // $ prefix syntax
278 | let _node = node as JSXAttribute;
279 | if (
280 | _node.name.type === "JSXIdentifier" &&
281 | _node.name.name.startsWith("$")
282 | ) {
283 | let RHSobject, RHSname;
284 | // TODO: in future - support RHS which is just a Literal? MAAAYBE, maybe not
285 | if (_node.value.type === "JSXExpressionContainer") {
286 | if (_node.value.expression.type === "Identifier") {
287 | // RHS is just an identifier
288 | RHSname = _node.value.expression.name;
289 | } else if (_node.value.expression.type === "MemberExpression") {
290 | // RHS is an object access
291 | let exp = _node.value.expression as MemberExpression & ASTNode;
292 | RHSobject = {
293 | objectName:
294 | (exp.object as Identifier).name ||
295 | ((exp.object as MemberExpression).object as Identifier).name, // either its an identifier '$foo.bar` or a memberexpression `$foo.bar.baz`
296 | fullAccessName: ms.slice(exp.start, exp.end),
297 | };
298 | } else {
299 | throw new Error(
300 | "warning - unrecognized RHS expression type in binding: " +
301 | _node.value.expression.type +
302 | ". We will probably do this wrong, pls report this along with your code"
303 | );
304 | }
305 | }
306 |
307 | bindValuesMap.set(
308 | node, // to replace
309 | {
310 | LHSname: _node.name.name.slice(1), // only tested to work for 'value'. remove the leading $
311 | RHSname,
312 | RHSobject,
313 | }
314 | );
315 | }
316 | }
317 | },
318 | // leave(node) {
319 | // // if (node.scope) scope = scope.parent;
320 | // }
321 | });
322 |
323 | // validations
324 | if (exportDefaultNode.pos_HeadOfDefault === undefined) return;
325 |
326 | /*
327 |
328 | // process it!
329 |
330 | */
331 | if (!isReactImported) ms.prepend(`import React from 'react';`);
332 | // remove STYLE and insert style jsx
333 | if (STYLEDECLARATION && STYLECONTENT) {
334 | ms.remove(STYLEDECLARATION.start, STYLEDECLARATION.end);
335 | if (exportDefaultNode.lastChildofDefault)
336 | ms.appendRight(
337 | exportDefaultNode.lastChildofDefault.end,
338 | ``
339 | );
340 | }
341 |
342 | // useState
343 | if (stateMap.size) {
344 | // for each state hook
345 | stateMap.forEach(({ node, value }, key) => {
346 | ms.remove(node.start, node.end);
347 | let newStr;
348 | if (userWantsUSWL) {
349 | // should be 'let' bc we want to mutate it
350 | newStr = `\nlet [${key}, set${key}] = use${key}_State(${value})`;
351 | // i would like to use only one instance, of useStateWithLabel
352 | // https://stackoverflow.com/questions/57659640/is-there-any-way-to-see-names-of-fields-in-react-multiple-state-with-react-dev
353 | // but currently devtools uses the NAME OF THE HOOK for state hooks
354 | // rather than useDebugValue. so we do a simple alias of the hook
355 | ms.append(`
356 | function use${key}_State(v) {
357 | const x = React.useState(v);
358 | ${
359 | isProduction
360 | ? "return x;"
361 | : `React.useDebugValue('${key}: ' + x[0]); return x;`
362 | }
363 | }`);
364 | } else {
365 | // just plain useState
366 | // should be 'let' bc we want to mutate it
367 | newStr = `\nlet [${key}, set${key}] = React.useState(${value})`;
368 | }
369 | ms.appendRight(exportDefaultNode.pos_HeadOfDefault!, newStr);
370 | });
371 | }
372 |
373 | // setState
374 | if (assignmentsMap.size) {
375 | assignmentsMap.forEach(({ node }, key) => {
376 | // strategy: use comma separator to turn
377 | // $count = $count + 1
378 | // into
379 | // ($count = $count + 1, set$count($count))
380 | ms.prependLeft(node.start, "(");
381 | ms.appendRight(node.end, `, set${key}(${key}))`);
382 | });
383 | }
384 |
385 | // binding
386 | if (bindValuesMap.size) {
387 | bindValuesMap.forEach(({ LHSname, RHSname, RHSobject }, node) => {
388 | if (RHSobject) {
389 | // create new object, mutate new object, THEN set it
390 | // must be new object or react doesnt rerender
391 | ms.overwrite(
392 | node.start,
393 | node.end,
394 | `${LHSname}={${RHSobject.fullAccessName}}
395 | onChange={e => {
396 | let temp = Object.assign({}, ${RHSobject.objectName});
397 | temp${RHSobject.fullAccessName.slice(
398 | RHSobject.objectName.length
399 | )} = e.target.${LHSname};
400 | set${RHSobject.objectName}(temp);
401 | }}`
402 | );
403 | } else if (RHSname) {
404 | ms.overwrite(
405 | node.start,
406 | node.end,
407 | `${LHSname}={${RHSname}} onChange={e => set${RHSname}(e.target.${LHSname})}`
408 | );
409 | } else {
410 | throw new Error("we should not get here. pls repurt this binding bug");
411 | }
412 | });
413 | }
414 |
415 | // adjust for displayName
416 | if (options.componentDisplayName && exportDefaultNode.node) {
417 | const componentDeclarationNode: any = exportDefaultNode.node.declaration;
418 | ms.overwrite(
419 | exportDefaultNode.node.start,
420 | exportDefaultNode.node.end,
421 | `const $$Component = ${ms.slice(
422 | componentDeclarationNode.start,
423 | componentDeclarationNode.end
424 | )};
425 | $$Component.displayName = "${options.componentDisplayName}"
426 | export default $$Component
427 | `
428 | );
429 | }
430 |
431 | let code = ms.toString();
432 | return {
433 | js: {
434 | code,
435 | map: null, // todo
436 | },
437 | css: {
438 | // TODO
439 | // TODO
440 | // TODO
441 | // TODO
442 | // TODO
443 | // TODO
444 | // TODO
445 | // static css
446 | // THIS WHOLE THING IS A MASSIVE TODO
447 | code: STYLESTATICCSS,
448 | map: null,
449 | // TODO
450 | // TODO
451 | // TODO
452 | // TODO
453 | // TODO
454 | // TODO
455 | // TODO
456 | // TODO
457 | },
458 | };
459 | }
460 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | note to reader - these tests arent setup at all yet
2 |
3 | just stubs/holdovers from prev attempts.
4 |
5 | tbd...
--------------------------------------------------------------------------------
/tests/__snapshots__/snapshots.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`passes binding-nested-obj snapshot test 1`] = `
4 | Object {
5 | "css": Object {
6 | "code": undefined,
7 | "map": null,
8 | },
9 | "js": Object {
10 | "code": "import * as React from 'react'
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | export default () => {
19 | let [_count, set_count] = use_count_State(23)
20 | let [_text, set_text] = use_text_State({
21 | foo: 1,
22 | moo: {
23 | djs: 3
24 | }
25 | })
26 | React.useEffect(() => console.log('rerendered')) // no need for React import
27 | return (
28 |