├── .eslintignore
├── .eslintrc.js
├── packages
├── fiddle
│ ├── .gitignore
│ ├── src
│ │ ├── react-app-env.d.ts
│ │ └── index.tsx
│ ├── public
│ │ └── index.html
│ ├── tsconfig.json
│ ├── config-overrides.js
│ └── package.json
└── build
│ ├── src
│ ├── index.ts
│ └── components
│ │ └── VisualProgramming.tsx
│ ├── tsconfig.json
│ ├── tsconfig.build.json
│ ├── package.json
│ └── package-lock.json
├── .travis.yml
├── .gitignore
├── lerna.json
├── tsconfig.build.json
├── tsconfig.json
├── README.md
├── package.json
└── LICENSE
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/packages/fiddle/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/packages/build/src/index.ts:
--------------------------------------------------------------------------------
1 | // Hello world
--------------------------------------------------------------------------------
/packages/build/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------
/packages/fiddle/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 10
4 |
5 | script:
6 | - npm test
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /nbproject/
2 | /.idea/*
3 | *.tmlanguage.cache
4 | *.tmPreferences.cache
5 | *.stTheme.cache
6 | *.sublime-workspace
7 | *.sublime-project
8 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": [
3 | "packages/*",
4 | "examples/*"
5 | ],
6 | "version": "independent",
7 | "useWorkspaces": true
8 | }
9 |
--------------------------------------------------------------------------------
/packages/fiddle/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | ReactDOM.render(
5 | Hi,
6 | document.getElementById("root")
7 | );
8 |
--------------------------------------------------------------------------------
/packages/build/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.build.json",
3 |
4 | "compilerOptions": {
5 | "outDir": "./dist"
6 | },
7 |
8 | "include": [
9 | "src/**/*"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es5",
5 | "sourceMap": true,
6 | "noEmitOnError": true
7 | },
8 |
9 | "exclude": [
10 | "node_modules",
11 | "dist"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.build.json",
3 |
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "paths": {
7 | "@build./*": ["packages/*/src"]
8 | },
9 | "jsx": "react",
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "types": []
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 |
4 |
5 | ## Status
6 |
7 | We have put this experiment on pause for now, to prioritize a simpler/easier interaction system in Builder. But this is still an area we would love to explore deeper in the future
8 |
--------------------------------------------------------------------------------
/packages/build/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@build./build",
3 | "version": "0.0.1",
4 | "main": "dist/index",
5 | "types": "dist/index",
6 | "files": [
7 | "dist"
8 | ],
9 | "scripts": {
10 | "build": "npm run clean && npm run compile",
11 | "clean": "rimraf -rf ./dist",
12 | "compile": "tsc -p tsconfig.build.json",
13 | "prepublishOnly": "npm run build",
14 | "test": "npm run build"
15 | },
16 | "devDependencies": {
17 | "rimraf": "~3.0.2",
18 | "typescript": "~4.0.0"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@build./repo",
3 | "private": true,
4 | "workspaces": [
5 | "packages/*",
6 | "examples/*"
7 | ],
8 | "scripts": {
9 | "docs": "doctoc --title '**Table of content**' README.md",
10 | "clean": "lerna run clean",
11 | "build": "lerna run build",
12 | "pub": "lerna publish",
13 | "test": "lerna run test"
14 | },
15 | "devDependencies": {
16 | "doctoc": "~1.4.0",
17 | "eslint": "~7.12.0",
18 | "lerna": "~3.22.0",
19 | "typescript": "~4.0.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/fiddle/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | React App
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/packages/fiddle/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "es5",
5 | "lib": [
6 | "dom",
7 | "dom.iterable",
8 | "esnext"
9 | ],
10 | "allowJs": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/packages/fiddle/config-overrides.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
3 |
4 | module.exports = (config) => {
5 | // Remove the ModuleScopePlugin which throws when we try to import something
6 | // outside of src/.
7 | config.resolve.plugins.pop();
8 |
9 | // Resolve the path aliases.
10 | config.resolve.plugins.push(new TsconfigPathsPlugin());
11 |
12 | // Let Babel compile outside of src/.
13 | const tsRule = config.module.rules[2].oneOf[1];
14 | tsRule.include = undefined;
15 | tsRule.exclude = /node_modules/;
16 |
17 | return config;
18 | };
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Builder.io, Inc
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/fiddle/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@build./fiddle",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "@material-ui/core": "^4.11.1",
6 | "@material-ui/icons": "^4.9.1",
7 | "@material-ui/lab": "^4.0.0-alpha.56",
8 | "dedent": "^0.7.0",
9 | "lodash": "^4.17.20",
10 | "mobx-react": "^7.0.5",
11 | "mobx-state-tree": "^4.0.2",
12 | "react": "~16.13.1",
13 | "react-dom": "~16.13.1",
14 | "react-monaco-editor": "^0.40.0",
15 | "traverse": "^0.6.6"
16 | },
17 | "devDependencies": {
18 | "@types/dedent": "^0.7.0",
19 | "@types/lodash": "^4.14.165",
20 | "@types/node": "~14.0.23",
21 | "@types/react": "~16.9.43",
22 | "@types/react-dom": "~16.9.8",
23 | "@types/react-monaco-editor": "^0.16.0",
24 | "@types/traverse": "^0.6.32",
25 | "cross-env": "~7.0.2",
26 | "react-app-rewired": "~2.1.6",
27 | "react-scripts": "~3.4.1",
28 | "tsconfig-paths-webpack-plugin": "~3.2.0"
29 | },
30 | "scripts": {
31 | "start": "cross-env SKIP_PREFLIGHT_CHECK=true react-app-rewired start",
32 | "build": "cross-env SKIP_PREFLIGHT_CHECK=true react-app-rewired build",
33 | "test": "yarn run build"
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/packages/build/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@build./build",
3 | "version": "1.0.0",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "balanced-match": {
8 | "version": "1.0.0",
9 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
10 | "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
11 | "dev": true
12 | },
13 | "brace-expansion": {
14 | "version": "1.1.11",
15 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
16 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
17 | "dev": true,
18 | "requires": {
19 | "balanced-match": "^1.0.0",
20 | "concat-map": "0.0.1"
21 | }
22 | },
23 | "concat-map": {
24 | "version": "0.0.1",
25 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
26 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
27 | "dev": true
28 | },
29 | "fs.realpath": {
30 | "version": "1.0.0",
31 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
32 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
33 | "dev": true
34 | },
35 | "glob": {
36 | "version": "7.1.6",
37 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
38 | "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
39 | "dev": true,
40 | "requires": {
41 | "fs.realpath": "^1.0.0",
42 | "inflight": "^1.0.4",
43 | "inherits": "2",
44 | "minimatch": "^3.0.4",
45 | "once": "^1.3.0",
46 | "path-is-absolute": "^1.0.0"
47 | }
48 | },
49 | "inflight": {
50 | "version": "1.0.6",
51 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
52 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
53 | "dev": true,
54 | "requires": {
55 | "once": "^1.3.0",
56 | "wrappy": "1"
57 | }
58 | },
59 | "inherits": {
60 | "version": "2.0.4",
61 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
62 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
63 | "dev": true
64 | },
65 | "minimatch": {
66 | "version": "3.0.4",
67 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
68 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
69 | "dev": true,
70 | "requires": {
71 | "brace-expansion": "^1.1.7"
72 | }
73 | },
74 | "once": {
75 | "version": "1.4.0",
76 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
77 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
78 | "dev": true,
79 | "requires": {
80 | "wrappy": "1"
81 | }
82 | },
83 | "path-is-absolute": {
84 | "version": "1.0.1",
85 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
86 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
87 | "dev": true
88 | },
89 | "rimraf": {
90 | "version": "3.0.2",
91 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
92 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
93 | "dev": true,
94 | "requires": {
95 | "glob": "^7.1.3"
96 | }
97 | },
98 | "typescript": {
99 | "version": "4.0.5",
100 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.5.tgz",
101 | "integrity": "sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ==",
102 | "dev": true
103 | },
104 | "wrappy": {
105 | "version": "1.0.2",
106 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
107 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
108 | "dev": true
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/packages/build/src/components/VisualProgramming.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren, useEffect, useRef } from 'react';
2 | import * as ts from 'typescript';
3 | import { useLocalStore, useObserver } from 'mobx-react';
4 | import MonacoEditor from 'react-monaco-editor';
5 | import * as dedent from 'dedent';
6 | import { useReaction } from 'hooks/use-reaction';
7 | import { useEventListener } from 'hooks/use-event-listener';
8 | import { safeLsSet, safeLsGet } from 'models/ls-sync';
9 | import { Portal } from 'react-portal';
10 | import { Transition } from 'react-transition-group';
11 | import { theme } from 'constants/theme.constant';
12 | import ContentEditable from 'react-contenteditable';
13 | import { pull, camelCase, debounce } from 'lodash';
14 | import traverse from 'traverse';
15 | import styled from '@emotion/styled';
16 | import { Switch, Tabs, Tab, Tooltip, IconButton, TextField, MenuItem } from '@material-ui/core';
17 | import { humanCase } from 'functions/human-case.function';
18 | import { appState } from 'constants/app-state.constant';
19 | import { ShoppingCart, Code, BubbleChart } from '@material-ui/icons';
20 |
21 | // TODO: add default lib to the language services below too
22 | monaco.languages.typescript.typescriptDefaults.addExtraLib(`
23 | declare var state = any;
24 | declare var context = any;
25 | `);
26 |
27 | // TODO: get dynamically from typeChecker or languageService
28 | const stateProperties = ['active', 'text'];
29 |
30 | export const FILE_NAME = 'code.tsx';
31 |
32 | export function getProgramForText(text: string) {
33 | const dummyFilePath = FILE_NAME;
34 | const textAst = ts.createSourceFile(dummyFilePath, text, ts.ScriptTarget.Latest);
35 | const options: ts.CompilerOptions = {};
36 | const host: ts.CompilerHost = {
37 | fileExists: filePath => filePath === dummyFilePath,
38 | directoryExists: dirPath => dirPath === '/',
39 | getCurrentDirectory: () => '/',
40 | getDirectories: () => [],
41 | getCanonicalFileName: fileName => fileName,
42 | getNewLine: () => '\n',
43 | getDefaultLibFileName: () => '',
44 | getSourceFile: filePath => (filePath === dummyFilePath ? textAst : undefined),
45 | readFile: filePath => (filePath === dummyFilePath ? text : undefined),
46 | useCaseSensitiveFileNames: () => true,
47 | writeFile: () => {},
48 | };
49 | const languageHost: ts.LanguageServiceHost = {
50 | getScriptFileNames: () => [FILE_NAME],
51 | // What is this?
52 | getScriptVersion: fileName => '3',
53 | getCurrentDirectory: () => '/',
54 | getCompilationSettings: () => options,
55 | getDefaultLibFileName: options => ts.getDefaultLibFilePath(options),
56 | fileExists: filePath => filePath === dummyFilePath,
57 | readFile: filePath => (filePath === dummyFilePath ? text : undefined),
58 | getScriptSnapshot: filePath =>
59 | filePath === dummyFilePath ? ts.ScriptSnapshot.fromString(text) : undefined,
60 | };
61 | const program = ts.createProgram({
62 | host,
63 | options,
64 | rootNames: [dummyFilePath],
65 | });
66 |
67 | // TODO: reaction for this on code change
68 | const checker = program.getTypeChecker();
69 |
70 | const languageService = ts.createLanguageService(languageHost);
71 |
72 | return {
73 | checker,
74 | languageService,
75 | program,
76 | };
77 | }
78 |
79 | type LanguageExtension = (node: ts.Node, options: Options) => void | JSX.Element;
80 |
81 | export function SetStateExtension(props: AstEditorProps) {
82 | const { node, options } = props;
83 | const propertyName = (node.left as ts.PropertyAccessExpression).name as ts.Identifier;
84 | const value = node.right;
85 |
86 | const fadedBlue = 'rgb(112, 141, 154)';
87 |
88 | const state = useLocalStore(() => ({
89 | // Get all state properties via typescripts type checker
90 | getPropertyNames() {
91 | return options.programState.program
92 | .getTypeChecker()
93 | .getTypeAtLocation((node.left as ts.PropertyAccessExpression).expression)
94 | .getApparentProperties()
95 | .map(item => item.name);
96 | },
97 | getCompletionItems() {
98 | return options.programState.languageService.getCompletionsAtPosition(
99 | FILE_NAME,
100 | (node.left as ts.PropertyAccessExpression).name.getStart(),
101 | {}
102 | );
103 | },
104 | }));
105 |
106 | return useObserver(() => {
107 | return (
108 |
109 |
114 | Set a state property.{' '}
115 | Learn about state
116 | >
117 | }
118 | >
119 |
120 |
121 |
122 | Set state
123 |
124 |
125 |
126 |
129 | Choose or create a name for your state property.{' '}
130 | Learn more
131 | >
132 | }
133 | >
134 |
135 |
136 |
137 |
138 |
139 | To
140 |
141 |
142 |
143 | );
144 | });
145 | }
146 |
147 | const SPACER_TOKEN = '__SPACER__';
148 |
149 | const createSpacer = () => ts.createIdentifier(SPACER_TOKEN);
150 |
151 | const normalizeExpression = (expression: string) => expression.replace(/\s+/g, '');
152 |
153 | export function LiquidBubble(props: AstEditorProps) {
154 | const { node, options } = props;
155 | const state = useLocalStore(() => ({
156 | hovering: false,
157 | showCode: false,
158 | }));
159 |
160 | const liquidEditorRef = useRef(null);
161 |
162 | useEffect(() => {
163 | if (state.showCode && liquidEditorRef.current) {
164 | setTimeout(() => {
165 | liquidEditorRef.current?.focus();
166 | }, 500);
167 | }
168 | }, [state.showCode]);
169 |
170 | return useObserver(() => {
171 | const liquidExpression = node.arguments[0] as ts.StringLiteral;
172 | const simpleExpression = humanCase(liquidExpression.text.split('|')[0]);
173 | return (
174 | (state.hovering = true)}
176 | onMouseLeave={() => (state.hovering = false)}
177 | >
178 |
179 |
180 |
181 | {simpleExpression}
182 |
189 | {
205 | (node.arguments as any)[0] = ts.createStringLiteral(stripHtml(e.target.value));
206 | options.programState.updateCode();
207 | }}
208 | />
209 |
210 |
211 |
218 |
221 | Toggle liquid code. Learn more{' '}
222 | >
223 | }
224 | >
225 | {
227 | e.stopPropagation();
228 | state.showCode = !state.showCode;
229 | }}
230 | css={{ padding: 2 }}
231 | >
232 |
233 |
234 |
235 |
236 |
237 |
238 | );
239 | });
240 | }
241 |
242 | export function EventListener(props: AstEditorProps) {
243 | const state = useLocalStore(() => ({}));
244 | const { options, node } = props;
245 |
246 | const eventNode = node.arguments[0] as ts.StringLiteral;
247 | const callback = node.arguments[1] as ts.ArrowFunction;
248 |
249 | return useObserver(() => {
250 | return (
251 |
252 |
253 |
254 | On page
255 | {
273 | (node.arguments as any)[0] = ts.createStringLiteral(stripHtml(e.target.value));
274 | options.programState.updateCode();
275 | }}
276 | >
277 | {['scroll', 'click', 'mousedown', 'keypress'].map(item => (
278 |
281 | ))}
282 |
283 |
284 |
285 | {callback.body && }
286 |
287 | );
288 | });
289 | }
290 |
291 | const languageExtensions: LanguageExtension[] = [
292 | // Liquid bubbles
293 | (node, options) => {
294 | if (
295 | ts.isCallExpression(node) &&
296 | normalizeExpression(node.getText().split('(')[0]) === 'context.shopify.liquid.get' &&
297 | node.arguments.length &&
298 | ts.isStringLiteral(node.arguments[0])
299 | ) {
300 | return ;
301 | }
302 | },
303 | // Liquid bubbles
304 | (node, options) => {
305 | if (
306 | ts.isPropertyAccessExpression(node) &&
307 | normalizeExpression(node.getText()) === 'document.body.scrollTop'
308 | ) {
309 | return (
310 |
311 | Page scroll position
312 |
313 | );
314 | }
315 | },
316 | // Liquid bubbles
317 | (node, options) => {
318 | if (
319 | ts.isCallExpression(node) &&
320 | normalizeExpression(node.getText().split('(')[0]) === 'document.addEventListener'
321 | ) {
322 | return ;
323 | }
324 | },
325 | // Render spacers
326 | (node, options) => {
327 | if (ts.isIdentifier(node) && node.text === SPACER_TOKEN) {
328 | // TODO: make component and listen for mouseup and delete all spacers
329 | return (
330 |
342 | );
343 | }
344 | },
345 |
346 | // `state.foo = 'bar' to "set state"
347 | (node, options) => {
348 | if (ts.isBinaryExpression(node) && node.operatorToken.getText() === '=') {
349 | if (ts.isPropertyAccessExpression(node.left)) {
350 | if (
351 | ts.isIdentifier(node.left.expression) &&
352 | node.left.expression.text === 'state' &&
353 | ts.isIdentifier(node.left.name)
354 | ) {
355 | return ;
356 | }
357 | }
358 | }
359 | },
360 | ];
361 |
362 | const replace = (arr: T[], newArr: T[]) => {
363 | arr.length = 0;
364 | arr.push(...newArr);
365 | };
366 |
367 | const Row = styled.div({ display: 'flex', alignItems: 'center', flexWrap: 'wrap' });
368 | const Stack = styled.div({ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' });
369 |
370 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
371 |
372 | const findKey = (obj: { [key: string]: any }, value: object) => {
373 | for (const key in obj) {
374 | if (obj[key] === value) {
375 | return key;
376 | }
377 | }
378 | };
379 |
380 | const replaceNode = (oldNode: ts.Node, newNode: ts.Node) => {
381 | const key = findKey(oldNode.parent, oldNode);
382 | if (key) {
383 | (oldNode.parent as any)[key] = newNode;
384 | } else {
385 | console.error('Could not find key to replace node', { oldNode, newNode });
386 | }
387 | };
388 |
389 | type VisualProgrammingProps = {
390 | className?: string;
391 | };
392 |
393 | export type ProgramState = {
394 | draggingNode: ts.Node | null;
395 | hoveringNode: ts.Node | null;
396 | ast: ts.SourceFile;
397 | selection: ts.Node[];
398 | updateCode: () => void;
399 | hoveringCodeEditor: boolean;
400 | program: ts.Program;
401 | languageService: ts.LanguageService;
402 | };
403 |
404 | type Options = {
405 | programState: ProgramState;
406 | };
407 |
408 | interface AstEditorProps {
409 | options: Options;
410 | node: NodeType;
411 | }
412 |
413 | const stripHtml = (html: string) => {
414 | const div = document.createElement('div');
415 | div.innerHTML = html;
416 | return div.innerText;
417 | };
418 |
419 | const localStorageCodeKey = 'builder.experiments.visualProgramming.code';
420 |
421 | export function VariableStatement(props: AstEditorProps) {
422 | const { node, options } = props;
423 | return useObserver(() => {
424 | return (
425 | <>
426 | {node.declarationList.declarations.map((item, index) => (
427 |
428 | ))}
429 | >
430 | );
431 | });
432 | }
433 |
434 | export function CallExpression(props: AstEditorProps) {
435 | const { node, options } = props;
436 | return useObserver(() => {
437 | return (
438 |
439 |
440 | Do action
441 |
442 | {node.expression && }
443 | {node.arguments &&
444 | node.arguments.map((arg, index) => {
445 | const isFirst = index === 0;
446 | return (
447 |
448 |
449 | {isFirst ? 'With' : ','}
450 |
451 |
452 |
453 | );
454 | })}
455 |
456 | );
457 | });
458 | }
459 |
460 | export function Identifier(
461 | props: AstEditorProps & { open?: 'left' | 'right' | 'both'; color?: string }
462 | ) {
463 | const { node, options } = props;
464 | return useObserver(() => {
465 | const isActive = Boolean(
466 | options.programState.selection.find(item => ts.isIdentifier(item) && item.text === node.text)
467 | );
468 |
469 | return (
470 | replace(options.programState.selection, [node])}
473 | onBlur={() => {
474 | if (options.programState.selection.includes(node)) {
475 | pull(options.programState.selection, node);
476 | }
477 | }}
478 | open={props.open}
479 | active={isActive}
480 | color={props.color || theme.colors.primary}
481 | onChange={text => {
482 | const file = node.getSourceFile();
483 | const newNode = ts.createIdentifier(text);
484 |
485 | // Update all references to this identifier
486 | // TODO: use ts.transform or ts.visitEachChild or another
487 | // built-in API after figuring out which one is actually right for this
488 | traverse(file).forEach((child: any) => {
489 | if (
490 | child &&
491 | ts.isIdentifier(child) &&
492 | !(
493 | ts.isPropertyAssignment(child.parent) || ts.isPropertyAccessExpression(child.parent)
494 | ) &&
495 | child.text === node.text
496 | ) {
497 | // Identifiers seem to be immutable in TS AST
498 | replaceNode(child, newNode);
499 | }
500 | });
501 | options.programState.updateCode();
502 | }}
503 | >
504 | {node.text}
505 |
506 | );
507 | });
508 | }
509 |
510 | const bubbleHeight = 30;
511 |
512 | export function Bubble(
513 | props: PropsWithChildren<{
514 | options: Options;
515 | color?: string;
516 | active?: boolean;
517 | className?: string;
518 | onFocus?: (event: React.FocusEvent) => void;
519 | onBlur?: (event: React.FocusEvent) => void;
520 | onChange?: (text: string) => void;
521 | open?: 'right' | 'left' | 'both' | 'none';
522 | humanCase?: boolean;
523 | htmlMode?: boolean;
524 | }>
525 | ) {
526 | const size = bubbleHeight;
527 |
528 | const spacerStyles: Partial = {
529 | backgroundColor: '#222',
530 | width: size + 3,
531 | height: size + 4,
532 | borderRadius: 100,
533 | };
534 |
535 | const openLeft = props.open === 'left' || props.open === 'both';
536 | const openRight = props.open === 'right' || props.open === 'both';
537 |
538 | const gap = 3;
539 | const htmlMode = props.htmlMode === true || props.onChange;
540 |
541 | return useObserver(() => (
542 |
568 | {openLeft && (
569 |
576 | )}
577 | {!htmlMode ? (
578 | props.children
579 | ) : (
580 |
{
591 | props.onChange?.(camelCase(stripHtml(e.target.value)));
592 | props.options.programState.updateCode();
593 | }}
594 | />
595 | )}
596 | {openRight && (
597 |
604 | )}
605 |
606 | ));
607 | }
608 |
609 | export function VariableDeclaration(props: AstEditorProps) {
610 | const { node, options } = props;
611 | return useObserver(() => {
612 | return (
613 |
614 |
615 | Set
616 |
617 |
618 |
619 | To
620 |
621 | {node.initializer && }
622 |
623 | );
624 | });
625 | }
626 |
627 | export function Block(props: AstEditorProps) {
628 | const { node, options } = props;
629 | const tabSpace = 40;
630 | return useObserver(() => {
631 | return (
632 |
640 |
651 | {node.statements.map((item, index) => (
652 |
653 | ))}
654 |
655 | );
656 | });
657 | }
658 |
659 | export function ExpressionStatement(props: AstEditorProps) {
660 | const { node, options } = props;
661 | return useObserver(() => {
662 | return <>{node.expression && }>;
663 | });
664 | }
665 |
666 | export function ReturnStatement(props: AstEditorProps) {
667 | const { node, options } = props;
668 | return useObserver(() => {
669 | return (
670 |
671 |
672 | Respond
673 |
674 | {node.expression && }
675 |
676 | );
677 | });
678 | }
679 |
680 | export function FunctionDeclaration(props: AstEditorProps) {
681 | const { node, options } = props;
682 |
683 | const color = 'rgb(226, 158, 56)';
684 |
685 | return useObserver(() => {
686 | return (
687 |
688 |
689 |
690 | Create action named
691 |
692 | {node.name && }
693 |
694 | that does
695 |
696 |
697 | {node.body && }
698 |
699 | );
700 | });
701 | }
702 | export function ArrowFunction(props: AstEditorProps) {
703 | const { node, options } = props;
704 |
705 | const color = 'rgb(226, 158, 56)';
706 |
707 | return useObserver(() => {
708 | return (
709 |
710 |
711 |
712 | Do
713 |
714 |
715 | {node.body && }
716 |
717 | );
718 | });
719 | }
720 |
721 | export function IfStatement(props: AstEditorProps) {
722 | const { node, options } = props;
723 |
724 | const thenHasIf = node.elseStatement && ts.isIfStatement(node.elseStatement);
725 |
726 | return useObserver(() => {
727 | return (
728 | <>
729 |
730 |
731 | If
732 |
733 |
734 |
735 |
736 | {thenHasIf ? (
737 |
738 |
739 | Otherwise
740 |
741 |
742 |
743 | ) : (
744 | node.elseStatement && (
745 | <>
746 | Otherwise
747 |
748 | >
749 | )
750 | )}
751 | >
752 | );
753 | });
754 | }
755 |
756 | export function SourceFile(props: AstEditorProps) {
757 | const { node, options } = props;
758 | return useObserver(() => {
759 | return (
760 |
761 | {node.statements.map((item, index) => (
762 |
763 | ))}
764 |
765 | );
766 | });
767 | }
768 | export function BinaryExpression(props: AstEditorProps) {
769 | const { node, options } = props;
770 |
771 | const tokenText = node.operatorToken.getText();
772 |
773 | const isEquals = tokenText === '=';
774 |
775 | const textMap: { [key: string]: string | undefined } = {
776 | '===': 'is',
777 | '==': 'is',
778 | '&&': 'and',
779 | '||': 'or',
780 | '!==': 'is not',
781 | '!=': 'is not',
782 | };
783 |
784 | const useText = textMap[tokenText] || tokenText;
785 |
786 | return useObserver(() => {
787 | return (
788 |
789 | {isEquals && (
790 |
791 | Set
792 |
793 | )}
794 |
795 |
796 | {useText}
797 |
798 |
799 |
800 | );
801 | });
802 | }
803 |
804 | const booleanBubbleStyles: Partial = {
805 | height: bubbleHeight,
806 | display: 'flex',
807 | alignItems: 'center',
808 | borderRadius: bubbleHeight,
809 | backgroundColor: '#555',
810 | position: 'relative',
811 | marginLeft: 3,
812 | zIndex: 2,
813 | };
814 |
815 | export function TrueKeyword(props: AstEditorProps) {
816 | const { node, options } = props;
817 | return useObserver(() => {
818 | return (
819 |
820 |
821 | {
825 | replaceNode(node, ts.createFalse());
826 | options.programState.updateCode();
827 | }}
828 | />
829 |
830 |
831 | );
832 | });
833 | }
834 | export function FalseKeyword(props: AstEditorProps) {
835 | const { node, options } = props;
836 | return useObserver(() => {
837 | return (
838 |
839 |
840 | {
843 | replaceNode(node, ts.createTrue());
844 | options.programState.updateCode();
845 | }}
846 | />
847 |
848 |
849 | );
850 | });
851 | }
852 |
853 | /**
854 | * This is things like `!foo` or `!state.foo` or `+foo`
855 | */
856 | export function PrefixUnaryExpression(props: AstEditorProps) {
857 | const { node, options } = props;
858 |
859 | return useObserver(() => {
860 | return (
861 |
862 | {/*
863 | TODO: handle other operators than "!", e.g. `-foo` of `-10`
864 | Rarer ones to add eventually are also things like `+foo` and `~foo` etc
865 | */}
866 |
867 | Not
868 |
869 |
870 |
871 | );
872 | });
873 | }
874 | export function PropertyAccessExpression(props: AstEditorProps) {
875 | const { node, options } = props;
876 | return useObserver(() => {
877 | return (
878 |
879 |
880 |
886 |
887 | );
888 | });
889 | }
890 |
891 | // TODO: if works for comments rename to NodeWrapper or something
892 | export function Hoverable(props: PropsWithChildren<{ node: ts.Node; options: Options }>) {
893 | const { options, node } = props;
894 | const state = useLocalStore(() => ({
895 | onMouseEnter() {
896 | options.programState.hoveringNode = node;
897 | if (options.programState.draggingNode) {
898 | // TODO: find first AST element parent that is an array, make that the subject, splice in
899 | // the spacer
900 | }
901 | },
902 | onMouseLeave() {
903 | if (options.programState.hoveringNode === node) {
904 | options.programState.hoveringNode = null;
905 | // TODO: remove all spacers from AST
906 | }
907 | },
908 | }));
909 |
910 | // TODO: turn back on when updated to handle below tasks
911 | const renderComments = false as boolean;
912 |
913 | const comments =
914 | renderComments &&
915 | ts.getLeadingCommentRanges(options.programState.ast.getFullText(), node.getFullStart());
916 |
917 | return (
918 | <>
919 | {/*
920 | TODO: special handling for jsdoc style
921 | TODO: handle same comment matching multiple times
922 | TODO: make editable
923 | */}
924 | {renderComments &&
925 | comments &&
926 | comments.map((item, index) => (
927 |
932 | {options.programState.ast.getFullText().slice(item.pos, item.end)}
933 |
934 | ))}
935 | {
938 | // if (!ts.isLiteralExpression(node)) {
939 | // options.programState.draggingNode = node;
940 | // }
941 | // }}
942 | onMouseEnter={state.onMouseEnter}
943 | onMouseLeave={state.onMouseLeave}
944 | >
945 | {props.children}
946 |
947 | >
948 | );
949 | }
950 |
951 | const hoverable = (
952 | node: ts.Node,
953 | options: Options,
954 | children: JSX.Element | (() => JSX.Element)
955 | ) => (
956 |
957 | {typeof children === 'function' ? children() : children}
958 |
959 | );
960 |
961 | export function Node(props: AstEditorProps) {
962 | const { node, options } = props;
963 | return useObserver(() =>
964 | hoverable(node, options, () => {
965 | for (const extension of languageExtensions) {
966 | const result = extension(node, options);
967 | if (result) {
968 | return result;
969 | }
970 | }
971 | if (ts.isVariableStatement(node)) {
972 | return ;
973 | }
974 | if (ts.isVariableDeclaration(node)) {
975 | return ;
976 | }
977 | if (ts.isSourceFile(node)) {
978 | return ;
979 | }
980 | if (ts.isIdentifier(node)) {
981 | return ;
982 | }
983 | if (ts.isPropertyAccessExpression(node)) {
984 | return ;
985 | }
986 | if (ts.isBinaryExpression(node)) {
987 | return ;
988 | }
989 | if (ts.isFunctionDeclaration(node)) {
990 | return ;
991 | }
992 | if (ts.isArrowFunction(node)) {
993 | return ;
994 | }
995 | if (ts.isPrefixUnaryExpression(node)) {
996 | return ;
997 | }
998 | if (ts.isBlock(node)) {
999 | return ;
1000 | }
1001 | if (ts.isExpressionStatement(node)) {
1002 | return ;
1003 | }
1004 | if (ts.isCallExpression(node)) {
1005 | return ;
1006 | }
1007 | if (ts.isReturnStatement(node)) {
1008 | return ;
1009 | }
1010 | if (ts.isIfStatement(node)) {
1011 | return ;
1012 | }
1013 |
1014 | // Can't seem to find a `ts.is*` method for these like the above
1015 | if (node.kind === 106) {
1016 | return ;
1017 | }
1018 | if (node.kind === 91) {
1019 | return ;
1020 | }
1021 |
1022 | if (ts.isStringLiteral(node)) {
1023 | return (
1024 | {
1028 | node.text = text;
1029 | }}
1030 | >
1031 | {node.text}
1032 |
1033 | );
1034 | }
1035 | if (ts.isNumericLiteral(node)) {
1036 | return (
1037 | {
1041 | node.text = text;
1042 | }}
1043 | >
1044 | {node.text}
1045 |
1046 | );
1047 | }
1048 |
1049 | return (
1050 |
1051 |
1052 |
1055 | appState.globalState.openDialog(
1056 |
1068 | {
1088 | replaceNode(node, parseCode(val));
1089 | options.programState.updateCode();
1090 | }}
1091 | />
1092 | {/* null}
1096 | /> */}
1097 |
1098 | )
1099 | }
1100 | >
1101 |
1108 |
1109 |
1110 |
1111 | );
1112 | })
1113 | );
1114 | }
1115 |
1116 | export const createSourceFile = (code: string) => {
1117 | return ts.createSourceFile(FILE_NAME, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
1118 | };
1119 |
1120 | // TODO: support multiple statements
1121 | const parseCode = (code: string) => {
1122 | const file = createSourceFile(code);
1123 | return file.statements[0];
1124 | };
1125 |
1126 | const defaultTemplates = [
1127 | [`state.active = true`, 'state'],
1128 | [`state.text = 'Hello!'`, 'state'],
1129 | // TODO: make shopify dynamic, for instance if there is a product-like name in current scope
1130 | [`context.shopify.liquid.get('product.price | currency')`, 'shopify'],
1131 | [`context.shopify.liquid.get('product.name')`, 'shopify'],
1132 | [`context.shopify.liquid.get('product.description')`, 'shopify'],
1133 | [
1134 | dedent`document.addEventListener('scroll', event => {
1135 | if (document.body.scrollTop > 10) {
1136 | state.scrolledDown = true
1137 | }
1138 | })`,
1139 | 'logic',
1140 | ],
1141 | [
1142 | dedent`if (state.active) {
1143 | state.active = false;
1144 | }`,
1145 | 'logic',
1146 | ],
1147 | [
1148 | dedent`function toggleState() {
1149 | state.active = !state.active;
1150 | }`,
1151 | 'logic',
1152 | ],
1153 | ].map(item => {
1154 | const [code, ...tags] = item;
1155 | return {
1156 | tags,
1157 | ast: parseCode(code),
1158 | };
1159 | });
1160 |
1161 | function Draggable(props: { node: ts.Node; options: Options }) {
1162 | const { node, options } = props;
1163 | return useObserver(() => (
1164 | {
1166 | e.preventDefault();
1167 | options.programState.draggingNode = node;
1168 | }}
1169 | css={{
1170 | opacity: 0.85,
1171 | marginBottom: 5,
1172 | cursor: 'pointer',
1173 | '&:hover': {
1174 | opacity: 1,
1175 | },
1176 | '& *': {
1177 | pointerEvents: 'none',
1178 | },
1179 | }}
1180 | >
1181 |
1182 |
1183 | ));
1184 | }
1185 |
1186 | function DraggingNodeOverlay(props: { options: Options }) {
1187 | const { options } = props;
1188 | return useObserver(() => {
1189 | const node = options.programState.draggingNode;
1190 | return (
1191 | node && (
1192 |
1206 |
1207 |
1208 | )
1209 | );
1210 | });
1211 | }
1212 |
1213 | function Toolbox(props: { options: Options; className?: string }) {
1214 | const { options } = props;
1215 | const templates = defaultTemplates;
1216 |
1217 | const TAB_KEY = 'builder.experiments.visualCodingTab';
1218 |
1219 | const state = useLocalStore(() => ({
1220 | tab: safeLsGet(TAB_KEY) ?? 0,
1221 | }));
1222 |
1223 | useReaction(
1224 | () => state.tab,
1225 | tab => safeLsSet(TAB_KEY, tab)
1226 | );
1227 |
1228 | const tabStyle: Partial = {
1229 | minWidth: 0,
1230 | minHeight: 0,
1231 | maxWidth: 'none',
1232 | height: 39,
1233 | color: '#888',
1234 | };
1235 |
1236 | return useObserver(() => {
1237 | return (
1238 |
1244 | (state.tab = value)}
1248 | indicatorColor="primary"
1249 | textColor="primary"
1250 | variant="fullWidth"
1251 | >
1252 |
1253 |
1254 |
1255 |
1256 |
1257 |
1258 | {templates
1259 | .filter(item => {
1260 | switch (state.tab) {
1261 | case 0:
1262 | return true;
1263 | case 1:
1264 | return item.tags.includes('state');
1265 | case 2:
1266 | return item.tags.includes('shopify');
1267 | case 3:
1268 | return item.tags.includes('logic');
1269 | case 4:
1270 | return false;
1271 | }
1272 | })
1273 | .map((item, index) => (
1274 |
1275 | ))}
1276 |
1277 | );
1278 | });
1279 | }
1280 |
1281 | export function VisualProgramming(props: VisualProgrammingProps) {
1282 | const state = useLocalStore(() => {
1283 | const initialCode = safeLsGet(localStorageCodeKey) || '';
1284 | return {
1285 | programState: {
1286 | hoveringCodeEditor: false,
1287 | draggingNode: null,
1288 | program: null as any,
1289 | hoveringNode: null,
1290 | updateCode() {
1291 | state.updateCode();
1292 | },
1293 | get ast(): ts.SourceFile {
1294 | return state.ast;
1295 | },
1296 | get selection(): ts.Node[] {
1297 | return state.selection;
1298 | },
1299 | set selection(arr) {
1300 | replace(state.selection, arr);
1301 | },
1302 | } as ProgramState,
1303 | selection: [] as ts.Node[],
1304 | code: initialCode,
1305 | ast: createSourceFile(initialCode),
1306 | codeToAst(this: { code: string }, code = this.code) {
1307 | return createSourceFile(code);
1308 | },
1309 | astToCode(this: { ast: ts.SourceFile | null }, ast = this.ast) {
1310 | return !ast ? '' : printer.printFile(ast);
1311 | },
1312 | updateAst() {
1313 | this.ast = this.codeToAst(this.code);
1314 | },
1315 | updateCode() {
1316 | this.code = this.astToCode(this.ast as ts.SourceFile);
1317 | },
1318 | };
1319 | });
1320 |
1321 | useReaction(
1322 | () => state.code,
1323 | code => safeLsSet(localStorageCodeKey, code)
1324 | );
1325 |
1326 | useReaction(
1327 | () => state.code,
1328 | () => {
1329 | state.updateAst();
1330 |
1331 | const tsInfo = getProgramForText(state.code);
1332 | state.programState.program = tsInfo.program;
1333 | state.programState.languageService = tsInfo.languageService;
1334 | }
1335 | );
1336 |
1337 | useEventListener(document, 'mouseup', () => {
1338 | if (state.programState.draggingNode) {
1339 | if (state.programState.hoveringCodeEditor) {
1340 | const node = state.programState.draggingNode;
1341 | traverse(node).forEach(function (child) {
1342 | if (child && ts.isStringLiteral(child)) {
1343 | this.update(ts.createStringLiteral(child.text));
1344 | }
1345 | if (child && ts.isNumericLiteral(child)) {
1346 | this.update(ts.createNumericLiteral(child.text));
1347 | }
1348 | });
1349 | // TODO: modify AST here
1350 | state.code += '\n' + printer.printNode(ts.EmitHint.Unspecified, node, state.ast);
1351 | }
1352 |
1353 | state.programState.draggingNode = null;
1354 | }
1355 | });
1356 |
1357 | useEventListener(document, 'keydown', e => {
1358 | const event = e as KeyboardEvent;
1359 | // Esc key
1360 | if (event.which === 27 && state.programState.draggingNode) {
1361 | state.programState.draggingNode = null;
1362 | }
1363 | });
1364 |
1365 | return useObserver(() => {
1366 | const options = { programState: state.programState };
1367 | return (
1368 |
1377 |
1378 |
1379 | {transitionState => (
1380 |
1395 | )}
1396 |
1397 |
1398 |
1404 |
1405 |
1406 |
1407 |
1408 |
1411 |
{
1419 | state.programState.hoveringCodeEditor = true;
1420 | }}
1421 | onMouseLeave={() => {
1422 | state.programState.hoveringCodeEditor = false;
1423 | }}
1424 | >
1425 | {state.ast &&
}
1426 | {state.programState.draggingNode &&
1427 | !state.programState.hoveringNode &&
1428 | state.programState.hoveringCodeEditor && (
1429 |
1437 | )}
1438 |
1439 |
1440 |
1448 | {
1464 | state.code = val;
1465 | }}
1466 | />
1467 |
1468 |
1469 |
1470 | );
1471 | });
1472 | }
1473 |
--------------------------------------------------------------------------------