├── .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 | *