diff --git a/client/README.md b/client/README.md new file mode 100644 index 00000000..634d83f4 --- /dev/null +++ b/client/README.md @@ -0,0 +1,25 @@ +Rewrite of TryPureScript using latest features of PS ecosystem, such as: +* Halogen Hooks +* Tailwind CSS + +Lots of HTML and JS code was eliminated. + +Also enables gist saving and tracking state in URL rather than local storage. + +### Local Development +``` +npm i +npm config set tps:configpath "config/dev/*.purs" +npm run gen-css # Create initial tailwind css files +npm run start # Launch local dev server with automatic reload/refresh. + +# Optional: +npm run build # To manually rebuild if IDE does not do this automatically. +npm run lock-css # To speed up rebuilds if you're not adding new css classes. +``` + +### Building for production +``` +npm config set tps:configpath "config/prod/*.purs" +npm run prod # Create minified production build +``` diff --git a/client/config/dev/Try.Config.purs b/client/config/dev/Try.Config.purs index 5c6218f1..e18b22ee 100644 --- a/client/config/dev/Try.Config.purs +++ b/client/config/dev/Try.Config.purs @@ -1,10 +1,23 @@ module Try.Config where +appDomain :: String +appDomain = "http://localhost:1234" + +tokenServerUrl :: String +--tokenServerUrl = "http://localhost:7071/api/localtrigger" +tokenServerUrl = "https://localtpsfunction.azurewebsites.net/api/localtps?code=Il1fqBKydiLWqoognUIzgppwi10qfmXjkhAa75yRg5S4S10LNfsiTw==" + +-- GitHub OAuth app for saving gists. +-- This is tied to a specific app domain. +-- I believe it's fine for client ID to be public information +clientID :: String +clientID = "6f4e10fd8cef6995ac09" + loaderUrl :: String -loaderUrl = "js/output" +--loaderUrl = "js/output" +--loaderUrl = "http://localhost:8080" +loaderUrl = "https://compile.purescript.org/output" compileUrl :: String -compileUrl = "http://localhost:8081" - -mainGist :: String -mainGist = "7ad2b2eef11ac7dcfd14aa1585dd8f69" +--compileUrl = "http://localhost:8081" +compileUrl = "https://compile.purescript.org" diff --git a/client/config/dev/index.js b/client/config/dev/index.js new file mode 100644 index 00000000..db8b552a --- /dev/null +++ b/client/config/dev/index.js @@ -0,0 +1 @@ +require("../output/Main/index.js").main(); diff --git a/client/config/prod/Try.Config.purs b/client/config/prod/Try.Config.purs index e2232d10..b2010f5a 100644 --- a/client/config/prod/Try.Config.purs +++ b/client/config/prod/Try.Config.purs @@ -1,10 +1,19 @@ module Try.Config where +appDomain :: String +appDomain = "https://try.ps.ai" + +tokenServerUrl :: String +tokenServerUrl = "https://tpsfunction.azurewebsites.net/api/tps?code=JmxFIJvNG9E4qFtrwyD2v40YIWAtKUt1HDxLQ9rjmP4bRafnxWjNZg==" + +-- GitHub OAuth app for saving gists. +-- This is tied to a specific app domain. +-- I believe it's fine for client ID to be public information +clientID :: String +clientID = "3634da383bb531261af5" + loaderUrl :: String loaderUrl = "https://compile.purescript.org/output" compileUrl :: String compileUrl = "https://compile.purescript.org" - -mainGist :: String -mainGist = "7ad2b2eef11ac7dcfd14aa1585dd8f69" diff --git a/client/css/css2purs.py b/client/css/css2purs.py new file mode 100755 index 00000000..3f7a75e4 --- /dev/null +++ b/client/css/css2purs.py @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +import re +import fileinput + +# Usage: +# cat tailwind.css | ./css2purs.py > Tailwind.purs + +# vim Regex +# /^\s*\.[^ ]* + +# Using list rather than set to preserve sorted order +# Assuming that duplicates are always adjacent +cssNames = [] + +def process(line): + # Example input: + # line = ' .-sm\:-space-y-0-w-1\/2:hover {' + regName = re.compile('^\s*\.([^ ]*?[^\\\\])(:.*)? .*$') + + m = regName.match(line) + + if m: + escaped = m.group(1) + # Just escaped class name + # -sm\:-space-y-0-w-1\/2 + + cssStr = escaped.replace('\\', '') + # Remove escaped symbols - this is the CSS string + # -sm:-space-y-0-w-1/2 + + # don't add duplicates + # assuming always adjacent + if len(cssNames) and cssNames[-1] == cssStr: + return + + cssNames.append(cssStr) + +def cssToPs(cssStr): + # Conversion to PureScript-compatible name + # Must remove symbols + + def negRepl(m): + return m.group(1) + 'neg' + m.group(3).upper() + negSub = re.sub(r'(^|:)(-)(.)', negRepl, cssStr) + # Replace leading dashes (used to represent negatives) with 'neg' + # Camel-case for next word + # negSm:negSpace-y-0-w-1/2 + + colonDivSub = negSub.replace(':', '-').replace('/', 'd') + # replace colon separator with dash + # replace division sign for fractions with 'd' + # negSm-negSpace-y-0-w-1d2 + + def dashRepl(m): + return m.group(1).upper() + dashSub = re.sub(r'-(.)', dashRepl, colonDivSub) + # Convert dash-separator to camelCase + # negSmNegSpaceY0W1d2 + + # Debug prints + # print('cssStr', cssStr) + # print(escaped) + # print(negSub) + # print(colonDivSub) + # print(dashSub) + + psName = dashSub + print() + print('-- | ' + cssStr) + print(psName + ' :: ClassName') + print(psName + ' = ClassName "' + cssStr + '"') + +for line in fileinput.input(): + process(line) + +print('-- | Autogenerated from tailwind.css') +print('module Tailwind where') +print() +print('import Halogen.HTML.Core (ClassName(..))') + +for cssName in cssNames: + cssToPs(cssName) diff --git a/client/css/tailwind_inputs.css b/client/css/tailwind_inputs.css new file mode 100644 index 00000000..bd6213e1 --- /dev/null +++ b/client/css/tailwind_inputs.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/client/package.json b/client/package.json index 176e15c5..ee200f1d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,17 +1,56 @@ { - "name": "trypurescript-client", + "name": "tps", "private": true, "config": { "configpath": "config/dev/*.purs" }, "scripts": { - "clean": "rimraf output", - "build": "spago bundle-app --path $npm_package_config_configpath --purs-args '--censor-lib --strict' --to public/js/index.js" + "c-user-facing": "# --------- user facing scripts -------", + + "c-gen-css": "# -- Generate tailwind css AND purs css wrapper", + "gen-css": "npm run build-css-only && npm run css2purs", + + "c1-lock-css": "# -- Strip away unused css from autogenerated purs files.", + "c2-lock-css": "# -- This improves rebuild times, but won't allow adding new css.", + "c3-lock-css": "# -- So re-run gen-css before making additional css changes.", + "lock-css": "npm run bundle && npm run css-purge && npm run css2purs", + + "c-build": "# -- Build purs code in project", + "build": "spago build --path $npm_package_config_configpath", + + "c-start": "# -- Launch development server in web browser", + "start": "npm run build && cp config/dev/index.js public && webpack-dev-server --port 1234 --open --config webpack.dev.js", + + "c-prod": "# -- Create minified production build", + "prod": "npm run bundle && npm run css-purge && webpack --config webpack.prod.js", + + "c-wp-dev-build": "# -- Create unminified dev build. This is useful for troubleshooting the production build.", + "wp-dev-build": "cp config/dev/index.js public && webpack --config webpack.dev.js", + + "c-serve-static": "# -- serves static files locally. For checking if everything was bundled correctly.", + "serve-static": "http-server dist/ -c-1 -o tps --port 1234 -P http://localhost:1234\\?", + + "c-internal": "# -------- internal helper scripts -------", + + "bundle": "spago bundle-app --path $npm_package_config_configpath --to public/index.js", + "build-css-only": "tailwindcss build css/tailwind_inputs.css -o public/tailwind.css", + "css2purs": "cat public/tailwind.css | ./css/css2purs.py > src/Tailwind.purs", + "css-purge": "NODE_ENV=production npm run build-css-only" }, "devDependencies": { - "purescript": "^0.13.6", - "purescript-psa": "^0.7.3", - "rimraf": "^2.5.4", - "spago": "^0.14.0" + "clean-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^6.0.3", + "css-loader": "^3.6.0", + "cssnano": "^4.1.10", + "exports-loader": "^1.1.0", + "file-loader": "^6.0.0", + "html-webpack-plugin": "^4.3.0", + "style-loader": "^1.2.1", + "tailwindcss": "^1.4.6", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.12", + "webpack-dev-server": "^3.11.0", + "webpack-merge": "^5.0.9", + "xhr2": "^0.2.0" } } diff --git a/client/packages.dhall b/client/packages.dhall index d293079b..c9fe001f 100644 --- a/client/packages.dhall +++ b/client/packages.dhall @@ -119,9 +119,12 @@ let additions = let upstream = - https://github.com/purescript/package-sets/releases/download/psc-0.13.6-20200404/packages.dhall sha256:f239f2e215d0cbd5c203307701748581938f74c4c78f4aeffa32c11c131ef7b6 + https://github.com/purescript/package-sets/releases/download/psc-0.13.8-20200724/packages.dhall sha256:bb941d30820a49345a0e88937094d2b9983d939c9fd3a46969b85ce44953d7d9 -let overrides = {=} +let overrides = + { halogen-hooks-extra = + upstream.halogen-hooks-extra // { version = "v0.7.1" } + } let additions = {=} diff --git a/client/public/CNAME b/client/public/CNAME new file mode 100644 index 00000000..d5796847 --- /dev/null +++ b/client/public/CNAME @@ -0,0 +1 @@ +try.ps.ai diff --git a/client/public/ace.css b/client/public/ace.css new file mode 100644 index 00000000..be55d39b --- /dev/null +++ b/client/public/ace.css @@ -0,0 +1,18 @@ +.error { + position: absolute; + z-index: 20; + border-bottom: 2px dotted red; +} + +/* Currently unsued, but can re-enable. Assuming it's spammy */ +.warning { + position: absolute; + z-index: 20; + border-bottom: 2px dotted #c4953a; +} + +/* Can re-enable if there's an issue without this option +.ace_gutter-tooltip { + white-space: pre-wrap; +} +*/ diff --git a/client/public/css/flare.css b/client/public/css/flare.css deleted file mode 100644 index c512eaff..00000000 --- a/client/public/css/flare.css +++ /dev/null @@ -1,207 +0,0 @@ -/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0} - -@import url(https://fonts.googleapis.com/css?family=Source+Code+Pro:400,700|Roboto:400,700); - -body { - margin: 20px; -} - -body, p, input, label, legend, h1, h2, h3, h4 { - font-family: 'Roboto', sans-serif; -} - -a, a:visited, a:active, a:hover { - color: #cc0000; -} - -.flare-input > label { - display: inline-block; - width: 150px; -} - -.flare-input { - margin-bottom: 10px; -} - -.flare-input-number { - width: 85px; -} - -input[type="range"] { - width: 150px; - border: 1px solid transparent; -} - -input:invalid { - background-color: #DEAFB4; -} - -label { - font-weight: bold; -} - -fieldset.flare-input-radioGroup { - margin: 0px; - margin-top: 15px; - border: none; - padding: 0px; -} - -.flare-input-radioGroup legend { - padding: 0px; - width: 150px; - float: left; - font-weight: bold; -} - -.flare-input-radioGroup label { - margin-left: 3px; - margin-right: 15px; - cursor: pointer; - font-weight: normal; -} - -code { - background-color: #f0f0f0; - padding-left: 3px; - padding-right: 3px; -} - -.sparkle-test pre, -.sparkle-test code, -.sparkle-test input, -.sparkle-test label, -.sparkle-test legend, -.sparkle-test select, -.sparkle-test textarea { - font-family: 'Source Code Pro', monospace; -} - -fieldset.sparkle-test { - margin-top: 15px; - margin-bottom: 40px; - max-width: 800px; - border: 1px solid #666; - border-radius: 3px; - padding: .35em .625em .75em; -} - -fieldset.sparkle-test > legend { - background-color: #f0f0f0; - border: 1px solid #666; - border-radius: 3px; - float: initial; - width: initial; -} - -.sparkle-test pre { - background-color: #f0f0f0; - padding: 10px; - max-width: 780px; - - /* http://stackoverflow.com/a/248013/704831 */ - white-space: pre-wrap; /* CSS 3 */ - white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ - white-space: -pre-wrap; /* Opera 4-6 */ - white-space: -o-pre-wrap; /* Opera 7 */ - word-wrap: break-word; /* Internet Explorer 5.5+ */ -} - -.sparkle-test .flare-input { - margin-bottom: 5px; -} - -.sparkle-test input:not([type="range"]) { - padding: 3px; - border-radius: 3px; - border: 1px solid #888; -} - -.sparkle-test input:invalid { - background-color: #DEAFB4; -} - -.sparkle-test .flare-input-number, -.sparkle-test .flare-input-int-number { - width: 80px; -} - -.sparkle-test fieldset { - border-radius: 3px; - margin-top: 5px; - margin-bottom: 20px; -} - -.sparkle-test label { - width: 160px; - display: inline-block; -} - -.sparkle-test legend { - padding: 2px 6px; - font-weight: 700; -} - -.sparkle-test .flare-input-list button { - margin-left: 5px; - margin-right: 5px; -} - -.sparkle-test .sparkle-okay { - background-color: #B4DEAF; -} - -.sparkle-test .sparkle-warn { - background-color: #DEAFB4; -} - -.sparkle-string { - color: #E91E63; -} - -.sparkle-number { - color: #1339E8; -} - -.sparkle-boolean { - font-weight: bold; -} - -.sparkle-constructor { - font-weight: bold; -} - -.sparkle-record-field { - font-style: italic; -} - -.sparkle-color { - width: 1em; - height: 1em; - margin-right: 0.5em; - display: inline-block; -} - -.sparkle-tooltip { - cursor: default; -} - -.sparkle-tooltip:hover { - background-color: #f2e7a6; -} - -.sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #e9d563; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #dfc220; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #938015; -} - -.sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip > .sparkle-tooltip:hover { - background-color: #62560e; -} diff --git a/client/public/css/index.css b/client/public/css/index.css deleted file mode 100644 index 1c2b202c..00000000 --- a/client/public/css/index.css +++ /dev/null @@ -1,269 +0,0 @@ -/* Page layout */ - -html, body { - height: 100%; -} - -body { - font-family: 'Roboto', sans-serif; - margin: 0; - color: rgb(29, 34, 45); -} - -#wrapper { - display: flex; - flex-direction: column; - height: 100vh; -} - -#body { - display: flex; - flex-direction: column; - flex: 1; -} - -#menu { - background-color: #1d222d; - color: white; - vertical-align: middle; - margin: 0; - padding: 0; -} - - #menu input { - vertical-align: middle; - margin-bottom: 5px; - cursor: pointer; - } - -#home_link { - padding: 0px 10px; - text-decoration: none; - text-align: center; - font-weight: bold; -} - - #home_link > img { - vertical-align: middle; - } - -.menu-item { - list-style: none; - display: inline-block; - border-left: 1px solid #454648; - min-width: 90px; -} - - .menu-item a { - color: white; - text-decoration: none; - } - - .menu-item > label, .menu-item > a > label { - text-align: center; - line-height: 40px; - padding: 0px 10px; - display: block; - } - - .menu-item label { - cursor: pointer; - } - - .menu-item:hover { - background-color: black; - } - - .menu-item input[type="checkbox"] { - display: none; - } - - .menu-item input[type="checkbox"]:checked + label { - background-color: #8490a9; - } - - .menu-item input[type="checkbox"]:not(:checked) + label { - text-decoration: line-through; - color: #ababab; - } - -.menu-dropdown { - position: relative; -} - - .menu-dropdown > label { - display: block; - } - - .menu-dropdown > label:after { - content: ' ▾'; - } - - .menu-dropdown > ul { - position:absolute; - top: 40px; - left: 0px; - min-width: 100%; - display: none; - margin: 0px; - padding: 0px; - background-color: #1d222d; - list-style: none; - z-index: 100000; - } - - .menu-dropdown > ul > li { - margin: 0; - padding: 0; - } - - .menu-dropdown:hover > ul { - display: inherit; - } - - .menu-dropdown li label { - display: block; - padding: 10px 8px; - margin: 0; - } - - .menu-dropdown label:hover { - background-color: black; - } - - .menu-dropdown input[type="radio"] { - display:none; - } - - .menu-dropdown input[type="radio"]:checked + label { - color: #c4953a; - } - - .menu-dropdown a { - display: block; - color: white; - } - - .menu-dropdown a:visited { - color: white; - } - -#editor_view { - display: flex; - flex-direction: row; - flex: 1; - margin-top: 0px; - position: relative; -} - - #editor_view[data-view-mode="output"] #column1 { - display: none; - } - - #editor_view[data-view-mode="code"] #column2_wrapper { - display: none; - } - -#column1, #column2_wrapper { - position: relative; - flex: 1; -} - -#column2_wrapper { - position: relative; - -webkit-overflow-scrolling: touch; - overflow: auto; -} - -#code { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -.separator { - flex-basis: 5px; - flex-grow: 0; - flex-shrink: 0; - background-color: rgb(250, 250, 250); -} - -.error { - position: absolute; - z-index: 20; - border-bottom: 2px dotted red; -} - -.warning { - position: absolute; - z-index: 20; - border-bottom: 2px dotted #c4953a; -} - -.ace_gutter-tooltip { - white-space: pre-wrap; -} - -.error-banner { - font-family: 'Roboto Slab', serif; - padding: 10px 20px; -} - -pre { - padding: 20px; - font-family: 'Monaco', monospace; -} - -pre code { - background: none; - border: 0; - margin: 0; -} - -iframe { - position: absolute; - left: 0; - right: 0; - width: 100%; - height: 100%; - border: 0; -} - -#loading { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: white url(../img/loading.gif); - background-position: center center; - background-repeat: no-repeat; - opacity: 0.85; - z-index: 10000; -} - -#code { - overflow: visible; -} - -.mobile-banner { - background: #dabf8b; - padding: 5px; - border-bottom: 1px solid #1d222d; - font-size: 14px; - margin-bottom: 10px; -} - -@media all and (max-width: 720px) { - .no-mobile { - display: none; - } -} - -@media all and (min-width: 720px) { - .mobile-only { - display: none; - } -} diff --git a/client/public/css/mathbox.css b/client/public/css/mathbox.css deleted file mode 100644 index dd93e59e..00000000 --- a/client/public/css/mathbox.css +++ /dev/null @@ -1,461 +0,0 @@ -.shadergraph-graph { - font: 12px sans-serif; - line-height: 25px; - position: relative; -} -.shadergraph-graph:after { - content: ' '; - display: block; - height: 0; - font-size: 0; - clear: both; -} -.shadergraph-graph svg { - pointer-events: none; -} -.shadergraph-clear { - clear: both; -} -.shadergraph-graph svg { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - width: auto; - height: auto; -} -.shadergraph-column { - float: left; -} -.shadergraph-node .shadergraph-graph { - float: left; - clear: both; - overflow: visible; -} -.shadergraph-node .shadergraph-graph .shadergraph-node { - margin: 5px 15px 15px; -} -.shadergraph-node { - margin: 5px 15px 25px; - background: rgba(0, 0, 0, .1); - border-radius: 5px; - box-shadow: 0 1px 2px rgba(0, 0, 0, .2), - 0 1px 10px rgba(0, 0, 0, .2); - min-height: 35px; - float: left; - clear: left; - position: relative; -} -.shadergraph-type { - font-weight: bold; -} -.shadergraph-header { - font-weight: bold; - text-align: center; - height: 25px; - background: rgba(0, 0, 0, .3); - text-shadow: 0 1px 2px rgba(0, 0, 0, .25); - color: #fff; - border-top-left-radius: 5px; - border-top-right-radius: 5px; - margin-bottom: 5px; - padding: 0 10px; -} -.shadergraph-outlet div { -} -.shadergraph-outlet-in .shadergraph-name { - margin-right: 7px; -} -.shadergraph-outlet-out .shadergraph-name { - margin-left: 7px; -} - -.shadergraph-name { - margin: 0 4px; -} -.shadergraph-point { - margin: 6px; - width: 11px; - height: 11px; - border-radius: 7.5px; - background: rgba(255, 255, 255, 1); -} -.shadergraph-outlet-in { - float: left; - clear: left; -} -.shadergraph-outlet-in div { - float: left; -} -.shadergraph-outlet-out { - float: right; - clear: right; -} -.shadergraph-outlet-out div { - float: right; -} - -.shadergraph-node-callback { - background: rgba(205, 209, 221, .5); - box-shadow: 0 1px 2px rgba(0, 10, 40, .2), - 0 1px 10px rgba(0, 10, 40, .2); -} -.shadergraph-node-callback > .shadergraph-header { - background: rgba(0, 20, 80, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-callback { - background: rgba(0, 20, 80, .1); -} - -.shadergraph-node-call { - background: rgba(209, 221, 205, .5); - box-shadow: 0 1px 2px rgba(10, 40, 0, .2), - 0 1px 10px rgba(10, 40, 0, .2); -} -.shadergraph-node-call > .shadergraph-header { - background: rgba(20, 80, 0, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-call { - background: rgba(20, 80, 0, .1); -} - -.shadergraph-node-isolate { - background: rgba(221, 205, 209, .5); - box-shadow: 0 1px 2px rgba(40, 0, 10, .2), - 0 1px 10px rgba(40, 0, 10, .2); -} -.shadergraph-node-isolate > .shadergraph-header { - background: rgba(80, 0, 20, .3); -} -.shadergraph-graph .shadergraph-graph .shadergraph-node-isolate { - background: rgba(80, 0, 20, .1); -} - -.shadergraph-node.shadergraph-has-code { - cursor: pointer; -} -.shadergraph-node.shadergraph-has-code::before { - position: absolute; - content: ' '; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: none; - border: 2px solid rgba(0, 0, 0, .25); - border-radius: 5px; -} -.shadergraph-node.shadergraph-has-code:hover::before { - display: block; -} -.shadergraph-code { - z-index: 10000; - display: none; - position: absolute; - background: #fff; - color: #000; - white-space: pre; - padding: 10px; - border-radius: 5px; - box-shadow: 0 1px 2px rgba(0, 0, 0, .2), - 0 1px 10px rgba(0, 0, 0, .2); - font-family: monospace; - font-size: 10px; - line-height: 12px; -} - -.shadergraph-overlay { - position: fixed; - top: 50%; - left: 0; - right: 0; - bottom: 0; - background: #fff; - border-top: 1px solid #CCC; -} -.shadergraph-overlay .shadergraph-view { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - overflow: auto; -} -.shadergraph-overlay .shadergraph-inside { - width: 4000px; - min-height: 100%; - box-sizing: border-box; -} -.shadergraph-overlay .shadergraph-close { - position: absolute; - top: 5px; - right: 5px; - padding: 4px; - border-radius: 16px; - background: rgba(255,255,255,.3); - color: rgba(0, 0, 0, .3); - cursor: pointer; - font-size: 24px; - line-height: 24px; - width: 24px; - text-align: center; - vertical-align: middle; -} -.shadergraph-overlay .shadergraph-close:hover { - background: rgba(255,255,255,1); - color: rgba(0, 0, 0, 1); -} -.shadergraph-overlay .shadergraph-graph { - padding-top: 10px; - overflow: visible; - min-height: 100%; -} -.shadergraph-overlay span { - display: block; - padding: 5px 15px; - margin: 0; - background: rgba(0, 0, 0, .1); - font-weight: bold; - font-family: sans-serif; -} -.mathbox-loader { - position: absolute; - top: 50%; - left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); - padding: 10px; - border-radius: 50%; - background: #fff; -} - -.mathbox-loader.mathbox-exit { - opacity: 0; - -webkit-transition: - opacity .15s ease-in-out; - transition: - opacity .15s ease-in-out; -} - -.mathbox-progress { - height: 10px; - border-radius: 5px; - width: 80px; - margin: 0 auto 20px; - box-shadow: - 1px 1px 1px rgba(255, 255, 255, .2), - 1px -1px 1px rgba(255, 255, 255, .2), - -1px 1px 1px rgba(255, 255, 255, .2), - -1px -1px 1px rgba(255, 255, 255, .2); - background: #ccc; - overflow: hidden; -} - -.mathbox-progress > div { - display: block; - width: 0px; - height: 10px; - background: #888; -} - -.mathbox-logo { - position: relative; - width: 140px; - height: 100px; - margin: 0 auto 10px; - -webkit-perspective: 200px; - perspective: 200px; -} - -.mathbox-logo > div { - position: absolute; - left: 0; - top: 0; - bottom: 0; - right: 0; - -webkit-transform-style: preserve-3d; - transform-style: preserve-3d; -} - -.mathbox-logo > :nth-child(1) { - -webkit-transform: rotateZ(22deg) rotateX(24deg) rotateY(30deg); - transform: rotateZ(22deg) rotateX(24deg) rotateY(30deg); -} - -.mathbox-logo > :nth-child(2) { - -webkit-transform: rotateZ(11deg) rotateX(12deg) rotateY(15deg) scale3d(.6, .6, .6); - transform: rotateZ(11deg) rotateX(12deg) rotateY(15deg) scale3d(.6, .6, .6); -} - -.mathbox-logo > div > div { - position: absolute; - top: 50%; - left: 50%; - margin-left: -100px; - margin-top: -100px; - width: 200px; - height: 200px; - box-sizing: border-box; - border-radius: 50%; -} - -.mathbox-logo > div > :nth-child(1) { - -webkit-transform: scale(0.5, 0.5); - transform: rotateX(30deg) scale(0.5, 0.5); -} - -.mathbox-logo > div > :nth-child(2) { - -webkit-transform: rotateX(90deg) scale(0.42, 0.42); - transform: rotateX(90deg) scale(0.42, 0.42); -} - -.mathbox-logo > div > :nth-child(3) { - -webkit-transform: rotateY(90deg) scale(0.35, 0.35); - transform: rotateY(90deg) scale(0.35, 0.35); -} - -.mathbox-logo > :nth-child(1) > :nth-child(1) { - border: 16px solid #808080; -} -.mathbox-logo > :nth-child(1) > :nth-child(2) { - border: 19px solid #A0A0A0; -} -.mathbox-logo > :nth-child(1) > :nth-child(3) { - border: 23px solid #C0C0C0; -} -.mathbox-logo > :nth-child(2) > :nth-child(1) { - border: 27px solid #808080; -} -.mathbox-logo > :nth-child(2) > :nth-child(2) { - border: 32px solid #A0A0A0; -} -.mathbox-logo > :nth-child(2) > :nth-child(3) { - border: 38px solid #C0C0C0; -} - -.mathbox-splash-blue .mathbox-progress { - background: #def; -} -.mathbox-splash-blue .mathbox-progress > div { - background: #1979e7; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(1) { - border-color: #1979e7; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(2) { - border-color: #33b0ff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(1) > :nth-child(3) { - border-color: #75eaff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(1) { - border-color: #18487F; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(2) { - border-color: #33b0ff; -} -.mathbox-splash-blue .mathbox-logo > :nth-child(2) > :nth-child(3) { - border-color: #75eaff; -} - - - - -.mathbox-overlays { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - pointer-events: none; - transform-style: preserve-3d; - overflow: hidden; -} -.mathbox-overlays > div { - transform-style: preserve-3d; -} -.mathbox-overlay > div { - position: absolute; - will-change: transform, opacity; -} -.mathbox-label { - font-family: sans-serif; -} -.mathbox-outline-1 { - text-shadow: - -1px -1px 0px rgb(255, 255, 255), - 1px 1px 0px rgb(255, 255, 255), - -1px 1px 0px rgb(255, 255, 255), - 1px -1px 0px rgb(255, 255, 255), - 1px 0px 1px rgb(255, 255, 255), - -1px 0px 1px rgb(255, 255, 255), - 0px -1px 1px rgb(255, 255, 255), - 0px 1px 1px rgb(255, 255, 255); -} -.mathbox-outline-2 { - text-shadow: - 0px -2px 0px rgb(255, 255, 255), - 0px 2px 0px rgb(255, 255, 255), - -2px 0px 0px rgb(255, 255, 255), - 2px 0px 0px rgb(255, 255, 255), - -1px -2px 0px rgb(255, 255, 255), - -2px -1px 0px rgb(255, 255, 255), - -1px 2px 0px rgb(255, 255, 255), - -2px 1px 0px rgb(255, 255, 255), - 1px 2px 0px rgb(255, 255, 255), - 2px 1px 0px rgb(255, 255, 255), - 1px -2px 0px rgb(255, 255, 255), - 2px -1px 0px rgb(255, 255, 255); -} -.mathbox-outline-3 { - text-shadow: - 3px 0px 0px rgb(255, 255, 255), - -3px 0px 0px rgb(255, 255, 255), - 0px 3px 0px rgb(255, 255, 255), - 0px -3px 0px rgb(255, 255, 255), - - -2px -2px 0px rgb(255, 255, 255), - -2px 2px 0px rgb(255, 255, 255), - 2px 2px 0px rgb(255, 255, 255), - 2px -2px 0px rgb(255, 255, 255), - - -1px -2px 1px rgb(255, 255, 255), - -2px -1px 1px rgb(255, 255, 255), - -1px 2px 1px rgb(255, 255, 255), - -2px 1px 1px rgb(255, 255, 255), - 1px 2px 1px rgb(255, 255, 255), - 2px 1px 1px rgb(255, 255, 255), - 1px -2px 1px rgb(255, 255, 255), - 2px -1px 1px rgb(255, 255, 255); -} -.mathbox-outline-4 { - text-shadow: - 4px 0px 0px rgb(255, 255, 255), - -4px 0px 0px rgb(255, 255, 255), - 0px 4px 0px rgb(255, 255, 255), - 0px -4px 0px rgb(255, 255, 255), - - -3px -2px 0px rgb(255, 255, 255), - -3px 2px 0px rgb(255, 255, 255), - 3px 2px 0px rgb(255, 255, 255), - 3px -2px 0px rgb(255, 255, 255), - - -2px -3px 0px rgb(255, 255, 255), - -2px 3px 0px rgb(255, 255, 255), - 2px 3px 0px rgb(255, 255, 255), - 2px -3px 0px rgb(255, 255, 255), - - -1px -2px 1px rgb(255, 255, 255), - -2px -1px 1px rgb(255, 255, 255), - -1px 2px 1px rgb(255, 255, 255), - -2px 1px 1px rgb(255, 255, 255), - 1px 2px 1px rgb(255, 255, 255), - 2px 1px 1px rgb(255, 255, 255), - 1px -2px 1px rgb(255, 255, 255), - 2px -1px 1px rgb(255, 255, 255); - -} -.mathbox-outline-fill, .mathbox-outline-fill * { - color: #fff !important; -} diff --git a/client/public/css/slides.css b/client/public/css/slides.css deleted file mode 100644 index 4d10df95..00000000 --- a/client/public/css/slides.css +++ /dev/null @@ -1,99 +0,0 @@ - -body { - margin: 0 auto; - -webkit-font-smoothing: antialiased; - font-size: 2vw; - color: #000; - line-height: 1.5em; -} - -pre { - font-size: 1.5vw !important; - line-height: 1.2em; -} - -h1 { - font-size: 3.5vw; -} - -h2 { - font-size: 3vw; -} - -h3 { - font-size: 1.5em -} - -img { - max-width: 100%; -} - -.flexbox { - margin: 5px; - - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - - flex-flow: row wrap; -} - - -.slide { - width: 80%; - height: 80%; - margin: auto; - display: flex; - justify-content: space-around; - align-items: center; -} - -.title { - display: inline-block; - text-align: center; - margin: auto; -} - -.marwid { - display: inline-block; - margin: auto; -} - -.rowflex { - display: flex; - flex-flow: row wrap; -} - -.colflex { - display: flex; - flex-flow: column wrap; -} - -.block { - display: block; -} - -.padapp { - padding: 0.2vw; -} - -.counter { - margin: 10px; - font-size: initial; -} - -.center { - display: flex; - margin: auto; - justify-content: center; -} - - -.boldEl { - font-weight: bold !important; -} -.italicEl { - font-style: italic !important; -} diff --git a/client/public/css/style.css b/client/public/css/style.css deleted file mode 100644 index 9a9d846e..00000000 --- a/client/public/css/style.css +++ /dev/null @@ -1,22 +0,0 @@ -@import url('https://fonts.googleapis.com/css?family=Roboto|Roboto+Slab'); - -body -{ - font-family: 'Roboto', sans-serif; - color: #404040; -} - -h1, h2, h3, h4, h5, h6 { - font-family: 'Roboto Slab', sans-serif; - color: #1d222d; -} - -a, a:visited, a:active, a:hover { - color: #c4953a; -} - -pre { - background-color: rgb(240, 240, 240); - padding: 10px; - border-radius: 10px; -} diff --git a/client/public/frame.html b/client/public/frame-error.html similarity index 51% rename from client/public/frame.html rename to client/public/frame-error.html index ba347939..6bc3a282 100644 --- a/client/public/frame.html +++ b/client/public/frame-error.html @@ -1,13 +1,11 @@
-Your browser is missing srcdoc support
diff --git a/client/public/img/favicon-black.ico b/client/public/img/favicon-black.ico new file mode 100644 index 00000000..f4cbbbd3 Binary files /dev/null and b/client/public/img/favicon-black.ico differ diff --git a/client/public/img/favicon-black.svg b/client/public/img/favicon-black.svg new file mode 100644 index 00000000..c8f3f7ea --- /dev/null +++ b/client/public/img/favicon-black.svg @@ -0,0 +1,67 @@ + + + + diff --git a/client/public/img/favicon_clear-16.png b/client/public/img/favicon_clear-16.png deleted file mode 100644 index 04dcd0cc..00000000 Binary files a/client/public/img/favicon_clear-16.png and /dev/null differ diff --git a/client/public/img/favicon_clear-256.png b/client/public/img/favicon_clear-256.png deleted file mode 100644 index 5abc0011..00000000 Binary files a/client/public/img/favicon_clear-256.png and /dev/null differ diff --git a/client/public/img/favicon_clear-32.png b/client/public/img/favicon_clear-32.png deleted file mode 100644 index 7d720bc6..00000000 Binary files a/client/public/img/favicon_clear-32.png and /dev/null differ diff --git a/client/public/index.html b/client/public/index.html index b546efb4..efa1505b 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -1,198 +1,11 @@ - -"
- code_ <- JQuery.create ""
- JQuery.append code_ pre
- JQuery.setText message code_
-
- JQuery.append h1 column2
- JQuery.append pre column2
-
--- | Display plain text in the right hand column.
-displayPlainText
- :: String
- -> Effect Unit
-displayPlainText s = do
- column2 <- JQuery.select "#column2"
- JQueryExtras.empty column2
- pre <- JQuery.create ""
- code_ <- JQuery.create ""
- JQuery.append code_ pre
- JQuery.setText s code_
- JQuery.append pre column2
-
-isShowJsChecked :: Effect Boolean
-isShowJsChecked = JQuery.select "#showjs" >>= \jq -> JQueryExtras.is jq ":checked"
-
-isAutoCompileChecked :: Effect Boolean
-isAutoCompileChecked = JQuery.select "#auto_compile" >>= \jq -> JQueryExtras.is jq ":checked"
-
--- | Update the view mode based on the menu selection
-changeViewMode :: Maybe String -> Effect Unit
-changeViewMode viewMode =
- for_ viewMode \viewMode_ ->
- JQuery.select "#editor_view" >>= JQuery.setAttr "data-view-mode" viewMode_
-
-getTextAreaContent :: Effect String
-getTextAreaContent = fold <$> (JQuery.select "#code_textarea" >>= JQueryExtras.getValueMaybe)
-
-setTextAreaContent :: String -> Effect Unit
-setTextAreaContent value = JQuery.select "#code_textarea" >>= JQuery.setValue value
-
--- | Set the editor content to the specified string.
-foreign import setEditorContent :: EffectFn1 String Unit
-
--- | Register a callback for editor change events.
-foreign import onEditorChanged
- :: EffectFn2 (EffectFn1 String Unit)
- Int
- Unit
-
--- | Clean up any global state associated with any visible error markers.
-foreign import cleanUpMarkers :: Effect Unit
-
--- | Add a visible marker at the specified location.
-foreign import addMarker :: EffectFn5 String Int Int Int Int Unit
-
-type Annotation =
- { row :: Int
- , column :: Int
- , type :: String
- , text :: String
- }
-
--- | Set the gutter annotations
-foreign import setAnnotations :: EffectFn1 (Array Annotation) Unit
-
-clearAnnotations :: Effect Unit
-clearAnnotations = runEffectFn1 setAnnotations []
-
--- | Set up a fresh iframe in the specified container, and use it
--- | to execute the provided JavaScript code.
-foreign import setupIFrame
- :: EffectFn2 JQuery.JQuery
- (Object JS)
- Unit
-
-loader :: Loader
-loader = makeLoader Config.loaderUrl
-
--- | Compile the current code and execute it.
-compile :: Effect Unit
-compile = do
- code <- getTextAreaContent
-
- displayLoadingMessage
- clearAnnotations
-
- runContT (runExceptT (API.compile Config.compileUrl code)) \res_ ->
- case res_ of
- Left err -> displayPlainText err
- Right res -> do
- cleanUpMarkers
-
- case res of
- Right (CompileSuccess (SuccessResult { js, warnings })) -> do
- showJs <- isShowJsChecked
- if showJs
- then do hideLoadingMessage
- displayPlainText js
- else runContT (runExceptT $ runLoader loader (JS js)) \sources -> do
- hideLoadingMessage
- for_ warnings \warnings_ -> do
- let toAnnotation (CompileWarning{ errorCode, position, message }) =
- position <#> \(ErrorPosition pos) ->
- { row: pos.startLine - 1
- , column: pos.startColumn - 1
- , type: "warning"
- , text: message
- }
- runEffectFn1 setAnnotations (mapMaybe toAnnotation warnings_)
- for_ sources (execute (JS js))
- Right (CompileFailed (FailedResult { error })) -> do
- hideLoadingMessage
- case error of
- CompilerErrors errs -> do
- displayErrors errs
-
- let toAnnotation (CompilerError{ position, message }) =
- position <#> \(ErrorPosition pos) ->
- { row: pos.startLine - 1
- , column: pos.startColumn - 1
- , type: "error"
- , text: message
- }
- runEffectFn1 setAnnotations (mapMaybe toAnnotation errs)
-
- for_ errs \(CompilerError{ position }) ->
- for_ position \(ErrorPosition pos) ->
- runEffectFn5 addMarker
- "error"
- pos.startLine
- pos.startColumn
- pos.endLine
- pos.endColumn
- OtherError err -> displayPlainText err
- Left errs -> do
- hideLoadingMessage
- displayPlainText "Unable to parse the response from the server"
- traverse_ (error <<< renderForeignError) errs
-
--- | Execute the compiled code in a new iframe.
-execute :: JS -> Object JS -> Effect Unit
-execute js modules = do
- let eventData = Object.insert "" js modules
- column2 <- JQuery.select "#column2"
- runEffectFn2 setupIFrame column2 eventData
-
--- | Setup the editor component and some event handlers.
-setupEditor :: forall r. { code :: String | r } -> Effect Unit
-setupEditor { code } = do
- loadOptions
-
- setTextAreaContent code
- runEffectFn1 setEditorContent code
-
- runEffectFn2 onEditorChanged (mkEffectFn1 \value -> do
- setTextAreaContent value
- cacheCurrentCode
- autoCompile <- isAutoCompileChecked
- when autoCompile do
- compile) 750
-
- JQuery.select "#showjs" >>= JQuery.on "change" \e _ ->
- compile
-
- JQuery.select "#compile_label" >>= JQuery.on "click" \e _ ->
- compile
-
- JQuery.select "#gist_save" >>= JQuery.on "click" \e _ ->
- publishNewGist
-
- compile
- cacheCurrentCode
-
-loadFromGist
- :: String
- -> ({ code :: String } -> Effect Unit)
- -> Effect Unit
-loadFromGist id_ k = do
- runContT (runExceptT (getGistById id_ >>= \gi -> tryLoadFileFromGist gi "Main.purs")) $
- case _ of
- Left err -> do
- window >>= alert err
- k { code: "" }
- Right code -> k { code }
-
-withSession
- :: String
- -> ({ code :: String } -> Effect Unit)
- -> Effect Unit
-withSession sessionId k = do
- state <- tryRetrieveSession sessionId
- case state of
- Just state' -> k state'
- Nothing -> do
- gist <- fromMaybe Config.mainGist <$> getQueryStringMaybe "gist"
- loadFromGist gist k
-
--- | Cache the current code in the session state
-cacheCurrentCode :: Effect Unit
-cacheCurrentCode = do
- sessionId <- getQueryStringMaybe "session"
- case sessionId of
- Just sessionId_ -> do
- code <- getTextAreaContent
- storeSession sessionId_ { code }
- Nothing -> error "No session ID"
-
--- | Create a new Gist using the current content
-publishNewGist :: Effect Unit
-publishNewGist = do
- ok <- window >>= confirm (intercalate "\n"
- [ "Do you really want to publish this code as an anonymous Gist?"
- , ""
- , "Note: this code will be available to anyone with a link to the Gist."
- ])
- when ok do
- content <- getTextAreaContent
- runContT (runExceptT (uploadGist content)) $
- case _ of
- Left err -> do
- window >>= alert "Failed to create gist"
- error ("Failed to create gist: " <> err)
- Right gistId -> do
- setQueryStrings (Object.singleton "gist" gistId)
-
--- | Navigate to the specified URL.
-navigateTo :: String -> Effect Unit
-navigateTo uri = void (window >>= location >>= setHref uri)
-
--- | Read query string options and update the state accordingly
-loadOptions :: Effect Unit
-loadOptions = do
- viewMode <- getQueryStringMaybe "view"
- case viewMode of
- Just viewMode_
- | viewMode_ `elem` ["sidebyside", "code", "output"]
- -> changeViewMode viewMode
- _ -> pure unit
-
- showJs <- getQueryStringMaybe "js"
- case showJs of
- Just showJs_ ->
- JQuery.select "input:checkbox[name=showjs]" >>= JQuery.setProp "checked" (showJs_ == "true")
- _ -> pure unit
-
- autoCompile <- getQueryStringMaybe "compile"
- case autoCompile of
- Just autoCompile_ ->
- JQuery.select "input:checkbox[name=auto_compile]" >>= JQuery.setProp "checked" (autoCompile_ == "true")
- _ -> pure unit
-
- gist <- getQueryStringMaybe "gist"
- case gist of
- Just gist_ -> JQuery.select ".view_gist" >>= JQuery.attr { href: "https://gist.github.com/" <> gist_ }
- Nothing -> JQuery.select ".view_gist_li" >>= JQuery.hide
+import Effect.Aff (launchAff_)
+import Effect.Class (liftEffect)
+import Effect.Class.Console (log)
+import Foreign (Foreign, unsafeToForeign)
+import Halogen as H
+import Halogen.Aff as HA
+import Halogen.VDom.Driver (runUI)
+import Routing.PushState (makeInterface, matches)
+import Try.Component (component, Query(..))
+import Try.Routing (route)
+import Web.HTML (HTMLElement)
main :: Effect Unit
-main = JQuery.ready do
- JQuery.select "input[name=view_mode]" >>= JQuery.on "change" \_ jq -> do
- viewMode <- JQueryExtras.filter jq ":checked" >>= JQueryExtras.getValueMaybe
- changeViewMode viewMode
-
- runContT (do sessionId <- ContT createSessionIdIfNecessary
- ContT (withSession sessionId)) setupEditor
+main =
+ HA.runHalogenAff do
+ (body :: HTMLElement) <- HA.awaitBody
+ (replaceState :: Foreign -> String -> Effect Unit) <-
+ liftEffect
+ $ do
+ nav <- makeInterface
+ pure nav.replaceState
+ halogenIO <- runUI component (replaceState $ unsafeToForeign {}) body
+ void
+ $ liftEffect do
+ nav <- makeInterface
+ nav
+ # matches route \oldRoute newRoute -> do
+ log $ show oldRoute <> " -> " <> show newRoute
+ launchAff_ $ halogenIO.query $ H.tell $ Nav newRoute
diff --git a/client/src/MyAce.js b/client/src/MyAce.js
new file mode 100644
index 00000000..28190265
--- /dev/null
+++ b/client/src/MyAce.js
@@ -0,0 +1,15 @@
+"use strict";
+
+// Upstream version missing inFront, which is
+// interpreted as `false` when omitted.
+exports.getMarkersImpl = function (inFront, session) {
+ return function () {
+ var markerObj = session.getMarkers(inFront);
+ var ks = Object.getOwnPropertyNames(markerObj);
+ var result = [];
+ for (var i = 0; i < ks.length; i++) {
+ result[i] = markerObj[ks[i]];
+ }
+ return result;
+ };
+};
\ No newline at end of file
diff --git a/client/src/MyAce.purs b/client/src/MyAce.purs
new file mode 100644
index 00000000..fb596a27
--- /dev/null
+++ b/client/src/MyAce.purs
@@ -0,0 +1,16 @@
+module MyAce where
+
+import Ace (EditSession, Marker)
+import Data.Function.Uncurried (Fn2, runFn2)
+import Effect (Effect)
+
+{-
+Fixes some issues in package.
+Todo - make PRs for these.
+-}
+-- Returns array of marker IDs
+-- Boolean to indicate front or back
+foreign import getMarkersImpl :: Fn2 Boolean EditSession (Effect (Array Marker))
+
+getMarkers :: Boolean -> EditSession -> Effect (Array Marker)
+getMarkers inFront session = runFn2 getMarkersImpl inFront session
diff --git a/client/src/Try/API.js b/client/src/Try/API.js
deleted file mode 100644
index a00c83f8..00000000
--- a/client/src/Try/API.js
+++ /dev/null
@@ -1,23 +0,0 @@
-"use strict";
-
-exports.get_ = function(uri, done, fail) {
- $.get(uri).done(done).fail(function(err) {
- fail(err.statusText);
- });
-};
-
-exports.compile_ = function(endpoint, code, done, fail) {
- $.ajax({
- url: endpoint + '/compile',
- dataType: 'json',
- data: code,
- method: 'POST',
- contentType: 'text/plain',
- success: function(res) {
- done(res);
- },
- error: function(res) {
- fail(res.responseText)
- }
- });
-}
diff --git a/client/src/Try/API.purs b/client/src/Try/API.purs
index ccb920eb..87bc33df 100644
--- a/client/src/Try/API.purs
+++ b/client/src/Try/API.purs
@@ -1,153 +1,182 @@
-module Try.API
- ( ErrorPosition(..)
- , CompilerError(..)
- , CompileError(..)
- , CompileWarning(..)
- , Suggestion(..)
- , SuccessResult(..)
- , FailedResult(..)
- , CompileResult(..)
- , get
- , compile
- ) where
+module Try.API where
import Prelude
-
-import Control.Alt ((<|>))
-import Control.Monad.Cont.Trans (ContT(ContT))
-import Control.Monad.Except (runExcept)
-import Control.Monad.Except.Trans (ExceptT(ExceptT))
+import Ace (Range)
+import Ace.Range as Range
+import Affjax as AX
+import Affjax.RequestBody as AXRB
+import Affjax.ResponseFormat as AXRF
+import Control.Alternative ((<|>))
+import Data.Argonaut (class DecodeJson, decodeJson)
+import Data.Argonaut.Core as J
+import Data.Argonaut.Decode.Generic.Rep (genericDecodeJsonWith)
+import Data.Argonaut.Types.Generic.Rep (defaultEncoding)
import Data.Either (Either(..))
import Data.Generic.Rep (class Generic)
-import Data.List.NonEmpty (NonEmptyList)
-import Data.Maybe (Maybe)
+import Data.Maybe (Maybe(..))
import Effect (Effect)
-import Effect.Uncurried (EffectFn1, EffectFn3, EffectFn4, mkEffectFn1, runEffectFn3, runEffectFn4)
-import Foreign (Foreign, ForeignError)
-import Foreign.Class (class Decode, decode)
-import Foreign.Generic (defaultOptions, genericDecode)
-import Foreign.Generic.Class (Options, SumEncoding(..))
-
-decodingOptions :: Options
-decodingOptions = defaultOptions { unwrapSingleConstructors = true }
-
--- | The range of text associated with an error
-newtype ErrorPosition = ErrorPosition
- { startLine :: Int
- , endLine :: Int
- , startColumn :: Int
- , endColumn :: Int
- }
-
-derive instance genericErrorPosition :: Generic ErrorPosition _
-
-instance decodeErrorPosition :: Decode ErrorPosition where
- decode = genericDecode decodingOptions
-
-newtype CompilerError = CompilerError
- { message :: String
- , position :: Maybe ErrorPosition
- }
-
-derive instance genericCompilerError :: Generic CompilerError _
-
-instance decodeCompilerError :: Decode CompilerError where
- decode = genericDecode decodingOptions
-
--- | An error reported from the compile API.
-data CompileError
- = CompilerErrors (Array CompilerError)
- | OtherError String
-
-derive instance genericCompileError :: Generic CompileError _
-
-instance decodeCompileError :: Decode CompileError where
- decode = genericDecode
- (defaultOptions
- { sumEncoding =
- TaggedObject
- { tagFieldName: "tag"
- , contentsFieldName: "contents"
- , constructorTagTransform: identity
- }
- })
-
-newtype Suggestion = Suggestion
- { replacement :: String
- , replaceRange :: Maybe ErrorPosition
- }
+import Effect.Aff (Aff)
+import Try.Common (Content(..))
+import Try.Config (compileUrl)
-derive instance genericSuggestion :: Generic Suggestion _
-
-instance decodeSuggestion :: Decode Suggestion where
- decode = genericDecode decodingOptions
-
-newtype CompileWarning = CompileWarning
- { errorCode :: String
- , message :: String
- , position :: Maybe ErrorPosition
- , suggestion :: Maybe Suggestion
- }
-
-derive instance genericCompileWarning :: Generic CompileWarning _
-
-instance decodeCompileWarning :: Decode CompileWarning where
- decode = genericDecode decodingOptions
-
-newtype SuccessResult = SuccessResult
- { js :: String
- , warnings :: Maybe (Array CompileWarning)
- }
-
-derive instance genericSuccessResult :: Generic SuccessResult _
-
-instance decodeSuccessResult :: Decode SuccessResult where
- decode = genericDecode decodingOptions
-
-newtype FailedResult = FailedResult
- { error :: CompileError }
-
-derive instance genericFailedResult :: Generic FailedResult _
-
-instance decodeFailedResult :: Decode FailedResult where
- decode = genericDecode decodingOptions
-
--- | The result of calling the compile API.
+------- Compile API types -------
+--
+-- The result of calling the compile API.
data CompileResult
= CompileSuccess SuccessResult
| CompileFailed FailedResult
--- | Parse the result from the compile API and verify it
-instance decodeCompileResult :: Decode CompileResult where
- decode f =
- CompileSuccess <$> genericDecode decodingOptions f
- <|> CompileFailed <$> genericDecode decodingOptions f
+-- A successful compilation result
+type SuccessResult
+ = { js :: String
+ , warnings :: Maybe (Array CompileWarning)
+ }
+
+-- A warning about the code found during compilation
+type CompileWarning
+ = { errorCode :: String
+ , message :: String
+ , position :: Maybe ErrorPosition
+ , suggestion :: Maybe Suggestion
+ }
+
+-- The range of text associated with an error or warning
+type ErrorPosition
+ = { startLine :: Int
+ , endLine :: Int
+ , startColumn :: Int
+ , endColumn :: Int
+ }
+
+-- A code suggestion
+type Suggestion
+ = { replacement :: String
+ , replaceRange :: Maybe ErrorPosition
+ }
+
+-- A failed compilation result
+type FailedResult
+ = { error :: CompileError }
+
+-- An error reported from the compile API
+data CompileError
+ = CompilerErrors (Array CompilerError)
+ -- Examples of `OtherError` include:
+ -- * Code is not "module Main"
+ -- * The code snippet is too large
+ | OtherError String
-foreign import get_
- :: EffectFn3
- String
- (EffectFn1 String Unit)
- (EffectFn1 String Unit)
- Unit
+-- An error found with the code during compilation
+type CompilerError
+ = { message :: String
+ , position :: Maybe ErrorPosition
+ }
+
+------- Json Decoding -------
+--
+-- The Compile API returns an object representing the contents of either:
+-- * CompileSuccess
+-- * CompileFailed
+-- Decoding to CompileResult requires attempting to match each of these.
+instance decodeJsonCompileResult :: DecodeJson CompileResult where
+ decodeJson j =
+ CompileSuccess <$> decodeJson j
+ <|> CompileFailed
+ <$> decodeJson j
+
+derive instance genericCompileResult :: Generic CompileResult _
+
+-- The Compile API encodes the CompileError tagged union differently than
+-- argonaut's generic options, so we need to adjust the default encoding
+-- options to successfully decode.
+instance decodeJsonCompileError :: DecodeJson CompileError where
+ decodeJson =
+ genericDecodeJsonWith
+ $ defaultEncoding
+ { valuesKey = "contents"
+ , unwrapSingleArguments = true
+ }
--- | A wrapper for `get` which uses `ContT`.
-get :: String -> ExceptT String (ContT Unit Effect) String
-get uri = ExceptT (ContT \k -> runEffectFn3 get_ uri (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
+derive instance genericCompileError :: Generic CompileError _
-- | POST the specified code to the Try PureScript API, and wait for
-- | a response.
-foreign import compile_
- :: EffectFn4
- String
- String
- (EffectFn1 Foreign Unit)
- (EffectFn1 String Unit)
- Unit
-
--- | A wrapper for `compileApi` which uses `ContT`.
-compile
- :: String
- -> String
- -> ExceptT String (ContT Unit Effect)
- (Either (NonEmptyList ForeignError) CompileResult)
-compile endpoint code = ExceptT (ContT \k -> runEffectFn4 compile_ endpoint code (mkEffectFn1 (k <<< Right <<< runExcept <<< decode)) (mkEffectFn1 (k <<< Left)))
+compile :: Content -> Aff (Either String CompileResult)
+compile (Content ct) = do
+ result <- AX.post AXRF.json (compileUrl <> "/compile") $ Just $ AXRB.string ct
+ pure
+ $ case result of
+ Left err -> Left $ "POST compile response failed to decode: " <> AX.printError err
+ Right response -> do
+ let
+ respStr = "POST /api response: " <> J.stringify response.body
+ case decodeJson response.body of
+ Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+ Right (decoded :: CompileResult) -> Right decoded
+
+------ generate errors for editor --------------
+-- Todo - move this to another file
+type Annotation
+ = { row :: Int
+ , column :: Int
+ , type :: String
+ , text :: String
+ }
+
+-- | Set the gutter annotations
+--foreign import setAnnotations :: EffectFn1 (Array Annotation) Unit
+data AnnotationType
+ = AnnotateWarning
+ | AnnotateError
+
+instance showAnnotationType :: Show AnnotationType where
+ show AnnotateWarning = "warning"
+ show AnnotateError = "error"
+
+-- Common fields of CompileWarning and CompilerError
+-- Todo - should both of these have `er` ending?
+type WarningOrError r
+ = { message :: String
+ , position :: Maybe ErrorPosition
+ | r
+ }
+
+-- Creates an annotation from a warning or error,
+-- but only if there's a position.
+toAnnotation :: forall r. AnnotationType -> WarningOrError r -> Maybe Annotation
+toAnnotation _ { position: Nothing } = Nothing
+
+toAnnotation annType { position: Just pos, message } =
+ Just
+ { row: pos.startLine - 1
+ , column: pos.startColumn - 1
+ , type: show annType
+ , text: message
+ }
+
+-- Make sure position's range is at least one character wide.
+nonZeroRange :: ErrorPosition -> ErrorPosition
+nonZeroRange p =
+ if p.startLine == p.endLine && p.endColumn <= p.startColumn then
+ if p.startColumn > 0 then
+ p { startColumn = p.endColumn - 1 }
+ else
+ p { endColumn = p.startColumn + 1 }
+ else
+ p
+
+-- Creates a Range for making Markers from a warning or error,
+-- but only if there's a position.
+mkMarkerRange :: forall r. WarningOrError r -> Effect (Maybe Range)
+mkMarkerRange { position: Nothing } = pure Nothing
+
+mkMarkerRange { position: Just p0 } = do
+ let
+ p = nonZeroRange p0
+ rg <-
+ Range.create
+ (p.startLine - 1)
+ (p.startColumn - 1)
+ (p.endLine - 1)
+ (p.endColumn - 1)
+ pure $ Just rg
diff --git a/client/src/Try/Classes.purs b/client/src/Try/Classes.purs
new file mode 100644
index 00000000..55106f8c
--- /dev/null
+++ b/client/src/Try/Classes.purs
@@ -0,0 +1,58 @@
+module Try.Classes where
+
+import Prelude
+import Halogen (ClassName)
+import Tailwind as T
+
+{-
+Groups of Tailwind CSS classes
+-}
+--
+data Responsiveness
+ = NonMobile
+ | MobileOnly
+ | RenderAlways
+
+nonMobileBlockClasses :: Array ClassName
+nonMobileBlockClasses = [ T.hidden, T.smBlock ]
+
+nonMobileBlock :: Responsiveness -> Array ClassName
+nonMobileBlock NonMobile = nonMobileBlockClasses
+
+nonMobileBlock _ = []
+
+commonBgClasses :: Array ClassName
+commonBgClasses =
+ [ T.bgTpsBlack
+ , T.hoverBgBlack
+ ]
+
+commonMenuClasses :: Array ClassName
+commonMenuClasses =
+ [ T.px3
+ , T.block
+ ]
+ <> commonBgClasses
+
+commonTextClasses :: Array ClassName
+commonTextClasses =
+ [ T.textWhite
+ , T.leading10
+ ]
+
+menuTextClasses :: Array ClassName
+menuTextClasses =
+ [ T.borderL
+ , T.borderSolid
+ , T.borderGray700
+ ]
+ <> commonMenuClasses
+ <> commonTextClasses
+
+dropdownItemClasses :: Array ClassName
+dropdownItemClasses =
+ [ T.block
+ , T.wFull
+ ]
+ <> commonBgClasses
+ <> commonTextClasses
diff --git a/client/src/Try/Common.purs b/client/src/Try/Common.purs
new file mode 100644
index 00000000..fc7b8fbe
--- /dev/null
+++ b/client/src/Try/Common.purs
@@ -0,0 +1,51 @@
+module Try.Common where
+
+import Prelude
+
+{-
+Common values and newtype wrappers
+-}
+--
+-- Page to launch on startup and when clicking home button
+homeRoute :: String
+homeRoute = "/?gist=93b9116a309c97af2af5d3f06f6a4479"
+
+-- Query param for compressed code.
+pursQP :: String
+pursQP = "purs"
+
+-- Query param for gist.
+gistQP :: String
+gistQP = "gist"
+
+newtype AuthCode
+ = AuthCode String
+
+instance showAuthCode :: Show AuthCode where
+ show (AuthCode c) = c
+
+newtype Compressed
+ = Compressed String
+
+instance showCompressed :: Show Compressed where
+ show (Compressed c) = c
+
+newtype Content
+ = Content String
+
+instance showContent :: Show Content where
+ show (Content c) = c
+
+derive instance eqContent :: Eq Content
+
+newtype GistID
+ = GistID String
+
+instance showGistID :: Show GistID where
+ show (GistID g) = g
+
+newtype GhToken
+ = GhToken String
+
+instance showToken :: Show GhToken where
+ show (GhToken t) = t
diff --git a/client/src/Try/Component.purs b/client/src/Try/Component.purs
new file mode 100644
index 00000000..7302a2be
--- /dev/null
+++ b/client/src/Try/Component.purs
@@ -0,0 +1,511 @@
+module Try.Component where
+
+import Prelude
+import Ace (Document, EditSession, Range, ace, edit)
+import Ace.Document as Document
+import Ace.EditSession as Session
+import Ace.Editor as Editor
+import Ace.Marker as Marker
+import Data.Argonaut (encodeJson, stringify)
+import Data.Array (catMaybes, concat, mapMaybe, mapWithIndex)
+import Data.Array as Array
+import Data.Either (Either(..))
+import Data.Foldable (traverse_)
+import Data.Maybe (Maybe(..), fromMaybe)
+import Data.Newtype (unwrap)
+import Data.Time.Duration (Milliseconds(..))
+import Data.Traversable (traverse)
+import Data.Tuple.Nested ((/\))
+import Effect.Aff.Class (class MonadAff, liftAff)
+import Effect.Class.Console (log)
+import Halogen (liftEffect)
+import Halogen as H
+import Halogen.HTML as HH
+import Halogen.HTML.Events as HE
+import Halogen.HTML.Properties as HP
+import Halogen.Hooks (useState)
+import Halogen.Hooks as HK
+import Halogen.Hooks.Extra.Hooks (useDebouncer, useModifyState_, usePutState)
+import Halogen.Query.EventSource as ES
+import LzString (compressToEncodedURIComponent)
+import MyAce as MyAce
+import Tailwind as T
+import Try.API (AnnotationType(..), CompileError(..), CompileResult(..), CompilerError, WarningOrError, compile, mkMarkerRange, toAnnotation)
+import Try.Classes (Responsiveness(..), commonMenuClasses, dropdownItemClasses, menuTextClasses, nonMobileBlock, nonMobileBlockClasses)
+import Try.Common (Content(..), GhToken, GistID(..), gistQP, homeRoute, pursQP)
+import Try.Gist (ghAuthorize, ghCreateGist, ghGetGist, ghRequestToken)
+import Try.Loader (Loader, makeLoader, runLoader)
+import Try.Routing (Route(..))
+import Try.Types (JS(..))
+import Try.Utility (ContentSource(..), PushRoute, ViewMode(..), compress, decompress)
+import Web.HTML (window)
+import Web.HTML.Location (setHref)
+import Web.HTML.Window (location)
+
+{-
+The main component for this app
+-}
+--
+type Input
+ = PushRoute
+
+-- Query must be (Type -> Type)
+data Query a
+ = Nav Route a
+
+-- This is required for good caching performance
+loader :: Loader
+loader = makeLoader
+
+-- What can be shown in the output pane
+data Output
+ = Text String -- startup or simple error
+ | Loading -- loading gif
+ | App String JS -- json string and main JS
+ | Errors (Array CompilerError) -- compiler errors
+
+component :: forall o m. MonadAff m => H.Component HH.HTML Query Input o m
+component =
+ -- No tokens for child slots or output
+ HK.component \({ queryToken } :: HK.ComponentTokens Query _ o) input -> HK.do
+ -- Annotations for initial values not required, but helps with clarity
+ viewMode /\ putViewMode <- usePutState (SideBySide :: ViewMode)
+ autoCompile /\ autoCompileIdx <- useState true
+ showJS /\ modifyShowJS <- useModifyState_ false
+ dropdownOpen /\ putDropdownOpen <- usePutState false
+ content /\ contentIdx <- useState $ Content ""
+ document /\ putDocument <- usePutState (Nothing :: Maybe Document)
+ session /\ sessionIdx <- useState (Nothing :: Maybe EditSession)
+ route /\ putRoute <- usePutState (Nothing :: Maybe Route)
+ ghToken /\ putGhToken <- usePutState (Nothing :: Maybe GhToken)
+ pushRoute /\ putPushRoute <- usePutState (input :: PushRoute)
+ contentSource /\ putContentSource <- usePutState (NewContent :: ContentSource)
+ output /\ putOutput <- usePutState $ (Text "" :: Output)
+ --
+ -- Helper functions to reduce code duplication
+ let
+ -- GhToken is a parameter rather than picked-up from state
+ -- to ensure it is not Nothing
+ doSaveGist gh_token = do
+ -- Cannot just use `content` - will be stale
+ currentContent <- HK.get contentIdx
+ log $ "saving gist, content: " <> show currentContent
+ eitherId <- liftAff $ ghCreateGist gh_token $ currentContent
+ case eitherId of
+ Left err -> log err
+ Right id -> do
+ putContentSource $ HaveGist id
+ liftEffect $ pushRoute $ "/?" <> gistQP <> "=" <> (show id)
+
+ -- Create editor annotations and markers for errors or warnings
+ annotateAndMark :: forall r. AnnotationType -> Array (WarningOrError r) -> _
+ annotateAndMark type_ arr = do
+ ses <- HK.get sessionIdx
+ case ses of
+ Nothing -> do
+ log "Error: Session unset"
+ Just s ->
+ liftEffect do
+ -- annotations
+ let
+ annotations = mapMaybe (toAnnotation type_) arr
+ Session.setAnnotations annotations s
+ -- markers
+ maybeRanges <- traverse mkMarkerRange arr
+ let
+ (ranges :: Array Range) = catMaybes maybeRanges
+ -- The shown type of "error" or "warning" should match the css class
+ -- found in ace.css.
+ -- `true` to show in "front"
+ traverse_ (\r -> Session.addMarker r (show type_) "text" true s) ranges
+
+ -- Clear annotations and marks from editor
+ removeAnnotationsAndMarks = do
+ ses <- HK.get sessionIdx
+ case ses of
+ Nothing -> log "Error: Session unset"
+ Just s ->
+ liftEffect do
+ -- Remove annotations
+ Session.clearAnnotations s
+ -- A `removeMarkers` function would be nice...
+ -- Get all inFront(=true) markers
+ markers <- MyAce.getMarkers true s
+ (markerIds :: Array Int) <- traverse Marker.getId markers
+ -- Remove markers
+ traverse_ (\m -> Session.removeMarker m s) markerIds
+
+ doCompile = do
+ -- Needed now that this is called from debouncer.
+ -- Stale otherwise.
+ currentContent <- HK.get contentIdx
+ -- Clear annotations and marks from editor
+ removeAnnotationsAndMarks
+ putOutput Loading
+ (res :: Either String CompileResult) <- liftAff $ compile currentContent
+ case res of
+ Right (CompileSuccess { js, warnings }) -> do
+ -- warnings is maybe array. Why not just let this be an array?
+ -- Create annotations and markers for warnings
+ annotateAndMark AnnotateWarning $ fromMaybe [] warnings
+ -- Get JS source files for all included modules
+ obj <- liftAff $ runLoader loader $ JS js
+ -- Compress source file contents.
+ -- This avoids non-trivial HTML + JSON string escaping
+ let
+ objcomp = map (unwrap >>> compressToEncodedURIComponent) obj
+ -- Convert object to JSON string
+ let
+ jsonStr = stringify $ encodeJson objcomp
+ -- Pass JS files to execute in iframe
+ putOutput $ App jsonStr $ JS js
+ Right (CompileFailed { error }) -> do
+ case error of
+ CompilerErrors (errs :: Array CompilerError) -> do
+ putOutput $ Errors errs
+ -- Create annotations and markers for errors
+ annotateAndMark AnnotateError errs
+ OtherError err -> do
+ putOutput $ Text err
+ Left err -> do
+ let
+ str = "bad result. Likely communication issue with compiler: " <> err
+ putOutput $ Text str
+ --
+ -- Debouncer for auto-compile
+ debouncedRecompile <-
+ useDebouncer (Milliseconds 1000.0) \_ -> do
+ -- Recompile if setting is enabled
+ autoRecomp <- HK.get autoCompileIdx
+ if autoRecomp then do
+ doCompile
+ else
+ pure unit -- don't recompile
+ --
+ -- Must put writeContent after debouncedRecompile
+ let
+ -- update content in editor and state
+ writeContent (Content ct) = do
+ --log $ "writing content: " <> ct
+ HK.put contentIdx $ Content ct
+ liftEffect $ traverse_ (Document.setValue ct) document
+ -- Queue an automatic recompile (ignored if setting disabled)
+ debouncedRecompile unit
+ --
+ -- Initialize Ace editor and subscribe to text changes
+ HK.useLifecycleEffect do
+ doc /\ ses <-
+ liftEffect do
+ -- Create an editor
+ editor <- edit "editor" ace
+ -- disable vertical line on right side of editor that
+ -- estimates where words will be wrapped on paper.
+ Editor.setShowPrintMargin false editor
+ session_ <- Editor.getSession editor
+ -- Haskell syntax highlighting.
+ -- We could build more specialized highlighting for purs:
+ -- https://ace.c9.io/#higlighter=&nav=higlighter
+ Session.setMode "ace/mode/haskell" session_
+ Session.setTabSize 2 session_
+ document_ <- Session.getDocument session_
+ pure $ document_ /\ session_
+ --
+ -- Handle changes within editor.
+ -- Ignoring returned subscription ID.
+ _ <-
+ HK.subscribe do
+ ES.effectEventSource \emitter -> do
+ -- Ignoring DocumentEvent
+ Document.onChange doc \_ -> do
+ str <- Document.getValue doc
+ let
+ newContent = Content str
+ ES.emit emitter do
+ -- Compare content to prevent clearing gist status immediately upon gist load
+ oldContent <- HK.get contentIdx
+ if (newContent /= oldContent) then do
+ -- New content clears existing contentSource
+ putContentSource NewContent
+ writeContent newContent
+ liftEffect $ pushRoute $ "/?" <> pursQP <> "=" <> (show $ compress newContent)
+ else do
+ -- Do nothing if content unchanged
+ pure unit
+ -- No finalizer, so return mempty
+ pure mempty
+ putDocument $ Just doc
+ HK.put sessionIdx $ Just ses
+ pure Nothing
+ --
+ -- Handle routing queries
+ HK.useQuery queryToken \(Nav rt a) -> do
+ -- multiple state modifications, but not a performance issue now.
+ putRoute $ Just rt
+ case rt of
+ AuthorizeCallback authCode compressed -> do
+ log "in auth callback"
+ -- Immediately show new content.
+ -- This also requires setting saving flag again, since state
+ -- is reset upon page refresh from callback.
+ writeContent $ decompress compressed
+ putContentSource SavingGist
+ -- Make ghToken request to private app server
+ res <- liftAff $ ghRequestToken authCode
+ case res of
+ Left err -> log err
+ Right gh_token -> do
+ -- Save ghToken
+ putGhToken $ Just gh_token
+ -- Save gist
+ doSaveGist gh_token
+ LoadCompressed compressed -> do
+ let
+ ct = decompress compressed
+ --log $ "Got content from url: " <> show ct
+ writeContent ct
+ LoadGist gist_id -> do
+ eitherContent <- liftAff $ ghGetGist gist_id
+ case eitherContent of
+ Left err -> putOutput $ Text $ "Failed to load gist at: https://gist.github.com/" <> show gist_id <> "\n" <> err
+ Right c -> do
+ --log $ "Got content from gist: " <> show c
+ writeContent c
+ putContentSource $ HaveGist gist_id
+ --
+ -- Note that pushRoute just sets the URL, but won't reload the page:
+ -- liftEffect $ pushRoute homeRoute
+ Home -> liftEffect $ window >>= location >>= setHref homeRoute
+ -- Required response boilerplate for query
+ pure (Just a)
+ --
+ -- Helper functions for rendering
+ let
+ menu =
+ HH.div
+ [ HP.classes
+ [ T.flex
+ , T.bgTpsBlack
+ ]
+ ]
+ [ HH.a
+ [ HP.href homeRoute
+ , HP.title "Try PureScript!"
+ , HP.classes commonMenuClasses
+ ]
+ -- Could also define image width/height in css
+ [ HH.img
+ [ HP.src $ "img/favicon-white.svg"
+ , HP.width 40
+ , HP.width 40
+ ]
+ ]
+ , viewModeDropdown
+ , mkClickButton
+ "Compile"
+ "Compile Now"
+ NonMobile
+ doCompile
+ , mkToggleButton
+ "Auto-Compile"
+ "Compile on code changes"
+ autoCompile
+ NonMobile
+ ( do
+ -- toggle setting
+ HK.modify_ autoCompileIdx not
+ -- Queue a recompile in the case of enabling.
+ -- Will be ignored if setting is disabled.
+ debouncedRecompile unit
+ )
+ , mkToggleButton
+ "Show JS"
+ "Show resulting JavaScript code instead of output"
+ showJS
+ RenderAlways
+ $ modifyShowJS not
+ , gistButtonOrLink
+ , newTabLink
+ "Help"
+ "Learn more about Try PureScript"
+ "https://github.com/purescript/trypurescript/blob/master/README.md"
+ ]
+
+ mkClickButton text title res action =
+ HH.button
+ [ HP.classes $ menuTextClasses <> nonMobileBlock res
+ , HP.title title
+ , HE.onClick \_ -> Just action
+ ]
+ [ HH.text text ]
+
+ mkToggleButton text title enabled res action =
+ HH.button
+ [ HP.classes $ menuTextClasses <> highlight <> nonMobileBlock res
+ , HP.title title
+ , HE.onClick \_ -> Just action
+ ]
+ [ HH.text text ]
+ where
+ highlight = if enabled then [ T.textTpsEnabled ] else [ T.textTpsDisabled, T.lineThrough ]
+
+ viewModeDropdown =
+ let
+ dropdownItems =
+ if dropdownOpen then
+ HH.div
+ [ HP.classes
+ [ T.absolute
+ , T.z10
+ , T.wFull
+ ]
+ ]
+ $ map
+ mkDropdownItem
+ [ SideBySide, Code, Output ]
+ else
+ HH.div_ []
+ in
+ HH.div
+ [ HP.classes [ T.relative ]
+ , HE.onMouseEnter \_ -> Just $ putDropdownOpen true
+ , HE.onMouseLeave \_ -> Just $ putDropdownOpen false
+ ]
+ [ HH.div
+ [ HP.classes $ menuTextClasses <> nonMobileBlockClasses
+ , HP.title "Select a view mode"
+ ]
+ [ HH.text "View Mode ▾" ]
+ , dropdownItems
+ ]
+
+ mkDropdownItem vm =
+ HH.button
+ [ HP.classes $ dropdownItemClasses <> highlight
+ , HE.onClick \_ -> Just $ putViewMode vm
+ ]
+ [ HH.text $ show vm ]
+ where
+ highlight = if vm == viewMode then [ T.textTpsEnabled ] else []
+
+ newTabLink text title href =
+ HH.a
+ [ HP.href href
+ , HP.target "_blank" -- Open in new tab
+ , HP.classes menuTextClasses
+ , HP.title title
+ ]
+ [ HH.text text ]
+
+ gistButtonOrLink = case contentSource of
+ NewContent ->
+ mkClickButton
+ "Save Gist"
+ "Save code to GitHub Gist (requires OAuth login)"
+ NonMobile
+ ( do
+ -- Immediately show "saving" status
+ putContentSource SavingGist
+ case ghToken of
+ Nothing -> do
+ log "need token - authorizing"
+ liftEffect $ ghAuthorize content
+ Just gh_token -> do
+ log "have token"
+ doSaveGist gh_token
+ )
+ SavingGist ->
+ HH.div
+ [ HP.classes menuTextClasses ]
+ [ HH.text "Saving..." ]
+ HaveGist (GistID id) ->
+ newTabLink
+ "View Gist"
+ "Open the original gist in a new window"
+ $ "https://gist.github.com/"
+ <> id
+
+ renderIframe jsonStr =
+ HH.iframe
+ [ sandbox "allow-scripts allow-top-navigation"
+ , HP.src "frame-error.html"
+ , srcDoc
+ $ ""
+ <> ""
+ , HP.classes [ T.flexGrow ]
+ ]
+
+ renderOutputInner = case output of
+ Text str -> [ HH.text str ]
+ Loading ->
+ [ HH.img
+ [ HP.src "img/loading.gif"
+ , HP.classes [ T.objectContain ]
+ ]
+ ]
+ App str (JS js) ->
+ if showJS then
+ [ HH.pre [ HP.classes [ T.textXs ] ] [ HH.code_ [ HH.text js ] ] ]
+ else
+ [ renderIframe str ]
+ Errors errs -> concat $ mapWithIndex mkError errs
+ where
+ len = show $ Array.length errs
+
+ mkError i { message } =
+ [ HH.code
+ [ HP.classes [ T.fontBold, T.text2xl, T.py2, T.px5 ] ]
+ [ HH.text $ "Error " <> show (i + 1) <> " of " <> len ]
+ , HH.pre_ [ HH.code_ [ HH.text message ] ]
+ ]
+
+ renderOutput =
+ HH.div
+ [ HP.classes $ [ T.flex, T.flexCol, T.flexGrow, T.w0 ] <> hidden ]
+ renderOutputInner
+ where
+ hidden = case viewMode of
+ Code -> [ T.hidden ]
+ _ -> [] -- don't hide for output or side-by-side
+
+ renderEditor =
+ HH.div
+ [ HP.classes $ [ T.hidden ] <> display
+ ]
+ [ HH.div
+ [ HP.classes [ T.flexGrow ]
+ , HP.id_ "editor"
+ ]
+ []
+ ]
+ where
+ display = case viewMode of
+ -- don't show in output-only view mode
+ Output -> []
+ -- display for code or side-by-side
+ _ -> [ T.smFlex, T.flexGrow, T.borderR4, T.borderGray100 ]
+
+ banner =
+ HH.div
+ [ HP.classes [ T.block, T.smHidden, T.bgTpsMobileBanner, T.textTpsBlack, T.textSm, T.borderB, T.borderTpsBlack, T.p1, T.mb2 ] ]
+ [ HH.text "Your screen size is too small. Code editing has been disabled." ]
+ --
+ -- Render
+ HK.pure do
+ HH.div [ HP.classes [ T.flex, T.flexCol, T.hScreen ] ]
+ [ menu
+ , banner
+ , HH.div
+ [ HP.classes [ T.flex, T.flexRow, T.flexGrow, T.spaceX1 ] ]
+ [ renderEditor, renderOutput ]
+ ]
+
+{-
+Todo create Halogen PRs for these missing props
+-}
+sandbox :: forall r i. String -> HH.IProp ( sandbox :: String | r ) i
+sandbox = HH.prop (HH.PropName "sandbox")
+
+srcDoc :: forall r i. String -> HH.IProp ( srcDoc :: String | r ) i
+srcDoc = HH.prop (HH.PropName "srcdoc")
diff --git a/client/src/Try/Gist.js b/client/src/Try/Gist.js
deleted file mode 100644
index 003adac1..00000000
--- a/client/src/Try/Gist.js
+++ /dev/null
@@ -1,48 +0,0 @@
-"use strict";
-
-exports.getGistById_ = function(id, done, fail) {
- $.ajax({
- url: 'https://api.github.com/gists/' + id,
- dataType: 'json'
- }).done(done).fail(function(err) {
- fail("Unable to load Gist metadata");
- });
-}
-
-exports.tryLoadFileFromGist_ = function(gistInfo, filename, done, fail) {
- if (gistInfo.files && gistInfo.files.hasOwnProperty(filename)) {
- var url = gistInfo.files[filename].raw_url;
-
- return $.ajax({
- url: url,
- dataType: 'text'
- }).done(done).fail(function(err) {
- fail(err.statusText);
- });
- } else {
- fail("Gist does not contain a file named " + filename);
- }
-};
-
-exports.uploadGist_ = function(content, done, fail) {
- var data = {
- "description": "Published with try.purescript.org",
- "public": false,
- "files": {
- "Main.purs": {
- "content": content
- }
- }
- };
-
- $.ajax({
- url: 'https://api.github.com/gists',
- type: 'POST',
- dataType: 'json',
- data: JSON.stringify(data)
- }).success(function(e) {
- done(e.id);
- }).error(function(e) {
- fail(e);
- });
-};
diff --git a/client/src/Try/Gist.purs b/client/src/Try/Gist.purs
index 6057e291..2e52e0df 100644
--- a/client/src/Try/Gist.purs
+++ b/client/src/Try/Gist.purs
@@ -1,49 +1,120 @@
-module Try.Gist
- ( GistInfo
- , uploadGist
- , getGistById
- , tryLoadFileFromGist
- ) where
-
--- | An abstract data type representing the data we get back from the GitHub API.
-import Prelude
+module Try.Gist where
-import Control.Monad.Cont.Trans (ContT(..))
-import Control.Monad.Except.Trans (ExceptT(..))
+import Prelude
+import Affjax as AX
+import Affjax.RequestBody as AXRB
+import Affjax.RequestHeader as AXRH
+import Affjax.ResponseFormat as AXRF
+import Data.Argonaut (decodeJson, encodeJson, stringify)
import Data.Either (Either(..))
+import Data.HTTP.Method (Method(..))
+import Data.Maybe (Maybe(..))
import Effect (Effect)
-import Effect.Uncurried (EffectFn1, EffectFn3, EffectFn4, mkEffectFn1, runEffectFn3, runEffectFn4)
-
--- | An abstract data type representing the data we get back from the GitHub API.
-data GistInfo
-
-foreign import uploadGist_
- :: EffectFn3 String
- (EffectFn1 String Unit)
- (EffectFn1 String Unit)
- Unit
-
--- | A wrapper for `uploadGist` which uses `ContT`.
-uploadGist :: String -> ExceptT String (ContT Unit Effect) String
-uploadGist content = ExceptT (ContT \k -> runEffectFn3 uploadGist_ content (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
-
--- | Get a gist by its ID
-foreign import getGistById_
- :: EffectFn3 String
- (EffectFn1 GistInfo Unit)
- (EffectFn1 String Unit)
- Unit
-
--- | A wrapper for `getGistById` which uses `ContT`.
-getGistById :: String -> ExceptT String (ContT Unit Effect) GistInfo
-getGistById id_ = ExceptT (ContT \k -> runEffectFn3 getGistById_ id_ (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
-
-foreign import tryLoadFileFromGist_
- :: EffectFn4 GistInfo
- String
- (EffectFn1 String Unit)
- (EffectFn1 String Unit)
- Unit
-
-tryLoadFileFromGist :: GistInfo -> String -> ExceptT String (ContT Unit Effect) String
-tryLoadFileFromGist gi filename = ExceptT (ContT \k -> runEffectFn4 tryLoadFileFromGist_ gi filename (mkEffectFn1 (k <<< Right)) (mkEffectFn1 (k <<< Left)))
+import Effect.Aff (Aff)
+import Try.Common (AuthCode(..), Content(..), GhToken(..), GistID(..), pursQP)
+import Try.Config (appDomain, clientID, tokenServerUrl)
+import Try.Utility (compress)
+import Web.HTML (window)
+import Web.HTML.Location (setHref)
+import Web.HTML.Window (location)
+
+{-
+Handles HTTP requests for fetching and saving github gists.
+-}
+--
+type TokenResp
+ = { access_token :: String }
+
+ghRequestToken :: AuthCode -> Aff (Either String GhToken)
+ghRequestToken (AuthCode code) = do
+ result <- AX.post AXRF.json tokenServerUrl $ Just $ AXRB.json $ encodeJson { code }
+ pure
+ $ case result of
+ Left err -> do
+ Left $ "POST /api response failed to decode: " <> AX.printError err
+ Right response -> do
+ let
+ respStr = "POST /api response: " <> stringify response.body
+ case decodeJson response.body of
+ Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+ Right (decoded :: TokenResp) -> Right $ GhToken decoded.access_token
+
+gistApiUrl :: String
+gistApiUrl = "https://api.github.com/gists"
+
+type GistJson
+ = { files :: { "Main.purs" :: { content :: String } }
+ }
+
+type GistJsonWithDescription
+ = { files ::
+ { "Main.purs" ::
+ { content :: String
+ , description :: String
+ }
+ }
+ }
+
+setGistContent :: Content -> GistJsonWithDescription
+setGistContent (Content content) =
+ { files:
+ { "Main.purs":
+ { content
+ , description: "Created by TryPurescript"
+ }
+ }
+ }
+
+getGistContent :: GistJson -> Content
+getGistContent obj = Content obj.files."Main.purs".content
+
+ghGetGist :: GistID -> Aff (Either String Content)
+ghGetGist (GistID gistID) = do
+ result <- AX.get AXRF.json $ gistApiUrl <> "/" <> gistID
+ pure
+ $ case result of
+ Left err -> Left $ "GET gist response failed to decode: " <> AX.printError err
+ Right response -> do
+ let
+ respStr = "POST /api response: " <> stringify response.body
+ case decodeJson response.body of
+ Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+ Right (decoded :: GistJson) -> Right $ getGistContent decoded
+
+ghCreateGist :: GhToken -> Content -> Aff (Either String GistID)
+ghCreateGist token content = do
+ result <-
+ AX.request
+ ( AX.defaultRequest
+ { url = gistApiUrl
+ , method = Left POST
+ , responseFormat = AXRF.json
+ , headers = [ AXRH.RequestHeader "Authorization" $ "token " <> show token ]
+ , content = Just $ AXRB.json $ encodeJson $ setGistContent content
+ }
+ )
+ pure
+ $ case result of
+ Left err -> do
+ Left $ "POST /api response failed to decode: " <> AX.printError err
+ Right response -> do
+ let
+ respStr = "POST /api response: " <> stringify response.body
+ case decodeJson response.body of
+ Left err -> Left $ "Failed to decode json response: " <> respStr <> ", Error: " <> show err
+ Right (decoded :: { id :: String }) -> Right $ GistID decoded.id
+
+ghAuthorize :: Content -> Effect Unit
+ghAuthorize content = window >>= location >>= setHref authUrl
+ where
+ authUrl =
+ "https://github.com/login/oauth/authorize?"
+ <> "client_id="
+ <> clientID
+ <> "&scope=gist"
+ <> "&redirect_uri="
+ <> appDomain
+ <> "/?"
+ <> pursQP
+ <> "="
+ <> (show $ compress content)
diff --git a/client/src/Try/Loader.purs b/client/src/Try/Loader.purs
index c26f1fd2..2214d369 100644
--- a/client/src/Try/Loader.purs
+++ b/client/src/Try/Loader.purs
@@ -1,17 +1,14 @@
-module Try.Loader
- ( Loader
- , makeLoader
- , runLoader
- ) where
+module Try.Loader where
import Prelude
-
+import Affjax as AX
+import Affjax.ResponseFormat as AXRF
+import Try.Config (loaderUrl)
import Control.Bind (bindFlipped)
-import Control.Monad.Cont (ContT)
-import Control.Monad.Except (ExceptT)
import Control.Parallel (parTraverse)
import Data.Array as Array
import Data.Array.NonEmpty as NonEmpty
+import Data.Either (Either(..))
import Data.Maybe (Maybe(..), fromMaybe)
import Data.Newtype (unwrap)
import Data.String (Pattern(..))
@@ -22,39 +19,45 @@ import Data.String.Regex.Flags (noFlags)
import Data.String.Regex.Unsafe (unsafeRegex)
import Data.Tuple (Tuple(..))
import Effect (Effect)
+import Effect.Aff (Aff)
import Effect.Class (liftEffect)
import Effect.Ref (Ref)
import Effect.Ref as Ref
import Effect.Unsafe (unsafePerformEffect)
import Foreign.Object (Object)
import Foreign.Object as Object
-import Try.API as API
import Try.Shim (shims)
import Try.Types (JS(..))
-type Module =
- { name :: String
- , path :: Maybe String
- , deps :: Array Dependency
- , src :: JS
- }
-
-type Dependency =
- { name :: String
- , path :: Maybe String
- }
+{-
+Collects all JS modules required by compled code.
+-}
+--
+type Module
+ = { name :: String
+ , path :: Maybe String
+ , deps :: Array Dependency
+ , src :: JS
+ }
+
+type Dependency
+ = { name :: String
+ , path :: Maybe String
+ }
requireRegex :: Regex
requireRegex = unsafeRegex """^var\s+\S+\s*=\s*require\(["']([^"']*)["']\)""" noFlags
+-- Consider replacing these with node-path dirname and concat
dirname :: String -> String
-dirname path = fromMaybe "" do
- ix <- String.lastIndexOf (Pattern "/") path
- pure $ String.take ix path
+dirname path =
+ fromMaybe "" do
+ ix <- String.lastIndexOf (Pattern "/") path
+ pure $ String.take ix path
resolvePath :: String -> String -> Maybe String
resolvePath a b
- | String.take 2 b == "./" = Just $ dirname a <> String.drop 1 b
+ | String.take 2 b == "./" = Just $ dirname a <> String.drop 1 b
| String.take 3 b == "../" = Just $ dirname (dirname a) <> String.drop 2 b
| otherwise = Nothing
@@ -65,23 +68,39 @@ parseDeps current = Array.mapMaybe go <<< String.split (Pattern "\n") <<< unwrap
go line = do
match <- Regex.match requireRegex line
requirePath <- join $ NonEmpty.index match 1
- pure $ case resolvePath current requirePath of
- Just path ->
- { name: path
- , path: String.stripPrefix (Pattern "/") path
- }
- _ ->
- { name: requirePath
- , path: Nothing
- }
-
-newtype Loader = Loader (JS -> ExceptT String (ContT Unit Effect) (Object JS))
-
-runLoader :: Loader -> JS -> ExceptT String (ContT Unit Effect) (Object JS)
-runLoader (Loader k) = k
-
-makeLoader :: String -> Loader
-makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
+ pure
+ $ case resolvePath current requirePath of
+ Just path ->
+ { name: path
+ , path: String.stripPrefix (Pattern "/") path
+ }
+ _ ->
+ { name: requirePath
+ , path: Nothing
+ }
+
+{-
+Notes
+
+Could change error handling, but kinda nice to
+just throw the errors from JS.
+
+Assuming makeLoader runLoader pattern is to save
+cache between calls to runLoader.
+
+-}
+newtype Loader
+ = Loader (JS -> Aff (Object JS))
+
+runLoader :: Loader -> JS -> Aff (Object JS)
+runLoader (Loader k) js = do
+ -- Run loader to collect all dependencies for compiled code
+ obj <- k js
+ -- Return dependencies along with compiled code
+ pure $ Object.insert "" js obj
+
+makeLoader :: Loader
+makeLoader = Loader (go Object.empty <<< parseDeps "")
where
moduleCache :: Ref (Object Module)
moduleCache = unsafePerformEffect (Ref.new Object.empty)
@@ -92,32 +111,51 @@ makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
getModule :: String -> Effect (Maybe Module)
getModule a = Object.lookup a <$> Ref.read moduleCache
- load :: Dependency -> ExceptT String (ContT Unit Effect) Module
+ load :: Dependency -> Aff Module
load { name, path } = do
cached <- liftEffect $ getModule name
case cached of
Just mod -> pure mod
Nothing -> do
- mod <-
- case path of
- Just path' -> do
- srcStr <- API.get (rootPath <> "/" <> path')
- let src = JS $ srcStr <> "\n//# sourceURL=" <> path'
- pure { name, path, deps: parseDeps name src, src }
- Nothing -> case Object.lookup name shims of
- Just shim -> do
- srcStr <- API.get shim.url
- let
- src = JS $ srcStr <> "\n//# sourceURL=" <> shim.url
+ mod <- case path of
+ -- Path means dependency is another file
+ Just path' -> do
+ let
+ url = loaderUrl <> "/" <> path'
+ --log $ "get: " <> url
+ res <- AX.get AXRF.string url
+ case res of
+ Left err -> pure { name, path, deps: [], src }
+ where
+ src = throwJSError $ "Could not get file " <> url <> ", " <> AX.printError err
+ Right { body } -> do
+ --log $ "got body:\n" <> body
+ pure { name, path, deps: parseDeps name src, src }
+ where
+ src = JS $ body <> "\n//# sourceURL=" <> path'
+ -- No path means dependency is a shim
+ Nothing -> case Object.lookup name shims of
+ Just shim -> do
+ res <- AX.get AXRF.string shim.url
+ case res of
+ Left err -> pure { name, path, deps: [], src }
+ where
+ src = throwJSError $ "Could not get shim " <> name <> " at " <> shim.url <> ", " <> AX.printError err
+ Right { body } -> pure { name, path, deps, src }
+ where
+ src = JS $ body <> "\n//# sourceURL=" <> shim.url
+
deps = { name: _, path: Nothing } <$> shim.deps
- pure { name, path, deps, src }
- Nothing ->
- pure { name, path, deps: [], src: ffiDep name }
+ Nothing -> pure { name, path, deps: [], src }
+ where
+ -- Todo - link to instructions for adding shims
+ src = throwJSError $ "FFI dependency not provided: " <> name
liftEffect $ putModule name mod
pure mod
- go :: Object JS -> Array Dependency -> ExceptT String (ContT Unit Effect) (Object JS)
- go ms [] = pure ms
+ go :: Object JS -> Array Dependency -> Aff (Object JS)
+ go ms [] = pure ms
+
go ms deps = do
modules <- parTraverse load deps
let
@@ -131,5 +169,5 @@ makeLoader rootPath = Loader (go Object.empty <<< parseDeps "")
# Array.nubBy (comparing _.name)
# go ms'
-ffiDep :: String -> JS
-ffiDep name = JS $ "throw new Error('FFI dependency not provided: " <> name <> "');"
+throwJSError :: String -> JS
+throwJSError err = JS $ "throw new Error('" <> err <> "');"
diff --git a/client/src/Try/QueryString.js b/client/src/Try/QueryString.js
deleted file mode 100644
index 56c5e290..00000000
--- a/client/src/Try/QueryString.js
+++ /dev/null
@@ -1,13 +0,0 @@
-"use strict";
-
-exports.getQueryString = function() {
- return window.location.search;
-};
-
-exports.setQueryParameters = function(params) {
- var encodedParams = Object.keys(params).map(function(key) {
- return key + '=' + encodeURIComponent(params[key].replace('/', ''));
- }).join('&');
-
- window.location.search = '?' + encodedParams;
-};
diff --git a/client/src/Try/QueryString.purs b/client/src/Try/QueryString.purs
deleted file mode 100644
index 8009936a..00000000
--- a/client/src/Try/QueryString.purs
+++ /dev/null
@@ -1,54 +0,0 @@
-module Try.QueryString
- ( getQueryParams
- , getQueryStringMaybe
- , setQueryString
- , setQueryStrings
- ) where
-
-import Prelude
-
-import Data.Array as Array
-import Data.Maybe (Maybe(..))
-import Data.Newtype (wrap)
-import Data.String as String
-import Data.Tuple (Tuple(..))
-import Effect (Effect)
-import Effect.Uncurried (EffectFn1, runEffectFn1)
-import Foreign.Object as Object
-import Global.Unsafe (unsafeDecodeURIComponent)
-
-foreign import getQueryString :: Effect String
-
--- | Get all of the URL's query parameters.
-getQueryParams :: Effect (Object.Object String)
-getQueryParams = breakQueryString <$> getQueryString where
- breakQueryString :: String -> Object.Object String
- breakQueryString =
- String.drop 1
- >>> String.split (wrap "&")
- >>> map (String.split (wrap "=") >>> parseQueryTerm)
- >>> Array.catMaybes
- >>> Object.fromFoldable
-
- parseQueryTerm :: Array String -> Maybe (Tuple String String)
- parseQueryTerm [k, v] = Just (Tuple k (unsafeDecodeURIComponent (spaces v)))
- parseQueryTerm _ = Nothing
-
- spaces :: String -> String
- spaces = String.replaceAll (wrap "+") (wrap " ")
-
--- | Try to get a key from the URL's query parameters.
-getQueryStringMaybe :: String -> Effect (Maybe String)
-getQueryStringMaybe key = Object.lookup key <$> getQueryParams
-
--- | Set the value of a query string parameter
-foreign import setQueryParameters :: EffectFn1 (Object.Object String) Unit
-
--- | Update the specified key in the URL's query parameters.
-setQueryString :: String -> String -> Effect Unit
-setQueryString k v = setQueryStrings (Object.singleton k v)
-
-setQueryStrings :: Object.Object String -> Effect Unit
-setQueryStrings ss = do
- params <- getQueryParams
- runEffectFn1 setQueryParameters (Object.union ss params)
diff --git a/client/src/Try/Routing.purs b/client/src/Try/Routing.purs
new file mode 100644
index 00000000..71a332d1
--- /dev/null
+++ b/client/src/Try/Routing.purs
@@ -0,0 +1,33 @@
+module Try.Routing where
+
+import Prelude
+import Data.Foldable (oneOf)
+import Data.Generic.Rep (class Generic)
+import Data.Generic.Rep.Show (genericShow)
+import Routing.Match (Match, end, param, root)
+import Try.Common (AuthCode(..), Compressed(..), GistID(..), gistQP, pursQP)
+
+{-
+Handles navigation within the single-page-app.
+-}
+--
+data Route
+ = AuthorizeCallback AuthCode Compressed
+ | LoadCompressed Compressed
+ | LoadGist GistID
+ | Home
+
+derive instance genericRoute :: Generic Route _
+
+instance showRoute :: Show Route where
+ show = genericShow
+
+route :: Match Route
+route =
+ root
+ *> oneOf
+ [ AuthorizeCallback <$> (AuthCode <$> param "code") <*> (Compressed <$> param pursQP)
+ , LoadCompressed <$> Compressed <$> param pursQP
+ , LoadGist <$> GistID <$> param gistQP
+ , Home <$ end
+ ]
diff --git a/client/src/Try/Session.js b/client/src/Try/Session.js
deleted file mode 100644
index 42c9971a..00000000
--- a/client/src/Try/Session.js
+++ /dev/null
@@ -1,18 +0,0 @@
-"use strict";
-
-exports.storeSession_ = function(sessionId, state) {
- if (window.localStorage) {
- localStorage.setItem(sessionId, state.code);
- localStorage.setItem(sessionId + 'backend', state.backend);
- }
-};
-
-exports.tryRetrieveSession_ = function(sessionId) {
- if (window.localStorage) {
- var code = localStorage.getItem(sessionId);
- var backend = localStorage.getItem(sessionId + 'backend');
- if (code && backend) {
- return { code: code, backend: backend };
- }
- }
-};
diff --git a/client/src/Try/Session.purs b/client/src/Try/Session.purs
deleted file mode 100644
index 2262bf26..00000000
--- a/client/src/Try/Session.purs
+++ /dev/null
@@ -1,59 +0,0 @@
-module Try.Session
- ( storeSession
- , tryRetrieveSession
- , createSessionIdIfNecessary
- ) where
-
-import Prelude
-
-import Data.Functor.App (App(..))
-import Data.Int (hexadecimal, toStringAs)
-import Data.Maybe (Maybe(..))
-import Data.Newtype (unwrap)
-import Data.Nullable (Nullable, toMaybe)
-import Data.String as String
-import Effect (Effect)
-import Effect.Random (randomInt)
-import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2)
-import Try.QueryString (getQueryStringMaybe, setQueryString)
-
-randomGuid :: Effect String
-randomGuid =
- unwrap (App s4 <> App s4 <> pure "-" <>
- App s4 <> pure "-" <>
- App s4 <> pure "-" <>
- App s4 <> pure "-" <>
- App s4 <> App s4 <> App s4)
- where
- s4 = padLeft <<< toStringAs hexadecimal <$> randomInt 0 (256 * 256)
- padLeft s = String.drop (String.length s - 1) ("000" <> s)
-
-foreign import storeSession_
- :: EffectFn2 String
- { code :: String }
- Unit
-
--- | Store the current session state in local storage
-storeSession
- :: String
- -> { code :: String }
- -> Effect Unit
-storeSession sessionId values = runEffectFn2 storeSession_ sessionId values
-
-foreign import tryRetrieveSession_
- :: EffectFn1 String
- (Nullable { code :: String })
-
--- | Retrieve the session state from local storage
-tryRetrieveSession :: String -> Effect (Maybe { code :: String })
-tryRetrieveSession sessionId = toMaybe <$> runEffectFn1 tryRetrieveSession_ sessionId
-
--- | Look up the session by ID, or create a new session ID.
-createSessionIdIfNecessary
- :: (String -> Effect Unit)
- -> Effect Unit
-createSessionIdIfNecessary k = do
- sessionId <- getQueryStringMaybe "session"
- case sessionId of
- Just sessionId_ -> k sessionId_
- Nothing -> randomGuid >>= setQueryString "session"
diff --git a/client/src/Try/Shim.purs b/client/src/Try/Shim.purs
index b6608b78..cff6471a 100644
--- a/client/src/Try/Shim.purs
+++ b/client/src/Try/Shim.purs
@@ -4,36 +4,42 @@ import Data.Tuple (Tuple(..))
import Foreign.Object (Object)
import Foreign.Object as Object
-type Shim =
- { url :: String
- , deps :: Array String
- }
+{-
+Allows loading additional dependencies, which are required by
+some libraries.
+Feel free to add additional entries to this file.
+-}
+--
+type Shim
+ = { url :: String
+ , deps :: Array String
+ }
shims :: Object Shim
-shims = Object.fromFoldable
- [ Tuple "react"
- { url: "https://unpkg.com/react@16.13.1/umd/react.development.js"
- , deps: []
- }
- , Tuple "react-dom"
- { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom.development.js"
- , deps: [ "react" ]
- }
- , Tuple "react-dom/server"
- { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom-server.browser.development.js"
- , deps: [ "react" ]
- }
- , Tuple "big-integer"
- { url: "https://unpkg.com/big-integer@1.6.48/BigInteger.min.js"
- , deps: []
- }
- , Tuple "decimal.js"
- { url: "https://unpkg.com/decimal.js@10.2.0/decimal.min.js"
- , deps: []
- }
- , Tuple "uuid"
- { url: "https://cdnjs.cloudflare.com/ajax/libs/uuid/8.1.0/uuid.min.js"
- , deps: []
- }
- ]
-
+shims =
+ Object.fromFoldable
+ [ Tuple "react"
+ { url: "https://unpkg.com/react@16.13.1/umd/react.development.js"
+ , deps: []
+ }
+ , Tuple "react-dom"
+ { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom.development.js"
+ , deps: [ "react" ]
+ }
+ , Tuple "react-dom/server"
+ { url: "https://unpkg.com/react-dom@16.13.1/umd/react-dom-server.browser.development.js"
+ , deps: [ "react" ]
+ }
+ , Tuple "big-integer"
+ { url: "https://unpkg.com/big-integer@1.6.48/BigInteger.min.js"
+ , deps: []
+ }
+ , Tuple "decimal.js"
+ { url: "https://unpkg.com/decimal.js@10.2.0/decimal.min.js"
+ , deps: []
+ }
+ , Tuple "uuid"
+ { url: "https://cdnjs.cloudflare.com/ajax/libs/uuid/8.1.0/uuid.min.js"
+ , deps: []
+ }
+ ]
diff --git a/client/src/Try/Types.purs b/client/src/Try/Types.purs
index a85d2e8b..c57fb212 100644
--- a/client/src/Try/Types.purs
+++ b/client/src/Try/Types.purs
@@ -1,12 +1,17 @@
-module Try.Types
- ( JS(..)
- ) where
+module Try.Types where
+import Data.Argonaut (class EncodeJson)
import Data.Newtype (class Newtype)
-import Foreign.Class (class Encode)
-newtype JS = JS String
+{-
+Some common types.
+Just the `JS` type for now.
+-}
+--
+newtype JS
+ = JS String
+-- enable `unwrap`
derive instance newtypeJS :: Newtype JS _
-derive newtype instance encodeJS :: Encode JS
+derive newtype instance encodeJsonJS :: EncodeJson JS
diff --git a/client/src/Try/Utility.purs b/client/src/Try/Utility.purs
new file mode 100644
index 00000000..3a273e9c
--- /dev/null
+++ b/client/src/Try/Utility.purs
@@ -0,0 +1,37 @@
+module Try.Utility where
+
+import Prelude
+import Try.Common (Compressed(..), Content(..), GistID)
+import Effect (Effect)
+import LzString (compressToEncodedURIComponent, decompressFromEncodedURIComponent)
+
+{-
+Helper functions that can exist outside of the main component.
+-}
+--
+data ViewMode
+ = SideBySide
+ | Code
+ | Output
+
+-- Could alternatively derive if displaying "SideBySide"(no hyphens) is okay.
+instance showViewMode :: Show ViewMode where
+ show SideBySide = "Side-by-side"
+ show Code = "Code"
+ show Output = "Output"
+
+derive instance eqViewMode :: Eq ViewMode
+
+type PushRoute
+ = String -> Effect Unit
+
+data ContentSource
+ = NewContent --NoGist
+ | SavingGist
+ | HaveGist GistID
+
+compress :: Content -> Compressed
+compress (Content c) = Compressed $ compressToEncodedURIComponent c
+
+decompress :: Compressed -> Content
+decompress (Compressed c) = Content $ decompressFromEncodedURIComponent c
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 00000000..bd52ff78
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,21 @@
+// tailwind.config.js
+module.exports = {
+ purge: [
+ './public/index.js',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ 'tps-black': '#1d222d',
+ //'tps-button-enabled-background': '#8490a9',
+ 'tps-disabled': '#ababab',
+ 'tps-enabled': '#c4953a',
+ 'tps-mobile-banner': '#dabf8b',
+ // mobile border same as button background
+
+ }
+ }
+ },
+ variants: {},
+ plugins: [],
+}
diff --git a/client/webpack.common.js b/client/webpack.common.js
new file mode 100644
index 00000000..5bd4ac03
--- /dev/null
+++ b/client/webpack.common.js
@@ -0,0 +1,44 @@
+const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+const CopyPlugin = require('copy-webpack-plugin');
+const webpack = require('webpack');
+
+module.exports = {
+ entry: {
+ "index.js": [
+ "./public/tailwind.css",
+ "./public/ace.css",
+ "./public/index.js",
+ ],
+ },
+ plugins: [
+ new CleanWebpackPlugin(),
+ new HtmlWebpackPlugin({
+ template: './public/index.html',
+ }),
+ new CopyPlugin({
+ patterns: [
+ {from: 'public/js/frame.js'},
+ {from: 'public/img', to: 'img'},
+ {from: 'public/CNAME'},
+ ],
+ }),
+ ],
+ module: {
+ rules: [
+ {
+ test: /\.css$/,
+ use: [
+ 'style-loader',
+ 'css-loader',
+ ],
+ },
+ ],
+ },
+ output: {
+ filename: '[name]',
+ path: path.resolve(__dirname, 'dist'),
+ publicPath: '/',
+ },
+};
diff --git a/client/webpack.dev.js b/client/webpack.dev.js
new file mode 100644
index 00000000..1af5978f
--- /dev/null
+++ b/client/webpack.dev.js
@@ -0,0 +1,11 @@
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+ mode: 'development',
+ devtool: 'inline-source-map',
+ devServer: {
+ contentBase: false,
+ },
+});
+
diff --git a/client/webpack.prod.js b/client/webpack.prod.js
new file mode 100644
index 00000000..ed0670ee
--- /dev/null
+++ b/client/webpack.prod.js
@@ -0,0 +1,6 @@
+const { merge } = require('webpack-merge');
+const common = require('./webpack.common.js');
+
+module.exports = merge(common, {
+ mode: 'production',
+});