├── .babelrc.cjs
├── .gitignore
├── .nvmrc
├── .yarnrc.yml
├── CHANGELOG.md
├── README.md
├── build.sh
├── package.json
├── patches
├── sucrase+3.35.0+001+add-exports.patch
└── sucrase+3.35.0+002+top-level-scope-tracking.patch
├── rollup.config.js
├── src
├── CodeBlock.tsx
├── Editor.tsx
├── Error.tsx
├── ErrorBoundary.tsx
├── InfoMessage.tsx
├── LineNumber.tsx
├── Preview.tsx
├── Provider.tsx
├── SimpleEditor.tsx
├── highlight.ts
├── index.tsx
├── prism.ts
├── transform
│ ├── ImportTransformer.ts
│ ├── index.ts
│ ├── parser.d.ts
│ ├── parser.js
│ └── wrapLastExpression.ts
├── transpile.ts
└── useTest.ts
├── test
├── Provider.test.tsx
├── setup.ts
├── sucrase.test.ts
├── transpile.test.ts
└── tsconfig.json
├── tsconfig.json
├── vendor
└── sucrase.js
├── vite.config.ts
├── www
├── .gitignore
├── README.md
├── babel.config.js
├── docs
│ └── index.mdx
├── docusaurus.config.js
├── package.json
├── plugins
│ ├── docgen
│ │ ├── doclets.js
│ │ ├── index.js
│ │ └── jsDocHandler.js
│ └── resolve-react.js
├── sidebars.js
├── src
│ ├── css
│ │ └── custom.css
│ └── theme
│ │ ├── CodeBlock.js
│ │ ├── CodeLiveScope.js
│ │ ├── Playground
│ │ ├── index.js
│ │ └── styles.module.css
│ │ └── PropsList
│ │ ├── index.js
│ │ └── styles.module.css
├── static
│ ├── .nojekyll
│ └── img
│ │ ├── favicon.svg
│ │ ├── logo.svg
│ │ └── logo_dark.svg
└── yarn.lock
└── yarn.lock
/.babelrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | return {
3 | presets: [
4 | [
5 | '@babel/preset-env',
6 | {
7 | bugfixes: true,
8 | shippedProposals: true,
9 | // exclude: ['dynamic-import'],
10 | modules: api.env() !== 'esm' ? 'commonjs' : false,
11 | targets: { esmodules: true },
12 | },
13 | ],
14 | ['@babel/preset-react', { runtime: 'automatic' }],
15 | ['@babel/preset-typescript', { allowDeclareFields: true }],
16 | ],
17 | plugins: ['@babel/plugin-syntax-dynamic-import'].filter(Boolean),
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | themes/
2 |
3 | # Created by https://www.gitignore.io/api/node
4 | # Edit at https://www.gitignore.io/?templates=node
5 | lib/
6 | cjs/
7 | public/
8 | .cache/
9 | ### Node ###
10 | # Logs
11 | logs
12 | *.log
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 | lerna-debug.log*
17 | *.tsbuildinfo
18 | .yarn/
19 |
20 | # Diagnostic reports (https://nodejs.org/api/report.html)
21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
22 |
23 | # Runtime data
24 | pids
25 | *.pid
26 | *.seed
27 | *.pid.lock
28 |
29 | # Directory for instrumented libs generated by jscoverage/JSCover
30 | lib-cov
31 |
32 | # Coverage directory used by tools like istanbul
33 | coverage
34 |
35 | # nyc test coverage
36 | .nyc_output
37 |
38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 | bower_components
43 |
44 | # node-waf configuration
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 | build/Release
49 |
50 | # Dependency directories
51 | node_modules/
52 | jspm_packages/
53 |
54 | # TypeScript v1 declaration files
55 | typings/
56 |
57 | # Optional npm cache directory
58 | .npm
59 |
60 | # Optional eslint cache
61 | .eslintcache
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 | .env.test
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 |
79 | # next.js build output
80 | .next
81 |
82 | # nuxt.js build output
83 | .nuxt
84 |
85 | # vuepress build output
86 | .vuepress/dist
87 |
88 | # Serverless directories
89 | .serverless/
90 |
91 | # FuseBox cache
92 | .fusebox/
93 |
94 | # DynamoDB Local files
95 | .dynamodb/
96 |
97 | # End of https://www.gitignore.io/api/node
98 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22.12.0
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [2.1.1](https://github.com/jquense/jarle/compare/v2.1.0...v2.1.1) (2023-01-18)
2 |
3 |
4 | ### Bug Fixes
5 |
6 | * don't render invalid code ([1a2cfe1](https://github.com/jquense/jarle/commit/1a2cfe165a1943ddf71d19a87eac66dadccee6aa))
7 |
8 |
9 |
10 |
11 |
12 | # [2.1.0](https://github.com/jquense/jarle/compare/v2.0.0...v2.1.0) (2023-01-18)
13 |
14 |
15 | ### Features
16 |
17 | * support named exports, and upgrade sucrase ([3031638](https://github.com/jquense/jarle/commit/3031638c51f67ede9cfe43c351ea4871ca76c473))
18 |
19 |
20 |
21 |
22 |
23 | # [2.0.0](https://github.com/jquense/jarle/compare/v2.0.0-beta.1...v2.0.0) (2022-01-18)
24 |
25 |
26 |
27 |
28 |
29 | # [1.3.0](https://github.com/jquense/jarle/compare/v1.2.2...v1.3.0) (2021-10-27)
30 |
31 |
32 | ### Features
33 |
34 | * use latest for acorn ecma target ([562f36e](https://github.com/jquense/jarle/commit/562f36e053accbcfa354217e6f9be7b12bb922b8))
35 |
36 |
37 |
38 |
39 |
40 | ## [1.2.2](https://github.com/jquense/jarle/compare/v1.2.1...v1.2.2) (2021-08-06)
41 |
42 |
43 | ### Bug Fixes
44 |
45 | * setState after unmount ([9063cf5](https://github.com/jquense/jarle/commit/9063cf5959ab40c8ccb0af3351f4a087cf48e6aa))
46 |
47 |
48 |
49 |
50 |
51 | ## [1.2.1](https://github.com/jquense/jarle/compare/v1.2.0...v1.2.1) (2021-08-06)
52 |
53 |
54 | ### Bug Fixes
55 |
56 | * peer dep ([474e084](https://github.com/jquense/jarle/commit/474e0842a8b67cf028bcedcb74956c116a9d28ec))
57 |
58 |
59 |
60 |
61 |
62 | # [1.2.0](https://github.com/jquense/jarle/compare/v1.1.2...v1.2.0) (2021-03-31)
63 |
64 |
65 | ### Features
66 |
67 | * bump deps ([98c525c](https://github.com/jquense/jarle/commit/98c525cb91744547a9956e086988c70d386a8915))
68 |
69 |
70 |
71 |
72 |
73 | ## [1.1.2](https://github.com/jquense/jarle/compare/v1.1.0...v1.1.2) (2021-02-02)
74 |
75 |
76 | ### Bug Fixes
77 |
78 | * @4c/tsconfig to dev dependencies ([#8](https://github.com/jquense/jarle/issues/8)) ([55582c8](https://github.com/jquense/jarle/commit/55582c842a8a77c9caa9c5526ff9345f87181916))
79 | * default import ([a63fad9](https://github.com/jquense/jarle/commit/a63fad971261c40b09bde999cd0c917056e63e54))
80 | * **Preview:** allow props to override defaults ([#5](https://github.com/jquense/jarle/issues/5)) ([3e3e3a7](https://github.com/jquense/jarle/commit/3e3e3a77b6611d0b7c759199833bc9a26c939f51)), closes [#4](https://github.com/jquense/jarle/issues/4)
81 |
82 |
83 |
84 |
85 |
86 | ## [1.1.1](https://github.com/jquense/jarle/compare/v1.1.0...v1.1.1) (2021-02-01)
87 |
88 |
89 | ### Bug Fixes
90 |
91 | * @4c/tsconfig to dev dependencies ([#8](https://github.com/jquense/jarle/issues/8)) ([55582c8](https://github.com/jquense/jarle/commit/55582c842a8a77c9caa9c5526ff9345f87181916))
92 | * **Preview:** allow props to override defaults ([#5](https://github.com/jquense/jarle/issues/5)) ([3e3e3a7](https://github.com/jquense/jarle/commit/3e3e3a77b6611d0b7c759199833bc9a26c939f51)), closes [#4](https://github.com/jquense/jarle/issues/4)
93 |
94 |
95 |
96 |
97 |
98 | # [1.1.0](https://github.com/jquense/jarle/compare/v1.0.3...v1.1.0) (2021-01-22)
99 |
100 |
101 | ### Features
102 |
103 | * allow component returns and export default ([1000f48](https://github.com/jquense/jarle/commit/1000f48271f0a9424c9e00b27323b5a89e99de42))
104 |
105 |
106 |
107 |
108 |
109 | ## [1.0.3](https://github.com/jquense/jarle/compare/v1.0.2...v1.0.3) (2021-01-12)
110 |
111 |
112 | ### Bug Fixes
113 |
114 | * removing imports ([b1d6473](https://github.com/jquense/jarle/commit/b1d647376b118204c9e37852f140ebc5674db9a0))
115 |
116 |
117 |
118 |
119 |
120 | ## [1.0.2](https://github.com/jquense/jarle/compare/v1.0.1...v1.0.2) (2021-01-12)
121 |
122 |
123 | ### Bug Fixes
124 |
125 | * remove unused deps ([81f20ab](https://github.com/jquense/jarle/commit/81f20ab0abecd477dac76a53acfd3013af13d23c))
126 |
127 |
128 |
129 |
130 |
131 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
J.A.R.L.E
2 |
3 | Just Another React Live Editor
4 |
5 |
6 | JARLE is a as lightweight but feature-rich React component editor with live
7 | preview. JARLE uses [sucrase](https://github.com/alangpierce/sucrase) for fast, minimal
8 | compilation of JSX and/or Typescript.
9 |
10 | ## Usage
11 |
12 | ```js
13 | import { Provider, Editor, Error, Preview } from 'jarle';
14 |
15 | function LiveEditor() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 | ```
25 |
26 | See **https://jquense.github.io/jarle/** for docs.
27 |
28 | ### Rendering Code
29 |
30 | Jarle removes boilerplate code in your examples, by rendering the **last** expression in your code block.
31 | Define variables, components, whatever, as long as the last line is a JSX expression.
32 |
33 | ```js
34 | Hello {subject}
38 | }
39 |
40 |
41 | `}
42 | />
43 | ```
44 |
45 | If you do need more control over what get's rendered, or need to render asynchronously, a
46 | `render` function is always in scope:
47 |
48 | ```js
49 | setTimeout(() => {
50 | render(I'm late!
);
51 | }, 1000);
52 | ```
53 |
54 | Jarle also supports rendering your code _as a component_, helpful for illustrating
55 | hooks or render behavior with minimal extra code. When using `renderAsComponent`
56 | the code text is used as the body of React function component, so you can use
57 | state and other hooks.
58 |
59 | ```js
60 | {
66 | let interval = setInterval(() => {
67 | setSeconds(prev => prev + 1)
68 | }, 1000)
69 |
70 | return () => clearInterval(interval)
71 | }, [])
72 |
73 | Seconds past: {secondsPast}
74 | `}
75 | />
76 | ```
77 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 |
4 | echo "Build parser"
5 | yarn patch-package
6 | yarn rollup -c rollup.config.js
7 |
8 | echo "Build library"
9 | yarn babel src --out-dir lib --delete-dir-on-start -x .ts,.tsx,.js,.mjs --env-name esm
10 | yarn babel src --out-dir cjs --delete-dir-on-start -x .ts,.tsx,.js,.mjs --env-name cjs
11 |
12 | echo "replace import placeholder"
13 | sed -i '' 's/__IMPORT__/(s) => import(\/* webpackIgnore: true \*\/ \/\* @vite-ignore \*\/ s)/' ./{lib,cjs}/Provider.js
14 |
15 | echo "{ \"type\": \"commonjs\" }" > ./cjs/package.json
16 |
17 | echo "Generate types"
18 | yarn tsc -p . --emitDeclarationOnly --declaration --outDir lib
19 | yarn tsc -p . --emitDeclarationOnly --declaration --outDir cjs
20 |
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jarle",
3 | "version": "3.1.1",
4 | "repository": {
5 | "type": "git",
6 | "url": "https://github.com/jquense/jarle.git"
7 | },
8 | "author": "Jason Quense ",
9 | "license": "MIT",
10 | "scripts": {
11 | "build": "./build.sh",
12 | "prepublishOnly": "yarn run build",
13 | "release": "4c release",
14 | "tdd": "vitest",
15 | "test": "vitest run"
16 | },
17 | "files": [
18 | "cjs",
19 | "lib"
20 | ],
21 | "type": "module",
22 | "exports": {
23 | ".": {
24 | "require": {
25 | "types": "./cjs/index.d.ts",
26 | "default": "./cjs/index.js"
27 | },
28 | "import": {
29 | "types": "./lib/index.d.ts",
30 | "default": "./lib/index.js"
31 | }
32 | },
33 | "./*": {
34 | "require": {
35 | "types": "./cjs/*.d.ts",
36 | "default": "./cjs/*.js"
37 | },
38 | "import": {
39 | "types": "./lib/*.d.ts",
40 | "default": "./lib/*.js"
41 | }
42 | }
43 | },
44 | "publishConfig": {
45 | "access": "public"
46 | },
47 | "prettier": {
48 | "singleQuote": true
49 | },
50 | "devDependencies": {
51 | "@4c/cli": "^3.0.1",
52 | "@babel/cli": "^7.24.7",
53 | "@babel/core": "^7.24.7",
54 | "@babel/preset-env": "^7.24.7",
55 | "@babel/preset-react": "^7.24.7",
56 | "@babel/preset-typescript": "^7.24.7",
57 | "@rollup/plugin-commonjs": "^21.0.1",
58 | "@rollup/plugin-node-resolve": "^13.0.6",
59 | "@testing-library/dom": "^10.4.0",
60 | "@testing-library/react": "^16.2.0",
61 | "@types/react": "^19.0.12",
62 | "@types/react-dom": "^19.0.4",
63 | "@types/react-is": "^19.0.0",
64 | "cpy": "^8.1.2",
65 | "glob": "^7.2.0",
66 | "jsdom": "^24.1.0",
67 | "patch-package": "^8.0.0",
68 | "react": "^19.1.0",
69 | "react-dom": "^19.1.0",
70 | "react-is": "^19.1.0",
71 | "rollup": "^2.59.0",
72 | "rollup-plugin-dts": "^4.0.1",
73 | "sucrase": "^3.35.0",
74 | "typescript": "^5.4.5",
75 | "vitest": "^1.6.0"
76 | },
77 | "dependencies": {
78 | "@restart/hooks": "^0.6.2",
79 | "prism-react-renderer": "^2.4.1",
80 | "sourcemap-codec": "^1.4.8"
81 | },
82 | "peerDependencies": {
83 | "react": ">=18.3.1",
84 | "react-dom": ">=18.3.1",
85 | "react-is": ">=18.3.1"
86 | },
87 | "packageManager": "yarn@4.8.1+sha512.bc946f2a022d7a1a38adfc15b36a66a3807a67629789496c3714dd1703d2e6c6b1c69ff9ec3b43141ac7a1dd853b7685638eb0074300386a59c18df351ef8ff6"
88 | }
89 |
--------------------------------------------------------------------------------
/patches/sucrase+3.35.0+001+add-exports.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/sucrase/dist/esm/index.js b/node_modules/sucrase/dist/esm/index.js
2 | index 41a7aa0..61b1d3e 100644
3 | --- a/node_modules/sucrase/dist/esm/index.js
4 | +++ b/node_modules/sucrase/dist/esm/index.js
5 | @@ -131,3 +131,5 @@ function getSucraseContext(code, options) {
6 | }
7 | return {tokenProcessor, scopes, nameManager, importProcessor, helperManager};
8 | }
9 | +
10 | +export { RootTransformer, getSucraseContext }
11 | diff --git a/node_modules/sucrase/dist/index.js b/node_modules/sucrase/dist/index.js
12 | index 6395245..8f12513 100644
13 | --- a/node_modules/sucrase/dist/index.js
14 | +++ b/node_modules/sucrase/dist/index.js
15 | @@ -131,3 +131,6 @@ function getSucraseContext(code, options) {
16 | }
17 | return {tokenProcessor, scopes, nameManager, importProcessor, helperManager};
18 | }
19 | +
20 | +exports.getSucraseContext = getSucraseContext;
21 | +exports.RootTransformer = _RootTransformer2.default
22 |
--------------------------------------------------------------------------------
/patches/sucrase+3.35.0+002+top-level-scope-tracking.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/sucrase/dist/esm/parser/traverser/statement.js b/node_modules/sucrase/dist/esm/parser/traverser/statement.js
2 | index 34a6511..4f19dcc 100644
3 | --- a/node_modules/sucrase/dist/esm/parser/traverser/statement.js
4 | +++ b/node_modules/sucrase/dist/esm/parser/traverser/statement.js
5 | @@ -89,7 +89,7 @@ import {
6 | } from "./util";
7 |
8 | export function parseTopLevel() {
9 | - parseBlockBody(tt.eof);
10 | + parseBlockBody(tt.eof, true);
11 | state.scopes.push(new Scope(0, state.tokens.length, true));
12 | if (state.scopeDepth !== 0) {
13 | throw new Error(`Invalid scope depth at end of file: ${state.scopeDepth}`);
14 | @@ -104,7 +104,7 @@ export function parseTopLevel() {
15 | // `if (foo) /blah/.exec(foo)`, where looking at the previous token
16 | // does not help.
17 |
18 | -export function parseStatement(declaration) {
19 | +export function parseStatement(declaration, isTopLevel) {
20 | if (isFlowEnabled) {
21 | if (flowTryParseStatement()) {
22 | return;
23 | @@ -113,10 +113,10 @@ export function parseStatement(declaration) {
24 | if (match(tt.at)) {
25 | parseDecorators();
26 | }
27 | - parseStatementContent(declaration);
28 | + parseStatementContent(declaration, isTopLevel);
29 | }
30 |
31 | -function parseStatementContent(declaration) {
32 | +function parseStatementContent(declaration, isTopLevel) {
33 | if (isTypeScriptEnabled) {
34 | if (tsTryParseStatementContent()) {
35 | return;
36 | @@ -124,6 +124,7 @@ function parseStatementContent(declaration) {
37 | }
38 |
39 | const starttype = state.type;
40 | + const startTokenIndex = state.tokens.length
41 |
42 | // Most types of statements are recognized by the keyword they
43 | // start with. Many are trivial to parse, some require a bit of
44 | @@ -147,11 +148,13 @@ function parseStatementContent(declaration) {
45 | if (lookaheadType() === tt.dot) break;
46 | if (!declaration) unexpected();
47 | parseFunctionStatement();
48 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
49 | return;
50 |
51 | case tt._class:
52 | if (!declaration) unexpected();
53 | parseClass(true);
54 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
55 | return;
56 |
57 | case tt._if:
58 | @@ -159,9 +162,11 @@ function parseStatementContent(declaration) {
59 | return;
60 | case tt._return:
61 | parseReturnStatement();
62 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
63 | return;
64 | case tt._switch:
65 | parseSwitchStatement();
66 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
67 | return;
68 | case tt._throw:
69 | parseThrowStatement();
70 | @@ -247,11 +252,15 @@ function parseStatementContent(declaration) {
71 | simpleName = token.contextualKeyword;
72 | }
73 | }
74 | +
75 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
76 | +
77 | if (simpleName == null) {
78 | semicolon();
79 | return;
80 | }
81 | if (eat(tt.colon)) {
82 | + state.tokens[startTokenIndex].isTopLevel = isTopLevel;
83 | parseLabeledStatement();
84 | } else {
85 | // This was an identifier, so we might want to handle flow/typescript-specific cases.
86 | @@ -581,9 +590,9 @@ export function parseBlock(isFunctionScope = false, contextId = 0) {
87 | state.scopeDepth--;
88 | }
89 |
90 | -export function parseBlockBody(end) {
91 | +export function parseBlockBody(end, isTopLevel) {
92 | while (!eat(end) && !state.error) {
93 | - parseStatement(true);
94 | + parseStatement(true, isTopLevel);
95 | }
96 | }
97 |
98 | diff --git a/node_modules/sucrase/dist/parser/traverser/statement.js b/node_modules/sucrase/dist/parser/traverser/statement.js
99 | index 6be3391..255fbb5 100644
100 | --- a/node_modules/sucrase/dist/parser/traverser/statement.js
101 | +++ b/node_modules/sucrase/dist/parser/traverser/statement.js
102 | @@ -89,7 +89,7 @@ var _lval = require('./lval');
103 | var _util = require('./util');
104 |
105 | function parseTopLevel() {
106 | - parseBlockBody(_types.TokenType.eof);
107 | + parseBlockBody(_types.TokenType.eof, true);
108 | _base.state.scopes.push(new (0, _state.Scope)(0, _base.state.tokens.length, true));
109 | if (_base.state.scopeDepth !== 0) {
110 | throw new Error(`Invalid scope depth at end of file: ${_base.state.scopeDepth}`);
111 | @@ -104,7 +104,7 @@ var _util = require('./util');
112 | // `if (foo) /blah/.exec(foo)`, where looking at the previous token
113 | // does not help.
114 |
115 | - function parseStatement(declaration) {
116 | + function parseStatement(declaration, isTopLevel) {
117 | if (_base.isFlowEnabled) {
118 | if (_flow.flowTryParseStatement.call(void 0, )) {
119 | return;
120 | @@ -113,10 +113,10 @@ var _util = require('./util');
121 | if (_tokenizer.match.call(void 0, _types.TokenType.at)) {
122 | parseDecorators();
123 | }
124 | - parseStatementContent(declaration);
125 | + parseStatementContent(declaration, isTopLevel);
126 | } exports.parseStatement = parseStatement;
127 |
128 | -function parseStatementContent(declaration) {
129 | +function parseStatementContent(declaration, isTopLevel) {
130 | if (_base.isTypeScriptEnabled) {
131 | if (_typescript.tsTryParseStatementContent.call(void 0, )) {
132 | return;
133 | @@ -124,6 +124,7 @@ function parseStatementContent(declaration) {
134 | }
135 |
136 | const starttype = _base.state.type;
137 | + const startTokenIndex = _base.state.tokens.length
138 |
139 | // Most types of statements are recognized by the keyword they
140 | // start with. Many are trivial to parse, some require a bit of
141 | @@ -147,11 +148,13 @@ function parseStatementContent(declaration) {
142 | if (_tokenizer.lookaheadType.call(void 0, ) === _types.TokenType.dot) break;
143 | if (!declaration) _util.unexpected.call(void 0, );
144 | parseFunctionStatement();
145 | + _base.state.tokens[startTokenIndex].isTopLevel = isTopLevel;
146 | return;
147 |
148 | case _types.TokenType._class:
149 | if (!declaration) _util.unexpected.call(void 0, );
150 | parseClass(true);
151 | + _base.state.tokens[startTokenIndex].isTopLevel = isTopLevel;
152 | return;
153 |
154 | case _types.TokenType._if:
155 | @@ -159,6 +162,7 @@ function parseStatementContent(declaration) {
156 | return;
157 | case _types.TokenType._return:
158 | parseReturnStatement();
159 | + _base.state.tokens[startTokenIndex].isTopLevel = isTopLevel;
160 | return;
161 | case _types.TokenType._switch:
162 | parseSwitchStatement();
163 | @@ -210,6 +214,7 @@ function parseStatementContent(declaration) {
164 | if (_tokenizer.match.call(void 0, _types.TokenType._function) && !_util.canInsertSemicolon.call(void 0, )) {
165 | _util.expect.call(void 0, _types.TokenType._function);
166 | parseFunction(functionStart, true);
167 | + _base.state.tokens[startTokenIndex].isTopLevel = isTopLevel;
168 | return;
169 | } else {
170 | _base.state.restoreFromSnapshot(snapshot);
171 | @@ -247,11 +252,15 @@ function parseStatementContent(declaration) {
172 | simpleName = token.contextualKeyword;
173 | }
174 | }
175 | +
176 | + _base.state.tokens[startTokenIndex].isTopLevel = isTopLevel;
177 | +
178 | if (simpleName == null) {
179 | _util.semicolon.call(void 0, );
180 | return;
181 | }
182 | if (_tokenizer.eat.call(void 0, _types.TokenType.colon)) {
183 | + _base.state.tokens[startTokenIndex].isTopLevel = false;
184 | parseLabeledStatement();
185 | } else {
186 | // This was an identifier, so we might want to handle flow/typescript-specific cases.
187 | @@ -581,9 +590,9 @@ function parseIdentifierStatement(contextualKeyword) {
188 | _base.state.scopeDepth--;
189 | } exports.parseBlock = parseBlock;
190 |
191 | - function parseBlockBody(end) {
192 | + function parseBlockBody(end, isTopLevel) {
193 | while (!_tokenizer.eat.call(void 0, end) && !_base.state.error) {
194 | - parseStatement(true);
195 | + parseStatement(true, isTopLevel);
196 | }
197 | } exports.parseBlockBody = parseBlockBody;
198 |
199 | diff --git a/node_modules/sucrase/dist/types/parser/tokenizer/index.d.ts b/node_modules/sucrase/dist/types/parser/tokenizer/index.d.ts
200 | index 45cd799..66b2480 100644
201 | --- a/node_modules/sucrase/dist/types/parser/tokenizer/index.d.ts
202 | +++ b/node_modules/sucrase/dist/types/parser/tokenizer/index.d.ts
203 | @@ -51,6 +51,7 @@ export declare class Token {
204 | isOptionalChainEnd: boolean;
205 | subscriptStartIndex: number | null;
206 | nullishStartIndex: number | null;
207 | + isTopLevel?: boolean;
208 | }
209 | export declare function next(): void;
210 | export declare function nextTemplateToken(): void;
211 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 |
4 | export default [
5 | {
6 | input: 'vendor/sucrase.js',
7 | output: {
8 | file: 'src/transform/parser.js',
9 | format: 'es',
10 | },
11 | plugins: [
12 | resolve(), // so Rollup can find `ms`
13 | commonjs(), // so Rollup can convert `ms` to an ES module
14 | ],
15 | },
16 | ];
17 |
--------------------------------------------------------------------------------
/src/CodeBlock.tsx:
--------------------------------------------------------------------------------
1 | import { Highlight, Language, Prism, PrismTheme } from 'prism-react-renderer';
2 | import React from 'react';
3 | import LineNumber from './LineNumber.js';
4 | import { LineOutputProps, RenderProps } from './prism.js';
5 |
6 | type MapTokens = RenderProps & {
7 | getLineNumbers?: (line: number) => React.ReactNode;
8 | errorLocation?: { line: number; col: number };
9 | };
10 |
11 | function addErrorHighlight(
12 | props: LineOutputProps,
13 | index: number,
14 | errorLocation?: MapTokens['errorLocation']
15 | ) {
16 | if (index + 1 === errorLocation?.line) {
17 | props.className = `${props.className || ''} token-line-error`;
18 | }
19 | return props;
20 | }
21 |
22 | export const mapTokens = ({
23 | tokens,
24 | getLineProps,
25 | getTokenProps,
26 | errorLocation,
27 | getLineNumbers,
28 | }: MapTokens) => (
29 | <>
30 | {tokens.map((line, i) => {
31 | const { key = i, ...lineProps } = getLineProps({ line, key: String(i) });
32 |
33 | return (
34 |
35 | {getLineNumbers?.(i + 1)}
36 | {line.map((token, ii) => {
37 | const { key = ii, ...props } = getTokenProps({
38 | token,
39 | key: String(ii),
40 | });
41 |
42 | return ;
43 | })}
44 |
45 | );
46 | })}
47 | >
48 | );
49 |
50 | interface Props {
51 | className?: string;
52 | style?: any;
53 | theme?: PrismTheme;
54 | code: string;
55 | language: Language;
56 | lineNumbers?: boolean;
57 | }
58 |
59 | function CodeBlock({ code, theme, language, lineNumbers, ...props }: Props) {
60 | const style = typeof theme?.plain === 'object' ? theme.plain : {};
61 |
62 | const getLineNumbers = lineNumbers
63 | ? (num: number) => {num}
64 | : undefined;
65 |
66 | return (
67 |
68 | {(hl) => (
69 |
73 | {mapTokens({ ...hl, getLineNumbers })}
74 |
75 | )}
76 |
77 | );
78 | }
79 |
80 | export default CodeBlock;
81 |
--------------------------------------------------------------------------------
/src/Editor.tsx:
--------------------------------------------------------------------------------
1 | import { Highlight, Prism, type PrismTheme } from 'prism-react-renderer';
2 | import React, {
3 | useCallback,
4 | useEffect,
5 | useLayoutEffect,
6 | useMemo,
7 | useRef,
8 | useState,
9 | } from 'react';
10 | import SimpleCodeEditor from './SimpleEditor.js';
11 |
12 | import { mapTokens } from './CodeBlock.js';
13 | import InfoMessage from './InfoMessage.js';
14 | import { useLiveContext } from './Provider.js';
15 | import LineNumber from './LineNumber.js';
16 |
17 | let uid = 0;
18 |
19 | function useStateFromProp(prop: TProp) {
20 | const state = useState(prop);
21 | const firstRef = useRef(true);
22 |
23 | useMemo(() => {
24 | if (firstRef.current) {
25 | firstRef.current = false;
26 | return;
27 | }
28 | state[1](prop);
29 | }, [prop]);
30 |
31 | return state;
32 | }
33 |
34 | function useInlineStyle() {
35 | useEffect(() => {
36 | if (document.getElementById('__jarle-style-tag')) {
37 | return;
38 | }
39 |
40 | const style = document.createElement('style');
41 | document.head.append(style);
42 | style.setAttribute('id', '__jarle-style-tag');
43 | style.sheet!.insertRule(`
44 | .__jarle {
45 | display: grid;
46 | position: relative;
47 | grid-template-columns: auto 1fr;
48 | }
49 | `);
50 | style.sheet!.insertRule(`
51 | .__jarle pre,
52 | .__jarle textarea {
53 | overflow: visible;
54 | }
55 | `);
56 | }, []);
57 | }
58 | export interface Props {
59 | className?: string;
60 |
61 | style?: any;
62 |
63 | /** A Prism theme object, can also be specified on the Provider */
64 | theme?: PrismTheme;
65 |
66 | /** Render line numbers */
67 | lineNumbers?: boolean;
68 |
69 | /** Styles the info component so that it is not visible but still accessible by screen readers. */
70 | infoSrOnly?: boolean;
71 |
72 | /** The component used to render A11y messages about keyboard navigation, override to customize the styling */
73 | infoComponent?: React.ComponentType;
74 | }
75 |
76 | /**
77 | * The Editor is the code text editor component, some props can be supplied directly
78 | * or take from the Provider context if available.
79 | */
80 | const Editor = React.forwardRef(
81 | (
82 | {
83 | style,
84 | className,
85 | theme,
86 | infoComponent: Info = InfoMessage,
87 | lineNumbers,
88 | infoSrOnly = false,
89 | }: Props,
90 | ref: any
91 | ) => {
92 | const {
93 | code: contextCode,
94 | theme: contextTheme,
95 | language,
96 | onChange,
97 | error,
98 | } = useLiveContext();
99 | const userTheme = theme || contextTheme;
100 | const [code, setCode] = useStateFromProp(contextCode);
101 |
102 | const mouseDown = useRef(false);
103 |
104 | useInlineStyle();
105 |
106 | useLayoutEffect(() => {
107 | onChange(code || '');
108 | }, [code, onChange]);
109 |
110 | const [{ visible, ignoreTab, keyboardFocused }, setState] = useState({
111 | visible: false,
112 | ignoreTab: false,
113 | keyboardFocused: false,
114 | });
115 |
116 | const id = useMemo(() => `described-by-${++uid}`, []);
117 |
118 | const handleKeyDown = (event: React.KeyboardEvent) => {
119 | const { key } = event;
120 |
121 | if (ignoreTab && key !== 'Tab' && key !== 'Shift') {
122 | if (key === 'Enter') event.preventDefault();
123 | setState(prev => ({ ...prev, ignoreTab: false }));
124 | }
125 | if (!ignoreTab && key === 'Escape') {
126 | setState(prev => ({ ...prev, ignoreTab: true }));
127 | }
128 | };
129 |
130 | const handleFocus = (e: React.FocusEvent) => {
131 | if (e.target !== e.currentTarget) return;
132 | setState({
133 | visible: true,
134 | ignoreTab: !mouseDown.current,
135 | keyboardFocused: !mouseDown.current,
136 | });
137 | };
138 |
139 | const handleBlur = (e: React.FocusEvent) => {
140 | if (e.target !== e.currentTarget) return;
141 | setState(prev => ({
142 | ...prev,
143 | visible: false,
144 | }));
145 | };
146 |
147 | const handleMouseDown = () => {
148 | mouseDown.current = true;
149 | setTimeout(() => {
150 | mouseDown.current = false;
151 | });
152 | };
153 |
154 | const errorLocation = error?.location || error?.loc;
155 | const highlight = useCallback(
156 | (value: string) => (
157 |
163 | {(hl) =>
164 | mapTokens({
165 | ...hl,
166 | errorLocation,
167 | getLineNumbers: (line: number) =>
168 | lineNumbers ? (
169 |
177 | {line}
178 |
179 | ) : null,
180 | })
181 | }
182 |
183 | ),
184 | [userTheme, lineNumbers, language, errorLocation]
185 | );
186 |
187 | const baseTheme = {
188 | whiteSpace: 'pre',
189 | fontFamily: 'monospace',
190 | ...(userTheme?.plain || {}),
191 | ...style,
192 | };
193 |
194 | return (
195 |
200 |
201 | {/*
202 | These line numbers are visually hidden in order to dynamically create enough space for the numbers.
203 | The visible numbers are added to the actual lines, and absolutely positioned to the left into the same space.
204 | this allows for soft wrapping lines as well as not changing the dimensions of the `pre` tag to keep
205 | the syntax highlighting synced with the textarea.
206 | */}
207 | {lineNumbers &&
208 | (code || '').split(/\n/g).map((_, i) => (
209 |
215 | {i + 1}
216 |
217 | ))}
218 |
219 |
232 | {visible && (keyboardFocused || !ignoreTab) && (
233 |
234 | {ignoreTab ? (
235 | <>
236 | Press enter or type a key to enable tab-to-indent
237 | >
238 | ) : (
239 | <>
240 | Press esc to disable tab-to-indent
241 | >
242 | )}
243 |
244 | )}
245 |
246 | );
247 | }
248 | );
249 |
250 | export default Editor;
251 |
--------------------------------------------------------------------------------
/src/Error.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useError } from './Provider.js';
4 |
5 | /**
6 | * Displays an syntax or runtime error that occurred when rendering the code
7 | */
8 | export default function Error(props: React.HTMLProps) {
9 | const error = useError();
10 |
11 | return error ? {error.toString()} : null;
12 | }
13 |
--------------------------------------------------------------------------------
/src/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from 'react';
2 | import { Context } from './Provider.js';
3 |
4 | interface Props {
5 | element: ReactElement | null;
6 | showLastValid: boolean;
7 | }
8 |
9 | type State = { hasError?: boolean; element?: ReactElement | null };
10 |
11 | class CodeLiveErrorBoundary extends React.Component {
12 | declare context: React.ContextType;
13 |
14 | lastGoodResult: ReactElement | null = null;
15 |
16 | constructor(props: Props) {
17 | super(props);
18 |
19 | this.state = {
20 | hasError: false,
21 | element: props.element,
22 | };
23 | }
24 |
25 | static getDerivedStateFromError() {
26 | return { hasError: true };
27 | }
28 |
29 | static getDerivedStateFromProps(props: Props, state: State) {
30 | if (props.element === state.element) {
31 | return state;
32 | }
33 |
34 | return { hasError: false, element: props.element };
35 | }
36 |
37 | componentDidCatch(error: Error) {
38 | this.context.onError(error);
39 | }
40 |
41 | componentDidMount() {
42 | if (!this.state.hasError) {
43 | this.lastGoodResult = this.props.element;
44 | }
45 | }
46 |
47 | componentDidUpdate() {
48 | if (!this.state.hasError) {
49 | this.lastGoodResult = this.props.element;
50 | }
51 | }
52 |
53 | render() {
54 | if (this.state.hasError) {
55 | // You can render any custom fallback UI
56 | return this.props.showLastValid ? this.lastGoodResult : null;
57 | }
58 |
59 | return this.props.element;
60 | }
61 | }
62 |
63 | CodeLiveErrorBoundary.contextType = Context;
64 |
65 | export default CodeLiveErrorBoundary;
66 |
--------------------------------------------------------------------------------
/src/InfoMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const srOnlyStyle = {
4 | clip: 'rect(1px, 1px, 1px, 1px)',
5 | clipPath: 'inset(50%)',
6 | height: 1,
7 | width: 1,
8 | margin: -1,
9 | overflow: 'hidden',
10 | padding: 0,
11 | };
12 | export default function InfoMessage({
13 | srOnly = false,
14 | ...props
15 | }: React.HTMLProps & { srOnly?: boolean }) {
16 | return (
17 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/LineNumber.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const lineNumberStyle = {
4 | textAlign: 'right',
5 | userSelect: 'none',
6 | pointerEvents: 'none',
7 | paddingRight: 12,
8 | } as const;
9 |
10 | function LineNumber({ children, className, theme = true, style }: any) {
11 | return (
12 |
25 | {children}
26 |
27 | );
28 | }
29 |
30 | export default LineNumber;
31 |
--------------------------------------------------------------------------------
/src/Preview.tsx:
--------------------------------------------------------------------------------
1 | import ErrorBoundary from './ErrorBoundary.js';
2 | import { useElement, useError } from './Provider.js';
3 |
4 | /**
5 | * The component that renders the user's code.
6 | */
7 | const Preview = ({
8 | className,
9 | showLastValid = true,
10 | preventLinks = true,
11 | ...props
12 | }: {
13 | className?: string;
14 | /**
15 | * Whether an error should reset the preview to an empty state or keep showing the last valid code result.
16 | */
17 | showLastValid?: boolean;
18 | /**
19 | * Prevent links from navigating when clicked.
20 | */
21 | preventLinks?: boolean;
22 | }) => {
23 | const element = useElement();
24 | const error = useError();
25 |
26 | // prevent links in examples from navigating
27 | const handleClick = (e: any) => {
28 | if (preventLinks && (e.target.tagName === 'A' || e.target.closest('a')))
29 | e.preventDefault();
30 | };
31 |
32 | const previewProps = {
33 | role: 'region',
34 | 'aria-label': 'Code Example',
35 | ...props,
36 | };
37 |
38 | return !showLastValid && error ? null : (
39 | // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
40 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default Preview;
47 |
--------------------------------------------------------------------------------
/src/Provider.tsx:
--------------------------------------------------------------------------------
1 | import useEventCallback from '@restart/hooks/useEventCallback';
2 | import useMounted from '@restart/hooks/useMounted';
3 | import type { PrismTheme } from 'prism-react-renderer';
4 | import React, {
5 | ReactNode,
6 | useContext,
7 | useEffect,
8 | useMemo,
9 | useState,
10 | isValidElement,
11 | createElement,
12 | useCallback,
13 | JSX,
14 | } from 'react';
15 | import { isValidElementType } from 'react-is';
16 | // import { decode } from 'sourcemap-codec';
17 | import { transform } from './transform/index.js';
18 |
19 | // try and match render() calls with arguments to avoid false positives with class components
20 | const hasRenderCall = (code: string) => !!code.match(/render\((?!\s*\))/gm);
21 |
22 | const prettierComment =
23 | /(\{\s*\/\*\s+prettier-ignore\s+\*\/\s*\})|(\/\/\s+prettier-ignore)/gim;
24 |
25 | const hooks = {};
26 | Object.entries(React).forEach(([key, value]) => {
27 | if (key.startsWith('use')) hooks[key] = value;
28 | });
29 |
30 | export type LiveError = Error & {
31 | location?: { line: number; col: number };
32 | loc?: { line: number; col: number };
33 | };
34 |
35 | export const isTypeScriptEnabled = (language?: string) => {
36 | if (!language) return false;
37 | const lower = language.toLowerCase();
38 | return lower === 'typescript' || lower === 'tsx' || lower === 'ts';
39 | };
40 |
41 | export interface LiveContext {
42 | code?: string;
43 | language?: string;
44 | theme?: PrismTheme;
45 | disabled?: boolean;
46 | error: LiveError | null;
47 | element: JSX.Element | null;
48 | onChange(code: string): void;
49 | onError(error: Error): void;
50 | }
51 |
52 | export const Context = React.createContext({} as any);
53 |
54 | const getRequire = (imports?: Record) =>
55 | function require(request: string) {
56 | if (!imports) throw new Error('no imports');
57 | if (!(request in imports)) throw new Error(`Module not found: ${request}`);
58 | const obj = imports[request];
59 | return obj && (obj.__esModule || obj[Symbol.toStringTag] === 'Module')
60 | ? obj
61 | : { default: obj };
62 | };
63 |
64 | function handleError(err: any, fn: Function): LiveError {
65 | const fnStr = fn.toString();
66 | // account for the function chrome lines
67 | const offset = fnStr.slice(0, fnStr.indexOf('{')).split(/\n/).length;
68 |
69 | let pos;
70 | if ('line' in err) {
71 | pos = { line: err.line, column: err.column };
72 | } else if ('lineNumber' in err) {
73 | pos = { line: err.lineNumber - 1, column: err.columnNumber - 1 };
74 | } else {
75 | const [, line, col] = err.stack?.match(
76 | /at eval.+:(\d+):(\d+)/m
77 | )!;
78 | pos = { line: +line - 1, column: +col - 1 };
79 | }
80 | if (!pos) return err;
81 |
82 | // if (result.map) {
83 | // const decoded = decode(result.map.mappings);
84 |
85 | // const line = pos.line - offset;
86 | // const mapping = decoded[line]?.find(([col]) => col === pos.column);
87 |
88 | // if (mapping) {
89 | // err.location = { line: mapping[2], column: mapping[3] };
90 | // }
91 | // }
92 |
93 | return err;
94 | }
95 |
96 | interface CodeToComponentOptions {
97 | scope?: S;
98 | exportToRender?: string;
99 | renderAsComponent?: boolean;
100 | preample?: string;
101 | isTypeScript?: boolean;
102 | }
103 |
104 | function codeToComponent(
105 | compiledCode: string,
106 | {
107 | scope,
108 | preample,
109 | exportToRender,
110 | renderAsComponent = false,
111 | }: CodeToComponentOptions
112 | ): Promise {
113 | return new Promise((resolve, reject) => {
114 | const isInline = !hasRenderCall(compiledCode);
115 |
116 | if (renderAsComponent && !isInline) {
117 | throw new Error(
118 | 'Code using `render()` cannot use top level hooks. ' +
119 | 'Either provide your own stateful component, or return a jsx element directly.'
120 | );
121 | }
122 |
123 | const render = (element: React.ReactElement) => {
124 | if (element === undefined) {
125 | reject(new SyntaxError('`render()` was called without a JSX element'));
126 | return;
127 | }
128 |
129 | resolve(element);
130 | };
131 |
132 | // DU NA NA NAAAH
133 | const finalScope = { ...hooks, ...scope };
134 | const exports: Record = {};
135 |
136 | const args = ['React', 'render', 'exports'].concat(Object.keys(finalScope));
137 | const values = [React, render, exports].concat(Object.values(finalScope));
138 |
139 | let body = compiledCode;
140 |
141 | if (renderAsComponent) {
142 | body = `return React.createElement(function StateContainer() {\n${body}\n})`;
143 | }
144 |
145 | if (preample) body = `${preample}\n\n${body}`;
146 |
147 | // eslint-disable-next-line no-new-func
148 | const fn = new Function(...args, body);
149 |
150 | let element: any;
151 | try {
152 | element = fn(...values);
153 | } catch (err) {
154 | reject(handleError(err, fn));
155 | return;
156 | }
157 |
158 | const exportedValues = Object.values(exports);
159 |
160 | if ('default' in exports) {
161 | element = exports.default ?? element;
162 | } else if (exportedValues.length) {
163 | element = exports[exportToRender!] ?? exportedValues[0] ?? element;
164 | }
165 |
166 | if (element === undefined) {
167 | if (isInline) {
168 | reject(new SyntaxError('The code did not return a JSX element'));
169 | }
170 | return;
171 | }
172 | if (!isValidElement(element)) {
173 | if (isValidElementType(element)) {
174 | element = createElement(element);
175 | } else if (isInline) {
176 | reject(
177 | new SyntaxError(
178 | 'The code did not return a valid React element or element type'
179 | )
180 | );
181 | }
182 | }
183 |
184 | resolve(element);
185 | });
186 | }
187 |
188 | export type ImportResolver = (
189 | requests: string[]
190 | ) => Promise | any[]>;
191 |
192 | export interface Props {
193 | /**
194 | * A string of code to render
195 | */
196 | code: string;
197 |
198 | /**
199 | * The named export of the code that JARLE should attempt to render if present
200 | */
201 | exportToRender?: string;
202 |
203 | /** A context object of values automatically available for use in editor code */
204 | scope?: TScope;
205 |
206 | /** Render subcomponents */
207 | children?: ReactNode;
208 |
209 | /** A Prism language string for selecting a grammar for syntax highlighting */
210 | language?: string;
211 |
212 | /** A Prism theme object, leave empty to not use a theme or use a traditional CSS theme. */
213 | theme?: PrismTheme;
214 |
215 | /** Whether the import statements in the initial `code` text are shown to the user or not. */
216 | showImports?: boolean;
217 |
218 | /**
219 | * Creates a react component using the code text as it's body. This allows
220 | * using top level hooks in your example without having to create and return your
221 | * own component. Cannot be used with `render()` in the example.
222 | *
223 | * ```jsx
224 | * import Button from './Button'
225 | *
226 | * const [active, setActive] = useState()
227 | *
228 | * setActive(true)}/>
229 | * ```
230 | */
231 | renderAsComponent?: boolean;
232 |
233 | /**
234 | * A function that maps an array of import requests to modules, may return a promise.
235 | *
236 | * ```ts
237 | * const resolveImports = (requests) =>
238 | * Promise.all(requests.map(req => import(req)))
239 | * ```
240 | *
241 | * Or an object hash of import requests to the result
242 | *
243 | * ```ts
244 | * const resolveImports = () => ({
245 | * './foo': Foo
246 | * })
247 | * ```
248 | * @default (requests) => Promise.all(requests.map(req => import(req)))
249 | */
250 | resolveImports?: ImportResolver;
251 | }
252 |
253 | export function useLiveContext() {
254 | return useContext(Context);
255 | }
256 |
257 | export function useElement() {
258 | return useLiveContext().element;
259 | }
260 |
261 | export function useError() {
262 | return useLiveContext().error;
263 | }
264 |
265 | interface State {
266 | element: React.ReactElement | null;
267 | }
268 |
269 | export const objectZip = (
270 | arr: T[],
271 | arr2: U[]
272 | ): Record => Object.fromEntries(arr.map((v, i) => [v, arr2[i]]));
273 |
274 | function defaultResolveImports(sources) {
275 | // @ts-ignore
276 | return Promise.all(sources.map(__IMPORT__));
277 | }
278 |
279 | function useCompiledCode(
280 | consumerCode: string,
281 | showImports: boolean,
282 | isTypeScript: boolean,
283 | setError: any
284 | ) {
285 | const compile = useCallback(
286 | (nextCode) => {
287 | const isInline = !hasRenderCall(nextCode);
288 |
289 | nextCode = nextCode.replace(prettierComment, '').trim();
290 |
291 | return transform(nextCode, {
292 | compiledFilename: 'compiled.js',
293 | filename: 'example.js',
294 | wrapLastExpression: isInline,
295 | syntax: isTypeScript ? 'typescript' : 'js',
296 | transforms: isTypeScript
297 | ? ['typescript', 'imports', 'jsx']
298 | : ['imports', 'jsx'],
299 | });
300 | },
301 | [isTypeScript]
302 | );
303 |
304 | const initialResult = useMemo(() => {
305 | const nextCode = consumerCode.replace(prettierComment, '').trim();
306 | const emptyResponse = { code: nextCode, imports: [] };
307 |
308 | if (showImports) {
309 | return { code: nextCode, imports: [] };
310 | }
311 |
312 | try {
313 | return transform(nextCode, {
314 | syntax: isTypeScript ? 'typescript' : 'js',
315 | compiledFilename: 'compiled.js',
316 | filename: 'example.js',
317 | removeImports: true,
318 | transforms: [],
319 | });
320 | } catch (error) {
321 | setError(error);
322 | return { code: nextCode, imports: [] };
323 | }
324 | }, [consumerCode, compile, setError, showImports]);
325 |
326 | return [
327 | {
328 | compiledCode: showImports
329 | ? initialResult.code
330 | : initialResult.code.trimStart(),
331 | removedImports: showImports ? [] : initialResult.imports,
332 | },
333 | compile,
334 | ] as const;
335 | }
336 |
337 | /**
338 | * The Provider supplies the context to the other components as well as handling
339 | * jsx transpilation and import resolution.
340 | */
341 | export default function Provider({
342 | scope,
343 | children,
344 | code: rawCode,
345 | language,
346 | theme,
347 | exportToRender,
348 | showImports = true,
349 | renderAsComponent = false,
350 | resolveImports = defaultResolveImports,
351 | }: Props) {
352 | const isMounted = useMounted();
353 | const [error, setError] = useState(null);
354 | const [{ element }, setState] = useState({ element: null });
355 | const isTypeScript = isTypeScriptEnabled(language);
356 |
357 | const [initialResult, compile] = useCompiledCode(
358 | rawCode,
359 | showImports,
360 | isTypeScript,
361 | setError
362 | );
363 | const initialCompiledCode = initialResult.compiledCode;
364 |
365 | const handleChange = useEventCallback((nextCode: string) => {
366 | try {
367 | const { code: compiledCode, imports } = compile(nextCode);
368 |
369 | const sources = Array.from(
370 | new Set(
371 | [...initialResult.removedImports, ...imports].map((i) => i.source)
372 | )
373 | );
374 |
375 | Promise.resolve(resolveImports(sources))
376 | .then((results) =>
377 | Array.isArray(results) ? objectZip(sources, results) : results
378 | )
379 | .then((fetchedImports) =>
380 | codeToComponent(compiledCode, {
381 | renderAsComponent,
382 | isTypeScript,
383 | exportToRender,
384 | // also include the orginal imports if they were removed
385 | preample: initialResult.removedImports
386 | .map((i) => i.code)
387 | .join('\n')
388 | .trimStart(),
389 | scope: {
390 | ...scope,
391 | require: getRequire(fetchedImports),
392 | },
393 | })
394 | )
395 | .then(
396 | (element) => {
397 | if (!isMounted()) return;
398 |
399 | setState({ element });
400 | setError(null);
401 | },
402 | (err) => {
403 | if (!isMounted()) return;
404 | setError(err);
405 | }
406 | );
407 | } catch (err: any) {
408 | setError(err);
409 | }
410 | });
411 |
412 | useEffect(() => {
413 | handleChange(initialCompiledCode);
414 | }, [initialCompiledCode, scope, handleChange]);
415 |
416 | const context = useMemo(
417 | () => ({
418 | theme,
419 | error,
420 | element,
421 | language,
422 | code: initialCompiledCode,
423 | onError: setError,
424 | onChange: handleChange,
425 | }),
426 | [initialCompiledCode, element, error, handleChange, language, theme]
427 | );
428 |
429 | return {children} ;
430 | }
431 |
--------------------------------------------------------------------------------
/src/SimpleEditor.tsx:
--------------------------------------------------------------------------------
1 | // Vendored react-simple-code-editor
2 | // https://github.com/react-simple-code-editor/react-simple-code-editor/blob/main/LICENSE.md
3 |
4 | import React, {
5 | useRef,
6 | useState,
7 | useCallback,
8 | useEffect,
9 | useImperativeHandle,
10 | } from 'react';
11 |
12 | type Padding = T | { top?: T; right?: T; bottom?: T; left?: T };
13 |
14 | type Props = React.HTMLAttributes & {
15 | // Props for the component
16 | highlight: (value: string) => string | React.ReactNode;
17 | ignoreTabKey?: boolean;
18 | insertSpaces?: boolean;
19 | onValueChange: (value: string) => void;
20 | padding?: Padding;
21 | style?: React.CSSProperties;
22 | tabSize?: number;
23 | value: string;
24 |
25 | // Props for the textarea
26 | autoFocus?: boolean;
27 | disabled?: boolean;
28 | form?: string;
29 | maxLength?: number;
30 | minLength?: number;
31 | name?: string;
32 | onBlur?: React.FocusEventHandler;
33 | onClick?: React.MouseEventHandler;
34 | onFocus?: React.FocusEventHandler;
35 | onKeyDown?: React.KeyboardEventHandler;
36 | onKeyUp?: React.KeyboardEventHandler;
37 | placeholder?: string;
38 | readOnly?: boolean;
39 | required?: boolean;
40 | textareaClassName?: string;
41 | textareaId?: string;
42 |
43 | // Props for the hightlighted code’s pre element
44 | preClassName?: string;
45 | };
46 |
47 | type Record = {
48 | value: string;
49 | selectionStart: number;
50 | selectionEnd: number;
51 | };
52 |
53 | type History = {
54 | stack: (Record & { timestamp: number })[];
55 | offset: number;
56 | };
57 |
58 | const KEYCODE_Y = 89;
59 | const KEYCODE_Z = 90;
60 | const KEYCODE_M = 77;
61 | const KEYCODE_PARENS = 57;
62 | const KEYCODE_BRACKETS = 219;
63 | const KEYCODE_QUOTE = 222;
64 | const KEYCODE_BACK_QUOTE = 192;
65 |
66 | const HISTORY_LIMIT = 100;
67 | const HISTORY_TIME_GAP = 3000;
68 |
69 | const isWindows =
70 | typeof window !== 'undefined' &&
71 | 'navigator' in window &&
72 | /Win/i.test(navigator.platform);
73 | const isMacLike =
74 | typeof window !== 'undefined' &&
75 | 'navigator' in window &&
76 | /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
77 |
78 | const className = 'npm__react-simple-code-editor__textarea';
79 |
80 | const cssText = /* CSS */ `
81 | /**
82 | * Reset the text fill color so that placeholder is visible
83 | */
84 | .${className}:empty {
85 | -webkit-text-fill-color: inherit !important;
86 | }
87 |
88 | /**
89 | * Hack to apply on some CSS on IE10 and IE11
90 | */
91 | @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
92 | /**
93 | * IE doesn't support '-webkit-text-fill-color'
94 | * So we use 'color: transparent' to make the text transparent on IE
95 | * Unlike other browsers, it doesn't affect caret color in IE
96 | */
97 | .${className} {
98 | color: transparent !important;
99 | }
100 |
101 | .${className}::selection {
102 | background-color: #accef7 !important;
103 | color: transparent !important;
104 | }
105 | }
106 | `;
107 |
108 | const SimpleEditor = React.forwardRef(function Editor(
109 | props: Props,
110 | ref: React.Ref
111 | ) {
112 | const {
113 | autoFocus,
114 | disabled,
115 | form,
116 | highlight,
117 | ignoreTabKey = false,
118 | insertSpaces = true,
119 | maxLength,
120 | minLength,
121 | name,
122 | onBlur,
123 | onClick,
124 | onFocus,
125 | onKeyDown,
126 | onKeyUp,
127 | onValueChange,
128 | padding = 0,
129 | placeholder,
130 | preClassName,
131 | readOnly,
132 | required,
133 | style,
134 | tabSize = 2,
135 | textareaClassName,
136 | textareaId,
137 | value,
138 | ...rest
139 | } = props;
140 |
141 | const historyRef = useRef({
142 | stack: [],
143 | offset: -1,
144 | });
145 | const inputRef = useRef(null);
146 | const [capture, setCapture] = useState(true);
147 | const contentStyle = {
148 | paddingTop: typeof padding === 'object' ? padding.top : padding,
149 | paddingRight: typeof padding === 'object' ? padding.right : padding,
150 | paddingBottom: typeof padding === 'object' ? padding.bottom : padding,
151 | paddingLeft: typeof padding === 'object' ? padding.left : padding,
152 | };
153 | const highlighted = highlight(value);
154 |
155 | const getLines = (text: string, position: number) =>
156 | text.substring(0, position).split('\n');
157 |
158 | const recordChange = useCallback(
159 | (record: Record, overwrite: boolean = false) => {
160 | const { stack, offset } = historyRef.current;
161 |
162 | if (stack.length && offset > -1) {
163 | // When something updates, drop the redo operations
164 | historyRef.current.stack = stack.slice(0, offset + 1);
165 |
166 | // Limit the number of operations to 100
167 | const count = historyRef.current.stack.length;
168 |
169 | if (count > HISTORY_LIMIT) {
170 | const extras = count - HISTORY_LIMIT;
171 |
172 | historyRef.current.stack = stack.slice(extras, count);
173 | historyRef.current.offset = Math.max(
174 | historyRef.current.offset - extras,
175 | 0
176 | );
177 | }
178 | }
179 |
180 | const timestamp = Date.now();
181 |
182 | if (overwrite) {
183 | const last = historyRef.current.stack[historyRef.current.offset];
184 |
185 | if (last && timestamp - last.timestamp < HISTORY_TIME_GAP) {
186 | // A previous entry exists and was in short interval
187 |
188 | // Match the last word in the line
189 | const re = /[^a-z0-9]([a-z0-9]+)$/i;
190 |
191 | // Get the previous line
192 | const previous = getLines(last.value, last.selectionStart)
193 | .pop()
194 | ?.match(re);
195 |
196 | // Get the current line
197 | const current = getLines(record.value, record.selectionStart)
198 | .pop()
199 | ?.match(re);
200 |
201 | if (previous?.[1] && current?.[1]?.startsWith(previous[1])) {
202 | // The last word of the previous line and current line match
203 | // Overwrite previous entry so that undo will remove whole word
204 | historyRef.current.stack[historyRef.current.offset] = {
205 | ...record,
206 | timestamp,
207 | };
208 |
209 | return;
210 | }
211 | }
212 | }
213 |
214 | // Add the new operation to the stack
215 | historyRef.current.stack.push({ ...record, timestamp });
216 | historyRef.current.offset++;
217 | },
218 | []
219 | );
220 |
221 | const recordCurrentState = useCallback(() => {
222 | const input = inputRef.current;
223 |
224 | if (!input) return;
225 |
226 | // Save current state of the input
227 | const { value, selectionStart, selectionEnd } = input;
228 |
229 | recordChange({
230 | value,
231 | selectionStart,
232 | selectionEnd,
233 | });
234 | }, [recordChange]);
235 |
236 | const updateInput = (record: Record) => {
237 | const input = inputRef.current;
238 |
239 | if (!input) return;
240 |
241 | // Update values and selection state
242 | input.value = record.value;
243 | input.selectionStart = record.selectionStart;
244 | input.selectionEnd = record.selectionEnd;
245 |
246 | onValueChange?.(record.value);
247 | };
248 |
249 | const applyEdits = (record: Record) => {
250 | // Save last selection state
251 | const input = inputRef.current;
252 | const last = historyRef.current.stack[historyRef.current.offset];
253 |
254 | if (last && input) {
255 | historyRef.current.stack[historyRef.current.offset] = {
256 | ...last,
257 | selectionStart: input.selectionStart,
258 | selectionEnd: input.selectionEnd,
259 | };
260 | }
261 |
262 | // Save the changes
263 | recordChange(record);
264 | updateInput(record);
265 | };
266 |
267 | const undoEdit = () => {
268 | const { stack, offset } = historyRef.current;
269 |
270 | // Get the previous edit
271 | const record = stack[offset - 1];
272 |
273 | if (record) {
274 | // Apply the changes and update the offset
275 | updateInput(record);
276 | historyRef.current.offset = Math.max(offset - 1, 0);
277 | }
278 | };
279 |
280 | const redoEdit = () => {
281 | const { stack, offset } = historyRef.current;
282 |
283 | // Get the next edit
284 | const record = stack[offset + 1];
285 |
286 | if (record) {
287 | // Apply the changes and update the offset
288 | updateInput(record);
289 | historyRef.current.offset = Math.min(offset + 1, stack.length - 1);
290 | }
291 | };
292 |
293 | const handleKeyDown = (e: React.KeyboardEvent) => {
294 | if (onKeyDown) {
295 | onKeyDown(e);
296 |
297 | if (e.defaultPrevented) {
298 | return;
299 | }
300 | }
301 |
302 | if (e.key === 'Escape') {
303 | e.currentTarget.blur();
304 | }
305 |
306 | const { value, selectionStart, selectionEnd } = e.currentTarget;
307 |
308 | const tabCharacter = (insertSpaces ? ' ' : '\t').repeat(tabSize);
309 |
310 | if (e.key === 'Tab' && !ignoreTabKey && capture) {
311 | // Prevent focus change
312 | e.preventDefault();
313 |
314 | if (e.shiftKey) {
315 | // Unindent selected lines
316 | const linesBeforeCaret = getLines(value, selectionStart);
317 | const startLine = linesBeforeCaret.length - 1;
318 | const endLine = getLines(value, selectionEnd).length - 1;
319 | const nextValue = value
320 | .split('\n')
321 | .map((line, i) => {
322 | if (
323 | i >= startLine &&
324 | i <= endLine &&
325 | line.startsWith(tabCharacter)
326 | ) {
327 | return line.substring(tabCharacter.length);
328 | }
329 |
330 | return line;
331 | })
332 | .join('\n');
333 |
334 | if (value !== nextValue) {
335 | const startLineText = linesBeforeCaret[startLine];
336 |
337 | applyEdits({
338 | value: nextValue,
339 | // Move the start cursor if first line in selection was modified
340 | // It was modified only if it started with a tab
341 | selectionStart: startLineText?.startsWith(tabCharacter)
342 | ? selectionStart - tabCharacter.length
343 | : selectionStart,
344 | // Move the end cursor by total number of characters removed
345 | selectionEnd: selectionEnd - (value.length - nextValue.length),
346 | });
347 | }
348 | } else if (selectionStart !== selectionEnd) {
349 | // Indent selected lines
350 | const linesBeforeCaret = getLines(value, selectionStart);
351 | const startLine = linesBeforeCaret.length - 1;
352 | const endLine = getLines(value, selectionEnd).length - 1;
353 | const startLineText = linesBeforeCaret[startLine];
354 |
355 | applyEdits({
356 | value: value
357 | .split('\n')
358 | .map((line, i) => {
359 | if (i >= startLine && i <= endLine) {
360 | return tabCharacter + line;
361 | }
362 |
363 | return line;
364 | })
365 | .join('\n'),
366 | // Move the start cursor by number of characters added in first line of selection
367 | // Don't move it if it there was no text before cursor
368 | selectionStart:
369 | startLineText && /\S/.test(startLineText)
370 | ? selectionStart + tabCharacter.length
371 | : selectionStart,
372 | // Move the end cursor by total number of characters added
373 | selectionEnd:
374 | selectionEnd + tabCharacter.length * (endLine - startLine + 1),
375 | });
376 | } else {
377 | const updatedSelection = selectionStart + tabCharacter.length;
378 |
379 | applyEdits({
380 | // Insert tab character at caret
381 | value:
382 | value.substring(0, selectionStart) +
383 | tabCharacter +
384 | value.substring(selectionEnd),
385 | // Update caret position
386 | selectionStart: updatedSelection,
387 | selectionEnd: updatedSelection,
388 | });
389 | }
390 | } else if (e.key === 'Backspace') {
391 | const hasSelection = selectionStart !== selectionEnd;
392 | const textBeforeCaret = value.substring(0, selectionStart);
393 |
394 | if (textBeforeCaret.endsWith(tabCharacter) && !hasSelection) {
395 | // Prevent default delete behaviour
396 | e.preventDefault();
397 |
398 | const updatedSelection = selectionStart - tabCharacter.length;
399 |
400 | applyEdits({
401 | // Remove tab character at caret
402 | value:
403 | value.substring(0, selectionStart - tabCharacter.length) +
404 | value.substring(selectionEnd),
405 | // Update caret position
406 | selectionStart: updatedSelection,
407 | selectionEnd: updatedSelection,
408 | });
409 | }
410 | } else if (e.key === 'Enter') {
411 | // Ignore selections
412 | if (selectionStart === selectionEnd) {
413 | // Get the current line
414 | const line = getLines(value, selectionStart).pop();
415 | const matches = line?.match(/^\s+/);
416 |
417 | if (matches?.[0]) {
418 | e.preventDefault();
419 |
420 | // Preserve indentation on inserting a new line
421 | const indent = '\n' + matches[0];
422 | const updatedSelection = selectionStart + indent.length;
423 |
424 | applyEdits({
425 | // Insert indentation character at caret
426 | value:
427 | value.substring(0, selectionStart) +
428 | indent +
429 | value.substring(selectionEnd),
430 | // Update caret position
431 | selectionStart: updatedSelection,
432 | selectionEnd: updatedSelection,
433 | });
434 | }
435 | }
436 | } else if (
437 | e.keyCode === KEYCODE_PARENS ||
438 | e.keyCode === KEYCODE_BRACKETS ||
439 | e.keyCode === KEYCODE_QUOTE ||
440 | e.keyCode === KEYCODE_BACK_QUOTE
441 | ) {
442 | let chars;
443 |
444 | if (e.keyCode === KEYCODE_PARENS && e.shiftKey) {
445 | chars = ['(', ')'];
446 | } else if (e.keyCode === KEYCODE_BRACKETS) {
447 | if (e.shiftKey) {
448 | chars = ['{', '}'];
449 | } else {
450 | chars = ['[', ']'];
451 | }
452 | } else if (e.keyCode === KEYCODE_QUOTE) {
453 | if (e.shiftKey) {
454 | chars = ['"', '"'];
455 | } else {
456 | chars = ["'", "'"];
457 | }
458 | } else if (e.keyCode === KEYCODE_BACK_QUOTE && !e.shiftKey) {
459 | chars = ['`', '`'];
460 | }
461 |
462 | // If text is selected, wrap them in the characters
463 | if (selectionStart !== selectionEnd && chars) {
464 | e.preventDefault();
465 |
466 | applyEdits({
467 | value:
468 | value.substring(0, selectionStart) +
469 | chars[0] +
470 | value.substring(selectionStart, selectionEnd) +
471 | chars[1] +
472 | value.substring(selectionEnd),
473 | // Update caret position
474 | selectionStart,
475 | selectionEnd: selectionEnd + 2,
476 | });
477 | }
478 | } else if (
479 | (isMacLike
480 | ? // Trigger undo with ⌘+Z on Mac
481 | e.metaKey && e.keyCode === KEYCODE_Z
482 | : // Trigger undo with Ctrl+Z on other platforms
483 | e.ctrlKey && e.keyCode === KEYCODE_Z) &&
484 | !e.shiftKey &&
485 | !e.altKey
486 | ) {
487 | e.preventDefault();
488 |
489 | undoEdit();
490 | } else if (
491 | (isMacLike
492 | ? // Trigger redo with ⌘+Shift+Z on Mac
493 | e.metaKey && e.keyCode === KEYCODE_Z && e.shiftKey
494 | : isWindows
495 | ? // Trigger redo with Ctrl+Y on Windows
496 | e.ctrlKey && e.keyCode === KEYCODE_Y
497 | : // Trigger redo with Ctrl+Shift+Z on other platforms
498 | e.ctrlKey && e.keyCode === KEYCODE_Z && e.shiftKey) &&
499 | !e.altKey
500 | ) {
501 | e.preventDefault();
502 |
503 | redoEdit();
504 | } else if (
505 | e.keyCode === KEYCODE_M &&
506 | e.ctrlKey &&
507 | (isMacLike ? e.shiftKey : true)
508 | ) {
509 | e.preventDefault();
510 |
511 | // Toggle capturing tab key so users can focus away
512 | setCapture((prev) => !prev);
513 | }
514 | };
515 |
516 | const handleChange = (e: React.ChangeEvent) => {
517 | const { value, selectionStart, selectionEnd } = e.currentTarget;
518 |
519 | recordChange(
520 | {
521 | value,
522 | selectionStart,
523 | selectionEnd,
524 | },
525 | true
526 | );
527 |
528 | onValueChange(value);
529 | };
530 |
531 | useEffect(() => {
532 | recordCurrentState();
533 | }, [recordCurrentState]);
534 |
535 | useImperativeHandle(
536 | ref,
537 | () => {
538 | return {
539 | get session() {
540 | return {
541 | history: historyRef.current,
542 | };
543 | },
544 | set session(session: { history: History }) {
545 | historyRef.current = session.history;
546 | },
547 | };
548 | },
549 | []
550 | );
551 |
552 | return (
553 |
554 |
' } }
560 | : { children: highlighted })}
561 | />
562 |
595 | {/* eslint-disable-next-line react/no-danger */}
596 |
597 |
598 | );
599 | });
600 |
601 | const styles = {
602 | container: {
603 | position: 'relative',
604 | textAlign: 'left',
605 | boxSizing: 'border-box',
606 | padding: 0,
607 | overflow: 'hidden',
608 | },
609 | textarea: {
610 | position: 'absolute',
611 | top: 0,
612 | left: 0,
613 | height: '100%',
614 | width: '100%',
615 | resize: 'none',
616 | color: 'inherit',
617 | overflow: 'hidden',
618 | MozOsxFontSmoothing: 'grayscale',
619 | WebkitFontSmoothing: 'antialiased',
620 | WebkitTextFillColor: 'transparent',
621 | },
622 | highlight: {
623 | position: 'relative',
624 | pointerEvents: 'none',
625 | },
626 | editor: {
627 | margin: 0,
628 | border: 0,
629 | background: 'none',
630 | boxSizing: 'inherit',
631 | display: 'inherit',
632 | fontFamily: 'inherit',
633 | fontSize: 'inherit',
634 | fontStyle: 'inherit',
635 | fontVariantLigatures: 'inherit',
636 | fontWeight: 'inherit',
637 | letterSpacing: 'inherit',
638 | lineHeight: 'inherit',
639 | tabSize: 'inherit',
640 | textIndent: 'inherit',
641 | textRendering: 'inherit',
642 | textTransform: 'inherit',
643 | whiteSpace: 'pre-wrap',
644 | wordBreak: 'keep-all',
645 | overflowWrap: 'break-word',
646 | },
647 | } as const;
648 |
649 | export default SimpleEditor;
650 |
--------------------------------------------------------------------------------
/src/highlight.ts:
--------------------------------------------------------------------------------
1 | import { Prism } from 'prism-react-renderer'
2 |
3 | export default (code: string, language?: string) => {
4 | const grammar = language && Prism.languages[language]
5 |
6 | return grammar ? Prism.highlight(code, grammar, language as any) : code
7 | }
8 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { Prism, themes } from 'prism-react-renderer';
2 |
3 | import CodeBlock from './CodeBlock.js';
4 | import Editor from './Editor.js';
5 | import Error from './Error.js';
6 | import InfoMessage from './InfoMessage.js';
7 | import Preview from './Preview.js';
8 | import Provider, {
9 | ImportResolver as _ImportResolver,
10 | useElement,
11 | useError,
12 | useLiveContext,
13 | } from './Provider.js';
14 | import highlight from './highlight.js';
15 |
16 | export type ImportResolver = _ImportResolver;
17 |
18 | export {
19 | Prism,
20 | CodeBlock,
21 | Error,
22 | Editor,
23 | Preview,
24 | Provider,
25 | InfoMessage,
26 | highlight,
27 | themes,
28 | useElement,
29 | useError,
30 | useLiveContext,
31 | };
32 |
33 | export type { PrismTheme, Language } from 'prism-react-renderer';
34 |
--------------------------------------------------------------------------------
/src/prism.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | export type Language =
4 | | 'markup'
5 | | 'bash'
6 | | 'clike'
7 | | 'c'
8 | | 'cpp'
9 | | 'css'
10 | | 'javascript'
11 | | 'jsx'
12 | | 'coffeescript'
13 | | 'actionscript'
14 | | 'css-extr'
15 | | 'diff'
16 | | 'git'
17 | | 'go'
18 | | 'graphql'
19 | | 'handlebars'
20 | | 'json'
21 | | 'less'
22 | | 'makefile'
23 | | 'markdown'
24 | | 'objectivec'
25 | | 'ocaml'
26 | | 'python'
27 | | 'reason'
28 | | 'sass'
29 | | 'scss'
30 | | 'sql'
31 | | 'stylus'
32 | | 'tsx'
33 | | 'typescript'
34 | | 'wasm'
35 | | 'yaml';
36 |
37 | export type PrismThemeEntry = {
38 | color?: string;
39 | backgroundColor?: string;
40 | fontStyle?: 'normal' | 'italic';
41 | fontWeight?:
42 | | 'normal'
43 | | 'bold'
44 | | '100'
45 | | '200'
46 | | '300'
47 | | '400'
48 | | '500'
49 | | '600'
50 | | '700'
51 | | '800'
52 | | '900';
53 | textDecorationLine?:
54 | | 'none'
55 | | 'underline'
56 | | 'line-through'
57 | | 'underline line-through';
58 | opacity?: number;
59 | [styleKey: string]: string | number | void;
60 | };
61 |
62 | export type PrismTheme = {
63 | plain: PrismThemeEntry;
64 | styles: Array<{
65 | types: string[];
66 | style: PrismThemeEntry;
67 | languages?: Language[];
68 | }>;
69 | };
70 |
71 | export type ThemeDict = {
72 | root: StyleObj;
73 | plain: StyleObj;
74 | [type: string]: StyleObj;
75 | };
76 |
77 | export type Token = {
78 | types: string[];
79 | content: string;
80 | empty?: boolean;
81 | };
82 |
83 | export type PrismToken = {
84 | type: string;
85 | content: Array | string;
86 | };
87 |
88 | export type StyleObj = CSSProperties;
89 |
90 | export type LineInputProps = {
91 | key?: string;
92 | style?: StyleObj;
93 | className?: string;
94 | line: Token[];
95 | [otherProp: string]: any;
96 | };
97 |
98 | export type LineOutputProps = {
99 | key?: string;
100 | style?: StyleObj;
101 | className: string;
102 | [otherProps: string]: any;
103 | };
104 |
105 | export type TokenInputProps = {
106 | key?: string;
107 | style?: StyleObj;
108 | className?: string;
109 | token: Token;
110 | [otherProp: string]: any;
111 | };
112 |
113 | export type TokenOutputProps = {
114 | key?: string;
115 | style?: StyleObj;
116 | className: string;
117 | children: string;
118 | [otherProp: string]: any;
119 | };
120 |
121 | export type RenderProps = {
122 | tokens: Token[][];
123 | className: string;
124 | style: StyleObj;
125 | getLineProps: (input: LineInputProps) => LineOutputProps;
126 | getTokenProps: (input: TokenInputProps) => TokenOutputProps;
127 | };
128 |
--------------------------------------------------------------------------------
/src/transform/ImportTransformer.ts:
--------------------------------------------------------------------------------
1 | import { SucraseContext } from 'sucrase';
2 | import {
3 | TokenProcessor,
4 | type CJSImportProcessor,
5 | type RootTransformer,
6 | tt,
7 | } from './parser.js';
8 |
9 | export type Import = {
10 | code: string;
11 | source: string;
12 | base: null | string;
13 | keys: Array<{ local: string; imported: string }>;
14 | };
15 |
16 | export default class ImportRemoverTransformer {
17 | tokens: TokenProcessor;
18 | importProcessor: CJSImportProcessor;
19 |
20 | imports: Import[] = [];
21 | private readonly removeImports: boolean;
22 |
23 | constructor(
24 | context: SucraseContext,
25 | { removeImports }: { removeImports?: boolean }
26 | ) {
27 | this.tokens = context.tokenProcessor;
28 | this.importProcessor = context.importProcessor!;
29 | this.removeImports = removeImports || false;
30 |
31 | // clear the replacements b/c we are handling imports
32 | // @ts-ignore private
33 | this.importProcessor.identifierReplacements.clear();
34 | }
35 |
36 | getPrefixCode() {
37 | return '';
38 | }
39 |
40 | getHoistedCode() {
41 | return '';
42 | }
43 |
44 | getSuffixCode() {
45 | return '';
46 | }
47 |
48 | process(): boolean {
49 | if (!this.tokens.matches1(tt._import)) return false;
50 |
51 | // dynamic import
52 | if (this.tokens.matches2(tt._import, tt.parenL)) {
53 | return true;
54 | }
55 |
56 | this.tokens.removeInitialToken();
57 | while (!this.tokens.matches1(tt.string)) {
58 | this.tokens.removeToken();
59 | }
60 |
61 | const path = this.tokens.stringValue();
62 |
63 | const detail = this.buildImport(path);
64 | this.importProcessor.claimImportCode(path);
65 | if (detail?.code) {
66 | this.imports.push(detail);
67 |
68 | this.tokens.replaceTokenTrimmingLeftWhitespace(
69 | this.removeImports ? '' : detail.code
70 | );
71 | } else {
72 | this.tokens.removeToken();
73 | }
74 |
75 | if (this.tokens.matches1(tt.semi)) {
76 | this.tokens.removeToken();
77 | }
78 |
79 | return true;
80 | }
81 |
82 | num = 0;
83 | getIdentifier(src: string) {
84 | return `${src.split('/').pop()!.replace(/\W/g, '_')}$${this.num++}`;
85 | }
86 |
87 | private buildImport(path: string) {
88 | // @ts-ignore
89 | if (!this.importProcessor.importInfoByPath.has(path)) {
90 | return null;
91 | }
92 |
93 | let FN = 'require';
94 |
95 | const { defaultNames, wildcardNames, namedImports, hasBareImport } =
96 | // @ts-ignore
97 | this.importProcessor.importInfoByPath.get(path);
98 |
99 | const req = `${FN}('${path}');`;
100 | const tmp = this.getIdentifier(path);
101 |
102 | const named = [] as string[];
103 | const details: Import = {
104 | base: null,
105 | source: path,
106 | keys: [],
107 | code: '',
108 | };
109 |
110 | namedImports.forEach((s) => {
111 | if (
112 | this.importProcessor.shouldAutomaticallyElideImportedName(s.localName)
113 | ) {
114 | return;
115 | }
116 |
117 | named.push(
118 | s.localName === s.importedName
119 | ? s.localName
120 | : `${s.importedName}: ${s.localName}`
121 | );
122 | details.keys.push({ local: s.localName, imported: s.importedName });
123 | });
124 |
125 | if (defaultNames.length || wildcardNames.length) {
126 | const name = defaultNames[0] || wildcardNames[0];
127 |
128 | if (!this.importProcessor.shouldAutomaticallyElideImportedName(name)) {
129 | details.base = name;
130 | // intentionally use `var` so that conflcits with Jarle provider scope get resolved naturally
131 | // with the import overriding the scoped identifier
132 | if (wildcardNames.length) {
133 | details.code = `var ${name} = ${req}`;
134 | } else {
135 | details.code = `var ${tmp} = ${req} var ${name} = ${tmp}.default;`;
136 | }
137 | }
138 | }
139 |
140 | if (named.length) {
141 | details.code += ` var { ${named.join(', ')} } = ${
142 | details.code ? `${tmp};` : req
143 | }`;
144 | }
145 |
146 | if (hasBareImport) {
147 | details.code = req;
148 | }
149 |
150 | details.code = details.code.trim();
151 | return details;
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/transform/index.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Transform,
3 | SucraseContext,
4 | Options as SucraseOptions,
5 | } from 'sucrase';
6 |
7 | import ImportRemoverTransformer, { Import } from './ImportTransformer.js';
8 | import { getSucraseContext, RootTransformer } from './parser.js';
9 | import wrapLastExpression from './wrapLastExpression.js';
10 |
11 | export type { Import };
12 |
13 | type TransformerResult = ReturnType;
14 |
15 | class JarleRootTransformer extends RootTransformer {
16 | private importTransformer: ImportRemoverTransformer;
17 |
18 | private wrapLastExpression: (result: TransformerResult) => TransformerResult;
19 |
20 | constructor(
21 | context: SucraseContext,
22 | options: SucraseOptions & Options,
23 | parsingTransforms: Transform[]
24 | ) {
25 | super(context, options.transforms ?? [], false, {
26 | ...options,
27 | });
28 |
29 | this.importTransformer = new ImportRemoverTransformer(context, options);
30 |
31 | // @ts-ignore
32 | this.transformers.unshift(this.importTransformer);
33 |
34 | this.wrapLastExpression = options.wrapLastExpression
35 | ? (result: TransformerResult) => {
36 | return (
37 | wrapLastExpression(
38 | getSucraseContext(result.code, {
39 | ...options,
40 | transforms: parsingTransforms,
41 | })
42 | ) ?? result
43 | );
44 | }
45 | : (result: TransformerResult) => result;
46 | }
47 |
48 | get imports() {
49 | return this.importTransformer.imports;
50 | }
51 |
52 | transform() {
53 | let result = super.transform();
54 |
55 | result.code = result.code
56 | .replace('"use strict";', '')
57 | .replace('exports. default =', 'exports.default =')
58 | .replace(
59 | 'Object.defineProperty(exports, "__esModule", {value: true});',
60 | ''
61 | );
62 |
63 | return this.wrapLastExpression(result);
64 | }
65 | }
66 |
67 | export interface Options {
68 | removeImports?: boolean;
69 | wrapLastExpression?: boolean;
70 | syntax?: 'js' | 'typescript';
71 | transforms?: Transform[];
72 | filename?: string;
73 | compiledFilename?: string;
74 | }
75 |
76 | export function transform(code: string, options: Options = {}) {
77 | const transforms = options.transforms || [];
78 | const parsingTransforms = ['imports', 'jsx'] as Transform[];
79 | const isTypeScriptEnabled =
80 | options.syntax === 'typescript' || transforms.includes('typescript');
81 |
82 | if (isTypeScriptEnabled) {
83 | parsingTransforms.push('typescript');
84 | }
85 |
86 | const sucraseOptions: SucraseOptions & Options = {
87 | ...options,
88 | transforms,
89 | preserveDynamicImport: true,
90 | enableLegacyBabel5ModuleInterop: false,
91 | enableLegacyTypeScriptModuleInterop: true,
92 | jsxRuntime: 'classic',
93 | };
94 |
95 | const ctx = getSucraseContext(code, {
96 | ...sucraseOptions,
97 | transforms: parsingTransforms,
98 | });
99 |
100 | const transformer = new JarleRootTransformer(
101 | ctx,
102 | sucraseOptions,
103 | parsingTransforms
104 | );
105 |
106 | return {
107 | code: transformer.transform().code,
108 | imports: transformer.imports,
109 | };
110 | }
111 |
--------------------------------------------------------------------------------
/src/transform/parser.d.ts:
--------------------------------------------------------------------------------
1 | import { Options, SucraseContext } from 'sucrase/';
2 |
3 | export { default as TokenProcessor } from 'sucrase/dist/types/TokenProcessor';
4 | export { default as RootTransformer } from 'sucrase/dist/types/transformers/RootTransformer';
5 | export { parse } from 'sucrase/dist/types/parser';
6 | export { transform } from 'sucrase/';
7 | export { TokenType as tt } from 'sucrase/dist/types/parser/tokenizer/types';
8 | export { default as CJSImportProcessor } from 'sucrase/dist/types/CJSImportProcessor';
9 | export { default as computeSourceMap } from 'sucrase/dist/types/computeSourceMap';
10 |
11 | export function getSucraseContext(
12 | code: string,
13 | options: Options
14 | ): SucraseContext;
15 |
--------------------------------------------------------------------------------
/src/transform/wrapLastExpression.ts:
--------------------------------------------------------------------------------
1 | import { SucraseContext } from 'sucrase';
2 | import {
3 | TokenProcessor,
4 | type CJSImportProcessor,
5 | type RootTransformer,
6 | tt,
7 | } from './parser.js';
8 |
9 | function findLastExpression(tokens: TokenProcessor) {
10 | let lastExprIdx: number | null = null;
11 |
12 | for (let i = 0; i < tokens.tokens.length; i++) {
13 | if (tokens.matches2AtIndex(i, tt._export, tt._default)) {
14 | return null;
15 | }
16 |
17 | // @ts-ignore
18 | if (tokens.tokens[i].isTopLevel) {
19 | const code = tokens.code.slice(
20 | tokens.tokens[i].start,
21 | tokens.tokens[i].end
22 | );
23 |
24 | if (code.startsWith('exports')) {
25 | return null;
26 | }
27 | if (tokens.matches1AtIndex(i, tt._return)) {
28 | return null;
29 | }
30 |
31 | lastExprIdx = i;
32 | }
33 | }
34 |
35 | return lastExprIdx;
36 | }
37 |
38 | function process(tokens: TokenProcessor, lastIndex: number) {
39 | if (tokens.currentIndex() !== lastIndex) {
40 | return false;
41 | }
42 |
43 | let prev = tokens.currentIndex() - 1;
44 |
45 | let lastWasSemi = prev >= 0 && !tokens.matches1AtIndex(prev, tt.semi);
46 |
47 | if (tokens.matches2(tt._export, tt._default)) {
48 | tokens.removeInitialToken();
49 | tokens.replaceTokenTrimmingLeftWhitespace(
50 | lastWasSemi ? 'return' : '; return'
51 | );
52 | } else {
53 | let code = `return ${tokens.currentTokenCode()}`;
54 | if (lastWasSemi) {
55 | code = `;${code}`;
56 | }
57 | tokens.replaceTokenTrimmingLeftWhitespace(code);
58 | }
59 | return true;
60 | }
61 |
62 | export default function wrapLastExpression({ tokenProcessor }: SucraseContext) {
63 | let lastExprIdx = findLastExpression(tokenProcessor);
64 |
65 | if (lastExprIdx == null) {
66 | return
67 | }
68 |
69 | while (!tokenProcessor.isAtEnd()) {
70 | let wasProcessed = process(tokenProcessor, lastExprIdx);
71 |
72 | if (!wasProcessed) {
73 | tokenProcessor.copyToken();
74 | }
75 | }
76 |
77 | return tokenProcessor.finish();
78 | }
79 |
--------------------------------------------------------------------------------
/src/transpile.ts:
--------------------------------------------------------------------------------
1 | import { Import, transform } from './transform/index.js';
2 |
3 | export type Options = {
4 | inline?: boolean;
5 | isTypeScript?: boolean;
6 | showImports?: boolean;
7 | wrapper?: (code: string) => string;
8 | };
9 |
10 | export default (
11 | input: string,
12 | { inline = false, isTypeScript, showImports }: Options = {}
13 | ) => {
14 | let { code, imports } = transform(input, {
15 | removeImports: !showImports,
16 | wrapLastExpression: inline,
17 | transforms: isTypeScript ? ['typescript', 'jsx'] : ['jsx'],
18 | compiledFilename: 'compiled.js',
19 | filename: 'example.js',
20 | });
21 |
22 | return { code, imports };
23 | };
24 |
--------------------------------------------------------------------------------
/src/useTest.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import useCommittedRef from '@restart/hooks/useCommittedRef';
3 |
4 | /**
5 | * Creates a `setInterval` that is properly cleaned up when a component unmounted
6 | *
7 | * @public
8 | * @param fn an function run on each interval
9 | * @param ms The milliseconds duration of the interval
10 | */
11 | function useInterval(fn: () => void, ms: number): void;
12 |
13 | /**
14 | * Creates a pausable `setInterval` that is properly cleaned up when a component unmounted
15 | *
16 | * @public
17 | * @param fn an function run on each interval
18 | * @param ms The milliseconds duration of the interval
19 | * @param paused Whether or not the interval is currently running
20 | */
21 | function useInterval(fn: () => void, ms: number, paused: boolean): void;
22 |
23 | /**
24 | * Creates a pausable `setInterval` that is properly cleaned up when a component unmounted
25 | *
26 | * @public
27 | * @param fn an function run on each interval
28 | * @param ms The milliseconds duration of the interval
29 | * @param paused Whether or not the interval is currently running
30 | * @param runImmediately Whether to run the function immediately on mount or unpause
31 | * rather than waiting for the first interval to elapse
32 | */
33 | function useInterval(
34 | fn: () => void,
35 | ms: number,
36 | paused: boolean,
37 | runImmediately: boolean
38 | ): void;
39 |
40 | function useInterval(
41 | fn: () => void,
42 | ms: number,
43 | paused = false,
44 | runImmediately = false
45 | ): void {
46 | let handle: number;
47 | const fnRef = useCommittedRef(fn);
48 | // this ref is necessary b/c useEffect will sometimes miss a paused toggle
49 | // orphaning a setTimeout chain in the aether, so relying on it's refresh logic is not reliable.
50 | const pausedRef = useCommittedRef(paused);
51 | const tick = () => {
52 | if (pausedRef.current) return;
53 | fnRef.current();
54 | schedule(); // eslint-disable-line no-use-before-define
55 | };
56 |
57 | const schedule = () => {
58 | clearTimeout(handle);
59 | handle = setTimeout(tick, ms) as any;
60 | };
61 |
62 | useEffect(() => {
63 | if (runImmediately) {
64 | tick();
65 | } else {
66 | schedule();
67 | }
68 | return () => clearTimeout(handle);
69 | }, [paused, runImmediately]);
70 | }
71 |
72 | export default useInterval;
73 |
--------------------------------------------------------------------------------
/test/Provider.test.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable require-await */
2 |
3 | import { fireEvent, render, act } from '@testing-library/react';
4 | import { describe, it, expect } from 'vitest';
5 | import Provider, { Props, useElement, useError } from '../src/Provider.js';
6 |
7 | describe('Provider', () => {
8 | async function mountProvider(props: Props<{}>) {
9 | let wrapper: ReturnType;
10 |
11 | function Child() {
12 | const el = useElement();
13 | const err = useError();
14 | if (err) console.error(err);
15 | return el;
16 | }
17 |
18 | wrapper = render( } {...props} />);
19 |
20 | await act(async () => {
21 | wrapper.rerender( } {...props} />);
22 | });
23 |
24 | return wrapper!;
25 | }
26 |
27 | it('should render', async () => {
28 | const wrapper = await mountProvider({
29 | code: `
30 |
31 | `,
32 | });
33 |
34 | expect(wrapper.getByTestId('test')).toBeDefined();
35 | });
36 |
37 | it('should render function component', async () => {
38 | const wrapper = await mountProvider({
39 | code: `
40 | function Example() {
41 | return
42 | }
43 | `,
44 | });
45 |
46 | expect(wrapper.getByTestId('test')).toBeDefined();
47 | });
48 |
49 | it('should render class component', async () => {
50 | const wrapper = await mountProvider({
51 | code: `
52 | class Example extends React.Component {
53 | render() {
54 | return
55 | }
56 | }
57 | `,
58 | });
59 |
60 | expect(wrapper.getByTestId('test')).toBeDefined();
61 | });
62 |
63 | it('should renderAsComponent', async () => {
64 | const wrapper = await mountProvider({
65 | renderAsComponent: true,
66 | code: `
67 | const [count, setCount] = useState(1);
68 |
69 | setCount(2)}>{count}
70 | `,
71 | });
72 |
73 | const div = wrapper.getByTestId('test');
74 |
75 | expect(div).toBeDefined();
76 | expect(div.textContent).toEqual('1');
77 |
78 | fireEvent.click(div);
79 | expect(div.textContent).toEqual('2');
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/test/setup.ts:
--------------------------------------------------------------------------------
1 | import { cleanup } from '@testing-library/react';
2 | import { vi } from 'vitest';
3 | import { afterEach } from 'vitest';
4 |
5 | // @ts-ignore
6 | window.__IMPORT__ = (s) => import(/* webpackIgnore: true */ s);
7 |
8 | afterEach(() => {
9 | cleanup();
10 | vi.useRealTimers();
11 | });
12 |
--------------------------------------------------------------------------------
/test/sucrase.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, test, expect } from 'vitest';
2 | import { transform } from '../src/transform/index.js';
3 |
4 | describe('general parsing smoketest', () => {
5 | it('parses', () => {
6 | transform(
7 | `
8 | const obj = { a: { b: 1, c: true }}
9 |
10 | let f = obj?.a?.b?.()
11 | `
12 | );
13 |
14 | transform(
15 | `
16 | const obj = { a: { b: 1, c: true }}
17 |
18 | function foo(a: T): T {
19 | return a;
20 | }
21 |
22 | let f: number = obj?.a?.b?.() satisfies number
23 | `,
24 | { transforms: ['typescript'] }
25 | );
26 | });
27 | });
28 |
29 | describe('import rewriting', () => {
30 | // it('parses', () => {
31 | // console.log(
32 | // transform(`
`, {
33 | // syntax: 'typescript',
34 | // wrapLastExpression: true,
35 | // }).code
36 | // );
37 | // });
38 |
39 | test.each([
40 | ['no import', 'import "./foo";', "require('./foo');", undefined],
41 |
42 | [
43 | 'default import',
44 | 'import Foo from "./foo";',
45 | "var foo$0 = require('./foo'); var Foo = foo$0.default;",
46 | undefined,
47 | ],
48 | [
49 | 'named imports',
50 | 'import { Bar, Baz } from "./foo";',
51 | "var { Bar, Baz } = require('./foo');",
52 | undefined,
53 | ],
54 | [
55 | 'namespace',
56 | 'import * as Foo from "./foo";',
57 | "var Foo = require('./foo');",
58 | undefined,
59 | ],
60 | ['side effect', 'import "./foo";', "require('./foo');", undefined],
61 | [
62 | 'mixed',
63 | 'import Foo, { Bar, Baz } from "./foo";',
64 | "var foo$0 = require('./foo'); var Foo = foo$0.default; var { Bar, Baz } = foo$0;",
65 | undefined,
66 | ],
67 | [
68 | 'type imports',
69 | 'import type Foo from "./foo";',
70 | '',
71 | { syntax: 'typescript' },
72 | ],
73 | [
74 | 'type only imports',
75 | 'import Bar from "./bar";\nimport Foo from "./foo";\nconst foo: Foo = Bar',
76 | "var bar$0 = require('./bar'); var Bar = bar$0.default;\n\nconst foo = Bar",
77 | { transforms: ['typescript'] },
78 | ],
79 | [
80 | 'preserves new lines',
81 | 'import { \nBar,\nBaz\n} from "./foo";',
82 | "\n\n\nvar { Bar, Baz } = require('./foo');",
83 | undefined,
84 | ],
85 | ])('compiles %s', (_, input, expected, options: any) => {
86 | expect(transform(input, options).code).toEqual(expected);
87 | });
88 |
89 | it('removes imports', () => {
90 | expect(
91 | transform(`import Foo from './foo';\nimport Bar from './bar';\n
`, {
92 | removeImports: true,
93 | }).code
94 | ).toEqual('\n\n
');
95 | });
96 |
97 | it('fills imports', () => {
98 | const { imports } = transform(
99 | `
100 | import Foo from './foo';
101 | import * as D from './foo2'
102 | import A, { B, c as C } from './foo3'
103 | `
104 | );
105 |
106 | expect(imports).toEqual([
107 | {
108 | base: 'Foo',
109 | source: './foo',
110 | keys: [],
111 | code: expect.anything(),
112 | },
113 | {
114 | base: 'D',
115 | source: './foo2',
116 | keys: [],
117 | code: expect.anything(),
118 | },
119 | {
120 | base: 'A',
121 | source: './foo3',
122 | keys: [
123 | { local: 'B', imported: 'B' },
124 | { local: 'C', imported: 'c' },
125 | ],
126 | code: expect.anything(),
127 | },
128 | ]);
129 | });
130 |
131 | it('excludes type imports', () => {
132 | const { imports } = transform(
133 | `
134 | import type Foo from './foo';
135 | import * as D from './foo2'
136 | import type { B, c as C } from './foo3'
137 |
138 | const foo: Foo = D;
139 | `,
140 | { transforms: ['typescript'] }
141 | );
142 |
143 | expect(imports).toEqual([
144 | {
145 | base: 'D',
146 | source: './foo2',
147 | keys: [],
148 | code: expect.anything(),
149 | },
150 | ]);
151 | });
152 |
153 | it('elides unused imports and types', () => {
154 | const { imports } = transform(
155 | `
156 | import Foo from './foo';
157 | import * as D from './foo2'
158 | import { B, c as C } from './foo3'
159 |
160 | const foo: Foo = D;
161 | `,
162 | { transforms: ['typescript'] }
163 | );
164 |
165 | expect(imports).toEqual([
166 | {
167 | base: 'D',
168 | source: './foo2',
169 | keys: [],
170 | code: expect.anything(),
171 | },
172 | ]);
173 | });
174 | });
175 |
176 | describe('wrap last expression', () => {
177 | test.each([
178 | [
179 | 'basic',
180 | "let a = 1;\nReact.createElement('i', null, a);",
181 | "let a = 1;\nreturn React.createElement('i', null, a);",
182 | ],
183 | ['single expression', '
', 'return
'],
184 | ['with semi', '
;', 'return
;'],
185 | [
186 | 'does nothing if already a return',
187 | '
;\nreturn ',
188 | '
;\nreturn ',
189 | ],
190 | [
191 | 'does nothing if already a return earlier',
192 | 'return ; \n
;',
193 | 'return ; \n
;',
194 | ],
195 | [
196 | 'multiline expression',
197 | `
198 | function Wrapper(ref) {
199 | let children = ref.children;
200 | return React.createElement('div', {id: 'foo'}, children);
201 | };
202 |
203 | React.createElement(Wrapper, null,
204 | React.createElement(Wrapper, null, React.createElement(Icon, {name: "plus"})),
205 | React.createElement(Wrapper, null, React.createElement(Icon, {name: "clip"}))
206 | );
207 | `,
208 | `
209 | function Wrapper(ref) {
210 | let children = ref.children;
211 | return React.createElement('div', {id: 'foo'}, children);
212 | };
213 |
214 | return React.createElement(Wrapper, null,
215 | React.createElement(Wrapper, null, React.createElement(Icon, {name: "plus"})),
216 | React.createElement(Wrapper, null, React.createElement(Icon, {name: "clip"}))
217 | );
218 | `,
219 | ],
220 | [
221 | 'replaces export default',
222 | `export default
`,
223 | `exports.default =
`,
224 | ],
225 | [
226 | 'prefers export default',
227 | `export default
;\n `,
228 | `exports.default =
;\n `,
229 | ],
230 | [
231 | 'return class',
232 | `const bar = true;\nclass foo {}`,
233 | `const bar = true;\nreturn class foo {}`,
234 | ],
235 | [
236 | 'export class',
237 | `export default class foo {}`,
238 | ` class foo {} exports.default = foo;`,
239 | ],
240 | [
241 | 'return function',
242 | `const bar = true\nfunction foo() {}`,
243 | `const bar = true\n;return function foo() {}`,
244 | ],
245 | [
246 | 'export function',
247 | `export default function foo() {}`,
248 | ` function foo() {} exports.default = foo;`,
249 | ],
250 | [
251 | 'export function 2',
252 | `function foo() {}\nfunction bar(baz= function() {}) {}`,
253 | `function foo() {}\n;return function bar(baz= function() {}) {}`,
254 | ],
255 |
256 | ['confusing expressions 1', `foo, bar\nbaz`, `foo, bar\n;return baz`],
257 | [
258 | 'confusing expressions 2',
259 | `foo, (() => {});\nbaz`,
260 | `foo, (() => {});\nreturn baz`,
261 | ],
262 | [
263 | 'confusing expressions 3',
264 | `foo, (() => {});baz\nquz`,
265 | `foo, (() => {});baz\n;return quz`,
266 | ],
267 | ['confusing expressions 4', `let foo = {};baz`, `let foo = {};return baz`],
268 | [
269 | 'confusing expressions 4',
270 | `function foo(){\nlet bar = 1; return baz;};baz`,
271 | `function foo(){\nlet bar = 1; return baz;};return baz`,
272 | ],
273 | ])('compiles %s', (_, input, expected) => {
274 | expect(
275 | transform(input, { transforms: ['imports'], wrapLastExpression: true })
276 | .code
277 | ).toEqual(expected);
278 | });
279 | });
280 |
--------------------------------------------------------------------------------
/test/transpile.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import transpile from '../src/transpile.js';
3 |
4 | describe('parseImports', () => {
5 | it('removes imports', () => {
6 | const result = transpile(
7 | `
8 | import Foo from './foo.js'
9 |
10 |
11 | `,
12 | { showImports: false }
13 | );
14 |
15 | expect(result.code).toMatchInlineSnapshot(`
16 | "const _jsxFileName = "";
17 |
18 |
19 | React.createElement(Foo, {__self: this, __source: {fileName: _jsxFileName, lineNumber: 4}} )
20 | "
21 | `);
22 |
23 | expect(result.imports).toMatchInlineSnapshot(`
24 | [
25 | {
26 | "base": "Foo",
27 | "code": "var foo_js$0 = require('./foo.js'); var Foo = foo_js$0.default;",
28 | "keys": [],
29 | "source": "./foo.js",
30 | },
31 | ]
32 | `);
33 | });
34 |
35 | it('removes imports with Typescript', () => {
36 | const result = transpile(
37 | `
38 | import Foo, { Bar } from './foo.js';
39 |
40 | />
41 | `,
42 | { showImports: false, isTypeScript: true }
43 | );
44 |
45 | expect(result.code).toMatchInlineSnapshot(`
46 | "const _jsxFileName = "";
47 |
48 |
49 | React.createElement(Foo, {__self: this, __source: {fileName: _jsxFileName, lineNumber: 4}} )
50 | "
51 | `);
52 |
53 | expect(result.imports).toMatchInlineSnapshot(`
54 | [
55 | {
56 | "base": "Foo",
57 | "code": "var foo_js$0 = require('./foo.js'); var Foo = foo_js$0.default;",
58 | "keys": [],
59 | "source": "./foo.js",
60 | },
61 | ]
62 | `);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "..",
5 | "noImplicitAny": false
6 | },
7 | "include": [".", "../src"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "rootDir": "src",
4 | "target": "esnext",
5 | "useDefineForClassFields": true,
6 | "lib": ["esnext", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "jsx": "react-jsx",
13 |
14 | /* Linting */
15 | "strict": true,
16 | "noImplicitAny": false,
17 | "module": "NodeNext",
18 | "moduleResolution": "NodeNext"
19 | },
20 | "include": ["src"]
21 | }
22 |
--------------------------------------------------------------------------------
/vendor/sucrase.js:
--------------------------------------------------------------------------------
1 | import { transform, getSucraseContext, RootTransformer } from 'sucrase/';
2 | import { TokenType as tt } from 'sucrase/dist/parser/tokenizer/types';
3 | import CJSImportProcessor from 'sucrase/dist/CJSImportProcessor';
4 | import computeSourceMap from 'sucrase/dist/computeSourceMap';
5 | import { parse } from 'sucrase/dist/esm/parser';
6 |
7 | export {
8 | RootTransformer,
9 | tt,
10 | parse,
11 | getSucraseContext,
12 | CJSImportProcessor,
13 | transform,
14 | computeSourceMap,
15 | };
16 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: 'jsdom',
6 | setupFiles: './test/setup.ts',
7 | // TODO: remove include prop after complete Vitest migration
8 | include: ['test/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/www/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/www/README.md:
--------------------------------------------------------------------------------
1 | # Website
2 |
3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator.
4 |
5 | ## Installation
6 |
7 | ```console
8 | yarn install
9 | ```
10 |
11 | ## Local Development
12 |
13 | ```console
14 | yarn start
15 | ```
16 |
17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server.
18 |
19 | ## Build
20 |
21 | ```console
22 | yarn build
23 | ```
24 |
25 | This command generates static content into the `build` directory and can be served using any static contents hosting service.
26 |
27 | ## Deployment
28 |
29 | ```console
30 | GIT_USER= USE_SSH=true yarn deploy
31 | ```
32 |
33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
34 |
--------------------------------------------------------------------------------
/www/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | require.resolve('@docusaurus/core/lib/babel/preset'),
4 |
5 | ['@babel/preset-typescript', { allowDeclareFields: true }],
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/www/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: docs
3 | title: JARLE
4 | slug: /
5 | ---
6 | {/*
7 | import providerMeta from '@metadata/Provider';
8 | import editor from '@metadata/Editor';
9 | import error from '@metadata/Error';
10 | import preview from '@metadata/Preview'; */}
11 | import PropsList from '@theme/PropsList';
12 |
13 | Write code and see the result as you type.
14 |
15 | ## Overview
16 |
17 | ```jsx live
18 | Hello World!
19 | ```
20 |
21 | JARLE only looks at the last thing you return, so write whatever you need in front
22 | of it.
23 |
24 | ```jsx live
25 | const DEFAULT = 'World';
26 |
27 | function Greet({ subject = DEFAULT }) {
28 | return Hello {subject}
;
29 | }
30 |
31 | class ClassyGreet extends React.Component {
32 | render() {
33 | const { subject } = this.props;
34 | return Hello {subject} ;
35 | }
36 | }
37 |
38 | <>
39 |
40 |
41 |
42 | >;
43 | ```
44 |
45 | If the last expression is a valid React element type it'll render that as well:
46 |
47 | ```jsx live
48 | const DEFAULT = 'World';
49 |
50 | function Greet() {
51 | return Hello world
;
52 | }
53 | ```
54 |
55 | Or with class components
56 |
57 | ```jsx live
58 | class ClassyGreet extends React.Component {
59 | render() {
60 | return Hello world ;
61 | }
62 | }
63 | ```
64 |
65 | If you want to be explicit you can also `export` a value directly (only the default export is used).
66 |
67 | ```jsx live
68 | export default React.forwardRef(() => {
69 | return I'm unique! ;
70 | });
71 | ```
72 |
73 | For punchy terse demostrations of component render logic, use `renderAsComponent`
74 | to have JARLE use your code as the body of a React function component.
75 |
76 | ```jsx live renderAsComponent
77 | const [seconds, setSeconds] = useState(0);
78 |
79 | useEffect(() => {
80 | let interval = setInterval(() => {
81 | setSeconds((prev) => prev + 1);
82 | }, 1000);
83 |
84 | return () => clearInterval(interval);
85 | }, []);
86 |
87 | return Seconds past: {seconds}
;
88 | ```
89 |
90 | If you do need more control over what get's rendered, or need to render asynchronously, a
91 | `render` function is always in scope:
92 |
93 | ```jsx live
94 | setTimeout(() => {
95 | render(I'm late!
);
96 | }, 1000);
97 | ```
98 |
99 | ### TypeScript
100 |
101 | JARLE supports compiling TypeScript if you specify the language as such.
102 |
103 | ```tsx live
104 | import type { Names } from './types';
105 |
106 | interface Props {
107 | name: string;
108 | }
109 |
110 | function Greeting({ name }: Props) {
111 | return Hello {name} ;
112 | }
113 |
114 | ;
115 | ```
116 |
117 | ## Scope
118 |
119 | You can control which values, clases, components, etc are provided automatically
120 | to the example code when it's run by adjusting the `scope`. The `scope` is an object
121 | map of identifiers and their values. The `React` namespace is provided as well as
122 | all of the built-in hooks (useState, useRef, etc) automatically along with a `render()`
123 | function for finely tuning the returned element.
124 |
125 | You can also add our own values:
126 |
127 | ```jsx
128 | import lodash from 'lodash';
129 |
130 | ;
131 | ```
132 |
133 | ## Importing modules in examples
134 |
135 | Import can be used in code blocks (as long as the browser supports [dynamic imports](https://caniuse.com/es6-module-dynamic-import)).
136 |
137 | ```jsx live
138 | import confetti from 'https://cdn.skypack.dev/canvas-confetti';
139 |
140 | confetti()}>Confetti ;
141 | ```
142 |
143 | Import statements are extracted from the example code and the requests
144 | are passed to a function responsible for resolving them into modules.
145 |
146 | The default behavior uses `import()`, relying on the browsers module support.
147 | You can however, customize the importer to suit your needs. The resolver
148 | may return an object map of possible requests to their module, useful
149 | when you only want to expose a few local values to examples (see also `scope`).
150 |
151 | ```jsx
152 | ({
154 | lodash: lodash,
155 | 'my-component': { default: () => Hey },
156 | })}
157 | />
158 | ```
159 |
160 | For dynamic resolution the resolve is also passed a list of requests to be mapped
161 | to their modules. Here is the default implementation
162 |
163 | ```jsx
164 |
166 | promise.all(requests.map((request) => import(request)))
167 | }
168 | />
169 | ```
170 |
171 | You can also mix together some of your own static analysis and tooling to
172 | build really neat integrations where imports are resolved a head of time, using webpack
173 | or other bundlers.
174 |
175 | > Note: Typescript type only imports are not passed to the import resolver, since they
176 | > are compile-time only fixtures.
177 |
178 | ## Usage
179 |
180 | A full example of how to use JARLE via another JARLE editor!
181 |
182 | ```jsx live inline=false
183 | import { Provider, Editor, Error, Preview, themes } from 'jarle';
184 |
185 | const code = `
186 | Here's an editor inside an editor
187 | `;
188 |
189 |
190 |
Yo Dawg I heard you liked editors
191 |
192 |
193 |
194 |
195 |
196 |
197 |
;
198 | ```
199 |
200 | ### Render into an iframe
201 |
202 | Previews are all rendered into the same document as the editor so they share stuff
203 | like global CSS. If want to sandbox the result from the parent document, render
204 | your preview into an `