├── .github └── workflows │ └── test.yml ├── .gitignore ├── .node-version ├── README.md ├── babel.config.js ├── examples ├── script.tsx └── tsconfig.json ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── __tests__ │ └── index.test.tsx ├── fs-renderer-host-config.ts ├── fs-renderer-types.ts ├── fs-renderer.ts └── index.ts ├── tsconfig.json └── types └── global └── index.d.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | name: Node.js ${{ matrix.os }} ${{ matrix.node-version }} 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | node-version: [12.10.x] 14 | os: [ubuntu-latest, windows-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: yarn install, and yarn test 23 | run: | 24 | yarn install 25 | yarn test 26 | env: 27 | CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 12.13.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @koba04/react-fs 2 | [![](https://github.com/koba04/react-fs/workflows/test/badge.svg)](https://github.com/koba04/react-fs/actions?workflow=test) 3 | 4 | **The GitHub repository will be published soon!!** 5 | 6 | A React custom renderer for file system APIs. 7 | 8 | 9 | ## Install 10 | 11 | This package depends on `recursive` option of `fs.rmdirSync`, so you have to use Node.js higher that `v12.10.0`. 12 | 13 | ``` 14 | npm i @koba04/react-fs 15 | ``` 16 | 17 | ## How to use 18 | 19 | ```js 20 | const React = require('react'); 21 | const { ReactFS } = require('@koba04/react-fs'); 22 | 23 | const targetDir = "test-react-fs-project"; 24 | ReactFS.render( 25 | <> 26 | 27 | # Title 28 | 29 | 30 | 31 | console.log("Hello"); 32 | 33 | 34 | , 35 | targetDir 36 | ); 37 | ``` 38 | 39 | ## TypeScript 40 | 41 | If you use `@koba04/react-fs` with TypeScript, you have to edit `typeRoots` like the following. 42 | 43 | ```json 44 | "typeRoots": [ 45 | "node_modules/@types", 46 | "node_modules/@koba04/react-fs/types", 47 | ] 48 | ``` 49 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /examples/script.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { ReactFS } from "../src"; 3 | import readline from "readline"; 4 | 5 | const askQuestion = (): Promise => { 6 | return new Promise(resolve => { 7 | const rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout 10 | }); 11 | rl.question("input a text: ", (text: string) => { 12 | if (text == null) return; 13 | resolve(text); 14 | rl.close(); 15 | }); 16 | }); 17 | }; 18 | 19 | const App = () => { 20 | const [name, setName] = useState(""); 21 | 22 | useEffect(() => { 23 | (async () => { 24 | const answer = await askQuestion(); 25 | console.log("answer is ", answer); 26 | setName(answer); 27 | })(); 28 | }, [name]); 29 | return ( 30 | <> 31 | 32 | const hello = "hello"; 33 | 34 | 35 | {name && {name}} 36 | 37 | # Hello File Renderer 38 | 39 | ); 40 | }; 41 | 42 | ReactFS.render(, "./demo"); 43 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": [] 4 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@koba04/react-fs", 3 | "version": "0.1.3", 4 | "description": "A React custom renderer for file system APIs", 5 | "main": "lib/index.js", 6 | "license": "MIT", 7 | "author": "koba04", 8 | "scripts": { 9 | "build": "run-s clean tsc", 10 | "clean": "rimraf lib", 11 | "example": "ts-node examples/script.tsx", 12 | "start": "tsc --watch", 13 | "test": "jest src", 14 | "tsc": "tsc" 15 | }, 16 | "types": "lib/index.d.ts", 17 | "files": [ 18 | "lib", 19 | "types" 20 | ], 21 | "keywords": [ 22 | "react", 23 | "react-reconciler", 24 | "react-renderer", 25 | "fs" 26 | ], 27 | "engines": { 28 | "node": ">=12.10" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "dependencies": { 34 | "react-reconciler": "^0.24.0" 35 | }, 36 | "peerDependencies": { 37 | "react": "^16.0.0" 38 | }, 39 | "devDependencies": { 40 | "@babel/preset-env": "^7.7.7", 41 | "@babel/preset-react": "^7.7.4", 42 | "@babel/preset-typescript": "^7.7.7", 43 | "@types/jest": "^24.0.25", 44 | "@types/node": "^12.12.42", 45 | "@types/react": "^16.9.35", 46 | "@types/react-reconciler": "^0.18.0", 47 | "jest": "^24.9.0", 48 | "npm-run-all": "^4.1.5", 49 | "react": "^16.12.0", 50 | "rimraf": "^3.0.2", 51 | "ts-node": "^8.8.2", 52 | "typescript": "^3.7.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@cybozu" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { ReactFS } from "../index"; 3 | import path from "path"; 4 | import { tmpdir } from "os"; 5 | import { rmdirSync, readFileSync, statSync, existsSync } from "fs"; 6 | 7 | const waitEffect = () => new Promise(r => setTimeout(r, 0)); 8 | 9 | describe("ReactFS", () => { 10 | const tempDir = path.join(tmpdir(), "react-fs"); 11 | console.log("tempDir is ", tempDir); 12 | afterEach(() => { 13 | rmdirSync(tempDir, { recursive: true }); 14 | }); 15 | describe("create a file and directory", () => { 16 | it("should be able to create a file", () => { 17 | ReactFS.render(Hello World, tempDir); 18 | expect(readFileSync(path.join(tempDir, "test.txt")).toString()).toBe( 19 | "Hello World" 20 | ); 21 | }); 22 | it("should be able to create a directory", () => { 23 | ReactFS.render(, tempDir); 24 | expect(statSync(path.join(tempDir, "test")).isDirectory()).toBe(true); 25 | }); 26 | }); 27 | describe("create a file into a directory", () => { 28 | it("should be able to create a file into a directory", () => { 29 | ReactFS.render( 30 | 31 | Hello World 32 | , 33 | tempDir 34 | ); 35 | expect( 36 | readFileSync(path.join(tempDir, "foo", "test.txt")).toString() 37 | ).toBe("Hello World"); 38 | }); 39 | it("should be able to add a new file", async () => { 40 | const App = () => { 41 | const [text, setText] = useState(""); 42 | useEffect(() => { 43 | setText("new"); 44 | }, []); 45 | return ( 46 | 47 | {text && 123} 48 | 49 | ); 50 | }; 51 | 52 | ReactFS.render(, tempDir); 53 | expect(existsSync(path.join(tempDir, "foo", "new.txt"))).toBe(false); 54 | await waitEffect(); 55 | expect( 56 | readFileSync(path.join(tempDir, "foo", "new.txt")).toString() 57 | ).toBe("123"); 58 | }); 59 | it("should be able to create a file into a nested directory", () => { 60 | ReactFS.render( 61 | 62 | 63 | Hello World 64 | 65 | , 66 | tempDir 67 | ); 68 | expect( 69 | readFileSync(path.join(tempDir, "foo", "bar", "test.txt")).toString() 70 | ).toBe("Hello World"); 71 | }); 72 | it("should be able to create multiple fles into a directory", () => { 73 | ReactFS.render( 74 | 75 | Foo 76 | Bar 77 | , 78 | tempDir 79 | ); 80 | expect( 81 | readFileSync(path.join(tempDir, "multiple", "foo.txt")).toString() 82 | ).toBe("Foo"); 83 | expect( 84 | readFileSync(path.join(tempDir, "multiple", "bar.txt")).toString() 85 | ).toBe("Bar"); 86 | }); 87 | }); 88 | describe("update a content and file name", () => { 89 | it("should be able to update a content of a file", async () => { 90 | const App = () => { 91 | const [text, setText] = useState("initial"); 92 | useEffect(() => { 93 | setText("updated"); 94 | }, []); 95 | return {text}; 96 | }; 97 | 98 | ReactFS.render(, tempDir); 99 | expect(readFileSync(path.join(tempDir, "foo.txt")).toString()).toBe( 100 | "initial" 101 | ); 102 | await waitEffect(); 103 | expect(readFileSync(path.join(tempDir, "foo.txt")).toString()).toBe( 104 | "updated" 105 | ); 106 | }); 107 | it("should be able to update a file name", async () => { 108 | const App = () => { 109 | const [text, setText] = useState("initial"); 110 | useEffect(() => { 111 | setText("updated"); 112 | }, []); 113 | return 123; 114 | }; 115 | 116 | ReactFS.render(, tempDir); 117 | expect(readFileSync(path.join(tempDir, "initial.txt")).toString()).toBe( 118 | "123" 119 | ); 120 | await waitEffect(); 121 | expect(readFileSync(path.join(tempDir, "updated.txt")).toString()).toBe( 122 | "123" 123 | ); 124 | expect(existsSync(path.join(tempDir, "initial.txt"))).toBe(false); 125 | }); 126 | }); 127 | describe("get a public instance", () => { 128 | it("should be able to get an instance filtered rootContainerInstance through ref", async () => { 129 | let ref: any; 130 | const App = () => { 131 | ref = useRef(null); 132 | return ( 133 | <> 134 | 135 | const num = 1; 136 | 137 | 138 | ); 139 | }; 140 | 141 | ReactFS.render(, tempDir); 142 | expect(ref.current).toEqual({ 143 | type: "file", 144 | props: { name: "index.js", children: "const num = 1;" } 145 | }); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/fs-renderer-host-config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Instance, 3 | HostContext, 4 | Props, 5 | Container, 6 | TextInstance, 7 | Type, 8 | UpdatePayload 9 | } from "./fs-renderer-types"; 10 | import path from "path"; 11 | import { writeFileSync, existsSync, mkdirSync, renameSync } from "fs"; 12 | 13 | const HOST_CONTEXT: HostContext = {}; 14 | 15 | export const getPublicInstance = (instance: Instance) => { 16 | const { rootContainerInstance, ...rest } = instance; 17 | return rest; 18 | }; 19 | export const getRootHostContext = (): HostContext => HOST_CONTEXT; 20 | export const getChildHostContext = () => HOST_CONTEXT; 21 | 22 | export const prepareForCommit = () => {}; 23 | export const resetAfterCommit = () => {}; 24 | export const createInstance = ( 25 | type: string, 26 | props: Props, 27 | rootContainerInstance: Container 28 | ): Instance => ({ type, props, rootContainerInstance }); 29 | export const createTextInstance = ( 30 | text: string, 31 | rootContainerInstance: Container 32 | ): TextInstance => ({ text, rootContainerInstance }); 33 | export const appendInitialChild = ( 34 | parentInstance: Instance, 35 | child: Instance | TextInstance 36 | ) => { 37 | child.parent = parentInstance; 38 | }; 39 | export const finalizeInitialChildren = () => true; 40 | export const prepareUpdate = () => ({}); 41 | export const shouldSetTextContent = () => false; 42 | export const shouldDeprioritizeSubtree = () => false; 43 | 44 | export const appendChildToContainer = () => {}; 45 | 46 | const buildParentPath = (instance: Instance | TextInstance) => { 47 | const names = []; 48 | let current = instance.parent; 49 | while (current) { 50 | names.push(current.props.name); 51 | current = current.parent; 52 | } 53 | return path.join(instance.rootContainerInstance.rootPath, ...names.reverse()); 54 | }; 55 | 56 | export const commitMount = ( 57 | instance: Instance, 58 | type: Type, 59 | newProps: Props 60 | ) => { 61 | const parentPath = buildParentPath(instance); 62 | const targetPath = path.join(parentPath, newProps.name); 63 | 64 | if (!existsSync(parentPath)) { 65 | mkdirSync(parentPath, { recursive: true }); 66 | } 67 | 68 | if (type === "file") { 69 | writeFileSync(targetPath, newProps.children); 70 | } else if (type === "directory" && !existsSync(targetPath)) { 71 | mkdirSync(targetPath); 72 | } 73 | }; 74 | 75 | export const commitUpdate = ( 76 | instance: Instance, 77 | updatePayload: UpdatePayload, 78 | type: Type, 79 | oldProps: Props, 80 | newProps: Props 81 | ) => { 82 | if (oldProps.name !== newProps.name) { 83 | instance.props = newProps; 84 | renameSync( 85 | path.join(buildParentPath(instance), oldProps.name), 86 | path.join(buildParentPath(instance), newProps.name) 87 | ); 88 | } 89 | }; 90 | 91 | export const commitTextUpdate = ( 92 | textInstance: TextInstance, 93 | oldText: string, 94 | newText: string 95 | ) => { 96 | if (oldText !== newText) { 97 | textInstance.text = newText; 98 | writeFileSync(buildParentPath(textInstance), newText); 99 | } 100 | }; 101 | export const removeChild = () => {}; 102 | export const appendChild = ( 103 | parentInstance: Instance, 104 | child: Instance | TextInstance 105 | ) => { 106 | child.parent = parentInstance; 107 | }; 108 | 109 | export const scheduleDeferredCallback = () => {}; 110 | export const cancelDeferredCallback = () => {}; 111 | export const setTimeout = global.setTimeout; 112 | export const clearTimeout = global.clearTimeout; 113 | export const noTimeout = {}; 114 | export const now = () => Date.now(); 115 | 116 | export const isPrimaryRenderer = true; 117 | export const supportsMutation = true; 118 | export const supportsPersistence = false; 119 | export const supportsHydration = false; 120 | -------------------------------------------------------------------------------- /src/fs-renderer-types.ts: -------------------------------------------------------------------------------- 1 | export type Type = string; 2 | export type Props = { 3 | [key: string]: any; 4 | }; 5 | 6 | export type TextInstance = { 7 | text: string; 8 | parent?: Instance; 9 | rootContainerInstance: Container; 10 | }; 11 | 12 | export type Instance = { 13 | type: Type; 14 | props: Props; 15 | parent?: Instance; 16 | rootContainerInstance: Container; 17 | }; 18 | 19 | export type PublicInstance = 20 | | Omit 21 | | Omit; 22 | 23 | export type Container = { 24 | rootPath: string; 25 | }; 26 | 27 | export type HostContext = {}; 28 | 29 | export type HydratableInstance = object; 30 | export type UpdatePayload = object; 31 | export type ChildSet = object; 32 | export type TimeoutHandle = object; 33 | export type NoTimeout = object; 34 | export type OpaqueHandle = any; 35 | -------------------------------------------------------------------------------- /src/fs-renderer.ts: -------------------------------------------------------------------------------- 1 | import Reconciler from "react-reconciler"; 2 | import * as HostConfig from "./fs-renderer-host-config"; 3 | import { 4 | Type, 5 | Props, 6 | Instance, 7 | TextInstance, 8 | HydratableInstance, 9 | PublicInstance, 10 | HostContext, 11 | UpdatePayload, 12 | ChildSet, 13 | TimeoutHandle, 14 | NoTimeout, 15 | Container 16 | } from "./fs-renderer-types"; 17 | 18 | // eslint-disable-next-line new-cap 19 | export const FSRenderer = Reconciler< 20 | Type, 21 | Props, 22 | Container, 23 | Instance, 24 | TextInstance, 25 | HydratableInstance, 26 | PublicInstance, 27 | HostContext, 28 | UpdatePayload, 29 | ChildSet, 30 | TimeoutHandle, 31 | NoTimeout 32 | >(HostConfig); 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FSRenderer } from "./fs-renderer"; 3 | import { Container } from "./fs-renderer-types"; 4 | import ReactReconciler from "react-reconciler"; 5 | import { rmdirSync } from "fs"; 6 | 7 | type RootContainer = { 8 | fiberRoot?: ReactReconciler.FiberRoot; 9 | container: Container; 10 | }; 11 | 12 | const rootContainerMap = new Map(); 13 | 14 | export const ReactFS = { 15 | render(element: React.ReactNode, rootPath: string) { 16 | let rootContainer = rootContainerMap.get(rootPath); 17 | if (!rootContainer) { 18 | rootContainer = { 19 | container: { 20 | rootPath 21 | } 22 | }; 23 | rootContainerMap.set(rootPath, rootContainer); 24 | } 25 | 26 | // First, we remove the root to clean up. 27 | // TODO: support hydration 28 | rmdirSync(rootPath, { recursive: true }); 29 | 30 | rootContainer.fiberRoot = FSRenderer.createContainer( 31 | rootContainer.container, 32 | false, 33 | false 34 | ); 35 | FSRenderer.updateContainer( 36 | element, 37 | rootContainer.fiberRoot, 38 | null, 39 | () => {} 40 | ); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "lib": ["es2015"], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | "typeRoots": [ 47 | "types", 48 | "node_modules/@types", 49 | "../../node_modules/@types", 50 | ], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | }, 67 | "exclude": [ 68 | "examples", 69 | "lib" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /types/global/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | interface IntrinsicElements { 3 | file: { 4 | name: string; 5 | children: React.ReactNode; 6 | ref?: import("react").MutableRefObject; 7 | }; 8 | directory: { 9 | name: string; 10 | children?: React.ReactNode; 11 | }; 12 | } 13 | } 14 | --------------------------------------------------------------------------------