├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .prettierrc.js
├── LICENSE
├── README.md
├── lerna.json
├── package.json
├── packages
├── cli
│ ├── .editorconfig
│ ├── .gitignore
│ ├── README.md
│ ├── bin
│ │ ├── run
│ │ └── run.cmd
│ ├── package.json
│ ├── src
│ │ ├── base.ts
│ │ ├── commands
│ │ │ ├── build.ts
│ │ │ ├── commit.ts
│ │ │ ├── destroy.ts
│ │ │ ├── init.ts
│ │ │ ├── pull.ts
│ │ │ ├── push.ts
│ │ │ ├── reload.ts
│ │ │ ├── sync.ts
│ │ │ └── up.ts
│ │ ├── config.ts
│ │ ├── constants.ts
│ │ ├── index.ts
│ │ └── utils
│ │ │ ├── __test__
│ │ │ └── index.test.ts
│ │ │ ├── collection.ts
│ │ │ ├── git.ts
│ │ │ ├── index.ts
│ │ │ ├── internals.ts
│ │ │ ├── logger.ts
│ │ │ └── nodes.ts
│ └── tsconfig.json
├── core
│ ├── package.json
│ ├── src
│ │ ├── constants.ts
│ │ ├── diff.ts
│ │ ├── heading.ts
│ │ ├── index.ts
│ │ ├── interfaces.ts
│ │ ├── steps.ts
│ │ ├── utils.ts
│ │ └── version.ts
│ └── tsconfig.json
├── editor
│ ├── .editorconfig
│ ├── .eslintignore
│ ├── .gitignore
│ ├── .prettierrc.js
│ ├── README.md
│ ├── api-server.js
│ ├── config-overrides.js
│ ├── fixtures
│ │ ├── .tuture
│ │ │ ├── collection.json
│ │ │ └── diff.json
│ │ └── mock-remotes.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── logo192.png
│ │ ├── logo512.png
│ │ ├── manifest.json
│ │ └── robots.txt
│ ├── src
│ │ ├── assets
│ │ │ ├── antd-custom.less
│ │ │ └── images
│ │ │ │ └── logo.svg
│ │ ├── components
│ │ │ ├── App.tsx
│ │ │ ├── ConnectedLayout
│ │ │ │ ├── ChildrenDrawerComponent
│ │ │ │ │ ├── CreateEditArticle.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── DrawerComponent
│ │ │ │ │ ├── CollectionCatalogue.tsx
│ │ │ │ │ ├── CollectionSetting.tsx
│ │ │ │ │ ├── ContactUs.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── LayoutHeader
│ │ │ │ │ ├── LastSavedTimestamp.tsx
│ │ │ │ │ ├── SyncModal.tsx
│ │ │ │ │ ├── Toolbar
│ │ │ │ │ │ ├── BlockButton.tsx
│ │ │ │ │ │ ├── Button.tsx
│ │ │ │ │ │ ├── EditLink.tsx
│ │ │ │ │ │ ├── HistoryButton.tsx
│ │ │ │ │ │ ├── HrButton.tsx
│ │ │ │ │ │ ├── ImageButton.tsx
│ │ │ │ │ │ ├── LinkButton.tsx
│ │ │ │ │ │ ├── MarkButton.tsx
│ │ │ │ │ │ ├── Menu.tsx
│ │ │ │ │ │ ├── NoteButton.tsx
│ │ │ │ │ │ ├── SaveButton.tsx
│ │ │ │ │ │ ├── SelectContentType.tsx
│ │ │ │ │ │ ├── SplitLine.tsx
│ │ │ │ │ │ ├── ToolbarIcon.tsx
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── MainMenu.tsx
│ │ │ │ ├── OutdatedNotification.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Content
│ │ │ │ ├── Editure
│ │ │ │ │ ├── element
│ │ │ │ │ │ ├── CodeBlock.tsx
│ │ │ │ │ │ ├── DiffBlock.tsx
│ │ │ │ │ │ ├── Explain.tsx
│ │ │ │ │ │ ├── Hr.tsx
│ │ │ │ │ │ ├── Image.tsx
│ │ │ │ │ │ ├── ListItem.tsx
│ │ │ │ │ │ ├── Note.tsx
│ │ │ │ │ │ └── index.tsx
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── leaf.tsx
│ │ │ │ ├── Highlight
│ │ │ │ │ ├── create-element.tsx
│ │ │ │ │ ├── highlight.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── languages
│ │ │ │ │ │ └── supported-languages.ts
│ │ │ │ │ └── styles
│ │ │ │ │ │ └── atom-dark.ts
│ │ │ │ ├── PageCatalogue.tsx
│ │ │ │ ├── PageHeader.tsx
│ │ │ │ ├── StepFileList.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── IconFont.tsx
│ │ │ ├── ScrollToTop.ts
│ │ │ ├── Toc
│ │ │ │ ├── ArticleStepList.tsx
│ │ │ │ ├── ReleasedStepList.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── styles.ts
│ │ │ │ └── widgets
│ │ │ │ │ ├── AddButton.tsx
│ │ │ │ │ ├── Caret.tsx
│ │ │ │ │ ├── DeleteButton.tsx
│ │ │ │ │ └── OutdatedTag.tsx
│ │ │ └── index.ts
│ │ ├── index.tsx
│ │ ├── models
│ │ │ ├── collection.ts
│ │ │ ├── diff.ts
│ │ │ ├── drawer.ts
│ │ │ ├── index.ts
│ │ │ ├── link.ts
│ │ │ ├── slate.ts
│ │ │ ├── sync.tsx
│ │ │ ├── toc.ts
│ │ │ └── util.d.ts
│ │ ├── pages
│ │ │ ├── Article.tsx
│ │ │ ├── Home.tsx
│ │ │ └── Toc.tsx
│ │ ├── react-app-env.d.ts
│ │ ├── serviceWorker.ts
│ │ ├── setupTests.ts
│ │ ├── shared
│ │ │ └── styles.tsx
│ │ ├── store
│ │ │ └── index.ts
│ │ └── utils
│ │ │ ├── constants.ts
│ │ │ ├── editor.ts
│ │ │ ├── environment.ts
│ │ │ ├── hiddenLines.ts
│ │ │ ├── hooks.ts
│ │ │ ├── hotkeys.ts
│ │ │ ├── image.ts
│ │ │ ├── note.ts
│ │ │ └── request.ts
│ └── tsconfig.json
└── local-server
│ ├── package.json
│ ├── src
│ ├── index.ts
│ ├── routes
│ │ ├── articles.ts
│ │ ├── collection-steps.ts
│ │ ├── diff.ts
│ │ ├── fragment.ts
│ │ ├── index.ts
│ │ ├── meta.ts
│ │ ├── remotes.ts
│ │ └── toc.ts
│ ├── server.ts
│ ├── types.ts
│ └── utils
│ │ ├── assets.ts
│ │ ├── collection.ts
│ │ ├── index.ts
│ │ └── task-queue.ts
│ └── tsconfig.json
├── rollup.config.js
├── tsconfig.json
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.md
2 | *.html
3 | *.json
4 | .github
5 | build
6 | dist
7 | node_modules
8 | tmp
9 | yarn.lock
10 | package.json
11 | test
12 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | 'plugin:import/typescript',
4 | 'prettier',
5 | 'prettier/@typescript-eslint',
6 | 'prettier/react',
7 | ],
8 | plugins: ['@typescript-eslint', 'import', 'react', 'prettier'],
9 | parser: '@typescript-eslint/parser',
10 | parserOptions: {
11 | sourceType: 'module',
12 | ecmaVersion: 2020,
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | },
17 | settings: {
18 | 'import/extensions': ['.js', '.ts', '.jsx', '.tsx'],
19 | react: {
20 | version: 'detect',
21 | },
22 | },
23 | env: {
24 | browser: true,
25 | es6: true,
26 | node: true,
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug 反馈
3 | about: 创建一份 Bug 反馈报告,帮助我们改进
4 | title: ''
5 | labels: 'bug'
6 | assignees: ''
7 | ---
8 |
9 | **简要描述 Bug**
10 | 一句话描述一下 Bug 的情况。
11 |
12 | **Tuture 版本信息**
13 | 例如 `@tuture/cli/0.0.2 darwin-x64 node-v12.10.0`,可通过 `tuture -v` 查看版本。
14 |
15 | **详细的报错调用栈信息**
16 | 终端或者浏览器控制台的任何报错信息,请直接复制在这里。
17 |
18 | **复现步骤**
19 | 复现 Bug 的完整步骤:
20 |
21 | 1. 打开 ...
22 | 2. 点击 ...
23 | 3. 发生错误
24 |
25 | **期望的行为逻辑**
26 | 描述一下你本来期望发生的事情。
27 |
28 | **屏幕截图**
29 | 出错时的屏幕截图(有 GIF 的话就更棒啦!)
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 功能/需求
3 | about: 想要什么样的新功能,或者觉得哪些地方可以改进?
4 | title: ''
5 | labels: 'enhancement'
6 | assignees: ''
7 | ---
8 |
9 | **Tuture 版本信息**
10 | 例如 `@tuture/cli/0.0.2 darwin-x64 node-v12.10.0`,可通过 `tuture -v` 查看版本。
11 |
12 | **对目前工具的哪些地方不满意?**
13 | 简要地描述一下让你不爽的问题(如果有截图就更好啦)。
14 |
15 | **描述一下你期望的功能**
16 | 简要地描述一下你所期望的功能,或者更理想的解决方案。
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | # not use npm
24 | package-lock.json
25 |
26 | # dist files should not include to the SCM
27 | dist
28 |
29 | tuture-error.log
30 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | bracketSpacing: true,
3 | singleQuote: true,
4 | jsxBracketSameLine: true,
5 | trailingComma: 'all',
6 | printWidth: 80,
7 | arrowParens: 'always',
8 | };
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Tuture Authors
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 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": ["packages/core", "packages/local-server", "packages/cli"],
3 | "npmClient": "yarn",
4 | "useWorkspaces": true,
5 | "version": "0.0.7",
6 | "command": {
7 | "publish": {
8 | "ignoreChanges": ["*.md"]
9 | },
10 | "bootstrap": {
11 | "npmClientArgs": ["--no-lockfile"]
12 | }
13 | },
14 | "publishConfig": {
15 | "access": "public"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tuture-monorepo",
3 | "version": "3.0.0-alpha10",
4 | "private": true,
5 | "description": "Write tutorials from the future, with the power of Git and community.",
6 | "workspaces": [
7 | "packages/*"
8 | ],
9 | "keywords": [
10 | "learn-by-doing",
11 | "learn-by-examples",
12 | "hands-on",
13 | "tutorial",
14 | "diff-viewer",
15 | "git"
16 | ],
17 | "engines": {
18 | "node": ">=8.0.0"
19 | },
20 | "scripts": {
21 | "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix",
22 | "test": "jest",
23 | "test:watch": "jest --watch",
24 | "build": "rollup -c",
25 | "build:watch": "rollup -c -w",
26 | "build:cli": "cd packages/cli && yarn && yarn build",
27 | "build:editor": "cd packages/editor && yarn && yarn build",
28 | "collect-editor": "cd packages/local-server && yarn collect-editor",
29 | "clean": "rimraf packages/**/dist && rimraf packages/cli/lib && rimraf packages/editor/build",
30 | "postinstall": "yarn build",
31 | "prerelease": "yarn clean && yarn build && yarn build:cli && yarn build:editor && yarn collect-editor",
32 | "release": "lerna publish"
33 | },
34 | "repository": {
35 | "type": "git",
36 | "url": "https://github.com/tuture-dev/tuture.git"
37 | },
38 | "license": "MIT",
39 | "bugs": {
40 | "url": "https://github.com/tuture-dev/tuture/issues"
41 | },
42 | "homepage": "https://github.com/tuture-dev/tuture#readme",
43 | "husky": {
44 | "hooks": {
45 | "pre-commit": "lint-staged"
46 | }
47 | },
48 | "lint-staged": {
49 | "*": [
50 | "pretty-quick --staged",
51 | "eslint . --fix"
52 | ]
53 | },
54 | "devDependencies": {
55 | "@rollup/plugin-node-resolve": "^7.1.1",
56 | "@types/faker": "^4.1.10",
57 | "@types/jest": "^23.3.1",
58 | "@types/lodash.clonedeep": "^4.5.6",
59 | "@types/rc": "^1.1.0",
60 | "@types/tmp": "^0.0.33",
61 | "@types/which": "^1.3.1",
62 | "@typescript-eslint/eslint-plugin": "^2.19.2",
63 | "@typescript-eslint/parser": "^2.19.2",
64 | "eslint": "^6.8.0",
65 | "eslint-config-airbnb": "^18.0.1",
66 | "eslint-config-prettier": "^6.10.0",
67 | "eslint-config-react-app": "^5.2.0",
68 | "eslint-plugin-emotion": "^10.0.27",
69 | "eslint-plugin-flowtype": "^4.6.0",
70 | "eslint-plugin-html": "^6.0.0",
71 | "eslint-plugin-import": "^2.20.1",
72 | "eslint-plugin-jsx-a11y": "^6.2.3",
73 | "eslint-plugin-prettier": "^3.1.2",
74 | "eslint-plugin-react": "^7.18.3",
75 | "eslint-plugin-react-hooks": "^1.7.0",
76 | "faker": "^4.1.0",
77 | "husky": "^4.2.3",
78 | "jest": "^24.9.0",
79 | "lerna": "^3.20.2",
80 | "lint-staged": "^10.0.7",
81 | "lodash.clonedeep": "^4.5.0",
82 | "prettier": "^1.19.1",
83 | "pretty-quick": "^2.0.1",
84 | "rimraf": "^2.6.2",
85 | "rollup": "^1.31.0",
86 | "rollup-plugin-babel": "^4.3.3",
87 | "rollup-plugin-commonjs": "^10.1.0",
88 | "rollup-plugin-css-only": "^2.0.0",
89 | "rollup-plugin-json": "^4.0.0",
90 | "rollup-plugin-jsx": "^1.0.3",
91 | "rollup-plugin-typescript2": "^0.26.0",
92 | "ts-jest": "^24.1.0",
93 | "typescript": "^3.8.3"
94 | },
95 | "jest": {
96 | "transform": {
97 | "^.+\\.tsx?$": "ts-jest"
98 | },
99 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
100 | "testPathIgnorePatterns": [
101 | "test/*"
102 | ],
103 | "moduleFileExtensions": [
104 | "ts",
105 | "tsx",
106 | "js"
107 | ],
108 | "collectCoverageFrom": [
109 | "build/**/*.ts"
110 | ],
111 | "testURL": "http://localhost/",
112 | "verbose": true
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/packages/cli/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.md]
11 | trim_trailing_whitespace = false
12 |
--------------------------------------------------------------------------------
/packages/cli/.gitignore:
--------------------------------------------------------------------------------
1 | *-debug.log
2 | *-error.log
3 | /.nyc_output
4 | /dist
5 | /lib
6 | /package-lock.json
7 | /tmp
8 | node_modules
9 |
--------------------------------------------------------------------------------
/packages/cli/README.md:
--------------------------------------------------------------------------------
1 | @tuture/cli
2 | ===========
3 |
4 | CLI for Tuture
5 |
6 | [](https://oclif.io)
7 | [](https://npmjs.org/package/@tuture/cli)
8 | [](https://npmjs.org/package/@tuture/cli)
9 | [](https://github.com//cli/blob/master/package.json)
10 |
11 |
12 | * [Usage](#usage)
13 | * [Commands](#commands)
14 |
15 | # Usage
16 |
17 | ```sh-session
18 | $ npm install -g @tuture/cli
19 | $ tuture COMMAND
20 | running command...
21 | $ tuture (-v|--version|version)
22 | @tuture/cli/0.0.2 darwin-x64 node-v12.10.0
23 | $ tuture --help [COMMAND]
24 | USAGE
25 | $ tuture COMMAND
26 | ...
27 | ```
28 |
29 | # Commands
30 |
31 | * [`tuture build`](#tuture-build)
32 | * [`tuture commit`](#tuture-commit)
33 | * [`tuture destroy`](#tuture-destroy)
34 | * [`tuture help [COMMAND]`](#tuture-help-command)
35 | * [`tuture init`](#tuture-init)
36 | * [`tuture pull`](#tuture-pull)
37 | * [`tuture push`](#tuture-push)
38 | * [`tuture reload`](#tuture-reload)
39 | * [`tuture sync`](#tuture-sync)
40 | * [`tuture up`](#tuture-up)
41 |
42 | ## `tuture build`
43 |
44 | Build tutorial into a markdown document
45 |
46 | ```
47 | USAGE
48 | $ tuture build
49 |
50 | OPTIONS
51 | -h, --help show CLI help
52 | -o, --out=out name of output directory
53 | --hexo hexo compatibility mode
54 | ```
55 |
56 | ## `tuture commit`
57 |
58 | Commit your tutorial to VCS (Git)
59 |
60 | ```
61 | USAGE
62 | $ tuture commit
63 |
64 | OPTIONS
65 | -h, --help show CLI help
66 | -m, --message=message commit message
67 | ```
68 |
69 | ## `tuture destroy`
70 |
71 | Delete all tuture files
72 |
73 | ```
74 | USAGE
75 | $ tuture destroy
76 |
77 | OPTIONS
78 | -f, --force destroy without confirmation
79 | -h, --help show CLI help
80 | ```
81 |
82 | ## `tuture help [COMMAND]`
83 |
84 | display help for tuture
85 |
86 | ```
87 | USAGE
88 | $ tuture help [COMMAND]
89 |
90 | ARGUMENTS
91 | COMMAND command to show help for
92 |
93 | OPTIONS
94 | --all see all commands in CLI
95 | ```
96 |
97 | _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.2.3/src/commands/help.ts)_
98 |
99 | ## `tuture init`
100 |
101 | Initialize a tuture tutorial
102 |
103 | ```
104 | USAGE
105 | $ tuture init
106 |
107 | OPTIONS
108 | -h, --help show CLI help
109 | -y, --yes do not ask for prompts
110 | ```
111 |
112 | ## `tuture pull`
113 |
114 | Pull the remote tuture branch to local
115 |
116 | ```
117 | USAGE
118 | $ tuture pull
119 |
120 | OPTIONS
121 | -h, --help show CLI help
122 | -r, --remote=remote name of remote to pull
123 | ```
124 |
125 | ## `tuture push`
126 |
127 | Push the tuture branch to remote
128 |
129 | ```
130 | USAGE
131 | $ tuture push
132 |
133 | OPTIONS
134 | -h, --help show CLI help
135 | -r, --remote=remote name of remote to push
136 | ```
137 |
138 | ## `tuture reload`
139 |
140 | Update workspace with latest commit history
141 |
142 | ```
143 | USAGE
144 | $ tuture reload
145 |
146 | OPTIONS
147 | -h, --help show CLI help
148 | ```
149 |
150 | ## `tuture sync`
151 |
152 | Synchronize workspace with local/remote branch
153 |
154 | ```
155 | USAGE
156 | $ tuture sync
157 |
158 | OPTIONS
159 | -h, --help show CLI help
160 | -m, --message=message commit message
161 | --configureRemotes configure remotes before synchronization
162 | --continue continue synchronization after resolving conflicts
163 | --noPull do not pull from remote
164 | --noPush do not push to remote
165 | ```
166 |
167 | ## `tuture up`
168 |
169 | Render and edit tutorial in browser
170 |
171 | ```
172 | USAGE
173 | $ tuture up
174 |
175 | OPTIONS
176 | -h, --help show CLI help
177 | -p, --port=port which port to use for editor server
178 | ```
179 |
180 |
--------------------------------------------------------------------------------
/packages/cli/bin/run:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('@oclif/command').run()
4 | .then(require('@oclif/command/flush'))
5 | .catch(require('@oclif/errors/handle'))
6 |
--------------------------------------------------------------------------------
/packages/cli/bin/run.cmd:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | node "%~dp0\run" %*
4 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tuture/cli",
3 | "description": "Command line interface for Tuture",
4 | "version": "0.0.7",
5 | "bin": {
6 | "tuture": "./bin/run"
7 | },
8 | "bugs": "https://github.com/tuture-dev/tuture/issues",
9 | "scripts": {
10 | "build": "rimraf lib && tsc -b",
11 | "postpack": "rimraf oclif.manifest.json",
12 | "prepack": "rimraf lib && tsc -b && oclif-dev manifest && oclif-dev readme",
13 | "test": "echo NO TESTS"
14 | },
15 | "dependencies": {
16 | "@oclif/command": "^1",
17 | "@oclif/config": "^1",
18 | "@oclif/plugin-help": "^2",
19 | "@tuture/core": "^0.0.7",
20 | "@tuture/local-server": "^0.0.7",
21 | "chalk": "^2.4.2",
22 | "editure": "^0.1.2",
23 | "editure-constants": "^0.1.2",
24 | "fs-extra": "^7.0.0",
25 | "get-port": "^5.1.1",
26 | "inquirer": "^6.0.0",
27 | "micromatch": "^3.1.10",
28 | "open": "^6.4.0",
29 | "parse-diff": "^0.4.2",
30 | "rc": "^1.2.8",
31 | "simple-git": "^1.129.0",
32 | "tslib": "^1",
33 | "tuture-slate": "0.57.4",
34 | "which": "^1.3.1",
35 | "winston": "^3.1.0",
36 | "yutang": "^0.0.4"
37 | },
38 | "devDependencies": {
39 | "@oclif/dev-cli": "^1",
40 | "@types/inquirer": "^6.5.0",
41 | "@types/micromatch": "^4.0.1",
42 | "@types/node": "^10",
43 | "globby": "^10",
44 | "rimraf": "^2.6.2",
45 | "ts-node": "^8",
46 | "typescript": "^3.3"
47 | },
48 | "engines": {
49 | "node": ">=8.0.0"
50 | },
51 | "files": [
52 | "/bin",
53 | "/lib",
54 | "/npm-shrinkwrap.json",
55 | "/oclif.manifest.json"
56 | ],
57 | "keywords": [
58 | "tuture",
59 | "cli",
60 | "oclif"
61 | ],
62 | "license": "MIT",
63 | "main": "lib/index.js",
64 | "oclif": {
65 | "commands": "./lib/commands",
66 | "bin": "tuture",
67 | "plugins": [
68 | "@oclif/plugin-help"
69 | ]
70 | },
71 | "types": "lib/index.d.ts"
72 | }
73 |
--------------------------------------------------------------------------------
/packages/cli/src/base.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '@oclif/command';
2 | import fs from 'fs-extra';
3 | import rc from 'rc';
4 | import { TUTURE_ROOT, TUTURE_IGNORE_PATH } from '@tuture/core';
5 |
6 | import defaultConfig from './config';
7 | import logger from './utils/logger';
8 | import { checkInitStatus } from './utils';
9 | import { EXIT_CODE } from './constants';
10 | import { git } from './utils/git';
11 |
12 | export default abstract class BaseCommand extends Command {
13 | // User configurations.
14 | userConfig: any = defaultConfig;
15 |
16 | // The branch which the user is working upon.
17 | currentBranch: string = 'master';
18 |
19 | async init() {
20 | this.userConfig = rc('tuture', defaultConfig);
21 |
22 | // Check initialization status when running commands except `init`.
23 | if (this.id !== 'init') {
24 | try {
25 | await checkInitStatus();
26 | } catch (err) {
27 | logger.log('error', err.message);
28 | this.exit(EXIT_CODE.NOT_INIT);
29 | }
30 | }
31 |
32 | if (!fs.existsSync(TUTURE_ROOT)) {
33 | fs.mkdirSync(TUTURE_ROOT);
34 | }
35 |
36 | if (fs.existsSync(TUTURE_IGNORE_PATH)) {
37 | const patterns = this.userConfig.ignoredFiles.concat(
38 | fs
39 | .readFileSync(TUTURE_IGNORE_PATH)
40 | .toString()
41 | .split('\n')
42 | .filter((pattern) => !pattern.match(/#/) && pattern.match(/\b/)),
43 | );
44 | this.userConfig.ignoredFiles = patterns;
45 | }
46 |
47 | if (await git.checkIsRepo()) {
48 | // Record the current branch.
49 | const status = await git.status();
50 | this.currentBranch = status.current;
51 | }
52 | }
53 |
54 | async finally() {
55 | // Clean tuture root if it's empty, since it's created for no reason..
56 | if (
57 | fs.existsSync(TUTURE_ROOT) &&
58 | fs.readdirSync(TUTURE_ROOT).length === 0
59 | ) {
60 | fs.removeSync(TUTURE_ROOT);
61 | }
62 |
63 | if (await git.checkIsRepo()) {
64 | try {
65 | // Ensure we are back to original branch.
66 | await git.checkout(['-q', this.currentBranch]);
67 | } catch {
68 | // Just silently failed.
69 | }
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/commit.ts:
--------------------------------------------------------------------------------
1 | import cp from 'child_process';
2 | import fs from 'fs-extra';
3 | import { flags } from '@oclif/command';
4 | import { prompt } from 'inquirer';
5 | import {
6 | collectionPath,
7 | collectionVcsPath,
8 | saveCheckpoint,
9 | collectionCheckpoint,
10 | } from '@tuture/local-server';
11 | import { TUTURE_BRANCH, COLLECTION_PATH, ASSETS_JSON_PATH } from '@tuture/core';
12 |
13 | import BaseCommand from '../base';
14 | import logger from '../utils/logger';
15 | import { git } from '../utils/git';
16 | import { initializeTutureBranch } from '../utils/collection';
17 |
18 | export default class Commit extends BaseCommand {
19 | static description = 'Commit your tutorial to VCS (Git)';
20 |
21 | static flags = {
22 | help: flags.help({ char: 'h' }),
23 | message: flags.string({
24 | char: 'm',
25 | description: 'commit message',
26 | }),
27 | };
28 |
29 | async run() {
30 | const { flags } = this.parse(Commit);
31 | this.userConfig = Object.assign(this.userConfig, flags);
32 |
33 | const message =
34 | flags.message ||
35 | (
36 | await prompt<{ message: string }>([
37 | {
38 | name: 'message',
39 | type: 'input',
40 | default: `Commit on ${new Date()}`,
41 | },
42 | ])
43 | ).message;
44 |
45 | await initializeTutureBranch();
46 |
47 | // Checkout tuture branch and add tuture.yml.
48 | await git.checkout(TUTURE_BRANCH);
49 |
50 | // Trying to copy and add collection data to staging.
51 | fs.copySync(collectionPath, collectionVcsPath);
52 | await git.add(collectionVcsPath);
53 |
54 | // COMPAT: remove collection.json and tuture-assets.json from project root.
55 | if (fs.existsSync(COLLECTION_PATH)) {
56 | await git.rm(COLLECTION_PATH);
57 | }
58 | if (fs.existsSync(ASSETS_JSON_PATH)) {
59 | await git.rm(ASSETS_JSON_PATH);
60 | }
61 |
62 | fs.removeSync(collectionCheckpoint);
63 |
64 | // Commit changes to tuture branch.
65 | cp.execSync(`git commit --allow-empty -m "tuture: ${message}"`);
66 | logger.log('success', `Committed to branch ${TUTURE_BRANCH} (${message})`);
67 |
68 | // Copy the last committed file.
69 | await saveCheckpoint();
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/destroy.ts:
--------------------------------------------------------------------------------
1 | import { flags } from '@oclif/command';
2 | import { prompt } from 'inquirer';
3 | import { TUTURE_BRANCH } from '@tuture/core';
4 |
5 | import BaseCommand from '../base';
6 | import logger from '../utils/logger';
7 | import { git } from '../utils/git';
8 | import { removeTutureSuite } from '../utils';
9 |
10 | export default class Destroy extends BaseCommand {
11 | static description = 'Delete all tuture files';
12 |
13 | static flags = {
14 | help: flags.help({ char: 'h' }),
15 | force: flags.boolean({
16 | char: 'f',
17 | description: 'destroy without confirmation',
18 | }),
19 | };
20 |
21 | async promptConfirm(message: string, defaultChoice = false) {
22 | const response = await prompt<{ answer: boolean }>([
23 | {
24 | type: 'confirm',
25 | name: 'answer',
26 | message,
27 | default: defaultChoice,
28 | },
29 | ]);
30 |
31 | return response.answer;
32 | }
33 |
34 | async run() {
35 | const { flags } = this.parse(Destroy);
36 |
37 | if (!flags.force) {
38 | const confirmed = await this.promptConfirm('Are you sure?');
39 | if (!confirmed) {
40 | this.exit(0);
41 | }
42 | }
43 |
44 | await removeTutureSuite();
45 |
46 | const { all: allBranches } = await git.branch({ '-a': true });
47 |
48 | // Remove local tuture branch if exists.
49 | if (allBranches.includes(TUTURE_BRANCH)) {
50 | await git.branch(['-D', TUTURE_BRANCH]);
51 | logger.log('success', 'Local tuture branch has been deleted.');
52 | }
53 |
54 | const remoteBranches = allBranches.filter(
55 | (branch) => branch.indexOf(TUTURE_BRANCH) > 0,
56 | );
57 |
58 | if (remoteBranches.length > 0) {
59 | const confirmed = await this.promptConfirm(
60 | `Do you want to delete ${remoteBranches.length} remote tuture branch(es)?`,
61 | );
62 |
63 | if (confirmed) {
64 | try {
65 | // Delete all remote branches.
66 | await Promise.all(
67 | remoteBranches.map(async (branch) => {
68 | const [_, remote, ref] = branch.split('/');
69 | await git.push(remote, ref, { '-d': true });
70 | logger.log('success', `${branch} has been deleted.`);
71 | }),
72 | );
73 | } catch (err) {
74 | logger.log('error', err.message);
75 | }
76 | }
77 | }
78 |
79 | logger.log('success', 'Tuture tutorial has been destroyed!');
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/init.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import fs from 'fs-extra';
3 | import { flags } from '@oclif/command';
4 | import { prompt } from 'inquirer';
5 | import { Collection, SCHEMA_VERSION, randHex } from '@tuture/core';
6 | import { collectionPath, saveCollection } from '@tuture/local-server';
7 |
8 | import logger from '../utils/logger';
9 | import BaseCommand from '../base';
10 | import { makeSteps, removeTutureSuite, selectRemotes } from '../utils';
11 | import { git, inferGithubField, appendGitignore } from '../utils/git';
12 |
13 | export default class Init extends BaseCommand {
14 | static description = 'Initialize a tuture tutorial';
15 |
16 | static flags = {
17 | help: flags.help({ char: 'h' }),
18 | yes: flags.boolean({
19 | char: 'y',
20 | description: 'do not ask for prompts',
21 | }),
22 | };
23 |
24 | async promptInitGit(yes: boolean) {
25 | const response = yes
26 | ? { answer: true }
27 | : await prompt<{
28 | answer: boolean;
29 | }>([
30 | {
31 | name: 'answer',
32 | type: 'confirm',
33 | message:
34 | 'You are not in a Git repository, do you want to initialize one?',
35 | default: false,
36 | },
37 | ]);
38 |
39 | if (!response.answer) {
40 | this.exit(0);
41 | } else {
42 | await git.init();
43 | logger.log('success', 'Git repository is initialized!');
44 | }
45 | }
46 |
47 | async promptMetaData(yes: boolean) {
48 | const answer = yes
49 | ? { name: 'My Awesome Tutorial' }
50 | : await prompt<{ name: string; description: string }>([
51 | {
52 | name: 'name',
53 | message: 'Collection Name',
54 | default: 'My Awesome Tutorial',
55 | },
56 | {
57 | name: 'description',
58 | message: 'Description',
59 | },
60 | ]);
61 |
62 | return answer;
63 | }
64 |
65 | async run() {
66 | const { flags } = this.parse(Init);
67 |
68 | if (fs.existsSync(collectionPath)) {
69 | logger.log('success', 'Tuture tutorial has already been initialized!');
70 | this.exit(0);
71 | }
72 |
73 | if (!(await git.checkIsRepo())) {
74 | await this.promptInitGit(flags.yes);
75 | }
76 |
77 | const meta = await this.promptMetaData(flags.yes);
78 |
79 | try {
80 | const steps = await makeSteps(this.userConfig.ignoredFiles);
81 | const defaultArticleId = randHex(8);
82 |
83 | steps.forEach((step) => {
84 | step.articleId = defaultArticleId;
85 | });
86 |
87 | const collection: Collection = {
88 | ...meta,
89 | id: randHex(32),
90 | created: new Date(),
91 | articles: [
92 | {
93 | id: defaultArticleId,
94 | name: meta.name,
95 | description: '',
96 | topics: [],
97 | categories: [],
98 | created: new Date(),
99 | cover: '',
100 | },
101 | ],
102 | steps,
103 | };
104 |
105 | const github = await inferGithubField();
106 | if (github) {
107 | logger.log(
108 | 'info',
109 | `Inferred github repository: ${chalk.underline(
110 | github,
111 | )}. Feel free to revise or delete it.`,
112 | );
113 | collection.github = github;
114 | }
115 |
116 | const remotes = await git.getRemotes(true);
117 |
118 | if (remotes.length > 0) {
119 | if (flags.yes) {
120 | collection.remotes = [remotes[0]];
121 | } else {
122 | collection.remotes = await selectRemotes(remotes);
123 | }
124 | }
125 |
126 | collection.version = SCHEMA_VERSION;
127 |
128 | saveCollection(collection);
129 | appendGitignore();
130 |
131 | logger.log('success', 'Tuture tutorial has been initialized!');
132 | } catch (err) {
133 | await removeTutureSuite();
134 | logger.log({
135 | level: 'error',
136 | message: err.message,
137 | error: err,
138 | });
139 | this.exit(1);
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/pull.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import { flags } from '@oclif/command';
3 | import { TUTURE_BRANCH } from '@tuture/core';
4 |
5 | import BaseCommand from '../base';
6 | import logger from '../utils/logger';
7 | import { git } from '../utils/git';
8 |
9 | export default class Pull extends BaseCommand {
10 | static description = 'Pull the remote tuture branch to local';
11 |
12 | static flags = {
13 | help: flags.help({ char: 'h' }),
14 | remote: flags.string({ char: 'r', description: 'name of remote to pull' }),
15 | };
16 |
17 | async run() {
18 | const { flags } = this.parse(Pull);
19 | this.userConfig = Object.assign(this.userConfig, flags);
20 |
21 | let remoteToPull = flags.remote;
22 | if (!remoteToPull) {
23 | const remotes = await git.getRemotes(true);
24 |
25 | if (remotes.length === 0) {
26 | logger.log('error', 'Remote repository has not been configured.');
27 | this.exit(1);
28 | } else {
29 | // Select the first remote by default.
30 | remoteToPull = remotes[0].name;
31 | }
32 | }
33 |
34 | try {
35 | await git.checkout(TUTURE_BRANCH);
36 |
37 | logger.log('info', `Starting to pull from ${remoteToPull}.`);
38 | const { files } = await git.pull(remoteToPull, TUTURE_BRANCH);
39 |
40 | if (files.length > 0) {
41 | // Commit changes to tuture branch.
42 | logger.log('success', `Pulled from ${remoteToPull} successfully.`);
43 | } else {
44 | logger.log('success', `Already up-to-date with ${remoteToPull}.`);
45 | }
46 | } catch (err) {
47 | const { conflicted } = await git.status();
48 | if (conflicted.length > 0) {
49 | logger.log(
50 | 'error',
51 | `Please manually resolve the conflict and run ${chalk.bold(
52 | 'tuture sync --continue',
53 | )} to move on.`,
54 | );
55 | } else {
56 | // No remote tuture branch.
57 | logger.log('error', String(err.message).trim());
58 | }
59 |
60 | this.exit(1);
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/push.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import fs from 'fs-extra';
3 | import { flags } from '@oclif/command';
4 | import { TUTURE_BRANCH } from '@tuture/core';
5 | import { collectionPath } from '@tuture/local-server';
6 |
7 | import BaseCommand from '../base';
8 | import logger from '../utils/logger';
9 | import { git } from '../utils/git';
10 | import { initializeTutureBranch } from '../utils/collection';
11 |
12 | export default class Push extends BaseCommand {
13 | static description = 'Push the tuture branch to remote';
14 |
15 | static flags = {
16 | help: flags.help({ char: 'h' }),
17 | remote: flags.string({ char: 'r', description: 'name of remote to push' }),
18 | };
19 |
20 | async run() {
21 | const { flags } = this.parse(Push);
22 | this.userConfig = Object.assign(this.userConfig, flags);
23 |
24 | let remoteToPush = flags.remote;
25 | if (!remoteToPush) {
26 | const remotes = await git.getRemotes(true);
27 |
28 | if (remotes.length === 0) {
29 | logger.log('error', 'Remote repository has not been configured.');
30 | this.exit(1);
31 | } else {
32 | // Select the first remote by default.
33 | remoteToPush = remotes[0].name;
34 | }
35 | }
36 |
37 | await initializeTutureBranch();
38 |
39 | try {
40 | // Checkout tuture branch and add tuture.yml.
41 | await git.checkout(TUTURE_BRANCH);
42 |
43 | if (!fs.existsSync(collectionPath)) {
44 | logger.log(
45 | 'error',
46 | `Cannot push empty tuture branch. Please commit your tutorial with ${chalk.bold(
47 | 'tuture commit',
48 | )}.`,
49 | );
50 | this.exit(1);
51 | }
52 |
53 | logger.log('info', `Starting to push to ${remoteToPush}.`);
54 |
55 | await git.push(remoteToPush, TUTURE_BRANCH);
56 | logger.log('success', `Pushed to ${remoteToPush} successfully.`);
57 | } catch (err) {
58 | logger.log('error', String(err.message).trim());
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/reload.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import mm from 'micromatch';
3 | import { flags } from '@oclif/command';
4 | import { Step, getStepTitle } from '@tuture/core';
5 | import {
6 | loadCollection,
7 | collectionPath,
8 | saveCollection,
9 | } from '@tuture/local-server';
10 |
11 | import sync from './sync';
12 | import BaseCommand from '../base';
13 | import { git } from '../utils/git';
14 | import logger from '../utils/logger';
15 | import { makeSteps, mergeSteps } from '../utils';
16 |
17 | export default class Reload extends BaseCommand {
18 | static description = 'Update workspace with latest commit history';
19 |
20 | static flags = {
21 | help: flags.help({ char: 'h' }),
22 | };
23 |
24 | async run() {
25 | this.parse(Reload);
26 |
27 | // Run sync command if workspace is not created.
28 | if (!fs.existsSync(collectionPath)) {
29 | await sync.run([]);
30 | }
31 |
32 | const collection = loadCollection();
33 |
34 | // Checkout master branch and add tuture.yml.
35 | await git.checkout('master');
36 |
37 | const ignoredFiles: string[] = this.userConfig.ignoredFiles;
38 | const currentSteps: Step[] = await makeSteps(ignoredFiles);
39 | const lastArticleId = collection.articles.slice(-1)[0].id;
40 |
41 | currentSteps.forEach((step) => {
42 | if (!collection.steps.map((step) => step.commit).includes(step.commit)) {
43 | logger.log(
44 | 'success',
45 | `New step: ${getStepTitle(step)} (${step.commit})`,
46 | );
47 |
48 | // For newly added steps, assign it to the last article.
49 | step.articleId = lastArticleId;
50 | }
51 | });
52 |
53 | collection.steps = mergeSteps(collection.steps, currentSteps);
54 |
55 | collection.steps.forEach((step) => {
56 | if (step.outdated) {
57 | logger.log(
58 | 'warning',
59 | `Outdated step: ${getStepTitle(step)} (${step.commit})`,
60 | );
61 | }
62 |
63 | // Set display to false for ignored files.
64 | step.children.forEach((child) => {
65 | if (child.type === 'file') {
66 | const diff = child.children[1];
67 | if (ignoredFiles.some((pattern) => mm.isMatch(diff.file, pattern))) {
68 | child.display = false;
69 | }
70 | }
71 | });
72 | });
73 |
74 | saveCollection(collection);
75 |
76 | logger.log('success', 'Reload complete!');
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/cli/src/commands/up.ts:
--------------------------------------------------------------------------------
1 | import open from 'open';
2 | import fs from 'fs-extra';
3 | import getPort from 'get-port';
4 | import { flags } from '@oclif/command';
5 | import {
6 | makeServer,
7 | loadCollection,
8 | collectionPath,
9 | } from '@tuture/local-server';
10 |
11 | import reload from './reload';
12 | import BaseCommand from '../base';
13 | import logger from '../utils/logger';
14 | import { diffPath, shouldReloadSteps } from '../utils/git';
15 |
16 | export default class Up extends BaseCommand {
17 | static description = 'Render and edit tutorial in browser';
18 |
19 | static flags = {
20 | help: flags.help({ char: 'h' }),
21 | port: flags.integer({
22 | char: 'p',
23 | description: 'which port to use for editor server',
24 | }),
25 | };
26 |
27 | async fireTutureServer() {
28 | const port = await getPort({ port: this.userConfig.port });
29 | const server = makeServer({
30 | baseUrl: '/api',
31 | onGitHistoryChange: () => {
32 | reload.run([]);
33 | },
34 | });
35 |
36 | server.listen(port, () => {
37 | const url = `http://localhost:${port}`;
38 | logger.log('success', `Tutorial editor is served on ${url}`);
39 |
40 | // Don't open browser in test environment.
41 | if (process.env.TEST !== 'yes') {
42 | open(url);
43 | }
44 | });
45 | }
46 |
47 | async run() {
48 | const { flags } = this.parse(Up);
49 | this.userConfig = Object.assign(this.userConfig, flags);
50 |
51 | // Run sync command if workspace is not prepared.
52 | if (
53 | !fs.existsSync(collectionPath) ||
54 | !fs.existsSync(diffPath) ||
55 | (await shouldReloadSteps())
56 | ) {
57 | await reload.run([]);
58 | }
59 |
60 | // Trying to load collection for sanity check.
61 | loadCollection();
62 |
63 | this.fireTutureServer();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/cli/src/config.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import { TUTURE_ROOT } from '@tuture/core';
3 |
4 | export default {
5 | // Directory to store assets temporarily.
6 | assetsRoot: path.join(TUTURE_ROOT, 'assets'),
7 |
8 | // Time interval to synchronize all assets.
9 | assetsSyncInterval: 10000,
10 |
11 | // Path to build outputs.
12 | buildPath: path.join(TUTURE_ROOT, 'build'),
13 |
14 | // Port to use for tuture-server.
15 | port: 3013,
16 |
17 | // Files that should be commited but won't be tracked by Tuture.
18 | ignoredFiles: [
19 | // Git-related files
20 | '.gitignore',
21 | '.gitattributes',
22 |
23 | // Tuture-related files
24 | '.tuturerc',
25 | '.tutureignore',
26 | ],
27 | };
28 |
--------------------------------------------------------------------------------
/packages/cli/src/constants.ts:
--------------------------------------------------------------------------------
1 | export enum EXIT_CODE {
2 | NOT_INIT = 1,
3 | NO_STAGE,
4 | NO_REMOTE,
5 | CONFLICT,
6 | }
7 |
--------------------------------------------------------------------------------
/packages/cli/src/index.ts:
--------------------------------------------------------------------------------
1 | export { run } from '@oclif/command';
2 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/collection.ts:
--------------------------------------------------------------------------------
1 | import logger from './logger';
2 | import { git } from './git';
3 | import { TUTURE_BRANCH } from '@tuture/core';
4 |
5 | /**
6 | * Whether the local tuture branch exists.
7 | */
8 | export async function hasLocalTutureBranch() {
9 | return (await git.branchLocal()).all.includes(TUTURE_BRANCH);
10 | }
11 |
12 | /**
13 | * Whether the remote tuture branch exists.
14 | */
15 | export async function hasRemoteTutureBranch() {
16 | const remote = await git.remote([]);
17 |
18 | if (!remote) {
19 | logger.log('warning', 'No remote found for this repository.');
20 | return false;
21 | }
22 |
23 | const branchExists = async (branch: string) => {
24 | const { all } = await git.branch({ '-a': true });
25 | return all.includes(branch);
26 | };
27 |
28 | const remoteBranch = `remotes/${remote.trim()}/${TUTURE_BRANCH}`;
29 |
30 | // Trying to update remote branches (time-consuming).
31 | await git.remote(['update', '--prune']);
32 |
33 | return await branchExists(remoteBranch);
34 | }
35 |
36 | /**
37 | * Fetch tuture branch from remote.
38 | */
39 | export async function initializeTutureBranch() {
40 | const { all: allBranches } = await git.branch({ '-a': true });
41 |
42 | // Already exists.
43 | if (allBranches.includes(TUTURE_BRANCH)) {
44 | return;
45 | }
46 |
47 | const remoteBranchIndex = allBranches
48 | .map((branch) => branch.split('/').slice(-1)[0])
49 | .indexOf(TUTURE_BRANCH);
50 |
51 | if (remoteBranchIndex < 0) {
52 | await git.branch([TUTURE_BRANCH]);
53 | logger.log('info', 'New tuture branch has been created.');
54 | } else {
55 | const [_, remote, branch] = allBranches[remoteBranchIndex].split('/');
56 | await git.fetch(remote, branch);
57 | logger.log('success', 'Remote tuture branch has been fetched.');
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/git.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 | import parseDiff from 'parse-diff';
4 | import simplegit from 'simple-git/promise';
5 | import { RawDiff, TUTURE_ROOT, DIFF_PATH, isCommitEqual } from '@tuture/core';
6 | import { loadCollection } from '@tuture/local-server';
7 |
8 | import logger from './logger';
9 |
10 | // Interface for running git commands.
11 | // https://github.com/steveukx/git-js
12 | export const git = simplegit().silent(true);
13 |
14 | export const diffPath = path.join(
15 | process.env.TUTURE_PATH || process.cwd(),
16 | TUTURE_ROOT,
17 | DIFF_PATH,
18 | );
19 |
20 | /**
21 | * Store diff of all commits.
22 | */
23 | export async function storeDiff(commits: string[]) {
24 | const diffPromises = commits.map(async (commit: string) => {
25 | const command = ['show', '-U99999', commit];
26 | const output = await git.raw(command);
27 | const diffText = output
28 | .replace(/\\ No newline at end of file\n/g, '')
29 | .split('\n\n')
30 | .slice(-1)[0];
31 | const diff = parseDiff(diffText);
32 | return { commit, diff } as RawDiff;
33 | });
34 |
35 | const diffs = await Promise.all(diffPromises);
36 |
37 | fs.writeFileSync(diffPath, JSON.stringify(diffs));
38 |
39 | return diffs;
40 | }
41 |
42 | /**
43 | * Append .tuture rule to gitignore.
44 | * If it's already ignored, do nothing.
45 | * If .gitignore doesn't exist, create one and add the rule.
46 | */
47 | export function appendGitignore() {
48 | if (!fs.existsSync('.gitignore')) {
49 | fs.writeFileSync('.gitignore', `${TUTURE_ROOT}\n`);
50 | logger.log('info', '.gitignore file created.');
51 | } else if (
52 | !fs
53 | .readFileSync('.gitignore')
54 | .toString()
55 | .includes(TUTURE_ROOT)
56 | ) {
57 | fs.appendFileSync('.gitignore', `\n${TUTURE_ROOT}`);
58 | logger.log('info', '.gitignore rules appended.');
59 | }
60 | }
61 |
62 | /**
63 | * Infer github field from available information.
64 | */
65 | export async function inferGithubField() {
66 | let github: string = '';
67 | try {
68 | // Trying to infer github repo url from origin.
69 | const remote = await git.remote([]);
70 | if (remote) {
71 | const origin = await git.remote(['get-url', remote.trim()]);
72 | if (origin) {
73 | github = origin.replace('.git', '').trim();
74 | }
75 | }
76 | } catch {
77 | // No remote url, infer github field from git username and cwd.
78 | let username = await git.raw(['config', '--get', 'user.name']);
79 | if (!username) {
80 | username = await git.raw(['config', '--global', '--get', 'user.name']);
81 | }
82 |
83 | if (username) {
84 | const { name: repoName } = path.parse(process.cwd());
85 | github = `https://github.com/${username.trim()}/${repoName}`;
86 | }
87 | }
88 |
89 | return github;
90 | }
91 |
92 | /**
93 | * Determine whether we should run `reload` command.
94 | */
95 | export async function shouldReloadSteps() {
96 | const { all } = await git.log();
97 | const gitCommits = all
98 | .filter((log) => !log.message.startsWith('tuture:'))
99 | .map((log) => log.hash);
100 |
101 | const { steps } = loadCollection();
102 | const collectionCommits = steps
103 | .filter((step) => !step.outdated)
104 | .map((step) => step.commit);
105 |
106 | collectionCommits.reverse();
107 |
108 | let shouldReload = false;
109 |
110 | for (
111 | let i = 0;
112 | i < Math.min(gitCommits.length, collectionCommits.length);
113 | i++
114 | ) {
115 | if (!isCommitEqual(gitCommits[i], collectionCommits[i])) {
116 | shouldReload = true;
117 | break;
118 | }
119 | }
120 |
121 | return shouldReload;
122 | }
123 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/internals.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generate HTML code for user profile.
3 | */
4 | export function generateUserProfile(github: string) {
5 | const matched = github.match(/github.com\/(.+)\/(.+)/);
6 | if (!matched) {
7 | return '';
8 | }
9 |
10 | const user = matched[1];
11 | const avatarUrl = `/images/avatars/${user}.jpg`;
12 | const homepageUrl = `https://github.com/${user}`;
13 |
14 | return `
15 |
16 |

17 |
18 |
26 |
`;
27 | }
28 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import winston, { format, createLogger } from 'winston';
3 |
4 | const logLevels = {
5 | error: 0,
6 | warning: 1,
7 | success: 2,
8 | info: 3,
9 | };
10 |
11 | const consoleFormat = format.printf((info) => {
12 | const { level, message } = info;
13 | switch (level) {
14 | case 'error':
15 | return `${chalk.red(level)} ${message}`;
16 | case 'warning':
17 | return `${chalk.yellow(level)} ${message}`;
18 | case 'success':
19 | return `${chalk.green(level)} ${message}`;
20 | case 'info':
21 | return `${chalk.blue(level)} ${message}`;
22 | default:
23 | return message;
24 | }
25 | });
26 |
27 | const fileFormat = format.printf((info) => {
28 | const { message, error, timestamp } = info;
29 | let log = `${timestamp} ${message}\n`;
30 | if (error) {
31 | const { message, stack } = error as Error;
32 | log += `${message}\n${stack}\n`;
33 | }
34 | return log;
35 | });
36 |
37 | const logger = createLogger({
38 | levels: logLevels,
39 | transports: [
40 | new winston.transports.Console({ level: 'info', format: consoleFormat }),
41 | ],
42 | });
43 |
44 | export default logger;
45 |
--------------------------------------------------------------------------------
/packages/cli/src/utils/nodes.ts:
--------------------------------------------------------------------------------
1 | import { Element } from 'tuture-slate';
2 | import { PARAGRAPH } from 'editure-constants';
3 | import { Explain } from '@tuture/core';
4 |
5 | export function getEmptyChildren(): Element[] {
6 | return [{ type: PARAGRAPH, children: [{ text: '' }] }];
7 | }
8 |
9 | export function getEmptyExplain(): Explain {
10 | return {
11 | type: 'explain',
12 | fixed: true,
13 | children: getEmptyChildren(),
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/packages/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "allowSyntheticDefaultImports": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "importHelpers": true,
8 | "module": "commonjs",
9 | "outDir": "lib",
10 | "rootDir": "src",
11 | "strict": true,
12 | "target": "es6",
13 | "typeRoots": ["node_modules/@types", "../../node_modules/@types"]
14 | },
15 | "include": ["src/**/*"]
16 | }
17 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tuture/core",
3 | "description": "Core logics and data interfaces for Tuture",
4 | "version": "0.0.7",
5 | "main": "dist/index.js",
6 | "module": "dist/index.esm.js",
7 | "types": "dist/index.d.ts",
8 | "files": [
9 | "dist/"
10 | ],
11 | "license": "MIT",
12 | "dependencies": {
13 | "editure": "^0.1.9",
14 | "lodash.omit": "^4.5.0",
15 | "tuture-slate": "^0.57.4"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/core/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const COLLECTION_PATH = 'collection.json';
2 |
3 | export const COLLECTION_CHECKPOINT = 'collection.ckpt.json';
4 |
5 | export const ASSETS_JSON_PATH = 'tuture-assets.json';
6 |
7 | export const TUTURE_IGNORE_PATH = '.tutureignore';
8 |
9 | // The workspace for storing tutorial-related data.
10 | export const TUTURE_ROOT = '.tuture';
11 |
12 | // Directory which houses tutorial data on VCS.
13 | export const TUTURE_VCS_ROOT = '.tuture-committed';
14 |
15 | export const DIFF_PATH = 'diff.json';
16 |
17 | export const TUTURE_COMMIT_PREFIX = 'tuture:';
18 |
19 | // Branch for commiting tutorial content.
20 | export const TUTURE_BRANCH = 'tuture';
21 |
--------------------------------------------------------------------------------
/packages/core/src/diff.ts:
--------------------------------------------------------------------------------
1 | // Ported from https://github.com/sergeyt/parse-diff.
2 |
3 | export interface DiffFile {
4 | chunks: Chunk[];
5 | deletions: number;
6 | additions: number;
7 | from?: string;
8 | to?: string;
9 | index?: string[];
10 | deleted?: true;
11 | new?: true;
12 | }
13 |
14 | export interface Chunk {
15 | content: string;
16 | changes: Change[];
17 | oldStart: number;
18 | oldLines: number;
19 | newStart: number;
20 | newLines: number;
21 | }
22 |
23 | export interface NormalChange {
24 | type: 'normal';
25 | ln1: number;
26 | ln2: number;
27 | normal: true;
28 | content: string;
29 | }
30 |
31 | export interface AddChange {
32 | type: 'add';
33 | add: true;
34 | ln: number;
35 | content: string;
36 | }
37 |
38 | export interface DeleteChange {
39 | type: 'del';
40 | del: true;
41 | ln: number;
42 | content: string;
43 | }
44 |
45 | export type ChangeType = 'normal' | 'add' | 'del';
46 |
47 | export type Change = NormalChange | AddChange | DeleteChange;
48 |
--------------------------------------------------------------------------------
/packages/core/src/heading.ts:
--------------------------------------------------------------------------------
1 | import { Node, Element } from 'editure';
2 |
3 | import { Step } from './interfaces';
4 |
5 | export interface HeadingItem {
6 | id: string;
7 | title: string;
8 | type: string;
9 | commit?: string;
10 | }
11 |
12 | function isHeading(node: Node) {
13 | return [
14 | 'heading-one',
15 | 'heading-two',
16 | 'heading-three',
17 | 'heading-four',
18 | 'heading-five',
19 | ].includes(node.type);
20 | }
21 |
22 | function getHeadingText(node: Element) {
23 | return node.children.map((child) => child.text).join('');
24 | }
25 |
26 | export function getHeadings(nodes: Node[]): HeadingItem[] {
27 | return nodes.flatMap((node) => {
28 | if (isHeading(node)) {
29 | return {
30 | ...(node as Partial),
31 | title: getHeadingText(node as Element),
32 | } as HeadingItem;
33 | }
34 | if (node.children) {
35 | return getHeadings(node.children);
36 | }
37 | return [];
38 | });
39 | }
40 |
41 | export function getStepTitle(nowStep: Step) {
42 | return getHeadings([nowStep]).filter((node) => node.commit)[0].title;
43 | }
44 |
--------------------------------------------------------------------------------
/packages/core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './interfaces';
2 | export * from './constants';
3 | export * from './utils';
4 | export * from './diff';
5 | export * from './steps';
6 | export * from './heading';
7 | export * from './version';
8 |
--------------------------------------------------------------------------------
/packages/core/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | import { Element } from 'editure';
2 |
3 | import { DiffFile } from './diff';
4 |
5 | export interface RawDiff {
6 | commit: string;
7 | diff: DiffFile[];
8 | }
9 |
10 | export interface Explain extends Element {
11 | type: 'explain';
12 | fixed: true;
13 | }
14 |
15 | export interface DiffBlock extends Element {
16 | type: 'diff-block';
17 | file: string;
18 | commit: string;
19 | hiddenLines?: [number, number][];
20 | }
21 |
22 | export interface File extends Element {
23 | type: 'file';
24 | file: string;
25 | display?: boolean;
26 | children: [Explain, DiffBlock, Explain];
27 | }
28 |
29 | export interface Meta {
30 | name: string;
31 | description?: string;
32 | id: string;
33 | cover?: string;
34 | created?: Date;
35 | topics?: string[];
36 | categories?: string[];
37 | github?: string;
38 | }
39 |
40 | export interface Article extends Meta {}
41 |
42 | export interface StepTitle extends Element {
43 | type: 'heading-two';
44 | commit: string;
45 | id: string;
46 | fixed: true;
47 | }
48 |
49 | export type StepChild = StepTitle | Explain | File;
50 |
51 | export interface Step extends Element {
52 | type: 'step';
53 | id: string;
54 | articleId?: string | null;
55 | outdated?: boolean;
56 | commit: string;
57 | children: StepChild[];
58 | }
59 |
60 | export interface Remote {
61 | name: string;
62 | refs: {
63 | fetch: string;
64 | push: string;
65 | };
66 | }
67 |
68 | export interface Collection extends Meta {
69 | version?: string;
70 | articles: Article[];
71 | remotes?: Remote[];
72 | steps: Step[];
73 | }
74 |
75 | export interface TutureConfig {
76 | ignoredFiles: string[];
77 | port: number;
78 | }
79 |
--------------------------------------------------------------------------------
/packages/core/src/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Generate a random hex number.
3 | */
4 | export function randHex(digits: number = 8) {
5 | return Math.random()
6 | .toString(16)
7 | .slice(2, digits + 2);
8 | }
9 |
10 | /**
11 | * Compare if two commit hash is equal.
12 | */
13 | export function isCommitEqual(
14 | commit1?: string | null,
15 | commit2?: string | null,
16 | ) {
17 | if (!commit1 || !commit2) {
18 | return false;
19 | }
20 | return (
21 | commit1.startsWith(String(commit2)) || commit2.startsWith(String(commit1))
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/packages/core/src/version.ts:
--------------------------------------------------------------------------------
1 | export const SCHEMA_VERSION = 'v1';
2 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist",
6 | "composite": true,
7 | "declaration": true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/editor/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/packages/editor/.eslintignore:
--------------------------------------------------------------------------------
1 | *.md
2 | *.json
3 | .github
4 | .next
5 | build
6 | dist
7 | node_modules
8 | tmp
9 | yarn.lock
10 | package.json
11 | next.config.js
12 | *.png
13 | *.svg
14 |
15 | src/serviceWorker.js
16 |
--------------------------------------------------------------------------------
/packages/editor/.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 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/packages/editor/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | trailingComma: 'all',
4 | printWidth: 80,
5 | arrowParens: 'always',
6 | };
7 |
--------------------------------------------------------------------------------
/packages/editor/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `yarn start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `yarn test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `yarn build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `yarn eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | 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.
35 |
36 | 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.
37 |
38 | 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.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
46 | ### Code Splitting
47 |
48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
49 |
50 | ### Analyzing the Bundle Size
51 |
52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
53 |
54 | ### Making a Progressive Web App
55 |
56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
57 |
58 | ### Advanced Configuration
59 |
60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
61 |
62 | ### Deployment
63 |
64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
65 |
66 | ### `yarn build` fails to minify
67 |
68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
69 |
--------------------------------------------------------------------------------
/packages/editor/api-server.js:
--------------------------------------------------------------------------------
1 | const { makeServer } = require('@tuture/local-server');
2 | const mockRemotes = require('./fixtures/mock-remotes.json');
3 |
4 | const PORT = 8000;
5 |
6 | const app = makeServer({
7 | baseUrl: '/api',
8 | mockRoutes: (app) => {
9 | app.get('/api/remotes', (req, res) => {
10 | res.json(mockRemotes);
11 | });
12 |
13 | app.get('/api/sync', (req, res) => {
14 | setTimeout(() => {
15 | res.sendStatus(200);
16 | }, 2000);
17 | });
18 | },
19 | });
20 |
21 | app.listen(PORT, () => {
22 | console.log(`API server is running on http://localhost:${PORT}!`);
23 | });
24 |
--------------------------------------------------------------------------------
/packages/editor/config-overrides.js:
--------------------------------------------------------------------------------
1 | const { override, fixBabelImports, addLessLoader } = require('customize-cra');
2 |
3 | module.exports = override(
4 | fixBabelImports('import', {
5 | libraryName: 'antd',
6 | libraryDirectory: 'es',
7 | style: true,
8 | }),
9 | fixBabelImports('emotion'),
10 | addLessLoader({
11 | javascriptEnabled: true,
12 | modifyVars: {
13 | '@primary-color': '#02b875',
14 | '@text-selection-bg': '#d8d8dc',
15 | '@text-color-inverse': 'inherit',
16 | },
17 | }),
18 | );
19 |
--------------------------------------------------------------------------------
/packages/editor/fixtures/mock-remotes.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "origin",
4 | "refs": {
5 | "fetch": "https://github.com/tuture-dev/tuture.git",
6 | "push": "https://github.com/tuture-dev/tuture.git"
7 | }
8 | },
9 | {
10 | "name": "gitlab",
11 | "refs": {
12 | "fetch": "https://gitlab.com/tuture-dev/tuture.git",
13 | "push": "https://gitlab.com/tuture-dev/tuture.git"
14 | }
15 | },
16 | {
17 | "name": "coding",
18 | "refs": {
19 | "fetch": "https://e.coding.net/tuture-dev/tuture.git",
20 | "push": "https://e.coding.net/tuture-dev/tuture.git"
21 | }
22 | }
23 | ]
24 |
--------------------------------------------------------------------------------
/packages/editor/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tuture/editor",
3 | "version": "0.0.7",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/core": "^10.0.27",
7 | "@emotion/styled": "^10.0.27",
8 | "@rematch/core": "^1.4.0",
9 | "@rematch/immer": "^1.2.0",
10 | "@rematch/loading": "^1.2.1",
11 | "@rematch/select": "^2.0.5",
12 | "@tuture/core": "^0.0.7",
13 | "@tuture/local-server": "^0.0.7",
14 | "@types/axios": "^0.14.0",
15 | "antd": "^3.26.9",
16 | "axios": "^0.19.2",
17 | "customize-cra": "^0.9.1",
18 | "dayjs": "^1.8.20",
19 | "editure": "0.1.13",
20 | "editure-constants": "0.1.2",
21 | "editure-react": "0.1.13",
22 | "emotion": "^10.0.27",
23 | "is-hotkey": "^0.1.6",
24 | "lodash.omit": "^4.5.0",
25 | "lodash.pick": "^4.4.0",
26 | "prism-react-renderer": "^1.0.2",
27 | "react": "^16.12.0",
28 | "react-dom": "^16.12.0",
29 | "react-lazy-load": "^3.0.13",
30 | "react-redux": "^7.1.3",
31 | "react-router-dom": "^5.1.2",
32 | "react-scripts": "3.4.0",
33 | "react-smooth-dnd": "^0.11.1",
34 | "react-table": "^7.0.0-rc.16",
35 | "react-use": "^14.1.1",
36 | "refractor": "^2.10.1",
37 | "yutang": "^0.0.4"
38 | },
39 | "scripts": {
40 | "start:api": "cross-env TUTURE_PATH=fixtures NODE_ENV=development node api-server.js",
41 | "start": "concurrently \"yarn start:api\" \"react-app-rewired start\"",
42 | "build": "react-app-rewired build",
43 | "postbuild": "rimraf build/**/*.map",
44 | "analyze": "source-map-explorer 'build/static/js/*.js'",
45 | "test": "react-app-rewired test",
46 | "eject": "react-scripts eject",
47 | "lint": "../node_modules/.bin/eslint . --fix"
48 | },
49 | "eslintConfig": {
50 | "extends": "react-app"
51 | },
52 | "proxy": "http://localhost:8000/",
53 | "browserslist": {
54 | "production": [
55 | ">0.2%",
56 | "not dead",
57 | "not op_mini all"
58 | ],
59 | "development": [
60 | "last 1 chrome version",
61 | "last 1 firefox version",
62 | "last 1 safari version"
63 | ]
64 | },
65 | "devDependencies": {
66 | "@testing-library/jest-dom": "^4.2.4",
67 | "@testing-library/react": "^9.3.2",
68 | "@testing-library/user-event": "^7.1.2",
69 | "@types/express": "^4.16.0",
70 | "@types/lodash.omit": "^4.5.6",
71 | "@types/lodash.pick": "^4.4.6",
72 | "@types/node": "^12.12.31",
73 | "@types/react": "^16.9.25",
74 | "@types/react-redux": "^7.1.7",
75 | "@types/react-router-dom": "^5.1.3",
76 | "@types/react-table": "^7.0.12",
77 | "@types/refractor": "^2.8.0",
78 | "babel-plugin-emotion": "^10.0.27",
79 | "babel-plugin-import": "^1.13.0",
80 | "concurrently": "^5.1.0",
81 | "cross-env": "^7.0.2",
82 | "less": "^3.11.1",
83 | "less-loader": "^5.0.0",
84 | "react-app-rewired": "^2.1.5",
85 | "source-map-explorer": "^2.4.2"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/packages/editor/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/favicon.ico
--------------------------------------------------------------------------------
/packages/editor/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | 图雀写作工具
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/packages/editor/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/logo192.png
--------------------------------------------------------------------------------
/packages/editor/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/logo512.png
--------------------------------------------------------------------------------
/packages/editor/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 |
--------------------------------------------------------------------------------
/packages/editor/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/packages/editor/src/assets/antd-custom.less:
--------------------------------------------------------------------------------
1 | @primary-color: #02b875; // 全局主色
2 | @link-color: #02b875; // 链接色
3 |
--------------------------------------------------------------------------------
/packages/editor/src/assets/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/editor/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Content from './Content';
4 |
5 | function App() {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
13 | export default App;
14 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/ChildrenDrawerComponent/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Drawer } from 'antd';
4 |
5 | import { Dispatch, RootState } from 'store';
6 | import { CREATE_ARTICLE, EDIT_ARTICLE } from 'utils/constants';
7 |
8 | import CreateEditArticle from './CreateEditArticle';
9 |
10 | const mapTypeToTitle: { [key: string]: string } = {
11 | [CREATE_ARTICLE]: '新建文章',
12 | [EDIT_ARTICLE]: '编辑文章',
13 | };
14 |
15 | function ChildrenDrawerComponent() {
16 | const dispatch = useDispatch();
17 | const { childrenDrawerType, childrenVisible } = useSelector(
18 | (state: RootState) => state.drawer,
19 | );
20 |
21 | function onClose() {
22 | dispatch.drawer.setChildrenVisible(false);
23 | }
24 |
25 | return (
26 |
36 |
37 |
38 | );
39 | }
40 |
41 | export default ChildrenDrawerComponent;
42 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/DrawerComponent/ContactUs.tsx:
--------------------------------------------------------------------------------
1 | /** @jsx jsx */
2 | import { css, jsx } from '@emotion/core';
3 |
4 | import IconFont from 'components/IconFont';
5 | import logo from 'assets/images/logo.svg';
6 |
7 | const link = css`
8 | width: 252px;
9 | height: 40px;
10 | display: flex;
11 | flex-direction: row;
12 | align-items: center;
13 | background: rgba(255, 255, 255, 1);
14 | border-radius: 4px;
15 | border: 1px solid rgba(232, 232, 232, 1);
16 | display: flex;
17 | margin-top: 16px;
18 | padding: 0 16px;
19 |
20 | &:hover {
21 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
22 | }
23 |
24 | transition: box-shadow 0.3s;
25 | `;
26 | const icon = css`
27 | margin: 0;
28 | padding: 0;
29 |
30 | & > svg {
31 | width: 24px;
32 | height: 24px;
33 | }
34 | `;
35 |
36 | const labelText = css`
37 | margin-left: 8px;
38 | display: inline-block;
39 | width: 120px;
40 | font-size: 14px;
41 | font-family: PingFangSC-Regular, PingFang SC;
42 | font-weight: 400;
43 | color: rgba(0, 0, 0, 1);
44 | `;
45 |
46 | const arrow = css`
47 | width: 10px;
48 | height: 10px;
49 | margin: 0;
50 | padding: 0;
51 | margin-left: 80px;
52 | color: #565d64;
53 |
54 | &:hover {
55 | color: #02b875;
56 | }
57 |
58 | transition: color 0.3s;
59 | `;
60 | const itemsData = [
61 | {
62 | id: '1',
63 | href: 'https://tuture.co/',
64 | icon: { logo },
65 | labelText: '图雀社区主站',
66 | },
67 | {
68 | id: '2',
69 | href: 'https://github.com/tuture-dev/tuture',
70 | icon: 'icon-github-fill',
71 | labelText: '写作工具地址',
72 | },
73 | {
74 | id: '3',
75 | href: 'https://zhuanlan.zhihu.com/tuture',
76 | icon: 'icon-zhihu-circle-fill',
77 | labelText: '知乎专栏',
78 | },
79 | {
80 | id: '4',
81 | href: 'https://tuture.co/images/social/wechat.png',
82 | icon: 'icon-wechat',
83 | labelText: '微信公众号',
84 | },
85 | {
86 | id: '5',
87 | href: 'https://juejin.im/user/5b33414351882574b9694d28',
88 | icon: 'icon-juejin',
89 | labelText: '掘金专栏',
90 | },
91 | {
92 | id: '6',
93 | href: 'https://www.imooc.com/u/8413857/articles',
94 | icon: 'icon-mukewang',
95 | labelText: '慕课手记',
96 | },
97 | ];
98 | const item_f = itemsData[0];
99 | itemsData.shift();
100 | const items = itemsData.map((item) => (
101 |
108 |
109 | {item.labelText}
110 |
111 |
112 | ));
113 |
114 | function ContactUs() {
115 | return (
116 |
138 | );
139 | }
140 |
141 | export default ContactUs;
142 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/DrawerComponent/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Drawer } from 'antd';
4 |
5 | import {
6 | COLLECTION_CATALOGUE,
7 | COLLECTION_SETTING,
8 | CONTACT_US,
9 | } from 'utils/constants';
10 |
11 | import CollectionCatalogue from './CollectionCatalogue';
12 | import CollectionSetting from './CollectionSetting';
13 | import ContactUs from './ContactUs';
14 |
15 | import { RootState, Dispatch } from 'store';
16 |
17 | const mapTypeToTitle: { [key: string]: string } = {
18 | [COLLECTION_CATALOGUE]: '文集目录',
19 | [COLLECTION_SETTING]: '文集设置',
20 | [CONTACT_US]: '联系我们',
21 | };
22 |
23 | const mapTypeToComponent: { [key: string]: ReactNode } = {
24 | [COLLECTION_CATALOGUE]: ,
25 | [COLLECTION_SETTING]: ,
26 | [CONTACT_US]: ,
27 | };
28 |
29 | function DrawerComponent() {
30 | const dispatch = useDispatch();
31 | const { drawerType, visible } = useSelector(
32 | (state: RootState) => state.drawer,
33 | );
34 |
35 | const RenderComponent = mapTypeToComponent[drawerType];
36 |
37 | const handleClose = () => {
38 | dispatch.drawer.setVisible(false);
39 | dispatch.drawer.setSelectedKeys([]);
40 | };
41 |
42 | return (
43 |
58 | {RenderComponent}
59 |
60 | );
61 | }
62 |
63 | export default DrawerComponent;
64 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/LastSavedTimestamp.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import dayjs from 'dayjs';
3 | import { useSelector } from 'react-redux';
4 |
5 | import { RootState } from 'store';
6 |
7 | function LastSavedTimestamp() {
8 | const saveFailed = useSelector(
9 | (state: RootState) => state.collection.saveFailed,
10 | );
11 | const lastSaved = useSelector(
12 | (state: RootState) => state.collection.lastSaved,
13 | );
14 |
15 | if (saveFailed) {
16 | return 保存失败,请检查服务器连接!
;
17 | }
18 |
19 | return (
20 |
21 | {lastSaved
22 | ? `上次保存时间:今天 ${dayjs(lastSaved).format('HH:mm')}`
23 | : ''}
24 |
25 | );
26 | }
27 |
28 | export default LastSavedTimestamp;
29 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/BlockButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEditure } from 'editure-react';
3 |
4 | import { IEditor } from 'utils/editor';
5 | import { BLOCK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys';
6 |
7 | import Button from './Button';
8 | import ToolbarIcon from './ToolbarIcon';
9 |
10 | type BlockButtonProps = {
11 | format: string;
12 | icon: string;
13 | };
14 |
15 | const BlockButton = ({ format, icon }: BlockButtonProps) => {
16 | const editor = useEditure() as IEditor;
17 | const isActive = editor.isBlockActive(format);
18 | const { hotkey, title } = BLOCK_HOTKEYS[format];
19 |
20 | return (
21 |
33 | );
34 | };
35 |
36 | export default BlockButton;
37 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 |
6 | type ButtonProps = {
7 | className: string;
8 | handleMouseDown: React.MouseEventHandler;
9 | handleClick: React.MouseEventHandler;
10 | [prop: string]: any;
11 | };
12 |
13 | const Button = React.forwardRef(
14 | ({ className, handleMouseDown, handleClick, ...props }, ref) => (
15 |
25 | ),
26 | );
27 |
28 | export default Button;
29 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/EditLink.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import { Modal, Input } from 'antd';
4 | import { useEditure } from 'editure-react';
5 |
6 | /** @jsx jsx */
7 | import { css, jsx } from '@emotion/core';
8 |
9 | import { selectLastPoint } from 'editure';
10 | import { LINK } from 'editure-constants';
11 |
12 | import { IEditor, syncDOMSelection } from 'utils/editor';
13 | import { Dispatch, RootState } from 'store';
14 |
15 | const EditLink = () => {
16 | const editor = useEditure() as IEditor;
17 | const dispatch = useDispatch();
18 | const { isEditing, text, url } = useSelector(
19 | (state: RootState) => state.link,
20 | );
21 |
22 | const handleOk = () => {
23 | // Go back to last selected point.
24 | selectLastPoint(editor);
25 | syncDOMSelection(editor);
26 |
27 | if (text) {
28 | if (!editor.isMarkActive(LINK)) {
29 | editor.insertLink({ text, url });
30 | } else {
31 | editor.updateLink({ text, url });
32 | }
33 | }
34 |
35 | dispatch.link.reset();
36 | };
37 |
38 | const handleCancel = () => {
39 | selectLastPoint(editor);
40 | syncDOMSelection(editor);
41 | dispatch.link.reset();
42 | };
43 |
44 | const onKeyDown = (e: React.KeyboardEvent) => {
45 | if (e.keyCode === 13) {
46 | // Enter key.
47 | e.preventDefault();
48 | handleOk();
49 | }
50 | };
51 |
52 | return (
53 |
61 |
67 | 文本
68 |
69 | dispatch.link.setText(e.target.value)}
75 | />
76 |
82 | 链接
83 |
84 | dispatch.link.setUrl(e.target.value)}
90 | />
91 |
92 | );
93 | };
94 |
95 | export default EditLink;
96 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/HistoryButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEditure } from 'editure-react';
3 |
4 | import { IEditor } from 'utils/editor';
5 | import { OP_HOTKEYS, getHotkeyHint } from 'utils/hotkeys';
6 |
7 | import Button from './Button';
8 | import ToolbarIcon from './ToolbarIcon';
9 |
10 | type HistoryButtonProps = {
11 | action: 'undo' | 'redo';
12 | icon: string;
13 | };
14 |
15 | const HistoryButton = ({ action, icon }: HistoryButtonProps) => {
16 | const editor = useEditure() as IEditor;
17 | const { hotkey, title } = OP_HOTKEYS[action];
18 |
19 | return (
20 |
33 | );
34 | };
35 |
36 | export default HistoryButton;
37 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/HrButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEditure } from 'editure-react';
3 | import { HR } from 'editure-constants';
4 |
5 | import { IEditor } from 'utils/editor';
6 | import { BLOCK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys';
7 |
8 | import Button from './Button';
9 | import ToolbarIcon from './ToolbarIcon';
10 |
11 | const HrButton = () => {
12 | const editor = useEditure() as IEditor;
13 | const { hotkey, title } = BLOCK_HOTKEYS[HR];
14 |
15 | const onMouseDown = (event: React.SyntheticEvent) => {
16 | event.preventDefault();
17 | editor.insertVoid(HR);
18 | };
19 |
20 | return (
21 |
27 | );
28 | };
29 |
30 | export default HrButton;
31 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/ImageButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { useEditure } from 'editure-react';
3 | import { IMAGE } from 'editure-constants';
4 |
5 | /** @jsx jsx */
6 | import { css, jsx } from '@emotion/core';
7 |
8 | import { IEditor } from 'utils/editor';
9 | import { BLOCK_HOTKEYS, getHotkeyHint, ButtonRefsContext } from 'utils/hotkeys';
10 | import { insertImage } from 'utils/image';
11 |
12 | import Button from './Button';
13 | import ToolbarIcon from './ToolbarIcon';
14 |
15 | const ImageButton = () => {
16 | const editor = useEditure() as IEditor;
17 | const { imageBtnRef: ref } = useContext(ButtonRefsContext);
18 | const { hotkey, title } = BLOCK_HOTKEYS[IMAGE];
19 |
20 | const onChange = (e: React.ChangeEvent) => {
21 | e.persist();
22 |
23 | if (e.target.files) {
24 | insertImage(editor, e.target.files);
25 | }
26 | };
27 |
28 | return (
29 |
54 | );
55 | };
56 |
57 | export default ImageButton;
58 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/LinkButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useEditure } from 'editure-react';
4 | import { LINK } from 'editure-constants';
5 | import { getSelectedString, selectLastPoint } from 'editure';
6 |
7 | import { IEditor } from 'utils/editor';
8 | import { Dispatch } from 'store';
9 | import { MARK_HOTKEYS, getHotkeyHint, ButtonRefsContext } from 'utils/hotkeys';
10 |
11 | import Button from './Button';
12 | import ToolbarIcon from './ToolbarIcon';
13 |
14 | const LinkButton = () => {
15 | const editor = useEditure() as IEditor;
16 | const dispatch = useDispatch();
17 | const { linkBtnRef: ref } = useContext(ButtonRefsContext);
18 | const { hotkey, title } = MARK_HOTKEYS[LINK];
19 |
20 | const isActive = editor.isMarkActive(LINK);
21 |
22 | const onClick = () => {
23 | selectLastPoint(editor);
24 |
25 | const { selection } = editor;
26 | if (!selection) return;
27 |
28 | if (editor.isMarkActive(LINK)) {
29 | return editor.removeLink();
30 | }
31 |
32 | const selected = getSelectedString(editor);
33 |
34 | if (selected) {
35 | dispatch.link.setText(selected);
36 | }
37 |
38 | dispatch.link.startEdit();
39 | };
40 |
41 | return (
42 |
49 | );
50 | };
51 |
52 | export default LinkButton;
53 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/MarkButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useEditure } from 'editure-react';
3 |
4 | import { IEditor } from 'utils/editor';
5 | import { MARK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys';
6 |
7 | import Button from './Button';
8 | import ToolbarIcon from './ToolbarIcon';
9 |
10 | type MarkButtonProps = {
11 | format: string;
12 | icon: string;
13 | };
14 |
15 | const MarkButton = ({ format, icon }: MarkButtonProps) => {
16 | const editor = useEditure() as IEditor;
17 | const isActive = editor.isMarkActive(format);
18 | const { hotkey, title } = MARK_HOTKEYS[format];
19 |
20 | return (
21 |
33 | );
34 | };
35 |
36 | export default MarkButton;
37 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/Menu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 |
6 | type MenuProps = {
7 | className?: string;
8 | [prop: string]: any;
9 | };
10 |
11 | const Menu = ({ className, ...props }: MenuProps) => (
12 | * {
17 | display: inline-block;
18 | }
19 | `}
20 | />
21 | );
22 |
23 | export default Menu;
24 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/NoteButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Dropdown, Menu } from 'antd';
3 | import { ClickParam } from 'antd/lib/menu';
4 | import { useEditure } from 'editure-react';
5 | import { selectLastPoint } from 'editure';
6 | import { NOTE } from 'editure-constants';
7 |
8 | import { IEditor, syncDOMSelection } from 'utils/editor';
9 | import { levels } from 'utils/note';
10 | import IconFont from 'components/IconFont';
11 |
12 | import Button from './Button';
13 | import ToolbarIcon from './ToolbarIcon';
14 |
15 | const NoteButton = () => {
16 | const editor = useEditure() as IEditor;
17 |
18 | const handleClick = (e: ClickParam) => {
19 | selectLastPoint(editor);
20 | editor.toggleBlock(NOTE, { level: e.key });
21 |
22 | syncDOMSelection(editor);
23 | };
24 |
25 | const menu = (
26 |
34 | );
35 |
36 | return (
37 |
38 |
41 |
42 | );
43 | };
44 |
45 | export default NoteButton;
46 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SaveButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react';
2 | import { useDispatch } from 'react-redux';
3 |
4 | import { ButtonRefsContext } from 'utils/hotkeys';
5 | import { Dispatch } from 'store';
6 |
7 | import Button from './Button';
8 | import ToolbarIcon from './ToolbarIcon';
9 |
10 | const SaveButton = () => {
11 | const dispatch = useDispatch
();
12 | const { saveBtnRef: ref } = useContext(ButtonRefsContext);
13 |
14 | const onClick = () => {
15 | dispatch.collection.save({ showMessage: true });
16 | };
17 |
18 | return (
19 |
22 | );
23 | };
24 |
25 | export default SaveButton;
26 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SelectContentType.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Select } from 'antd';
3 | import { useEditure } from 'editure-react';
4 | import { H1, H2, H3, H4, H5, PARAGRAPH } from 'editure-constants';
5 |
6 | import { IEditor, syncDOMSelection } from 'utils/editor';
7 | import IconFont from 'components/IconFont';
8 |
9 | /** @jsx jsx */
10 | import { css, jsx } from '@emotion/core';
11 |
12 | const { Option } = Select;
13 |
14 | type ContentType = {
15 | name: string;
16 | fontSize: string;
17 | fontWeight: number;
18 | };
19 |
20 | const types: Record = {
21 | [H1]: { name: '标题 1', fontSize: '24px', fontWeight: 500 },
22 | [H2]: { name: '标题 2', fontSize: '22px', fontWeight: 500 },
23 | [H3]: { name: '标题 3', fontSize: '20px', fontWeight: 500 },
24 | [H4]: { name: '标题 4', fontSize: '18px', fontWeight: 500 },
25 | [H5]: { name: '标题 5', fontSize: '16px', fontWeight: 500 },
26 | [PARAGRAPH]: { name: '正文', fontSize: '14px', fontWeight: 400 },
27 | };
28 |
29 | const SelectContentType = () => {
30 | const editor = useEditure() as IEditor;
31 | const type = editor.detectBlockFormat(Object.keys(types)) || PARAGRAPH;
32 |
33 | const handleChange = (value: string) => {
34 | editor.toggleBlock(value);
35 | syncDOMSelection(editor);
36 | };
37 |
38 | const suffixIcon = ;
39 |
40 | return (
41 |
73 | );
74 | };
75 |
76 | export default SelectContentType;
77 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SplitLine.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 |
6 | const SplitLine = () => {
7 | return (
8 |
16 | |
17 |
18 | );
19 | };
20 |
21 | export default SplitLine;
22 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/ToolbarIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Tooltip } from 'antd';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 |
7 | import IconFont from 'components/IconFont';
8 |
9 | type ToolbarIconProps = {
10 | isActive?: boolean;
11 | icon: string;
12 | title: string;
13 | };
14 |
15 | const ToolbarIcon = ({ isActive = false, icon, title }: ToolbarIconProps) => (
16 |
17 |
32 |
33 | );
34 |
35 | export default ToolbarIcon;
36 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/index.tsx:
--------------------------------------------------------------------------------
1 | import * as F from 'editure-constants';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 |
6 | import React from 'react';
7 | import Menu from './Menu';
8 | import EditLink from './EditLink';
9 | import SplitLine from './SplitLine';
10 | import SaveButton from './SaveButton';
11 | import MarkButton from './MarkButton';
12 | import BlockButton from './BlockButton';
13 | import ImageButton from './ImageButton';
14 | import HrButton from './HrButton';
15 | import NoteButton from './NoteButton';
16 | import LinkButton from './LinkButton';
17 | import HistoryButton from './HistoryButton';
18 | import SelectContentType from './SelectContentType';
19 |
20 | const Toolbar = (props: { [prop: string]: any }) => {
21 | return (
22 |
84 | );
85 | };
86 |
87 | export default Toolbar;
88 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/LayoutHeader/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 | import { Button, Row, Col, Breadcrumb } from 'antd';
6 | import { useDispatch, useSelector } from 'react-redux';
7 | import { useRouteMatch, Link } from 'react-router-dom';
8 |
9 | import ToolBar from './Toolbar';
10 | import SyncModal from './SyncModal';
11 | import LastSavedTimestamp from './LastSavedTimestamp';
12 |
13 | import { Dispatch, RootState } from 'store';
14 |
15 | function LayoutHeader() {
16 | const dispatch = useDispatch();
17 | const { meta } = useSelector((state: RootState) => state.collection);
18 | const { name = '' } = meta || {};
19 |
20 | const isSavingToc = useSelector(
21 | (state: RootState) => state.loading.effects.toc.save,
22 | );
23 | const isSyncing = useSelector(
24 | (state: RootState) => state.loading.effects.sync.sync,
25 | );
26 |
27 | const isToc = useRouteMatch('/toc');
28 |
29 | function handleSaveToc() {
30 | dispatch.toc.save(true);
31 | }
32 |
33 | return (
34 |
35 |
36 | {isToc ? (
37 |
42 |
43 | {name}
44 |
45 | 文集目录
46 |
47 | ) : (
48 |
49 | )}
50 |
51 |
52 | {!isToc && }
53 |
54 |
55 | {isToc ? (
56 |
59 | ) : (
60 |
61 |
71 |
72 |
73 | )}
74 |
75 |
76 | );
77 | }
78 |
79 | export default LayoutHeader;
80 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/MainMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Layout, Menu, Icon } from 'antd';
3 | import { ClickParam } from 'antd/lib/menu';
4 |
5 | import { useSelector, useDispatch } from 'react-redux';
6 | import { useHistory } from 'react-router-dom';
7 |
8 | /** @jsx jsx */
9 | import { css, jsx } from '@emotion/core';
10 |
11 | import logo from 'assets/images/logo.svg';
12 | import {
13 | COLLECTION_CATALOGUE,
14 | COLLECTION_SETTING,
15 | CONTACT_US,
16 | } from 'utils/constants';
17 | import { Dispatch, RootState } from 'store';
18 |
19 | const { Sider } = Layout;
20 |
21 | const mapKeyToDrawerType: { [key: string]: string } = {
22 | '1': COLLECTION_CATALOGUE,
23 | '3': COLLECTION_SETTING,
24 | '4': CONTACT_US,
25 | };
26 |
27 | function MainMenu() {
28 | const dispatch = useDispatch();
29 | const { visible, drawerType, childrenVisible, selectedKeys } = useSelector(
30 | (state: RootState) => state.drawer,
31 | );
32 | const history = useHistory();
33 |
34 | function onMenuClick({ key }: ClickParam) {
35 | if (key === '2') {
36 | dispatch.drawer.setVisible(false);
37 | dispatch.drawer.setSelectedKeys([key]);
38 | history.push('/toc');
39 |
40 | return;
41 | }
42 |
43 | const toggleDrawerType = mapKeyToDrawerType[key];
44 |
45 | if (!visible) {
46 | dispatch.drawer.setVisible(true);
47 | dispatch.drawer.setDrawerType(toggleDrawerType);
48 | dispatch.drawer.setSelectedKeys([key]);
49 | } else {
50 | if (drawerType === toggleDrawerType) {
51 | dispatch.drawer.setVisible(false);
52 | dispatch.drawer.setSelectedKeys([]);
53 | } else {
54 | dispatch.drawer.setDrawerType(toggleDrawerType);
55 | dispatch.drawer.setSelectedKeys([key]);
56 | }
57 | }
58 |
59 | if (childrenVisible) {
60 | dispatch.drawer.setChildrenVisible(false);
61 | }
62 | }
63 |
64 | return (
65 |
75 |
83 |

93 |
94 |
118 |
119 | );
120 | }
121 |
122 | export default MainMenu;
123 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/OutdatedNotification.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button, Icon, notification } from 'antd';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 |
7 | export function openOutdatedNotification(onClick: () => void) {
8 | const key: string = `open${Date.now()}`;
9 | const btn = (
10 |
13 | );
14 | const description = (
15 |
47 | );
48 | notification.warning({
49 | message: '过时步骤提醒',
50 | description,
51 | btn,
52 | key,
53 | duration: null,
54 | onClick,
55 | });
56 | }
57 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ConnectedLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useEffect, ReactNode } from 'react';
2 |
3 | import { useSelector, useDispatch, useStore } from 'react-redux';
4 | import { Layout, Affix, BackTop } from 'antd';
5 | import { Editure, ReactEditor } from 'editure-react';
6 | import { Node, updateLastSelection } from 'editure';
7 | import { useHistory } from 'react-router-dom';
8 | import { Meta } from '@tuture/core';
9 |
10 | /** @jsx jsx */
11 | import { css, jsx } from '@emotion/core';
12 |
13 | import { Store, Dispatch, RootState } from 'store';
14 | import { initializeEditor } from 'utils/editor';
15 | import { buttonRefs, ButtonRefsContext } from 'utils/hotkeys';
16 | import { useDebouncedSave } from 'utils/hooks';
17 |
18 | import { openOutdatedNotification } from './OutdatedNotification';
19 | import LayoutHeader from './LayoutHeader';
20 | import MainMenu from './MainMenu';
21 | import DrawerComponent from './DrawerComponent';
22 | import ChildrenDrawerComponent from './ChildrenDrawerComponent';
23 |
24 | const { Header, Content } = Layout;
25 |
26 | function ConnectedLayout(props: { children: ReactNode }) {
27 | const { children } = props;
28 | const history = useHistory();
29 | const editor = useMemo(initializeEditor, []) as ReactEditor;
30 |
31 | const store = useStore() as Store;
32 | const dispatch = useDispatch();
33 | const { name: pageTitle } =
34 | useSelector(store.select.collection.nowArticleMeta) || {};
35 | const { fragment, outdatedNotificationClicked } = useSelector(
36 | (state: RootState) => state.collection,
37 | );
38 |
39 | const outdatedExisted = !!fragment.filter((node) => node.outdated).length;
40 |
41 | useEffect(() => {
42 | if (outdatedExisted) {
43 | openOutdatedNotification(() => {
44 | dispatch.collection.setOutdatedNotificationClicked(true);
45 | });
46 | }
47 | }, [dispatch, outdatedExisted]);
48 |
49 | useEffect(() => {
50 | dispatch.diff.fetchDiff();
51 | dispatch.collection.fetchMeta();
52 | dispatch.collection.fetchArticles();
53 | }, [dispatch]);
54 |
55 | useEffect(() => {
56 | if (pageTitle) {
57 | document.title = pageTitle;
58 | }
59 | }, [pageTitle]);
60 |
61 | useEffect(() => {
62 | if (outdatedNotificationClicked) {
63 | dispatch.collection.setOutdatedNotificationClicked(false);
64 | history.push('/toc');
65 | }
66 | }, [dispatch, history, outdatedNotificationClicked]);
67 |
68 | const setDirty = useDebouncedSave(['fragment'], 3000, [fragment]);
69 | function onContentChange(val: Node[]) {
70 | if (editor.selection) {
71 | updateLastSelection(editor.selection);
72 | }
73 |
74 | dispatch.collection.setFragment(val);
75 | setDirty(true);
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
102 |
111 | {children}
112 |
117 |
118 |
119 |
120 |
121 |
122 | );
123 | }
124 |
125 | export default ConnectedLayout;
126 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Select } from 'antd';
3 | import { useEditure } from 'editure-react';
4 | import { CODE_BLOCK } from 'editure-constants';
5 | import { useDispatch } from 'react-redux';
6 | import { languages, getValidId } from 'yutang';
7 |
8 | /** @jsx jsx */
9 | import { css, jsx } from '@emotion/core';
10 |
11 | import IconFont from 'components/IconFont';
12 | import { IS_MAC } from 'utils/environment';
13 | import { Dispatch } from 'store';
14 |
15 | import { ElementProps } from './index';
16 |
17 | const { Option } = Select;
18 |
19 | function CodeBlockElement(props: ElementProps) {
20 | const { element, attributes, children } = props;
21 | const { lang: defaultLang = 'Plain Text' } = element;
22 | const dispatch = useDispatch();
23 |
24 | const [lang, setLang] = useState(defaultLang);
25 | const editor = useEditure();
26 |
27 | function handleChange(value: string) {
28 | setLang(value);
29 | dispatch.slate.setLang(value);
30 |
31 | editor.updateBlock(CODE_BLOCK, { lang: value });
32 | }
33 |
34 | const suffixIcon = (
35 |
36 | );
37 |
38 | return (
39 |
52 |
53 |
69 |
70 |
77 |
96 |
112 | {IS_MAC ? '按 ⌘+↩ 退出' : '按 ⌃+↩ 退出'}
113 |
114 |
115 |
116 | );
117 | }
118 |
119 | export default CodeBlockElement;
120 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/Explain.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 | import { Node } from 'editure';
6 |
7 | import { STEP_START, STEP_END, FILE_START, FILE_END } from 'utils/constants';
8 | import { ElementProps } from './index';
9 |
10 | const mapExplainTypeToContent = (explainType: string) => {
11 | switch (explainType) {
12 | case STEP_START: {
13 | return '撰写此步骤的前置解释 ...';
14 | }
15 |
16 | case STEP_END: {
17 | return '撰写此步骤的后置解释 ...';
18 | }
19 |
20 | case FILE_START: {
21 | return '撰写如下文件的前置解释 ...';
22 | }
23 |
24 | case FILE_END: {
25 | return '撰写如上文件的后置解释 ...';
26 | }
27 |
28 | default: {
29 | return '撰写一点解释 ...';
30 | }
31 | }
32 | };
33 |
34 | const mapExplainTypeToBorder = (explainType: string) => {
35 | switch (explainType) {
36 | case FILE_START: {
37 | return css`
38 | border-bottom: none;
39 | border-bottom-right-radius: 0;
40 | border-bottom-left-radius: 0;
41 | `;
42 | }
43 |
44 | case FILE_END: {
45 | return css`
46 | border-top: none;
47 | border-top-right-radius: 0;
48 | border-top-left-radius: 0;
49 | `;
50 | }
51 |
52 | default: {
53 | return null;
54 | }
55 | }
56 | };
57 |
58 | const emptyChildrenStyles = (explainType: string) => css`
59 | border: 1px solid #ddd;
60 | position: relative;
61 |
62 | ${mapExplainTypeToBorder(explainType)}
63 |
64 | &::before {
65 | content: '${mapExplainTypeToContent(explainType)}';
66 | position: absolute;
67 | bottom: 4px;
68 | color: #bfbfbf;
69 | }
70 | `;
71 |
72 | function ExplainElement(props: ElementProps) {
73 | const { attributes, children, element } = props;
74 | const explainStr = Node.string(element);
75 |
76 | return (
77 |
97 | {children}
98 |
99 | );
100 | }
101 |
102 | export default ExplainElement;
103 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/Hr.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelected, useFocused } from 'editure-react';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 | import { ElementProps } from './index';
7 |
8 | function HrElement({ attributes, children }: ElementProps) {
9 | const selected = useSelected();
10 | const focused = useFocused();
11 |
12 | return (
13 |
20 | {children}
21 |
22 | );
23 | }
24 |
25 | export default HrElement;
26 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/Image.tsx:
--------------------------------------------------------------------------------
1 | import { Spin, message } from 'antd';
2 | import React, { useState, useEffect } from 'react';
3 | import { Transforms, Node } from 'editure';
4 | import { IMAGE } from 'editure-constants';
5 | import { useFocused, useSelected, useEditure } from 'editure-react';
6 |
7 | /** @jsx jsx */
8 | import { css, jsx } from '@emotion/core';
9 | import { ElementProps } from './index';
10 | import { uploadImage } from 'utils/image';
11 | import { IEditor } from 'utils/editor';
12 |
13 | function ImageElement(props: ElementProps) {
14 | const { attributes, children, element } = props;
15 | const { url, file } = element;
16 |
17 | const editor = useEditure() as IEditor;
18 | const selected = useSelected();
19 | const focused = useFocused();
20 |
21 | const [uploading, setUploading] = useState(false);
22 |
23 | useEffect(() => {
24 | if (url.startsWith('data:image')) {
25 | setUploading(true);
26 |
27 | uploadImage(file, (err, hostingUrl) => {
28 | setUploading(false);
29 |
30 | if (err) {
31 | return message.error('图片上传失败!');
32 | }
33 |
34 | for (const [node, path] of Node.nodes(editor, { reverse: true })) {
35 | if (node.type === IMAGE && node.url === url) {
36 | Transforms.setNodes(editor, { url: hostingUrl }, { at: path });
37 | Transforms.unsetNodes(editor, ['file'], { at: path });
38 | break;
39 | }
40 | }
41 | });
42 | }
43 | }, [editor, file, url]);
44 |
45 | return (
46 |
47 |
48 |
49 |

62 |
63 | {children}
64 |
65 |
66 | );
67 | }
68 |
69 | export default ImageElement;
70 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/ListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { BULLETED_LIST } from 'editure-constants';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 | import { ElementProps } from './index';
7 |
8 | const bulletedListStyleType = ['disc', 'circle', 'square'];
9 |
10 | function ListItemElement(props: ElementProps) {
11 | const { attributes, children, element } = props;
12 | const { parent, level, number } = element;
13 |
14 | const bulletedStyle = css`
15 | margin-left: ${(level || 0) * 2 + 2}em;
16 | list-style-type: ${bulletedListStyleType[element.level % 3]};
17 | `;
18 |
19 | const numberedStyle = css`
20 | ::before {
21 | content: "${number || 1}. ";
22 | }
23 | margin-left: ${(level || 0) * 2 + 1}em;
24 | list-style-type: none;
25 | `;
26 |
27 | return (
28 |
32 | {children}
33 |
34 | );
35 | }
36 |
37 | export default ListItemElement;
38 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/Note.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Select } from 'antd';
3 | import { useEditure } from 'editure-react';
4 | import { NOTE } from 'editure-constants';
5 |
6 | /** @jsx jsx */
7 | import { css, jsx } from '@emotion/core';
8 |
9 | import { levels } from 'utils/note';
10 | import IconFont from 'components/IconFont';
11 | import { IS_MAC } from 'utils/environment';
12 |
13 | import { ElementProps } from './index';
14 |
15 | const { Option } = Select;
16 |
17 | function NoteElement(props: ElementProps) {
18 | const { attributes, children, element } = props;
19 | const { level: defaultLevel = 'default' } = element;
20 |
21 | const realLevel = Object.keys(levels).includes(defaultLevel)
22 | ? defaultLevel
23 | : 'default';
24 |
25 | const [level, setLevel] = useState(realLevel);
26 | const editor = useEditure();
27 |
28 | function handleChange(value: string) {
29 | setLevel(value);
30 | editor.updateBlock(NOTE, { level: value });
31 | }
32 |
33 | const baseStyle = css`
34 | margin: 1em 0;
35 | padding: 15px;
36 | padding-top: 5px;
37 | position: relative;
38 | border: 1px solid #eee;
39 | border-left-width: 5px;
40 | border-radius: 0px;
41 | transition: background-color 1s;
42 | `;
43 |
44 | const noteStyle = css`
45 | border-left-color: ${levels[level].border};
46 | background-color: ${levels[level].background};
47 | `;
48 |
49 | const suffixIcon = ;
50 |
51 | return (
52 |
64 |
65 |
94 |
95 |
101 | {children}
102 |
103 |
119 | {IS_MAC ? '按 ⌘+↩ 退出' : '按 ⌃+↩ 退出'}
120 |
121 |
122 | );
123 | }
124 |
125 | export default NoteElement;
126 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/element/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as F from 'editure-constants';
3 | import { Element } from 'editure';
4 |
5 | /** @jsx jsx */
6 | import { css, jsx } from '@emotion/core';
7 |
8 | import {
9 | STEP,
10 | FILE,
11 | EXPLAIN,
12 | DIFF_BLOCK,
13 | STEP_END,
14 | STEP_START,
15 | FILE_START,
16 | FILE_END,
17 | } from 'utils/constants';
18 |
19 | import ExplainElement from './Explain';
20 | import NoteElement from './Note';
21 | import ImageElement from './Image';
22 | import HrElement from './Hr';
23 | import CodeBlockElement from './CodeBlock';
24 | import DiffBlockElement from './DiffBlock';
25 | import ListItemElement from './ListItem';
26 |
27 | export type ElementProps = {
28 | attributes: Record;
29 | children: React.ReactNode;
30 | element: Element;
31 | };
32 |
33 | export default (props: ElementProps) => {
34 | const { attributes, children, element } = props;
35 |
36 | switch (element.type) {
37 | case F.BLOCK_QUOTE:
38 | return (
39 |
40 | {children}
41 |
42 | );
43 | case F.LIST_ITEM:
44 | return ;
45 | case F.BULLETED_LIST:
46 | return (
47 |
55 | );
56 | case F.NUMBERED_LIST:
57 | return (
58 |
64 | {children}
65 |
66 | );
67 | case F.H1:
68 | return (
69 |
70 | {children}
71 |
72 | );
73 | case F.H2:
74 | return (
75 |
76 | {children}
77 |
78 | );
79 | case F.H3:
80 | return (
81 |
82 | {children}
83 |
84 | );
85 | case F.H4:
86 | return (
87 |
88 | {children}
89 |
90 | );
91 | case F.H5:
92 | return (
93 |
94 | {children}
95 |
96 | );
97 | case F.CODE_BLOCK:
98 | return ;
99 | case F.CODE_LINE:
100 | return (
101 |
102 |
109 | {children}
110 | |
111 |
112 | );
113 | case F.NOTE:
114 | return ;
115 | case F.IMAGE:
116 | return ;
117 | case F.HR:
118 | return ;
119 | case EXPLAIN:
120 | return ;
121 | case DIFF_BLOCK:
122 | return ;
123 |
124 | case STEP:
125 | case STEP_START:
126 | case STEP_END:
127 | case FILE_START:
128 | case FILE_END:
129 | case FILE:
130 | return ;
131 |
132 | default:
133 | return {children}
;
134 | }
135 | };
136 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 |
3 | /** @jsx jsx */
4 | import { jsx } from '@emotion/core';
5 | import { Node, Path, Point } from 'editure';
6 | import { Editable, useEditure } from 'editure-react';
7 | import { useSelector } from 'react-redux';
8 | import { AST } from 'refractor';
9 |
10 | import { RootState } from 'store';
11 | import { IEditor } from 'utils/editor';
12 | import { createDropListener } from 'utils/image';
13 | import { createHotKeysHandler } from 'utils/hotkeys';
14 |
15 | import Element from './element';
16 | import Leaf from './leaf';
17 | import { getCodeTree, wrapLinesInSpan } from '../Highlight/highlight';
18 |
19 | interface Decoration {
20 | anchor: Point;
21 | focus: Point;
22 | className: string[];
23 | codeHighlight: boolean;
24 | }
25 |
26 | function createDecoration(props: {
27 | path: Path;
28 | textStart: number;
29 | textEnd: number;
30 | className: string[];
31 | }): Decoration {
32 | const { path, textStart, textEnd, className } = props;
33 |
34 | return {
35 | anchor: { path, offset: textStart },
36 | focus: { path, offset: textEnd },
37 | className,
38 | codeHighlight: true,
39 | };
40 | }
41 |
42 | function Editure() {
43 | const editor = useEditure() as IEditor;
44 | const lang = useSelector((state: RootState) => state.slate.lang);
45 |
46 | const renderElement = useCallback(Element, [lang]);
47 | const renderLeaf = useCallback(Leaf, [lang]);
48 |
49 | const hotKeyHandler = createHotKeysHandler(editor);
50 | const dropListener = createDropListener(editor);
51 |
52 | const decorate = useCallback(
53 | ([node, path]) => {
54 | const ranges: Decoration[] = [];
55 |
56 | const lang = node.lang;
57 | if (!lang) {
58 | return ranges;
59 | }
60 |
61 | let code = '';
62 |
63 | for (let textNode of Node.texts(node)) {
64 | const { text } = textNode[0];
65 | code += `${text}\n`;
66 | }
67 |
68 | const codeTree = getCodeTree(code, lang);
69 | const tree = wrapLinesInSpan(codeTree).slice(0, -1);
70 |
71 | tree.forEach((codeStrLine, lineIndex) => {
72 | let textStart = 0;
73 | let textEnd = 0;
74 |
75 | codeStrLine.children.forEach((codeStrToken) => {
76 | let text = '';
77 |
78 | if (codeStrToken.type === 'text') {
79 | text = codeStrToken.value || '';
80 | } else {
81 | text = (codeStrToken.children[0] as AST.Text)?.value || '';
82 | }
83 |
84 | const className =
85 | (codeStrToken as AST.Element)?.properties?.className || [];
86 |
87 | textEnd = textStart + text.length;
88 |
89 | const decoration = createDecoration({
90 | path: [...path, lineIndex],
91 | textStart,
92 | textEnd,
93 | className,
94 | });
95 |
96 | ranges.push(decoration);
97 | textStart = textEnd;
98 | });
99 | });
100 |
101 | return ranges;
102 | },
103 | [lang],
104 | );
105 |
106 | return (
107 | hotKeyHandler(e as any)}
113 | onDrop={dropListener}
114 | onCopy={(e) => {
115 | e.clipboardData.setData(
116 | 'application/x-editure-fragment',
117 | JSON.stringify(true),
118 | );
119 | }}
120 | />
121 | );
122 | }
123 |
124 | export default Editure;
125 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Editure/leaf.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { selectLastPoint, Node } from 'editure';
4 | import { useEditure } from 'editure-react';
5 | import { Popover, Popconfirm, Tooltip } from 'antd';
6 |
7 | /** @jsx jsx */
8 | import { css, jsx } from '@emotion/core';
9 |
10 | import { Dispatch } from 'store';
11 | import IconFont from 'components/IconFont';
12 | import styles from '../Highlight/styles/atom-dark';
13 |
14 | function getStyleFromClassNameArr(classNameArr: string[]) {
15 | let style: Record = {};
16 |
17 | classNameArr.forEach((className) => {
18 | style = { ...style, ...styles[className] };
19 | });
20 |
21 | return style;
22 | }
23 |
24 | type LeafProps = {
25 | attributes: Record;
26 | children: React.ReactNode;
27 | leaf: Node;
28 | };
29 |
30 | const Link = (props: LeafProps) => {
31 | const { attributes, children, leaf } = props;
32 | const editor = useEditure();
33 | const dispatch = useDispatch();
34 |
35 | const iconStyle = css`
36 | margin: 0 2px;
37 |
38 | &:hover {
39 | color: #1db777;
40 | cursor: pointer;
41 | }
42 | `;
43 |
44 | const onClickEdit = () => {
45 | selectLastPoint(editor);
46 |
47 | const { text, url } = editor.getLinkData();
48 | if (text) dispatch.link.setText(text);
49 | if (url) dispatch.link.setUrl(url);
50 |
51 | dispatch.link.startEdit();
52 | };
53 |
54 | const handleDeleteLink = () => {
55 | selectLastPoint(editor);
56 | editor.removeLink();
57 | };
58 |
59 | const content = (
60 |
83 | );
84 |
85 | return (
86 |
87 |
88 | {children}
89 |
90 |
91 | );
92 | };
93 |
94 | export default (props: LeafProps) => {
95 | const { attributes, leaf } = props;
96 | let { children } = props;
97 |
98 | if (leaf.bold) {
99 | children = {children};
100 | }
101 |
102 | if (leaf.code) {
103 | children = {children}
;
104 | }
105 |
106 | if (leaf.italic) {
107 | children = {children};
108 | }
109 |
110 | if (leaf.strikethrough) {
111 | children = (
112 | {children}
113 | );
114 | }
115 |
116 | if (leaf.underline) {
117 | children = {children};
118 | }
119 |
120 | if (leaf.link) {
121 | children = {children};
122 | }
123 |
124 | let highlightProperty: any = {};
125 |
126 | if (leaf.codeHighlight) {
127 | highlightProperty.style = getStyleFromClassNameArr(leaf.className);
128 | }
129 |
130 | return (
131 |
132 | {children}
133 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Highlight/create-element.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { RefractorNode } from 'refractor';
3 |
4 | export function createStyleObject(
5 | classNames: string[],
6 | elementStyle = {},
7 | stylesheet: any,
8 | ) {
9 | return classNames.reduce((styleObject, className) => {
10 | return { ...styleObject, ...stylesheet[className] };
11 | }, elementStyle);
12 | }
13 |
14 | export function createClassNameString(classNames: string[]) {
15 | return classNames.join(' ');
16 | }
17 |
18 | export function createChildren(stylesheet: any, useInlineStyles: boolean) {
19 | let childrenCount = 0;
20 | return (children: RefractorNode[]) => {
21 | childrenCount += 1;
22 | return children.map((child, i) =>
23 | createElement({
24 | node: child,
25 | stylesheet,
26 | useInlineStyles,
27 | key: `code-segment-${childrenCount}-${i}`,
28 | }),
29 | );
30 | };
31 | }
32 |
33 | export default function createElement(props: {
34 | node: RefractorNode;
35 | stylesheet: any;
36 | style?: any;
37 | useInlineStyles: boolean;
38 | key: string;
39 | }) {
40 | const { node, stylesheet, style = {}, useInlineStyles, key } = props;
41 |
42 | if (node.type === 'text') {
43 | return node.value;
44 | }
45 |
46 | const { properties, tagName: TagName } = node;
47 |
48 | const childrenCreator = createChildren(stylesheet, useInlineStyles);
49 | const nonStylesheetClassNames =
50 | useInlineStyles &&
51 | properties.className &&
52 | properties.className.filter((className) => !stylesheet[className]);
53 | const className =
54 | nonStylesheetClassNames && nonStylesheetClassNames.length
55 | ? nonStylesheetClassNames
56 | : undefined;
57 |
58 | const tagProps = useInlineStyles
59 | ? {
60 | ...properties,
61 | ...{ className: className && createClassNameString(className) },
62 | style: createStyleObject(
63 | properties.className!,
64 | Object.assign({}, properties.style, style),
65 | stylesheet,
66 | ),
67 | }
68 | : {
69 | ...properties,
70 | className: createClassNameString(properties.className!),
71 | };
72 |
73 | const children = childrenCreator(node.children);
74 |
75 | return (
76 | // @ts-ignore
77 |
78 | {children}
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Highlight/index.ts:
--------------------------------------------------------------------------------
1 | import highlight from './highlight';
2 | import defaultStyle from './styles/atom-dark';
3 | import refractor from 'refractor';
4 | import supportedLanguages from './languages/supported-languages';
5 |
6 | const highlighter = highlight(refractor, defaultStyle);
7 |
8 | // @ts-ignore
9 | highlighter.supportedLanguages = supportedLanguages;
10 |
11 | export default highlighter;
12 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Highlight/languages/supported-languages.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | 'abap',
3 | 'actionscript',
4 | 'ada',
5 | 'apacheconf',
6 | 'apl',
7 | 'applescript',
8 | 'arduino',
9 | 'arff',
10 | 'asciidoc',
11 | 'asm6502',
12 | 'aspnet',
13 | 'autohotkey',
14 | 'autoit',
15 | 'bash',
16 | 'basic',
17 | 'batch',
18 | 'bison',
19 | 'brainfuck',
20 | 'bro',
21 | 'c',
22 | 'clike',
23 | 'clojure',
24 | 'coffeescript',
25 | 'cpp',
26 | 'crystal',
27 | 'csharp',
28 | 'csp',
29 | 'css-extras',
30 | 'css',
31 | 'd',
32 | 'dart',
33 | 'diff',
34 | 'django',
35 | 'docker',
36 | 'eiffel',
37 | 'elixir',
38 | 'elm',
39 | 'erb',
40 | 'erlang',
41 | 'flow',
42 | 'fortran',
43 | 'fsharp',
44 | 'gedcom',
45 | 'gherkin',
46 | 'git',
47 | 'glsl',
48 | 'go',
49 | 'graphql',
50 | 'groovy',
51 | 'haml',
52 | 'handlebars',
53 | 'haskell',
54 | 'haxe',
55 | 'hpkp',
56 | 'hsts',
57 | 'http',
58 | 'ichigojam',
59 | 'icon',
60 | 'inform7',
61 | 'ini',
62 | 'io',
63 | 'j',
64 | 'java',
65 | 'javascript',
66 | 'jolie',
67 | 'json',
68 | 'jsx',
69 | 'julia',
70 | 'keyman',
71 | 'kotlin',
72 | 'latex',
73 | 'less',
74 | 'liquid',
75 | 'lisp',
76 | 'livescript',
77 | 'lolcode',
78 | 'lua',
79 | 'makefile',
80 | 'markdown',
81 | 'markup-templating',
82 | 'markup',
83 | 'matlab',
84 | 'mel',
85 | 'mizar',
86 | 'monkey',
87 | 'n4js',
88 | 'nasm',
89 | 'nginx',
90 | 'nim',
91 | 'nix',
92 | 'nsis',
93 | 'objectivec',
94 | 'ocaml',
95 | 'opencl',
96 | 'oz',
97 | 'parigp',
98 | 'parser',
99 | 'pascal',
100 | 'perl',
101 | 'php-extras',
102 | 'php',
103 | 'plsql',
104 | 'powershell',
105 | 'processing',
106 | 'prolog',
107 | 'properties',
108 | 'protobuf',
109 | 'pug',
110 | 'puppet',
111 | 'pure',
112 | 'python',
113 | 'q',
114 | 'qore',
115 | 'r',
116 | 'reason',
117 | 'renpy',
118 | 'rest',
119 | 'rip',
120 | 'roboconf',
121 | 'ruby',
122 | 'rust',
123 | 'sas',
124 | 'sass',
125 | 'scala',
126 | 'scheme',
127 | 'scss',
128 | 'smalltalk',
129 | 'smarty',
130 | 'soy',
131 | 'sql',
132 | 'stylus',
133 | 'swift',
134 | 'tap',
135 | 'tcl',
136 | 'textile',
137 | 'tsx',
138 | 'tt2',
139 | 'twig',
140 | 'typescript',
141 | 'vbnet',
142 | 'velocity',
143 | 'verilog',
144 | 'vhdl',
145 | 'vim',
146 | 'visual-basic',
147 | 'wasm',
148 | 'wiki',
149 | 'xeora',
150 | 'xojo',
151 | 'xquery',
152 | 'yaml',
153 | ];
154 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/Highlight/styles/atom-dark.ts:
--------------------------------------------------------------------------------
1 | const styles: { [className: string]: { [attr: string]: string } } = {
2 | 'code[class*="language-"]': {
3 | color: '#c5c8c6',
4 | textShadow: '0 1px rgba(0, 0, 0, 0.3)',
5 | fontFamily:
6 | "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
7 | direction: 'ltr',
8 | textAlign: 'left',
9 | whiteSpace: 'pre',
10 | wordSpacing: 'normal',
11 | wordBreak: 'normal',
12 | lineHeight: '1.5',
13 | MozTabSize: '4',
14 | OTabSize: '4',
15 | tabSize: '4',
16 | WebkitHyphens: 'none',
17 | MozHyphens: 'none',
18 | msHyphens: 'none',
19 | hyphens: 'none',
20 | },
21 | 'pre[class*="language-"]': {
22 | color: '#c5c8c6',
23 | textShadow: '0 1px rgba(0, 0, 0, 0.3)',
24 | fontFamily:
25 | "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo,Courier, monospace",
26 | direction: 'ltr',
27 | textAlign: 'left',
28 | whiteSpace: 'pre',
29 | wordSpacing: 'normal',
30 | wordBreak: 'normal',
31 | lineHeight: '1.5',
32 | MozTabSize: '4',
33 | OTabSize: '4',
34 | tabSize: '4',
35 | WebkitHyphens: 'none',
36 | MozHyphens: 'none',
37 | msHyphens: 'none',
38 | hyphens: 'none',
39 | padding: '1em',
40 | margin: '.5em 0',
41 | overflow: 'auto',
42 | borderRadius: '0.3em',
43 | background: '#1d1f21',
44 | },
45 | ':not(pre) > code[class*="language-"]': {
46 | background: '#1d1f21',
47 | padding: '.1em',
48 | borderRadius: '.3em',
49 | },
50 | comment: {
51 | color: '#7C7C7C',
52 | },
53 | prolog: {
54 | color: '#7C7C7C',
55 | },
56 | doctype: {
57 | color: '#7C7C7C',
58 | },
59 | cdata: {
60 | color: '#7C7C7C',
61 | },
62 | punctuation: {
63 | color: '#c5c8c6',
64 | },
65 | '.namespace': {
66 | Opacity: '.7',
67 | },
68 | property: {
69 | color: '#96CBFE',
70 | },
71 | keyword: {
72 | color: '#96CBFE',
73 | },
74 | tag: {
75 | color: '#96CBFE',
76 | },
77 | 'class-name': {
78 | color: '#FFFFB6',
79 | textDecoration: 'underline',
80 | },
81 | boolean: {
82 | color: '#99CC99',
83 | },
84 | constant: {
85 | color: '#99CC99',
86 | },
87 | symbol: {
88 | color: '#f92672',
89 | },
90 | deleted: {
91 | color: '#f92672',
92 | },
93 | number: {
94 | color: '#FF73FD',
95 | },
96 | selector: {
97 | color: '#A8FF60',
98 | },
99 | 'attr-name': {
100 | color: '#A8FF60',
101 | },
102 | string: {
103 | color: '#A8FF60',
104 | },
105 | char: {
106 | color: '#A8FF60',
107 | },
108 | builtin: {
109 | color: '#A8FF60',
110 | },
111 | inserted: {
112 | color: '#A8FF60',
113 | },
114 | variable: {
115 | color: '#C6C5FE',
116 | },
117 | operator: {
118 | color: '#EDEDED',
119 | },
120 | entity: {
121 | color: '#FFFFB6',
122 | cursor: 'help',
123 | },
124 | url: {
125 | color: '#96CBFE',
126 | },
127 | '.language-css .token.string': {
128 | color: '#87C38A',
129 | },
130 | '.style .token.string': {
131 | color: '#87C38A',
132 | },
133 | atrule: {
134 | color: '#F9EE98',
135 | },
136 | 'attr-value': {
137 | color: '#F9EE98',
138 | },
139 | function: {
140 | color: '#DAD085',
141 | },
142 | regex: {
143 | color: '#E9C062',
144 | },
145 | important: {
146 | color: '#fd971f',
147 | fontWeight: 'bold',
148 | },
149 | bold: {
150 | fontWeight: 'bold',
151 | },
152 | italic: {
153 | fontStyle: 'italic',
154 | },
155 | };
156 |
157 | export default styles;
158 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/PageHeader.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Article } from '@tuture/core';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 | import { Input } from 'antd';
7 | import { useDispatch, useSelector, useStore } from 'react-redux';
8 |
9 | import { Dispatch, Store, RootState } from 'store';
10 |
11 | const noBorderAndShadow = css`
12 | border: none;
13 |
14 | &:hover {
15 | border: none;
16 | box-shadow: none;
17 | }
18 |
19 | &:active {
20 | border: none;
21 | box-shadow: none;
22 | }
23 |
24 | &:focus {
25 | border: none;
26 | box-shadow: none;
27 | }
28 | `;
29 |
30 | const { TextArea } = Input;
31 |
32 | function PageHeader() {
33 | const store = useStore() as Store;
34 | const { nowArticleId } = useSelector((state: RootState) => state.collection);
35 | const { name = '', description = '' } =
36 | useSelector(
37 | store.select.collection.getArticleMetaById({ id: nowArticleId }),
38 | ) || {};
39 | const dispatch = useDispatch();
40 | const [timeoutHeaderState, setTimeoutHeaderState] = useState(
41 | null,
42 | );
43 | const [timeoutDescriptionState, setTimeoutDescriptionState] = useState<
44 | number | null
45 | >(null);
46 |
47 | function resetTimeout(id: number | null, newId: any) {
48 | if (id) {
49 | clearTimeout(id);
50 | }
51 |
52 | return newId;
53 | }
54 |
55 | function handleSaveCollection() {
56 | dispatch.collection.save({
57 | keys: ['meta', 'articles'],
58 | showMessage: false,
59 | });
60 | }
61 |
62 | function handleHeaderChange(e: React.ChangeEvent) {
63 | dispatch.collection.setArticleById({
64 | props: { name: e.target.value },
65 | });
66 |
67 | setTimeoutHeaderState(
68 | resetTimeout(timeoutHeaderState, setTimeout(handleSaveCollection, 1000)),
69 | );
70 | }
71 |
72 | function handleDescriptionChange(e: React.ChangeEvent) {
73 | dispatch.collection.setArticleById({
74 | props: { description: e.target.value },
75 | });
76 |
77 | setTimeoutDescriptionState(
78 | resetTimeout(
79 | timeoutDescriptionState,
80 | setTimeout(handleSaveCollection, 1000),
81 | ),
82 | );
83 | }
84 |
85 | return (
86 |
91 |
98 |
116 |
117 |
135 |
136 | );
137 | }
138 |
139 | export default PageHeader;
140 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Content/index.tsx:
--------------------------------------------------------------------------------
1 | /** @jsx jsx */
2 | import { css, jsx } from '@emotion/core';
3 | import { Row, Col, Affix } from 'antd';
4 | import Editure from './Editure';
5 | import PageHeader from './PageHeader';
6 | import StepFileList from './StepFileList';
7 | import PageCatalogue from './PageCatalogue';
8 |
9 | const contentStyles = css`
10 | padding: 48px 60px 64px;
11 |
12 | & .ant-select-selection {
13 | background: none;
14 | border: none;
15 | padding: 2px;
16 | }
17 |
18 | & .ant-select-selection:active {
19 | border: none;
20 | box-shadow: none;
21 | }
22 |
23 | & .ant-select-selection:focus {
24 | border: none;
25 | box-shadow: none;
26 | }
27 |
28 | & .ant-select-selection-selected-value {
29 | font-size: 14px;
30 | font-weight: 400;
31 | }
32 | `;
33 |
34 | function Content() {
35 | return (
36 |
37 |
38 | document.getElementById('scroll-container')}>
39 |
40 |
41 |
42 |
43 |
47 |
48 |
49 | document.getElementById('scroll-container')}>
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | export default Content;
58 |
--------------------------------------------------------------------------------
/packages/editor/src/components/IconFont.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from 'antd';
2 |
3 | const IconFont = Icon.createFromIconfontCN({
4 | scriptUrl: '//at.alicdn.com/t/font_1629955_27uzalcrqrmj.js',
5 | });
6 |
7 | export default IconFont;
8 |
--------------------------------------------------------------------------------
/packages/editor/src/components/ScrollToTop.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | function ScrollToTopOnMount() {
5 | const { pathname } = useLocation();
6 |
7 | useEffect(() => {
8 | window.scrollTo(0, 0);
9 | }, [pathname]);
10 |
11 | return null;
12 | }
13 |
14 | export default ScrollToTopOnMount;
15 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { Row, Col } from 'antd';
4 |
5 | /** @jsx jsx */
6 | import { css, jsx } from '@emotion/core';
7 |
8 | import { Dispatch } from 'store';
9 | import ReleasedStepList from './ReleasedStepList';
10 | import ArticleStepList from './ArticleStepList';
11 |
12 | function Toc() {
13 | const dispatch: Dispatch = useDispatch();
14 |
15 | useEffect(() => {
16 | dispatch.toc.fetchToc();
17 | }, [dispatch]);
18 |
19 | return (
20 |
26 |
27 |
35 |
36 |
37 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | export default Toc;
54 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/styles.ts:
--------------------------------------------------------------------------------
1 | /** @jsx jsx */
2 | import { css, jsx } from '@emotion/core';
3 |
4 | export const headerStyle = css`
5 | border-bottom: 1px solid #eeeeee;
6 | padding-bottom: 16px;
7 | `;
8 |
9 | export const assistInfoStyle = css`
10 | font-size: 14px;
11 | font-family: PingFangSC-Regular, PingFang SC;
12 | font-weight: 400;
13 | color: rgba(140, 140, 140, 1);
14 | line-height: 20px;
15 | margin-top: 8px;
16 | `;
17 |
18 | export const containerStyle = css`
19 | background: rgba(255, 255, 255, 1);
20 | border: 1px solid rgba(232, 232, 232, 1);
21 | padding-top: 32px;
22 | padding-bottom: 32px;
23 | `;
24 |
25 | export const listItemActionStyle = css`
26 | font-size: 12px;
27 | font-family: PingFangSC-Regular, PingFang SC;
28 | font-weight: 400;
29 | color: rgba(140, 140, 140, 1);
30 | line-height: 22px;
31 | `;
32 |
33 | export const listItemStyle = css`
34 | position: relative;
35 | display: flex;
36 | flex-direction: row;
37 | justify-content: space-between;
38 | align-items: center;
39 |
40 | background: rgba(247, 247, 250, 1);
41 | border-radius: 2px;
42 | border: 1px solid rgba(232, 232, 232, 1);
43 | padding-left: 16px;
44 | padding-right: 24px;
45 | margin-bottom: 16px;
46 |
47 | &:hover {
48 | cursor: pointer;
49 | }
50 |
51 | &:hover .list-item-action {
52 | visibility: visible;
53 | }
54 | `;
55 |
56 | export const activeListItemStyle = css`
57 | border: 1px solid #999999;
58 | `;
59 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/widgets/AddButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx, SerializedStyles } from '@emotion/core';
5 |
6 | import IconFont from 'components/IconFont';
7 |
8 | import { listItemActionStyle } from '../styles';
9 |
10 | function AddButton(props: {
11 | css?: SerializedStyles;
12 | onClick?: React.MouseEventHandler;
13 | }) {
14 | return (
15 |
26 |
31 | 添加
32 |
33 | svg {
37 | width: 8px;
38 | height: 8px;
39 | }
40 | `}
41 | />
42 |
43 | );
44 | }
45 |
46 | export default AddButton;
47 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/widgets/Caret.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Icon } from 'antd';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 |
7 | function Caret(props: { type: 'caret-down' | 'caret-right' }) {
8 | return (
9 |
16 | svg {
20 | width: 10px;
21 | height: 10px;
22 | }
23 | `}
24 | />
25 |
26 | );
27 | }
28 |
29 | export default Caret;
30 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/widgets/DeleteButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /** @jsx jsx */
4 | import { css, jsx } from '@emotion/core';
5 |
6 | import IconFont from 'components/IconFont';
7 |
8 | function DeleteButton(props: { onClick?: React.MouseEventHandler }) {
9 | return (
10 | svg {
22 | width: 12px;
23 | height: 12px;
24 | }
25 | `}
26 | />
27 | );
28 | }
29 |
30 | export default DeleteButton;
31 |
--------------------------------------------------------------------------------
/packages/editor/src/components/Toc/widgets/OutdatedTag.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Tooltip, Tag } from 'antd';
3 |
4 | /** @jsx jsx */
5 | import { css, jsx } from '@emotion/core';
6 |
7 | function OutdatedTip() {
8 | return (
9 |
10 |
21 |
29 | 过时
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default OutdatedTip;
37 |
--------------------------------------------------------------------------------
/packages/editor/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { default as App } from './App';
2 | export { default as Toc } from './Toc';
3 | export { default as ScrollToTop } from './ScrollToTop';
4 |
--------------------------------------------------------------------------------
/packages/editor/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { Route, BrowserRouter as Router, Switch } from 'react-router-dom';
5 |
6 | import { globalStyles } from './shared/styles';
7 | import { store } from './store';
8 | import * as serviceWorker from './serviceWorker';
9 | import { ScrollToTop } from './components';
10 | import ConnectedLayout from './components/ConnectedLayout';
11 |
12 | import Toc from './pages/Toc';
13 | import Article from './pages/Article';
14 | import Home from './pages/Home';
15 |
16 | ReactDOM.render(
17 |
18 | {globalStyles}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | ,
36 | document.getElementById('root'),
37 | );
38 |
39 | // If you want your app to work offline and load faster, you can change
40 | // unregister() to register() below. Note this comes with some pitfalls.
41 | // Learn more about service workers: https://bit.ly/CRA-PWA
42 | serviceWorker.unregister();
43 |
--------------------------------------------------------------------------------
/packages/editor/src/models/diff.ts:
--------------------------------------------------------------------------------
1 | import { message } from 'antd';
2 | import { RawDiff, isCommitEqual } from '@tuture/core';
3 | import { Slicer, SelectorCreator, Parameterizer } from '@rematch/select';
4 |
5 | import { Dispatch } from '../store';
6 |
7 | export type DiffState = {
8 | diff: RawDiff[] | null;
9 | };
10 |
11 | const initialState: DiffState = { diff: null };
12 |
13 | export const diff = {
14 | state: initialState,
15 | reducers: {
16 | setDiffData(state: DiffState, diff: RawDiff[]) {
17 | state.diff = diff;
18 | return state;
19 | },
20 | },
21 | effects: (dispatch: Dispatch) => ({
22 | async fetchDiff() {
23 | try {
24 | const response = await fetch('/api/diff');
25 | const data = await response.json();
26 | dispatch.diff.setDiffData(data);
27 | } catch {
28 | message.error('数据获取失败,请稍后重试!');
29 | }
30 | },
31 | }),
32 | selectors: (
33 | slice: Slicer,
34 | createSelector: SelectorCreator,
35 | hasProps: Parameterizer,
36 | ) => ({
37 | getDiffItemByCommitAndFile: hasProps(
38 | (__: any, props: { commit: string; file: string }) => {
39 | return slice((diffState: DiffState) => {
40 | const emptyVal = { chunks: [] };
41 | if (!diffState?.diff) {
42 | return emptyVal;
43 | }
44 |
45 | const commit = diffState.diff.filter((item: any) =>
46 | isCommitEqual(item.commit, props.commit),
47 | )[0];
48 |
49 | if (!commit) {
50 | return emptyVal;
51 | }
52 |
53 | return (
54 | commit.diff.filter((item) => item.to === props.file)[0] || emptyVal
55 | );
56 | });
57 | },
58 | ),
59 | }),
60 | };
61 |
--------------------------------------------------------------------------------
/packages/editor/src/models/drawer.ts:
--------------------------------------------------------------------------------
1 | import { DRAWER_UNSELECT } from '../utils/constants';
2 |
3 | export type DrawerState = {
4 | visible: boolean;
5 | childrenVisible: boolean;
6 | drawerType: string;
7 | childrenDrawerType: string;
8 | selectedKeys: string[];
9 | };
10 |
11 | const initialState: DrawerState = {
12 | childrenVisible: false,
13 | visible: false,
14 | drawerType: '',
15 | childrenDrawerType: DRAWER_UNSELECT,
16 | selectedKeys: [],
17 | };
18 |
19 | export const drawer = {
20 | state: initialState,
21 | reducers: {
22 | setVisible(state: DrawerState, visible: boolean) {
23 | state.visible = visible;
24 | return state;
25 | },
26 | setDrawerType(state: DrawerState, type: string) {
27 | state.drawerType = type;
28 | return state;
29 | },
30 | setSelectedKeys(state: DrawerState, keys: string[]) {
31 | state.selectedKeys = keys;
32 | return state;
33 | },
34 | setChildrenVisible(state: DrawerState, visible: boolean) {
35 | state.childrenVisible = visible;
36 | return state;
37 | },
38 | setChildrenDrawerType(state: DrawerState, type: string) {
39 | state.childrenDrawerType = type;
40 | return state;
41 | },
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/packages/editor/src/models/index.ts:
--------------------------------------------------------------------------------
1 | export { collection } from './collection';
2 | export { diff } from './diff';
3 | export { toc } from './toc';
4 | export { drawer } from './drawer';
5 | export { link } from './link';
6 | export { slate } from './slate';
7 | export { sync } from './sync';
8 |
--------------------------------------------------------------------------------
/packages/editor/src/models/link.ts:
--------------------------------------------------------------------------------
1 | export type LinkState = {
2 | isEditing: boolean;
3 | text: string;
4 | url: string;
5 | };
6 |
7 | const initialState: LinkState = {
8 | isEditing: false,
9 | text: '',
10 | url: '',
11 | };
12 |
13 | export const link = {
14 | state: initialState,
15 | reducers: {
16 | startEdit(state: LinkState) {
17 | state.isEditing = true;
18 | return state;
19 | },
20 | reset(state: LinkState) {
21 | state.isEditing = false;
22 | state.text = '';
23 | state.url = '';
24 |
25 | return state;
26 | },
27 | setText(state: LinkState, text: string) {
28 | state.text = text;
29 | return state;
30 | },
31 | setUrl(state: LinkState, url: string) {
32 | state.url = url;
33 | return state;
34 | },
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/packages/editor/src/models/slate.ts:
--------------------------------------------------------------------------------
1 | export type SlateState = {
2 | lang: string;
3 | };
4 |
5 | const initialState: SlateState = {
6 | lang: '',
7 | };
8 |
9 | export const slate = {
10 | state: initialState,
11 | reducers: {
12 | setLang(state: SlateState, lang: string) {
13 | state.lang = lang;
14 |
15 | return state;
16 | },
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/packages/editor/src/models/sync.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { message, notification } from 'antd';
3 | import axios from 'axios';
4 | import { Remote } from '@tuture/core';
5 |
6 | import { Dispatch } from '../store';
7 |
8 | export type SyncState = {
9 | syncVisible: boolean;
10 | gitRemotes: Remote[];
11 | };
12 |
13 | const initialState: SyncState = {
14 | syncVisible: false,
15 | gitRemotes: [],
16 | };
17 |
18 | export const sync = {
19 | state: initialState,
20 | reducers: {
21 | setSyncVisible(state: SyncState, visible: boolean) {
22 | state.syncVisible = visible;
23 | },
24 | setGitRemotes(state: SyncState, remotes: Remote[]) {
25 | state.gitRemotes = remotes;
26 | },
27 | },
28 | effects: (dispatch: Dispatch) => ({
29 | async sync(payload: { showMessage: boolean }) {
30 | await dispatch.collection.save({
31 | keys: ['meta', 'articles', 'fragment', 'remotes'],
32 | showMessage: true,
33 | });
34 |
35 | try {
36 | const response = await axios.get('/api/sync');
37 |
38 | if (response.status === 200) {
39 | if (payload?.showMessage) {
40 | message.success('同步内容成功!');
41 | }
42 | }
43 | } catch (err) {
44 | if (payload?.showMessage) {
45 | if (payload?.showMessage) {
46 | notification.error({
47 | message: '内容同步可能发生了文件冲突',
48 | description: (
49 |
50 |
请检查代码仓库是否存在冲突文件,并通过以下流程解决:
51 |
1)打开发生冲突的文件,解决冲突;
52 |
2)将冲突的文件添加到暂存区;
53 |
54 | 3)运行 tuture sync --continue
继续同步。
55 |
56 |
57 | ),
58 | duration: null,
59 | });
60 | }
61 | }
62 | }
63 | },
64 | async fetchGitRemotes() {
65 | const response = await fetch('/api/remotes?fromGit=true');
66 | const body = await response.json();
67 |
68 | dispatch.sync.setGitRemotes(body as Remote[]);
69 | },
70 | }),
71 | };
72 |
--------------------------------------------------------------------------------
/packages/editor/src/models/toc.ts:
--------------------------------------------------------------------------------
1 | import { message } from 'antd';
2 | import { TocItem, TocStepItem } from '@tuture/local-server';
3 |
4 | import { Dispatch, RootState } from '../store';
5 | import { saveData } from '../utils/request';
6 |
7 | export type TocState = {
8 | isSaving: boolean;
9 | activeArticle: string;
10 | needDeleteOutdatedStepList: string[];
11 | articleStepList: TocItem[];
12 | unassignedStepList: TocStepItem[];
13 | deleteOutdatedStepList?: TocStepItem[];
14 | };
15 |
16 | const initialState: TocState = {
17 | isSaving: false,
18 | activeArticle: '',
19 | needDeleteOutdatedStepList: [],
20 | articleStepList: [],
21 | unassignedStepList: [],
22 | };
23 |
24 | export const toc = {
25 | state: initialState,
26 | reducers: {
27 | setSaveStatus(state: TocState, isSaving: boolean) {
28 | state.isSaving = isSaving;
29 | },
30 | setArticleStepList(state: TocState, payload: TocStepItem[]) {
31 | state.articleStepList = payload;
32 | },
33 | setActiveArticle(state: TocState, payload: string) {
34 | state.activeArticle = payload;
35 | },
36 | setUnassignedStepList(state: TocState, payload: TocStepItem[]) {
37 | state.unassignedStepList = payload;
38 | },
39 | deleteOutdatedStepList(state: TocState, stepId: string) {
40 | state.needDeleteOutdatedStepList = state.needDeleteOutdatedStepList.concat(
41 | stepId,
42 | );
43 |
44 | if (state.unassignedStepList) {
45 | const newUnassignedStepList = state.unassignedStepList.filter(
46 | (step) => step.id !== stepId,
47 | );
48 | state.unassignedStepList = newUnassignedStepList;
49 | }
50 | },
51 | reset(state: TocState) {
52 | state.activeArticle = '';
53 | state.needDeleteOutdatedStepList = [];
54 | state.articleStepList = [];
55 | state.unassignedStepList = [];
56 | state.isSaving = false;
57 | },
58 | },
59 | effects: (dispatch: Dispatch) => ({
60 | async fetchToc() {
61 | try {
62 | const response = await fetch('/api/toc');
63 | const data: {
64 | articleStepList: TocStepItem[];
65 | unassignedStepList: TocStepItem[];
66 | } = await response.json();
67 |
68 | dispatch.toc.setUnassignedStepList(data.unassignedStepList);
69 | dispatch.toc.setArticleStepList(data.articleStepList);
70 | } catch {
71 | message.error('获取目录失败!');
72 | }
73 | },
74 | async save(_: any, rootState: RootState) {
75 | const {
76 | articleStepList = [],
77 | unassignedStepList = [],
78 | needDeleteOutdatedStepList = [],
79 | } = rootState.toc;
80 |
81 | const success = await saveData(
82 | {
83 | articleStepList,
84 | unassignedStepList,
85 | needDeleteOutdatedStepList,
86 | },
87 | '/api/toc',
88 | );
89 |
90 | if (!success) {
91 | return message.error('目录保存失败!');
92 | }
93 |
94 | const { nowArticleId = '' } = rootState.collection;
95 |
96 | const nowArticleIdList = articleStepList
97 | .filter((item) => item.type === 'article')
98 | .map((item) => item.id);
99 |
100 | if (nowArticleId && !nowArticleIdList.includes(nowArticleId)) {
101 | dispatch.collection.setNowArticle('');
102 | }
103 |
104 | // Update collection data.
105 | await dispatch.collection.fetchArticles();
106 | await dispatch.collection.fetchFragment();
107 |
108 | message.success('目录保存成功');
109 | },
110 | }),
111 | };
112 |
--------------------------------------------------------------------------------
/packages/editor/src/models/util.d.ts:
--------------------------------------------------------------------------------
1 | type ModelReducers = {
2 | [key: string]: (state: S, payload: any) => S;
3 | };
4 | type ModelEffects = {
5 | [key: string]: (payload?: any, rootState?: RootState) => Promise;
6 | };
7 | type ModelSelect = {
8 | [key: string]: () => (rootState: RootState) => any;
9 | };
10 | export type ModelConfig = {
11 | state: S;
12 | reducers: ModelReducers;
13 | effects: (dispatch: any) => ModelEffects;
14 | selectors?: (slice: any) => ModelSelect;
15 | };
16 | export type Models = {
17 | [key: string]: ModelConfig;
18 | };
19 | export type RematchRootState = {
20 | [modelKey in keyof M]: M[modelKey]['state'];
21 | };
22 | export type RematchRootSelect = {
23 | [modelKey in keyof M]: M[modelKey]['selectors'] extends Function
24 | ? SelectFromModel>
25 | : {};
26 | };
27 | export type SelectFromModel = {
28 | [selectKey in keyof S]: ReturnType>;
29 | };
30 | export type ReducerFromModel = {
31 | [reducerKey in keyof R]: Reducer2connect;
32 | };
33 | export type EffectFromModel = {
34 | [effectKey in keyof E]: Effect2connect;
35 | };
36 |
37 | type Reducer2connect = R extends (
38 | state: infer S,
39 | ...payload: infer P
40 | ) => any
41 | ? (...payload: P) => S
42 | : () => void;
43 |
44 | type Effect2connect = E extends () => infer S
45 | ? () => Promise
46 | : E extends (payload: infer P, ...args: any[]) => infer S
47 | ? (payload: P) => Promise
48 | : () => Promise;
49 |
50 | export type RematchDispatch = {
51 | [modelKey in keyof M]: ReducerFromModel &
52 | EffectFromModel>;
53 | };
54 |
55 | // @ts-ignore
56 | export type RematchLoadingState = {
57 | loading: {
58 | global: boolean;
59 | models: { [modelName in keyof M]: boolean };
60 | effects: {
61 | [modelName in keyof M]: {
62 | [effectName in keyof ExtractRematchDispatchersFromEffects<
63 | M[modelName]['effects']
64 | >]: boolean;
65 | };
66 | };
67 | };
68 | };
69 |
70 | declare module '@rematch/core' {
71 | export function init(config: any): any;
72 | }
73 |
--------------------------------------------------------------------------------
/packages/editor/src/pages/Article.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { useRouteMatch } from 'react-router-dom';
4 | import { Spin } from 'antd';
5 |
6 | import { App } from '../components/';
7 |
8 | /** @jsx jsx */
9 | import { css, jsx } from '@emotion/core';
10 |
11 | import { Dispatch, RootState } from '../store';
12 |
13 | function Article() {
14 | const dispatch = useDispatch();
15 | const { fetchMeta, fetchArticles, fetchFragment } = useSelector(
16 | (state: RootState) => state.loading.effects.collection,
17 | );
18 | const loading = fetchMeta || fetchArticles || fetchFragment;
19 |
20 | const match = useRouteMatch('/articles/:id');
21 | const { id = '' }: any = match?.params;
22 |
23 | useEffect(() => {
24 | dispatch.collection.setNowArticle(id);
25 | dispatch.collection.fetchFragment();
26 | }, [dispatch, id]);
27 |
28 | return (
29 |
41 | );
42 | }
43 |
44 | export default Article;
45 |
--------------------------------------------------------------------------------
/packages/editor/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Spin } from 'antd';
4 |
5 | /** @jsx jsx */
6 | import { css, jsx } from '@emotion/core';
7 |
8 | import { Dispatch, RootState } from 'store';
9 | import { App } from '../components';
10 |
11 | function Home() {
12 | const dispatch = useDispatch();
13 | const { fetchMeta, fetchArticles, fetchFragment } = useSelector(
14 | (state: RootState) => state.loading.effects.collection,
15 | );
16 | const loading = fetchMeta || fetchArticles || fetchFragment;
17 |
18 | useEffect(() => {
19 | dispatch.collection.fetchFragment();
20 | }, [dispatch]);
21 |
22 | return (
23 |
39 | );
40 | }
41 |
42 | export default Home;
43 |
--------------------------------------------------------------------------------
/packages/editor/src/pages/Toc.tsx:
--------------------------------------------------------------------------------
1 | /** @jsx jsx */
2 | import { css, jsx } from '@emotion/core';
3 | import { useSelector } from 'react-redux';
4 | import { Spin } from 'antd';
5 |
6 | import { RootState } from 'store';
7 | import { Toc as TocComponent } from '../components';
8 |
9 | function Toc() {
10 | const loading = useSelector(
11 | (state: RootState) => state.loading.effects.toc.fetchToc,
12 | );
13 |
14 | return (
15 |
20 |
21 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | export default Toc;
39 |
--------------------------------------------------------------------------------
/packages/editor/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/editor/src/setupTests.ts:
--------------------------------------------------------------------------------
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/extend-expect';
6 |
--------------------------------------------------------------------------------
/packages/editor/src/shared/styles.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css, Global } from '@emotion/core';
3 |
4 | export const globalStyles = (
5 | code {
71 | font-family: monospace;
72 | background-color: #eee;
73 | padding: 3px;
74 | }
75 |
76 | img {
77 | max-width: 100%;
78 | max-height: 20em;
79 | }
80 |
81 | blockquote {
82 | border-left: 2px solid #ddd;
83 | margin: 0.5em 0;
84 | padding-left: 10px;
85 | color: #aaa;
86 | }
87 |
88 | blockquote[dir='rtl'] {
89 | border-left: none;
90 | margin: 0.5em 0;
91 | padding-left: 0;
92 | padding-right: 10px;
93 | border-right: 2px solid #ddd;
94 | }
95 |
96 | table {
97 | border-collapse: collapse;
98 | }
99 |
100 | td {
101 | padding: 10px;
102 | border: 2px solid #ddd;
103 | }
104 |
105 | a {
106 | cursor: pointer;
107 | word-wrap: break-word;
108 | text-decoration: none;
109 | color: #096dd9;
110 | }
111 |
112 | [data-slate-node='element']:not(li) {
113 | margin-top: 1em;
114 | }
115 |
116 | .anticon > svg {
117 | height: 16px;
118 | width: 16px;
119 | }
120 |
121 | .ant-select-selection-selected-value {
122 | font-size: 14px;
123 | font-weight: 400;
124 | }
125 |
126 | .ant-drawer-left.ant-drawer-open .ant-drawer-content-wrapper {
127 | box-shadow: none;
128 | }
129 |
130 | .ant-drawer-body {
131 | padding: 24px 0;
132 | }
133 |
134 | .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
135 | background: transparent;
136 | }
137 | `}
138 | />
139 | );
140 |
--------------------------------------------------------------------------------
/packages/editor/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { init } from '@rematch/core';
2 | import immerPlugin from '@rematch/immer';
3 | import selectPlugin from '@rematch/select';
4 | import createLoadingPlugin from '@rematch/loading';
5 |
6 | import * as models from '../models';
7 | import {
8 | RematchDispatch,
9 | RematchRootState,
10 | RematchLoadingState,
11 | } from '../models/util';
12 |
13 | const loadingOptions = {};
14 | const devtoolOptions = {
15 | trace: true,
16 | shouldCatchErrors: true,
17 | disabled: !(process.env.NODE_ENV === 'development'),
18 | };
19 |
20 | export const store = init({
21 | models,
22 | plugins: [immerPlugin(), selectPlugin(), createLoadingPlugin(loadingOptions)],
23 | redux: {
24 | devtoolOptions,
25 | },
26 | });
27 |
28 | export type Store = typeof store;
29 |
30 | // @ts-ignore
31 | export type RootState = RematchRootState &
32 | // @ts-ignore
33 | RematchLoadingState;
34 |
35 | // @ts-ignore
36 | export type Dispatch = RematchDispatch;
37 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Common Status constants
3 | */
4 | export const NORMAL = 'NORMAL';
5 | export const SUCCESS = 'SUCCESS';
6 | export const ERROR = 'ERROR';
7 |
8 | /**
9 | * Get Data Status constants
10 | */
11 | export const LOADING = 'LOADING';
12 | export const LOADING_SUCCESS = 'LOADING_SUCCESS';
13 | export const LOADING_ERROR = 'LOADING_ERROR';
14 |
15 | /**
16 | * commit Status constants
17 | */
18 | export const COMMIT = 'COMMIT';
19 | export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
20 | export const COMMIT_ERROR = 'COMMIT_ERROR';
21 |
22 | /**
23 | * Open drawer type constants
24 | */
25 | export const DRAWER_UNSELECT = 'DRAWER_UNSELECT';
26 | export const PAGE_CATAGUE = 'PAGE_CATAGUE';
27 | export const COLLECTION_CATALOGUE = 'COLLECTION_CATALOGUE';
28 | export const COLLECTION_SETTING = 'COLLECTION_SETTING';
29 | export const CONTACT_US = 'CONTACT_US';
30 |
31 | /**
32 | * Open children drawer type constants
33 | *
34 | */
35 | export const CREATE_ARTICLE = 'CREATE_ARTICLE';
36 | export const EDIT_ARTICLE = 'EDIT_ARTICLE';
37 |
38 | /**
39 | * breakpoint type constants
40 | *
41 | */
42 | export const XS = 'XS';
43 | export const SM = 'SM';
44 | export const MD = 'MD';
45 | export const LG = 'LG';
46 | export const XL = 'XL';
47 | export const XXL = 'XXL';
48 |
49 | /**
50 | * Explain type constants
51 | *
52 | */
53 | export const STEP_PRE_EXPLAIN = 'STEP_PRE_EXPLAIN';
54 | export const STEP_POST_EXPLAIN = 'STEP_POST_EXPLAIN';
55 | export const DIFF_PRE_EXPLAIN = 'DIFF_PRE_EXPLAIN';
56 | export const DIFF_POST_EXPLAIN = 'DIFF_POST_EXPLAIN';
57 |
58 | /**
59 | * Additional block types.
60 | */
61 | export const STEP = 'step';
62 | export const FILE = 'file';
63 | export const EXPLAIN = 'explain';
64 | export const DIFF_BLOCK = 'diff-block';
65 |
66 | /**
67 | * empty bound types.
68 | */
69 | export const STEP_START = 'step_start';
70 | export const STEP_END = 'step_end';
71 | export const FILE_START = 'file_start';
72 | export const FILE_END = 'file_end';
73 | export const NOW_STEP_START = 'now_step_start';
74 |
75 | /**
76 | * sync constants
77 | */
78 | export const NO_REMOTE_GITHUB = 'NO_REMOTE_GITHUB';
79 |
80 | /**
81 | * sync exit code
82 | */
83 | export enum EXIT_CODE {
84 | NOT_INIT = 1,
85 | NO_STAGE,
86 | NO_REMOTE,
87 | CONFLICT,
88 | }
89 |
90 | export const mapExitCodeToMessage = {
91 | [EXIT_CODE.NOT_INIT]: '你的 GIT 仓库还没有初始化成 Tuture 教程',
92 | [EXIT_CODE.NO_STAGE]: '你的 GIT 仓库还没有任何提交',
93 | [EXIT_CODE.NO_REMOTE]: '你的 GIT 仓库还没有设置远程仓库,无法进行同步',
94 | [EXIT_CODE.CONFLICT]:
95 | '你的 GIT 仓库在与远程仓库同步时存在冲突,请手动解决冲突',
96 | };
97 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/editor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createEditor,
3 | defaultPlugins,
4 | withHistory,
5 | Transforms,
6 | Range,
7 | Editor,
8 | EditorWithLink,
9 | EditorWithList,
10 | EditorWithBlock,
11 | EditorWithMark,
12 | EditorWithVoid,
13 | EditorWithContainer,
14 | } from 'editure';
15 | import { withPaste, withReact, ReactEditor } from 'editure-react';
16 | import * as F from 'editure-constants';
17 |
18 | import { IS_FIREFOX } from './environment';
19 | import { withImages } from './image';
20 | import { EXPLAIN, DIFF_BLOCK } from '../utils/constants';
21 |
22 | const withCommitHeaderLayout = (editor: Editor) => {
23 | const { normalizeNode } = editor;
24 |
25 | editor.normalizeNode = ([node, path]) => {
26 | if (path.length === 1 && node.fixed) {
27 | if (node.type === F.PARAGRAPH) {
28 | const title = { type: F.H2, children: [{ text: '' }] };
29 | Transforms.setNodes(editor, title);
30 | }
31 | }
32 |
33 | return normalizeNode([node, path]);
34 | };
35 |
36 | return editor;
37 | };
38 |
39 | const withExplainLayout = (editor: IEditor) => {
40 | const { deleteBackward } = editor;
41 |
42 | editor.deleteBackward = (unit) => {
43 | const { selection } = editor;
44 |
45 | if (selection && Range.isCollapsed(selection)) {
46 | const parent = Editor.parent(editor, selection);
47 | const grandparent = Editor.parent(editor, parent[1]);
48 |
49 | // If selection is start of EXPLAIN, forbid to deleteBackward
50 | if (
51 | grandparent[0].type === EXPLAIN &&
52 | Editor.isStart(editor, selection.anchor, parent[1]) &&
53 | Editor.isStart(editor, selection.anchor, grandparent[1])
54 | ) {
55 | return;
56 | }
57 | }
58 |
59 | deleteBackward(unit);
60 | };
61 |
62 | return editor;
63 | };
64 |
65 | const withDiffBlockVoid = (editor: Editor) => {
66 | const { isVoid } = editor;
67 |
68 | editor.isVoid = (element) => {
69 | return element.type === DIFF_BLOCK ? true : isVoid(element);
70 | };
71 |
72 | return editor;
73 | };
74 |
75 | const plugins: Function[] = [
76 | withReact,
77 | ...defaultPlugins,
78 | withImages,
79 | withExplainLayout,
80 | withCommitHeaderLayout,
81 | withDiffBlockVoid,
82 | withPaste,
83 | withHistory,
84 | ];
85 |
86 | export type IEditor = Editor &
87 | EditorWithList &
88 | EditorWithLink &
89 | EditorWithVoid &
90 | EditorWithBlock &
91 | EditorWithMark &
92 | EditorWithContainer &
93 | ReactEditor;
94 |
95 | export const initializeEditor = () =>
96 | plugins.reduce(
97 | (augmentedEditor, plugin) => plugin(augmentedEditor),
98 | createEditor() as IEditor,
99 | ) as IEditor;
100 |
101 | export const syncDOMSelection = (editor: IEditor) => {
102 | const { selection } = editor;
103 | const domSelection = window.getSelection();
104 |
105 | if (!domSelection) {
106 | return;
107 | }
108 |
109 | const newDomRange = selection && ReactEditor.toDOMRange(editor, selection);
110 |
111 | const el = ReactEditor.toDOMNode(editor, editor);
112 | domSelection.removeAllRanges();
113 |
114 | if (newDomRange) {
115 | domSelection.addRange(newDomRange!);
116 | }
117 |
118 | setTimeout(() => {
119 | // COMPAT: In Firefox, it's not enough to create a range, you also need
120 | // to focus the contenteditable element too. (2016/11/16)
121 | if (newDomRange && IS_FIREFOX) {
122 | el.focus();
123 | }
124 | });
125 | };
126 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/environment.ts:
--------------------------------------------------------------------------------
1 | export const IS_MAC =
2 | typeof window != 'undefined' &&
3 | /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
4 |
5 | export const IS_FIREFOX =
6 | typeof navigator !== 'undefined' &&
7 | /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
8 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/hiddenLines.ts:
--------------------------------------------------------------------------------
1 | type HiddenRange = [number, number];
2 |
3 | export function flattenHiddenLines(rangeGroups: HiddenRange[]) {
4 | return rangeGroups.flatMap((range) => {
5 | const [start, end] = range;
6 | return [...Array(end - start + 1).keys()].map((elem) => elem + start);
7 | });
8 | }
9 |
10 | export function unflattenHiddenLines(hiddenLines: number[]) {
11 | const rangeGroups: HiddenRange[] = [];
12 | let startNumber = null;
13 |
14 | for (let i = 0; i < hiddenLines.length; i++) {
15 | const prev = hiddenLines[i - 1];
16 | const current = hiddenLines[i];
17 | const next = hiddenLines[i + 1];
18 |
19 | if (current !== prev + 1 && current !== next - 1) {
20 | rangeGroups.push([current, current]);
21 | } else if (current !== prev + 1) {
22 | startNumber = hiddenLines[i];
23 | } else if (current + 1 !== next) {
24 | rangeGroups.push([startNumber!, hiddenLines[i]]);
25 | }
26 | }
27 |
28 | return rangeGroups;
29 | }
30 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useState, DependencyList } from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { useDebounce } from 'react-use';
4 |
5 | import { Dispatch } from 'store';
6 | import { SaveKey } from 'models/collection';
7 |
8 | export function useDebouncedSave(
9 | saveKeys: SaveKey[],
10 | ms: number,
11 | deps: DependencyList,
12 | ) {
13 | const [dirty, setDirty] = useState(false);
14 | const dispatch = useDispatch();
15 |
16 | useDebounce(
17 | () => {
18 | if (dirty) {
19 | dispatch.collection.save({ keys: saveKeys });
20 | }
21 | },
22 | ms,
23 | deps,
24 | );
25 |
26 | return setDirty;
27 | }
28 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/image.ts:
--------------------------------------------------------------------------------
1 | import { IMAGE, PARAGRAPH } from 'editure-constants';
2 | import { Editor, Transforms, getBeforeText } from 'editure';
3 |
4 | import { IEditor } from './editor';
5 |
6 | export const IMAGE_HOSTING_URL = 'https://fc.tuture.co/upload';
7 |
8 | export const insertImage = (editor: IEditor, files: FileList) => {
9 | for (const file of files) {
10 | const reader = new FileReader();
11 | const [mime] = file.type.split('/');
12 |
13 | if (mime === 'image') {
14 | reader.addEventListener('load', () => {
15 | const url = reader.result;
16 |
17 | if (url) {
18 | editor.insertVoid(IMAGE, { url: url.toString(), file });
19 | }
20 | });
21 |
22 | reader.readAsDataURL(file);
23 | }
24 | }
25 | };
26 |
27 | export const uploadImage = (
28 | file: File,
29 | callback: (err?: Error | null, url?: string) => void,
30 | ) => {
31 | const data = new FormData();
32 | data.append('file', file);
33 |
34 | fetch(IMAGE_HOSTING_URL, {
35 | method: 'POST',
36 | body: data,
37 | })
38 | .then((res) => {
39 | if (res.status !== 200) {
40 | throw new Error(res.statusText);
41 | }
42 | return res.json();
43 | })
44 | .then((data) => {
45 | callback(null, data.url);
46 | })
47 | .catch((err: Error) => callback(err));
48 | };
49 |
50 | export const createDropListener = (editor: IEditor) => (e: React.DragEvent) => {
51 | e.preventDefault();
52 | e.persist();
53 |
54 | const { files } = e.dataTransfer;
55 |
56 | if (files.length === 0 || !/\.(png|jpe?g|bmp|gif)$/.test(files[0].name)) {
57 | // No file, or not an image.
58 | return;
59 | }
60 |
61 | insertImage(editor, files);
62 | };
63 |
64 | export const withImages = (editor: IEditor) => {
65 | const { insertData, deleteBackward, isVoid } = editor;
66 |
67 | editor.isVoid = (element) =>
68 | element.type === IMAGE ? true : isVoid(element);
69 |
70 | editor.insertData = (data) => {
71 | const { files } = data;
72 |
73 | if (files && files.length > 0) {
74 | insertImage(editor, files);
75 | } else {
76 | insertData(data);
77 | }
78 | };
79 |
80 | editor.deleteBackward = (unit) => {
81 | const [node] = Editor.nodes(editor, { match: (n) => n.type === IMAGE });
82 |
83 | // Transform into a empty paragraph when trying to delete an image.
84 | if (node) {
85 | Transforms.setNodes(editor, { type: PARAGRAPH });
86 | Transforms.unsetNodes(editor, ['url']);
87 | return;
88 | }
89 |
90 | const { beforeText } = getBeforeText(editor);
91 | const previous = Editor.previous(editor);
92 |
93 | // When the previous element is an image, select it rather than delete it.
94 | if (previous && previous[0].type === IMAGE && !beforeText) {
95 | Transforms.deselect(editor);
96 | return Transforms.select(editor, previous[1]);
97 | }
98 |
99 | deleteBackward(unit);
100 | };
101 |
102 | return editor;
103 | };
104 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/note.ts:
--------------------------------------------------------------------------------
1 | type Level = {
2 | name: string;
3 | border: string;
4 | background: string;
5 | };
6 |
7 | export const levels: { [type: string]: Level } = {
8 | default: { name: '默认', border: '#777', background: '#f7f7f7' },
9 | primary: { name: '主要', border: '#6f42c1', background: '#f5f0fa' },
10 | success: { name: '成功', border: '#5cb85c', background: '#eff8f0' },
11 | info: { name: '提示', border: '#428bca', background: '#eef7fa' },
12 | warning: { name: '注意', border: '#f0ad4e', background: '#fdf8ea' },
13 | danger: { name: '危险', border: '#d9534f', background: '#fcf1f2' },
14 | };
15 |
--------------------------------------------------------------------------------
/packages/editor/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | export async function saveData(data: unknown, url: string) {
2 | try {
3 | const response = await fetch(url, {
4 | method: 'PUT',
5 | headers: {
6 | 'Content-Type': 'application/json',
7 | Accept: 'application/json',
8 | },
9 | body: JSON.stringify(data),
10 | });
11 | return response.ok;
12 | } catch {
13 | return false;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/editor/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "forceConsistentCasingInFileNames": true,
6 | "esModuleInterop": true,
7 | "importHelpers": true,
8 | "module": "esnext",
9 | "outDir": "./build",
10 | "strict": true,
11 | "target": "es2017",
12 | "typeRoots": ["./node_modules/@types"],
13 | "allowJs": true,
14 | "skipLibCheck": true,
15 | "allowSyntheticDefaultImports": true,
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "downlevelIteration": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react"
22 | },
23 | "include": ["src"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/local-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tuture/local-server",
3 | "version": "0.0.7",
4 | "description": "Local server for Tuture",
5 | "main": "dist/index.js",
6 | "module": "dist/index.esm.js",
7 | "types": "dist/index.d.ts",
8 | "files": [
9 | "dist/"
10 | ],
11 | "license": "MIT",
12 | "scripts": {
13 | "collect-editor": "rimraf dist/editor && cp -r ../editor/build dist/editor"
14 | },
15 | "dependencies": {
16 | "@tuture/core": "^0.0.7",
17 | "express": "^4.16.3",
18 | "fs-extra": "^7.0.0",
19 | "lodash.omit": "^4.5.0",
20 | "lodash.pick": "^4.4.0",
21 | "morgan": "^1.9.1",
22 | "simple-git": "^1.129.0"
23 | },
24 | "devDependencies": {
25 | "@types/express": "^4.16.0",
26 | "@types/fs-extra": "^5.0.4",
27 | "@types/lodash.omit": "^4.5.6",
28 | "@types/lodash.pick": "^4.4.6",
29 | "@types/morgan": "^1.7.35"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/local-server/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './utils';
2 | export * from './server';
3 | export * from './types';
4 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/articles.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 |
3 | import TaskQueue from '../utils/task-queue';
4 |
5 | export function createArticlesRouter(queue: TaskQueue) {
6 | const router = Router();
7 |
8 | router.get('/', (_, res) => {
9 | const { articles } = queue.readCollection();
10 | res.json(articles);
11 | });
12 |
13 | router.put('/', (req, res) => {
14 | queue.addTask((c) => ({ ...c, articles: req.body }), 0);
15 | res.sendStatus(200);
16 | });
17 |
18 | router.get('/:articleId', (req, res) => {
19 | const { articleId } = req.params;
20 | const { articles } = queue.readCollection();
21 | const article = articles.filter(({ id }) => articleId === id);
22 |
23 | res.json(article);
24 | });
25 |
26 | router.delete('/:articleId', (req, res) => {
27 | const { articleId } = req.params;
28 |
29 | queue.addTask((c) => {
30 | const { articles, steps } = c;
31 | return {
32 | ...c,
33 | articles: articles.filter((article) => article.id !== articleId),
34 | steps: steps.map((step) =>
35 | step.articleId === articleId ? { ...step, articleId: null } : step,
36 | ),
37 | };
38 | }, 0);
39 |
40 | res.sendStatus(200);
41 | });
42 |
43 | return router;
44 | }
45 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/collection-steps.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import { getHeadings, Article, Collection, Step } from '@tuture/core';
3 |
4 | import TaskQueue from '../utils/task-queue';
5 | import { CollectionStep } from '../types';
6 |
7 | export function createCollectionStepsRouter(queue: TaskQueue) {
8 | const router = Router();
9 |
10 | router.get('/', (_, res) => {
11 | const { steps, articles } = queue.readCollection();
12 |
13 | const getArticleIndexAndName = (articleId: string, articles: Article[]) => {
14 | let targetArticleIndex = 0;
15 | const targetArticle = articles.filter((article, index) => {
16 | if (article.id === articleId) {
17 | targetArticleIndex = index;
18 | return true;
19 | }
20 | return false;
21 | })[0];
22 |
23 | return {
24 | articleIndex: targetArticleIndex,
25 | articleName: targetArticle?.name || '',
26 | };
27 | };
28 |
29 | const collectionSteps = steps.map((step, index) => ({
30 | key: String(index),
31 | id: step.id,
32 | articleId: step.articleId,
33 | title: getHeadings([step])[0].title,
34 | ...getArticleIndexAndName(step.articleId || '', articles),
35 | })) as CollectionStep[];
36 |
37 | res.json(collectionSteps);
38 | });
39 |
40 | router.put('/', (req, res) => {
41 | const { updatedStepsId = [], articleId = '' } = req.body;
42 |
43 | const task = function(collcetion: Collection) {
44 | return {
45 | ...collcetion,
46 | steps: collcetion.steps.map((step) => {
47 | if ((updatedStepsId as string[]).includes(step.id)) {
48 | return { ...step, articleId };
49 | }
50 |
51 | return step;
52 | }),
53 | };
54 | };
55 | queue.addTask({
56 | task,
57 | callback: function() {
58 | res.sendStatus(200);
59 | },
60 | });
61 | });
62 |
63 | return router;
64 | }
65 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/diff.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { Router } from 'express';
4 | import { TUTURE_ROOT, DIFF_PATH } from '@tuture/core';
5 |
6 | const workspace = process.env.TUTURE_PATH || process.cwd();
7 | const diffPath = path.join(workspace, TUTURE_ROOT, DIFF_PATH);
8 |
9 | export const createDiffRouter = () => {
10 | const router = Router();
11 |
12 | router.get('/', (_, res) => {
13 | res.json(JSON.parse(fs.readFileSync(diffPath).toString()));
14 | });
15 |
16 | return router;
17 | };
18 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/fragment.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import { flattenSteps, unflattenSteps, isCommitEqual } from '@tuture/core';
3 | import TaskQueue from '../utils/task-queue';
4 |
5 | export function createFragmentRouter(queue: TaskQueue) {
6 | const router = Router();
7 |
8 | router.get('/', (req, res) => {
9 | const { articles, steps } = queue.readCollection();
10 | const { articleId = articles[0].id } = req.query;
11 |
12 | const fragment = flattenSteps(
13 | steps.filter((step) => step.articleId === articleId),
14 | );
15 |
16 | res.json(fragment);
17 | });
18 |
19 | router.put('/', (req, res) => {
20 | const fragment = req.body;
21 | const updatedSteps = unflattenSteps(fragment);
22 |
23 | queue.addTask((c) => {
24 | const { steps } = c;
25 | return {
26 | ...c,
27 | steps: steps.map(
28 | (step) =>
29 | updatedSteps.filter((node) =>
30 | isCommitEqual(node.commit, step.commit),
31 | )[0] || step,
32 | ),
33 | };
34 | });
35 |
36 | res.json({ success: true });
37 | });
38 |
39 | return router;
40 | }
41 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/index.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import cp from 'child_process';
3 |
4 | import { createArticlesRouter } from './articles';
5 | import { createCollectionStepsRouter } from './collection-steps';
6 | import { createDiffRouter } from './diff';
7 | import { createFragmentRouter } from './fragment';
8 | import { createMetaRouter } from './meta';
9 | import { createRemotesRouter } from './remotes';
10 | import { createTocRouter } from './toc';
11 | import TaskQueue from '../utils/task-queue';
12 |
13 | export function createBaseRouter(queue: TaskQueue) {
14 | const router = Router();
15 |
16 | router.use('/articles', createArticlesRouter(queue));
17 | router.use('/collection-steps', createCollectionStepsRouter(queue));
18 | router.use('/diff', createDiffRouter());
19 | router.use('/fragment', createFragmentRouter(queue));
20 | router.use('/meta', createMetaRouter(queue));
21 | router.use('/remotes', createRemotesRouter(queue));
22 | router.use('/toc', createTocRouter(queue));
23 |
24 | router.get('/sync', async (req, res) => {
25 | cp.execFile('tuture', ['sync'], {}, (err) => {
26 | if (err) {
27 | res.status(500).json({ exitCode: err.code });
28 | } else {
29 | res.sendStatus(200);
30 | }
31 | });
32 | });
33 |
34 | return router;
35 | }
36 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/meta.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import pick from 'lodash.pick';
3 | import { Collection } from '@tuture/core';
4 |
5 | import TaskQueue from '../utils/task-queue';
6 |
7 | function getCollectionMeta(collection: Collection) {
8 | return pick(collection, [
9 | 'name',
10 | 'description',
11 | 'id',
12 | 'created',
13 | 'topics',
14 | 'categories',
15 | 'github',
16 | ]);
17 | }
18 |
19 | export function createMetaRouter(queue: TaskQueue) {
20 | const router = Router();
21 |
22 | router.get('/', (_, res) => {
23 | const collection = queue.readCollection();
24 | res.json(getCollectionMeta(collection));
25 | });
26 |
27 | router.put('/', (req, res) => {
28 | queue.addTask((c) => ({ ...c, ...req.body }), 0);
29 | res.sendStatus(200);
30 | });
31 |
32 | return router;
33 | }
34 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/remotes.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import simplegit from 'simple-git/promise';
3 |
4 | import TaskQueue from '../utils/task-queue';
5 |
6 | export function createRemotesRouter(queue: TaskQueue) {
7 | const router = Router();
8 | const git = simplegit().silent(true);
9 |
10 | router.get('/', (req, res) => {
11 | const { fromGit = false } = req.query;
12 |
13 | if (fromGit) {
14 | git
15 | .getRemotes(true)
16 | .then((remotes) => res.json(remotes))
17 | .catch((err) => res.status(500).json(err));
18 | } else {
19 | const { remotes } = queue.readCollection();
20 | res.json(remotes);
21 | }
22 | });
23 |
24 | router.put('/', (req, res) => {
25 | queue.addTask((c) => ({ ...c, remotes: req.body }), 0);
26 | res.sendStatus(200);
27 | });
28 |
29 | return router;
30 | }
31 |
--------------------------------------------------------------------------------
/packages/local-server/src/routes/toc.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import { getStepTitle } from '@tuture/core';
3 | import pick from 'lodash.pick';
4 |
5 | import { TocStepItem, TocArticleItem, TocItem } from '../types';
6 | import TaskQueue from '../utils/task-queue';
7 |
8 | interface TocUpdateBody {
9 | articleStepList: TocItem[];
10 | unassignedStepList: TocStepItem[];
11 | needDeleteOutdatedStepList: string[];
12 | }
13 |
14 | export function createTocRouter(queue: TaskQueue) {
15 | const router = Router();
16 |
17 | router.get('/', (_, res) => {
18 | const { articles = [], steps = [] } = queue.readCollection();
19 |
20 | const articleStepList = articles.reduce(
21 | (initialArticleStepList, nowArticle) => {
22 | const articleItem: TocArticleItem = {
23 | ...pick(nowArticle, ['id', 'name']),
24 | type: 'article',
25 | level: 0,
26 | };
27 | const stepList: TocStepItem[] = steps
28 | .filter((step) => step.articleId === nowArticle.id)
29 | .map((step) => ({
30 | ...pick(step, ['id', 'articleId', 'outdated']),
31 | type: 'step',
32 | level: 1,
33 | number: steps.findIndex(({ id }) => step.id === id),
34 | name: getStepTitle(step),
35 | }));
36 |
37 | return initialArticleStepList.concat(articleItem, ...stepList);
38 | },
39 | [],
40 | );
41 |
42 | const unassignedStepList = steps
43 | .filter((step) => !step.articleId)
44 | .map((step) => ({
45 | id: step.id,
46 | outdated: step.outdated,
47 | type: 'step',
48 | level: 1,
49 | number: steps.findIndex(({ id }) => step.id === id),
50 | name: getStepTitle(step),
51 | })) as TocStepItem[];
52 |
53 | res.json({ articleStepList, unassignedStepList });
54 | });
55 |
56 | router.put('/', (req, res) => {
57 | const {
58 | articleStepList,
59 | unassignedStepList,
60 | needDeleteOutdatedStepList,
61 | }: TocUpdateBody = req.body;
62 |
63 | queue.addTask((c) => {
64 | let { steps, articles } = c;
65 |
66 | // handle article deletion
67 | const validArticles = articleStepList
68 | .filter((item) => item.type === 'article')
69 | .map((item) => item.id);
70 | articles = articles.filter(({ id }) => validArticles.includes(id));
71 |
72 | // handle step allocation
73 | const nowAllocationStepList = articleStepList.filter(
74 | (item) => item.type === 'step',
75 | ) as TocStepItem[];
76 | const nowAllocationStepIdList = nowAllocationStepList.map(
77 | (item) => item.id,
78 | );
79 | const unassignedStepIdList = unassignedStepList.map((step) => step.id);
80 |
81 | steps = steps
82 | .map((step) => {
83 | if (nowAllocationStepIdList.includes(step.id)) {
84 | step.articleId = nowAllocationStepList.filter(
85 | (item) => item.id === step.id,
86 | )[0].articleId;
87 | }
88 | if (unassignedStepIdList.includes(step.id)) {
89 | step.articleId = null;
90 | }
91 | return step;
92 | })
93 | .filter((step) => !needDeleteOutdatedStepList.includes(step.id));
94 |
95 | return { ...c, articles, steps };
96 | });
97 |
98 | res.sendStatus(200);
99 | });
100 |
101 | return router;
102 | }
103 |
--------------------------------------------------------------------------------
/packages/local-server/src/server.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 | import logger from 'morgan';
4 | import express, { Express } from 'express';
5 |
6 | import { createBaseRouter } from './routes';
7 | import TaskQueue from './utils/task-queue';
8 |
9 | // Editor path
10 | const EDITOR_PATH = path.join(__dirname, 'editor');
11 | const EDITOR_STATIC_PATH = path.join(EDITOR_PATH, 'static');
12 |
13 | export interface ServerOptions {
14 | baseUrl?: string;
15 | mockRoutes?: (app: Express) => void;
16 | onGitHistoryChange?: (curr: fs.Stats, prev: fs.Stats) => void;
17 | }
18 |
19 | export const makeServer = (options?: ServerOptions) => {
20 | const app = express();
21 | const queue = new TaskQueue();
22 | const apiRouter = createBaseRouter(queue);
23 |
24 | // Make sure the task queue is flushed
25 | process.on('exit', () => queue.flush());
26 |
27 | const { mockRoutes, baseUrl = '/', onGitHistoryChange } = options || {};
28 |
29 | // Watch for changes of git master ref if listener is provided.
30 | if (onGitHistoryChange) {
31 | fs.watchFile(
32 | '.git/refs/heads/master',
33 | { interval: 1000 },
34 | onGitHistoryChange,
35 | );
36 | }
37 |
38 | if (process.env.NODE_ENV === 'development') {
39 | app.use(logger('dev'));
40 | }
41 |
42 | app.use(express.json({ limit: '50mb' }));
43 | app.use(express.urlencoded({ extended: false }));
44 | app.use('/static', express.static(EDITOR_STATIC_PATH));
45 |
46 | // Register mocking routes. This will override real routes below.
47 | // (For development purposes only.)
48 | if (mockRoutes) {
49 | mockRoutes(app);
50 | }
51 |
52 | app.use(baseUrl, apiRouter);
53 |
54 | app.get('*', (_, res) => {
55 | const html = fs
56 | .readFileSync(path.join(EDITOR_PATH, 'index.html'))
57 | .toString();
58 | res.send(html);
59 | });
60 |
61 | return app;
62 | };
63 |
--------------------------------------------------------------------------------
/packages/local-server/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface TocArticleItem {
2 | type: 'article';
3 | id: string;
4 | name: string;
5 | level: number;
6 | }
7 |
8 | export interface TocStepItem {
9 | type: 'step';
10 | id: string;
11 | name: string;
12 | level: number;
13 | number: number;
14 | articleId?: string | null;
15 | outdated?: boolean;
16 | }
17 |
18 | export type TocItem = TocArticleItem | TocStepItem;
19 |
20 | export interface CollectionStep {
21 | key: string;
22 | id: string;
23 | title: string;
24 | articleId?: string | null;
25 | articleIndex: number;
26 | articleName: string;
27 | }
28 |
--------------------------------------------------------------------------------
/packages/local-server/src/utils/assets.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 |
4 | import { TUTURE_ROOT, TUTURE_VCS_ROOT, ASSETS_JSON_PATH } from '@tuture/core';
5 |
6 | export interface Asset {
7 | localPath: string;
8 | hostingUri?: string;
9 | }
10 |
11 | export const assetsTablePath = path.join(
12 | process.env.TUTURE_PATH || process.cwd(),
13 | TUTURE_ROOT,
14 | ASSETS_JSON_PATH,
15 | );
16 |
17 | export const assetsTableVcsPath = path.join(
18 | process.env.TUTURE_PATH || process.cwd(),
19 | TUTURE_VCS_ROOT,
20 | ASSETS_JSON_PATH,
21 | );
22 |
23 | /**
24 | * Load assets from tuture-assets.json.
25 | * If not present, return an empty array.
26 | */
27 | export function loadAssetsTable(): Asset[] {
28 | if (fs.existsSync(assetsTablePath)) {
29 | return JSON.parse(fs.readFileSync(assetsTablePath).toString());
30 | }
31 |
32 | return [];
33 | }
34 |
--------------------------------------------------------------------------------
/packages/local-server/src/utils/collection.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs-extra';
2 | import path from 'path';
3 | import {
4 | Collection,
5 | TUTURE_ROOT,
6 | TUTURE_VCS_ROOT,
7 | COLLECTION_PATH,
8 | COLLECTION_CHECKPOINT,
9 | SCHEMA_VERSION,
10 | } from '@tuture/core';
11 |
12 | import { loadAssetsTable } from './assets';
13 |
14 | export const collectionPath = path.join(
15 | process.env.TUTURE_PATH || process.cwd(),
16 | TUTURE_ROOT,
17 | COLLECTION_PATH,
18 | );
19 |
20 | export const collectionCheckpoint = path.join(
21 | process.env.TUTURE_PATH || process.cwd(),
22 | TUTURE_ROOT,
23 | COLLECTION_CHECKPOINT,
24 | );
25 |
26 | export const collectionVcsPath = path.join(
27 | process.env.TUTURE_PATH || process.cwd(),
28 | TUTURE_VCS_ROOT,
29 | COLLECTION_PATH,
30 | );
31 |
32 | /**
33 | * Load collection.
34 | */
35 | export function loadCollection(): Collection {
36 | let rawCollection = fs.readFileSync(collectionPath).toString();
37 | const assetsTable = loadAssetsTable();
38 |
39 | // COMPAT: convert all asset paths
40 | assetsTable.forEach((asset) => {
41 | const { localPath, hostingUri } = asset;
42 | if (hostingUri) {
43 | rawCollection = rawCollection.replace(
44 | new RegExp(localPath, 'g'),
45 | hostingUri,
46 | );
47 | }
48 | });
49 | const collection = JSON.parse(rawCollection);
50 |
51 | if (collection.version !== 'v1') {
52 | const convertHiddenLines = (hiddenLines: number[]) => {
53 | const rangeGroups = [];
54 | let startNumber = null;
55 |
56 | for (let i = 0; i < hiddenLines.length; i++) {
57 | const prev = hiddenLines[i - 1];
58 | const current = hiddenLines[i];
59 | const next = hiddenLines[i + 1];
60 |
61 | if (current !== prev + 1 && current !== next - 1) {
62 | rangeGroups.push([current, current]);
63 | } else if (current !== prev + 1) {
64 | startNumber = hiddenLines[i];
65 | } else if (current + 1 !== next) {
66 | rangeGroups.push([startNumber, hiddenLines[i]]);
67 | }
68 | }
69 |
70 | return rangeGroups;
71 | };
72 |
73 | for (const step of collection.steps) {
74 | for (const node of step.children) {
75 | if (node.type === 'file') {
76 | const diffBlock = node.children[1];
77 | if (diffBlock.hiddenLines) {
78 | diffBlock.hiddenLines = convertHiddenLines(diffBlock.hiddenLines);
79 | }
80 | }
81 | }
82 | }
83 |
84 | collection.version = 'v1';
85 | }
86 |
87 | // COMPAT: normalize children of all diff blocks
88 | for (const step of collection.steps) {
89 | for (const node of step.children) {
90 | if (node.type === 'file') {
91 | const diffBlock = node.children[1];
92 | diffBlock.children = [{ text: '' }];
93 | }
94 | }
95 | }
96 |
97 | return collection;
98 | }
99 |
100 | /**
101 | * Save the entire collection back to workspace.
102 | */
103 | export function saveCollection(collection: Collection) {
104 | collection.version = SCHEMA_VERSION;
105 | fs.writeFileSync(collectionPath, JSON.stringify(collection, null, 2));
106 | }
107 |
108 | export function saveCheckpoint() {
109 | // Copy the last committed file.
110 | fs.copySync(collectionPath, collectionCheckpoint, { overwrite: true });
111 | }
112 |
113 | export function hasCollectionChangedSinceCheckpoint() {
114 | if (!fs.existsSync(collectionCheckpoint)) {
115 | return true;
116 | }
117 | return !fs
118 | .readFileSync(collectionPath)
119 | .equals(fs.readFileSync(collectionCheckpoint));
120 | }
121 |
--------------------------------------------------------------------------------
/packages/local-server/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './assets';
2 | export * from './collection';
3 |
--------------------------------------------------------------------------------
/packages/local-server/src/utils/task-queue.ts:
--------------------------------------------------------------------------------
1 | import { Collection } from '@tuture/core';
2 | import { saveCollection, loadCollection } from './collection';
3 |
4 | export type Task = (c: Collection) => Collection;
5 | export type TaskWithCallback = {
6 | task: (c: Collection) => Collection;
7 | callback: Function;
8 | };
9 |
10 | export default class TaskQueue {
11 | tasks: (Task | TaskWithCallback)[];
12 | flushTimeout: NodeJS.Timeout | null;
13 |
14 | constructor() {
15 | this.tasks = [];
16 | this.flushTimeout = null;
17 | }
18 |
19 | private resetFlushTimeout(ms: number = 1000) {
20 | if (this.flushTimeout) {
21 | clearTimeout(this.flushTimeout);
22 | }
23 |
24 | this.flushTimeout = setTimeout(() => this.flush(), ms);
25 | }
26 |
27 | readCollection() {
28 | return loadCollection();
29 | }
30 |
31 | isEmpty() {
32 | return this.tasks.length === 0;
33 | }
34 |
35 | addTask(task: Task | TaskWithCallback, delay?: number) {
36 | this.tasks.push(task);
37 | this.resetFlushTimeout(delay);
38 | }
39 |
40 | flush() {
41 | let collection = loadCollection();
42 |
43 | while (!this.isEmpty()) {
44 | const task = this.tasks.shift();
45 |
46 | if (!task) break;
47 |
48 | if (typeof task === 'function') {
49 | collection = task(collection);
50 | } else if (typeof task === 'object') {
51 | collection = task.task(collection);
52 | }
53 |
54 | saveCollection(collection);
55 |
56 | if (typeof task === 'object') {
57 | task.callback(collection);
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/packages/local-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ES5",
5 | "rootDir": "./src",
6 | "outDir": "./dist",
7 | "composite": true,
8 | "declaration": true,
9 | "declarationMap": true
10 | },
11 | "references": [{ "path": "../core" }]
12 | }
13 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from '@rollup/plugin-node-resolve';
2 | import json from 'rollup-plugin-json';
3 | import typescript from 'rollup-plugin-typescript2';
4 |
5 | export default [
6 | {
7 | input: 'packages/core/src/index.ts',
8 | output: [
9 | {
10 | file: 'packages/core/dist/index.esm.js',
11 | format: 'esm',
12 | sourcemap: true,
13 | },
14 | {
15 | file: 'packages/core/dist/index.js',
16 | format: 'cjs',
17 | exports: 'named',
18 | sourcemap: true,
19 | },
20 | ],
21 | plugins: [
22 | typescript({
23 | tsconfig: 'packages/core/tsconfig.json',
24 | }),
25 | ],
26 | external: (id) => !id.startsWith('.') && !id.startsWith('/'),
27 | },
28 | {
29 | input: 'packages/local-server/src/index.ts',
30 | output: [
31 | {
32 | file: 'packages/local-server/dist/index.esm.js',
33 | format: 'esm',
34 | sourcemap: true,
35 | },
36 | {
37 | file: 'packages/local-server/dist/index.js',
38 | format: 'cjs',
39 | exports: 'named',
40 | sourcemap: true,
41 | },
42 | ],
43 | plugins: [
44 | resolve({ browser: true }),
45 | json(),
46 | typescript({
47 | tsconfig: 'packages/local-server/tsconfig.json',
48 | }),
49 | ],
50 | external: (id) => !id.startsWith('.') && !id.startsWith('/'),
51 | },
52 | ];
53 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "esModuleInterop": true,
5 | "jsx": "react",
6 | "module": "esnext",
7 | "lib": ["DOM", "ESNext", "ES2019"],
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "strict": true,
11 | "suppressImplicitAnyIndexErrors": true,
12 | "target": "esnext"
13 | },
14 | "references": [
15 | { "path": "./packages/core" },
16 | { "path": "./packages/local-server" }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------