playroom 0.33.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # playroom
2
2
 
3
+ ## 0.34.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e3b820b: Add favicon to Playroom site.
8
+ - 4fb69cb: Improve affordance of error marker detail
9
+
10
+ ## 0.34.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 1c8ae6b: Use smaller React pragmas to reduce the payload sent to iframes
15
+ - c4b639c: Replace `@babel/standalone` with `sucrase` for JSX compilation
16
+
17
+ ### Patch Changes
18
+
19
+ - 1c8ae6b: Highlight the correct error location when code has syntax errors
20
+
3
21
  ## 0.33.0
4
22
 
5
23
  ### Minor Changes
Binary file
Binary file
@@ -141,6 +141,10 @@ module.exports = async (playroomConfig, options) => {
141
141
  include: path.dirname(require.resolve('codemirror/package.json')),
142
142
  use: [MiniCssExtractPlugin.loader, require.resolve('css-loader')],
143
143
  },
144
+ {
145
+ test: /\.png$/i,
146
+ type: 'asset/resource',
147
+ },
144
148
  ],
145
149
  },
146
150
  optimization: {
@@ -163,6 +167,7 @@ module.exports = async (playroomConfig, options) => {
163
167
  chunksSortMode: 'none',
164
168
  chunks: ['index'],
165
169
  filename: 'index.html',
170
+ favicon: 'images/favicon.png',
166
171
  base: playroomConfig.baseUrl,
167
172
  }),
168
173
  new HtmlWebpackPlugin({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playroom",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "Design with code, powered by your own component library",
5
5
  "main": "utils/index.js",
6
6
  "types": "utils/index.d.ts",
@@ -26,12 +26,11 @@
26
26
  },
27
27
  "homepage": "https://github.com/seek-oss/playroom#readme",
28
28
  "dependencies": {
29
- "@babel/cli": "^7.19.3",
30
29
  "@babel/core": "^7.20.5",
30
+ "@babel/parser": "^7.23.4",
31
31
  "@babel/preset-env": "^7.20.2",
32
32
  "@babel/preset-react": "^7.18.6",
33
33
  "@babel/preset-typescript": "^7.18.6",
34
- "@babel/standalone": "^7.20.6",
35
34
  "@soda/friendly-errors-webpack-plugin": "^1.8.1",
36
35
  "@types/base64-url": "^2.2.0",
37
36
  "@types/codemirror": "^5.60.5",
@@ -63,6 +62,7 @@
63
62
  "localforage": "^1.10.0",
64
63
  "lodash": "^4.17.21",
65
64
  "lz-string": "^1.4.4",
65
+ "memoize-one": "^6.0.0",
66
66
  "mini-css-extract-plugin": "^2.7.2",
67
67
  "parse-prop-types": "^0.3.0",
68
68
  "polished": "^4.2.2",
@@ -75,8 +75,9 @@
75
75
  "react-use": "^17.4.0",
76
76
  "read-pkg-up": "^7.0.1",
77
77
  "scope-eval": "^1.0.0",
78
+ "sucrase": "^3.34.0",
78
79
  "typescript": ">=5.0.0",
79
- "use-debounce": "^9.0.2",
80
+ "use-debounce": "^9.0.4",
80
81
  "webpack": "^5.75.0",
81
82
  "webpack-dev-server": "^4.11.1",
82
83
  "webpack-merge": "^5.8.0"
@@ -23,6 +23,9 @@ export const errorMarker = style([
23
23
  opacity: 0,
24
24
  }),
25
25
  {
26
+ ':hover': {
27
+ cursor: 'help',
28
+ },
26
29
  backgroundColor: colorPaletteVars.background.critical,
27
30
  color: colorPaletteVars.foreground.critical,
28
31
  minWidth: minimumLineNumberWidth,
@@ -1,5 +1,4 @@
1
1
  import { useRef, useContext, useEffect, useCallback } from 'react';
2
- // @ts-expect-error no types
3
2
  import { useDebouncedCallback } from 'use-debounce';
4
3
  import type { Editor } from 'codemirror';
5
4
  import 'codemirror/lib/codemirror.css';
@@ -10,11 +9,7 @@ import {
10
9
  type CursorPosition,
11
10
  } from '../../StoreContext/StoreContext';
12
11
  import { formatCode as format, isMac } from '../../utils/formatting';
13
- import {
14
- closeFragmentTag,
15
- compileJsx,
16
- openFragmentTag,
17
- } from '../../utils/compileJsx';
12
+ import { validateCode } from '../../utils/compileJsx';
18
13
 
19
14
  import * as styles from './CodeEditor.css';
20
15
 
@@ -42,33 +37,19 @@ import {
42
37
  } from './keymaps/cursors';
43
38
  import { wrapInTag } from './keymaps/wrap';
44
39
 
45
- const validateCode = (editorInstance: Editor, code: string) => {
46
- editorInstance.clearGutter('errorGutter');
40
+ const validateCodeInEditor = (editorInstance: Editor, code: string) => {
41
+ const maybeValid = validateCode(code);
47
42
 
48
- try {
49
- compileJsx(code);
50
- } catch (err) {
51
- const errorMessage = err instanceof Error ? err.message : '';
52
- const matches = errorMessage.match(/\(([0-9]+):/);
53
- const lineNumber =
54
- matches && matches.length >= 2 && matches[1] && parseInt(matches[1], 10);
43
+ if (maybeValid === true) {
44
+ editorInstance.clearGutter('errorGutter');
45
+ } else {
46
+ const errorMessage = maybeValid.message;
47
+ const lineNumber = maybeValid.loc?.line;
55
48
 
56
49
  if (lineNumber) {
57
- // Remove our wrapping Fragment from error message
58
- const openWrapperStartIndex = errorMessage.indexOf(openFragmentTag);
59
- const closeWrapperStartIndex = errorMessage.lastIndexOf(closeFragmentTag);
60
- const formattedMessage = [
61
- errorMessage.slice(0, openWrapperStartIndex),
62
- errorMessage.slice(
63
- openWrapperStartIndex + openFragmentTag.length,
64
- closeWrapperStartIndex
65
- ),
66
- errorMessage.slice(closeWrapperStartIndex + closeFragmentTag.length),
67
- ].join('');
68
-
69
50
  const marker = document.createElement('div');
70
51
  marker.setAttribute('class', styles.errorMarker);
71
- marker.setAttribute('title', formattedMessage);
52
+ marker.setAttribute('title', errorMessage);
72
53
  marker.innerText = String(lineNumber);
73
54
  editorInstance.setGutterMarker(lineNumber - 1, 'errorGutter', marker);
74
55
  }
@@ -172,7 +153,7 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
172
153
  }
173
154
 
174
155
  editorInstanceRef.current.setValue(code);
175
- validateCode(editorInstanceRef.current, code);
156
+ validateCodeInEditor(editorInstanceRef.current, code);
176
157
  }
177
158
  }, [code, previewCode]);
178
159
 
@@ -213,12 +194,12 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
213
194
  <ReactCodeMirror
214
195
  editorDidMount={(editorInstance) => {
215
196
  editorInstanceRef.current = editorInstance;
216
- validateCode(editorInstance, code);
197
+ validateCodeInEditor(editorInstance, code);
217
198
  setCursorPosition(cursorPosition);
218
199
  }}
219
200
  onChange={(editorInstance, data, newCode) => {
220
201
  if (editorInstance.hasFocus() && !previewCode) {
221
- validateCode(editorInstance, newCode);
202
+ validateCodeInEditor(editorInstance, newCode);
222
203
  debouncedChange(newCode);
223
204
  }
224
205
  }}
@@ -16,10 +16,9 @@ interface FramesProps {
16
16
  widths: PlayroomProps['widths'];
17
17
  }
18
18
 
19
- let renderCode = '<React.Fragment></React.Fragment>';
20
-
21
19
  export default function Frames({ code, themes, widths }: FramesProps) {
22
20
  const scrollingPanelRef = useRef<HTMLDivElement | null>(null);
21
+ const renderCode = useRef<string>('');
23
22
 
24
23
  const frames = flatMap(widths, (width) =>
25
24
  themes.map((theme) => ({
@@ -30,7 +29,7 @@ export default function Frames({ code, themes, widths }: FramesProps) {
30
29
  );
31
30
 
32
31
  try {
33
- renderCode = compileJsx(code);
32
+ renderCode.current = compileJsx(code);
34
33
  } catch (e) {}
35
34
 
36
35
  return (
@@ -45,7 +44,7 @@ export default function Frames({ code, themes, widths }: FramesProps) {
45
44
  <Iframe
46
45
  intersectionRootRef={scrollingPanelRef}
47
46
  src={frameSrc(
48
- { themeName: frame.theme, code: renderCode },
47
+ { themeName: frame.theme, code: renderCode.current },
49
48
  playroomConfig
50
49
  )}
51
50
  className={styles.frame}
@@ -1,6 +1,5 @@
1
1
  import { useContext, type ComponentType, Fragment } from 'react';
2
2
  import classnames from 'classnames';
3
- // @ts-expect-error no types
4
3
  import { useDebouncedCallback } from 'use-debounce';
5
4
  import { Resizable } from 're-resizable';
6
5
  import Frames from './Frames/Frames';
@@ -4,10 +4,32 @@ import scopeEval from 'scope-eval';
4
4
  // eslint-disable-next-line import/no-unresolved
5
5
  import useScope from '__PLAYROOM_ALIAS__USE_SCOPE__';
6
6
 
7
+ import {
8
+ ReactCreateElementPragma,
9
+ ReactFragmentPragma,
10
+ } from '../../utils/compileJsx';
11
+
7
12
  export default function RenderCode({ code, scope }) {
8
- return scopeEval(code, {
13
+ const userScope = {
9
14
  ...(useScope() ?? {}),
10
15
  ...scope,
16
+ };
17
+
18
+ if (ReactCreateElementPragma in userScope) {
19
+ throw new Error(
20
+ `'${ReactCreateElementPragma}' is used internally by Playroom and is not allowed in scope`
21
+ );
22
+ }
23
+ if (ReactFragmentPragma in userScope) {
24
+ throw new Error(
25
+ `'${ReactFragmentPragma}' is used internally by Playroom and is not allowed in scope`
26
+ );
27
+ }
28
+
29
+ return scopeEval(code, {
30
+ ...userScope,
11
31
  React,
32
+ [ReactCreateElementPragma]: React.createElement,
33
+ [ReactFragmentPragma]: React.Fragment,
12
34
  });
13
35
  }
@@ -1,7 +1,6 @@
1
1
  import { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import classnames from 'classnames';
3
3
  import fuzzy from 'fuzzy';
4
- // @ts-expect-error no types
5
4
  import { useDebouncedCallback } from 'use-debounce';
6
5
  import type { PlayroomProps } from '../Playroom';
7
6
  import type { Snippet } from '../../../utils';
@@ -9,7 +9,6 @@ import copy from 'copy-to-clipboard';
9
9
  import localforage from 'localforage';
10
10
  import lzString from 'lz-string';
11
11
  import dedent from 'dedent';
12
- // @ts-expect-error no types
13
12
  import { useDebouncedCallback } from 'use-debounce';
14
13
 
15
14
  import { type Snippet, compressParams } from '../../utils';
package/src/index.js CHANGED
@@ -2,6 +2,8 @@ import { renderElement } from './render';
2
2
  import Playroom from './Playroom/Playroom';
3
3
  import { StoreProvider } from './StoreContext/StoreContext';
4
4
  import playroomConfig from './config';
5
+ import faviconPath from '../images/favicon.png';
6
+ import faviconInvertedPath from '../images/favicon-inverted.png';
5
7
 
6
8
  const polyfillIntersectionObserver = () =>
7
9
  typeof window.IntersectionObserver !== 'undefined'
@@ -14,6 +16,15 @@ polyfillIntersectionObserver().then(() => {
14
16
  const outlet = document.createElement('div');
15
17
  document.body.appendChild(outlet);
16
18
 
19
+ const selectedElement = document.head.querySelector('link[rel="icon"]');
20
+ const favicon = window.matchMedia('(prefers-color-scheme: dark)').matches
21
+ ? faviconInvertedPath
22
+ : faviconPath;
23
+
24
+ if (selectedElement) {
25
+ selectedElement.setAttribute('href', favicon);
26
+ }
27
+
17
28
  const renderPlayroom = ({
18
29
  themes = require('./themes'),
19
30
  components = require('./components'),
@@ -0,0 +1,66 @@
1
+ import dedent from 'dedent';
2
+ import { type ErrorWithLocation, compileJsx, validateCode } from './compileJsx';
3
+
4
+ describe('compileJsx', () => {
5
+ test('valid code', () => {
6
+ expect(
7
+ compileJsx(dedent`
8
+ <Foo />
9
+ <Bar />
10
+ `)
11
+ ).toMatchInlineSnapshot(`
12
+ "R_cE(R_F, null, R_cE(Foo, null )
13
+ , R_cE(Bar, null ))"
14
+ `);
15
+ });
16
+
17
+ test('invalid code - no error', () => {
18
+ expect(
19
+ compileJsx(`
20
+ <Foo--BarBaz ::invalid />
21
+ `)
22
+ ).toMatchInlineSnapshot(
23
+ `"R_cE(R_F, null, R_cE(Foo--BarBaz ::invalid, null ))"`
24
+ );
25
+ });
26
+
27
+ test('invalid code - with error', () => {
28
+ expect(() =>
29
+ compileJsx(`
30
+ <Foo />
31
+ <Bar />
32
+ <Foo--BarBaz>
33
+ `)
34
+ ).toThrowErrorMatchingInlineSnapshot(`"Unterminated JSX contents (3:25)"`);
35
+ });
36
+ });
37
+
38
+ describe('validateCode', () => {
39
+ test('valid code', () => {
40
+ expect(
41
+ validateCode(`
42
+ <Foo />
43
+ <Bar />
44
+ `)
45
+ ).toBe(true);
46
+ });
47
+
48
+ test('invalid code', () => {
49
+ const error = validateCode(`- line 1
50
+ <Foo /> - line 2
51
+ <Bar /> - line 3
52
+ <This is not ::valid /> - line 4
53
+ ^ column 20
54
+ `);
55
+ expect(error).toMatchInlineSnapshot(
56
+ `[SyntaxError: Unexpected token (4:20)]`
57
+ );
58
+ expect((error as ErrorWithLocation).loc).toMatchInlineSnapshot(`
59
+ Position {
60
+ "column": 20,
61
+ "index": 113,
62
+ "line": 4,
63
+ }
64
+ `);
65
+ });
66
+ });
@@ -1,18 +1,46 @@
1
- import { transform } from '@babel/standalone';
1
+ import { transform } from 'sucrase';
2
+ import { parseExpression } from '@babel/parser';
3
+ import memoizeOne from 'memoize-one';
2
4
 
3
- export const openFragmentTag = '<React.Fragment>';
4
- export const closeFragmentTag = '</React.Fragment>';
5
+ export const ReactFragmentPragma = 'R_F';
6
+ export const ReactCreateElementPragma = 'R_cE';
5
7
 
6
- export const compileJsx = (code: string) =>
7
- transform(`${openFragmentTag}${code.trim() || ''}${closeFragmentTag}`, {
8
- presets: ['react'],
9
- }).code;
8
+ export const openFragmentTag = '<>';
9
+ export const closeFragmentTag = '</>';
10
10
 
11
- export const validateCode = (code: string) => {
11
+ const wrapInFragment = (code: string) =>
12
+ `${openFragmentTag}${code}${closeFragmentTag}`;
13
+
14
+ export const compileJsx = memoizeOne(
15
+ (code: string) =>
16
+ transform(wrapInFragment(code.trim()), {
17
+ transforms: ['jsx'],
18
+ jsxPragma: ReactCreateElementPragma,
19
+ jsxFragmentPragma: ReactFragmentPragma,
20
+ production: true,
21
+ }).code
22
+ );
23
+
24
+ const parseWithBabel = memoizeOne((code: string) =>
25
+ parseExpression(wrapInFragment(code), {
26
+ plugins: ['jsx'],
27
+ sourceType: 'script',
28
+ strictMode: true,
29
+ })
30
+ );
31
+
32
+ export interface ErrorWithLocation extends Error {
33
+ loc?: {
34
+ line: number;
35
+ column: number;
36
+ };
37
+ }
38
+
39
+ export const validateCode = (code: string): true | ErrorWithLocation => {
12
40
  try {
13
- compileJsx(code);
41
+ parseWithBabel(code);
14
42
  return true;
15
43
  } catch (err) {
16
- return false;
44
+ return err as ErrorWithLocation;
17
45
  }
18
46
  };
@@ -35,4 +35,4 @@ export const isValidLocation = ({
35
35
  cursor,
36
36
  snippet: breakoutString,
37
37
  })
38
- );
38
+ ) === true;
package/tsconfig.json CHANGED
@@ -15,6 +15,6 @@
15
15
  "lib": ["dom", "es2022"],
16
16
  "target": "es2022"
17
17
  },
18
- "include": ["src", "@types"],
18
+ "include": ["src"],
19
19
  "exclude": ["cypress", "node_modules"]
20
20
  }
@@ -1,12 +0,0 @@
1
- interface TransformResponse {
2
- code: string;
3
- }
4
-
5
- declare module '@babel/standalone' {
6
- import type { TransformOptions } from '@babel/core';
7
-
8
- function transform(
9
- code: string,
10
- options: TransformOptions
11
- ): TransformResponse;
12
- }