├── .babelrc ├── .eslintrc ├── .github └── workflows │ └── semgrep.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── app ├── assets │ ├── eager-logo.svg │ ├── favicon.png │ ├── left-arrow.svg │ ├── logo.svg │ └── right-arrow.svg ├── components │ ├── application │ │ ├── application.pug │ │ ├── application.styl │ │ ├── demos │ │ │ ├── emoji-react.js │ │ │ ├── forecast-io.js │ │ │ ├── index.js │ │ │ └── nectar-ninja.js │ │ ├── index.js │ │ └── navigation-button.pug │ ├── attribute-picker │ │ ├── attribute-picker.pug │ │ ├── attribute-picker.styl │ │ └── index.js │ ├── base-component.js │ ├── image-uploader │ │ ├── image-uploader.pug │ │ ├── image-uploader.styl │ │ └── index.js │ └── steps │ │ ├── creating │ │ ├── creating.pug │ │ ├── creating.styl │ │ └── index.js │ │ ├── details │ │ ├── details.pug │ │ ├── details.styl │ │ └── index.js │ │ ├── download │ │ ├── download.pug │ │ ├── download.styl │ │ └── index.js │ │ ├── embed-code │ │ ├── embed-code.pug │ │ ├── embed-code.styl │ │ └── index.js │ │ ├── index.js │ │ ├── intro │ │ ├── index.js │ │ ├── intro.pug │ │ └── intro.styl │ │ ├── preview │ │ ├── index.js │ │ ├── inline-assets │ │ │ └── preview-overrides.styl │ │ ├── preview.pug │ │ └── preview.styl │ │ └── schema │ │ ├── entity.pug │ │ ├── index.js │ │ ├── schema.pug │ │ └── schema.styl ├── external-assets │ ├── bee.png │ ├── default-plugin-logo.png │ ├── forecast.png │ └── tada.png ├── index.js ├── index.pug ├── index.styl ├── lib │ ├── constants.js │ ├── create-eager-schema.js │ ├── create-element.js │ ├── escape-template.js │ ├── key-map.js │ ├── parse-url.js │ ├── preview-image-file.js │ └── unique-id.js ├── segment.js └── styl │ ├── buttons.styl │ ├── colors.styl │ ├── font-smoothing.styl │ ├── fonts.styl │ ├── form.styl │ ├── inputs.styl │ ├── link-underlines.styl │ ├── loading-dots.styl │ ├── media-query-variables.styl │ ├── more-links-and-buttons.styl │ └── sizes.styl ├── circle.yml ├── deploy.yaml ├── media ├── favicon.sketch └── logo.sketch ├── package.json ├── routes.json ├── scripts └── watch.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "babel-plugin-transform-decorators-legacy", 4 | "array-includes", 5 | "transform-object-assign", 6 | "transform-array-from", 7 | "transform-proto-to-assign" 8 | ], 9 | "presets": [["es2015", {"modules": false, "loose": true}], "stage-0"] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | }, 6 | "extends": "eslint:recommended", 7 | "ecmaVersion": 7, 8 | "globals": { 9 | "INSTALL_OPTIONS": null, 10 | "API_BASE": null, 11 | "APP_BASE": null, 12 | "ASSET_BASE": null, 13 | "EAGER_BASE": null, 14 | "Eager": null, 15 | "process": null, 16 | "VERSION": null 17 | }, 18 | "parser": "babel-eslint", 19 | "plugins": [ 20 | "babel" 21 | ], 22 | "rules": { 23 | "babel/object-curly-spacing": ["warn", "never"], 24 | "array-bracket-spacing": ["warn", "never"], 25 | "arrow-parens": ["warn", "as-needed"], 26 | "arrow-spacing": ["warn", { 27 | "before": true, 28 | "after": true 29 | }], 30 | "block-spacing": ["warn", "always"], 31 | "brace-style": ["warn", "stroustrup", { "allowSingleLine": true }], 32 | "camelcase": "off", 33 | "comma-dangle": ["error", "never"], 34 | "comma-spacing": ["error", { 35 | "before": false, 36 | "after": true 37 | }], 38 | "comma-style": ["warn", "last"], 39 | "computed-property-spacing": ["warn", "never"], 40 | "consistent-this": ["error", "self"], 41 | "consistent-return": "off", 42 | "constructor-super": "error", 43 | "curly": "off", 44 | "default-case": "warn", 45 | "dot-location": ["error", "property"], 46 | "dot-notation": "warn", 47 | "eol-last": "warn", 48 | "eqeqeq": ["error", "allow-null"], 49 | "indent": ["warn", 2, { 50 | "SwitchCase": 1, 51 | "VariableDeclarator": 1 52 | }], 53 | "key-spacing": ["warn", { 54 | "afterColon": true, 55 | "beforeColon": false, 56 | "mode": "strict" 57 | }], 58 | "keyword-spacing": ["warn", { 59 | "after": true, 60 | "before": true 61 | }], 62 | "jsx-quotes": ["warn", "prefer-double"], 63 | "linebreak-style": ["error", "unix"], 64 | "max-nested-callbacks": ["warn", 6], 65 | "new-cap": "error", 66 | "no-cond-assign": ["error", "except-parens"], 67 | "new-parens": "error", 68 | "newline-after-var": "warn", 69 | "no-array-constructor": "error", 70 | "no-caller": "error", 71 | "no-class-assign": "error", 72 | "no-console": "off", 73 | "no-const-assign": "error", 74 | "no-constant-condition": "warn", 75 | "no-continue": "warn", 76 | "no-debugger": "warn", 77 | "no-delete-var": "error", 78 | "no-dupe-class-members": "error", 79 | "no-else-return": "warn", 80 | "no-eval": "error", 81 | "no-extend-native": "error", 82 | "no-extra-bind": "warn", 83 | "no-extra-parens": ["warn", "all"], 84 | "no-floating-decimal": "error", 85 | "no-implied-eval": "error", 86 | "no-inline-comments": "off", 87 | "no-labels": "warn", 88 | "no-label-var": "error", 89 | "no-lone-blocks": "error", 90 | "no-lonely-if": "warn", 91 | "no-loop-func": "error", 92 | "no-mixed-spaces-and-tabs": "warn", 93 | "no-multi-spaces": "warn", 94 | "no-multiple-empty-lines": "warn", 95 | "no-native-reassign": "error", 96 | "no-nested-ternary": "warn", 97 | "no-new": "off", 98 | "no-new-func": "error", 99 | "no-new-object": "error", 100 | "no-new-wrappers": "error", 101 | "no-restricted-syntax": ["error", 102 | "WithStatement" 103 | ], 104 | "no-redeclare": "error", 105 | "no-return-assign": "off", 106 | "no-script-url": "error", 107 | "no-self-compare": "error", 108 | "no-sequences": "error", 109 | "no-shadow-restricted-names": "error", 110 | "no-spaced-func": "warn", 111 | "no-this-before-super": "error", 112 | "no-throw-literal": "error", 113 | "no-undef": "error", 114 | "no-undef-init": "error", 115 | "no-undefined": "error", 116 | "no-unexpected-multiline": "error", 117 | "no-unused-vars": "warn", 118 | "no-use-before-define": "error", 119 | "no-useless-call": "warn", 120 | "no-useless-concat": "warn", 121 | "no-var": "error", 122 | "no-void": "warn", 123 | "no-with": "error", 124 | "object-shorthand": ["warn", "always"], 125 | "one-var": ["warn", { 126 | "let": "never", 127 | "const": "never" 128 | }], 129 | "operator-assignment": ["warn", "always"], 130 | "operator-linebreak": ["warn", "after"], 131 | "padded-blocks": ["warn", "never"], 132 | "prefer-arrow-callback": "warn", 133 | "prefer-const": "error", 134 | "prefer-reflect": "off", 135 | "prefer-spread": "warn", 136 | "quote-props": ["warn", "as-needed"], 137 | "quotes": ["warn", "double"], 138 | "semi": ["error", "never"], 139 | "semi-spacing": ["warn", { 140 | "after": true, 141 | "before": false 142 | }], 143 | "space-before-blocks": ["warn", "always"], 144 | "space-in-parens": ["warn", "never"], 145 | "space-infix-ops": ["warn"], 146 | "space-unary-ops": ["warn"], 147 | "spaced-comment": ["warn", "always"], 148 | "yoda": ["warn", "never", { 149 | "exceptRange": true 150 | }] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | bower_components 4 | *.log 5 | site-deploy 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | deploy.yaml 2 | circle.yml 3 | webpack.config.js 4 | webpack.site.js 5 | test/ 6 | media/ 7 | scripts/ 8 | examples/ 9 | app/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Eager Platform Co. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InstantPlugin 2 | 3 | _Convert an embed code to a plugin._ 4 | 5 | http://instantwordpressplugin.com 6 | -------------------------------------------------------------------------------- /app/assets/eager-logo.svg: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /app/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/InstantPlugin/6ad65d25cf99d64068c01a8bf76f03e8fef61928/app/assets/favicon.png -------------------------------------------------------------------------------- /app/assets/left-arrow.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /app/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /app/assets/right-arrow.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /app/components/application/application.pug: -------------------------------------------------------------------------------- 1 | main(data-component="application" data-flow="column") 2 | header.app-header 3 | .content-container(data-ref="appHeader") 4 | .content(data-ref="appHeaderContent") 5 | != require("../../assets/logo.svg") 6 | h1.headline(data-ref="title") 7 | 8 | .navigation-actions(data-ref="navigationContainer") 9 | 10 | .app-content 11 | .steps-container(data-ref="stepsContainer") 12 | -------------------------------------------------------------------------------- /app/components/application/application.styl: -------------------------------------------------------------------------------- 1 | @import "~styl/colors" 2 | @import "~styl/fonts" 3 | @import "~styl/font-smoothing" 4 | 5 | stepsProgression = \ 6 | "intro", \ 7 | "embedCode", \ 8 | "schema", \ 9 | "preview", \ 10 | "details", \ 11 | "creating", \ 12 | "download" 13 | 14 | headerHeight = 5.5em 15 | 16 | [data-component="application"] 17 | line-height 1.5 18 | height 100vh 19 | width 100vw 20 | 21 | &[data-previous-step] 22 | .app-header .content 23 | transition .3s transform ease-in-out 24 | 25 | .app-content 26 | flex 1 1 auto 27 | position relative 28 | overflow hidden 29 | width 100vw 30 | 31 | .box 32 | background #fff 33 | color containedTextColor 34 | border-radius 0.1875em 35 | position relative 36 | box-shadow 0 .1875em .375em -0.215em rgba(#000, .325) 37 | border-radius 0.1875em 38 | 39 | .steps-container 40 | flex 0 0 auto 41 | transition transform .3s ease-in-out 42 | transform translate3d(0, 0, 0) 43 | will-change visibility, transform, opacity 44 | position absolute 45 | top 0 46 | right 0 47 | bottom 0 48 | left 0 49 | 50 | .step 51 | flex 0 0 auto 52 | overflow scroll 53 | transition visibility .4s linear 54 | width 100vw 55 | 56 | &.padded 57 | padding 0 1em 1em 58 | 59 | &.with-header-offset 60 | margin-top -(headerHeight) 61 | 62 | .step-details 63 | flex 0 0 auto 64 | align-items center 65 | justify-content center 66 | margin 1.5em 0 0.5em 67 | min-height 2em 68 | 69 | p 70 | margin 0 71 | 72 | .column 73 | flex-flow column 74 | 75 | + .column 76 | margin-left 1em 77 | 78 | .headline 79 | justify-content flex-start 80 | 81 | .secondary-details 82 | justify-content space-between 83 | 84 | 85 | .step[data-step] 86 | visibility hidden 87 | 88 | for step, index in stepsProgression 89 | &[data-active-step={step}] .steps-container 90 | transform translate3d((-100vw * index), 0, 0) 91 | 92 | &[data-active-step={step}] [data-step={step}] 93 | visibility visible 94 | 95 | .code-input 96 | antialiasedFonts() 97 | border 1px solid transparent 98 | font-family monospaceFonts 99 | line-height 1.6 100 | height 100% 101 | padding .8em 1.125em 102 | position relative 103 | resize none 104 | width 100% 105 | transition .3s border-color linear 106 | 107 | &:focus 108 | outline none 109 | border-color alpha(brandOrange, 0.2) 110 | 111 | .app-header 112 | align-items center 113 | background-color lighterGray 114 | box-shadow 0 .1875em .375em -0.19em rgba(#000, .325) 115 | flex 0 0 auto 116 | padding 1em 117 | position relative 118 | height headerHeight 119 | z-index 1 120 | 121 | .navigation-actions .button 122 | &, &:hover 123 | box-shadow 0 0.1875em 0.375em -0.2875em rgba(0,0,0,0.325) 124 | 125 | &.primary 126 | min-width 10em // Fix button width jumping. 127 | 128 | .headline 129 | align-items center 130 | display flex 131 | font-size 1.3em 132 | font-weight 300 133 | justify-content center 134 | margin 0 135 | 136 | .step-number 137 | align-items center 138 | border 2px solid brandOrangeDark 139 | border-radius 0.4em 140 | display flex 141 | height 2em 142 | justify-content center 143 | padding 1em 144 | width 2em 145 | 146 | + .label 147 | margin-left 1em 148 | 149 | .content-container 150 | justify-content flex-start 151 | position absolute 152 | top 0 153 | right 0 154 | bottom 0 155 | left 0 156 | width 100% 157 | padding-left 1em 158 | 159 | .content 160 | align-items center 161 | flex 0 0 auto 162 | 163 | svg 164 | height 3em 165 | width @height 166 | 167 | + .headline 168 | margin-left .5em 169 | 170 | path 171 | fill brandOrange 172 | 173 | .navigation-actions 174 | flex 1 0 auto 175 | justify-content flex-end 176 | 177 | .button 178 | align-items center 179 | justify-content center 180 | display flex 181 | 182 | + .button 183 | margin-left 1em 184 | 185 | .icon 186 | display flex 187 | flex 0 0 auto 188 | height 1em 189 | 190 | svg 191 | display inline-block 192 | fill currentColor 193 | height auto 194 | width 1em 195 | 196 | .icon + .label 197 | margin-left .5em 198 | 199 | .label + .icon 200 | margin-left .5em 201 | -------------------------------------------------------------------------------- /app/components/application/demos/emoji-react.js: -------------------------------------------------------------------------------- 1 | import autosize from "autosize" 2 | 3 | const script = String.raw`` 28 | 29 | export default function runDemo(app) { 30 | const {steps} = app 31 | const {attributePicker} = steps.schema 32 | const {locationSelect} = steps.schema.refs 33 | const {embedCodeInput} = steps.embedCode.refs 34 | const {detailsForm} = steps.details.refs 35 | 36 | embedCodeInput.autofocus = false 37 | embedCodeInput.value = script 38 | app.$embedCode = embedCodeInput.value 39 | autosize.update(embedCodeInput) 40 | steps.embedCode.syncButtonState() 41 | 42 | locationSelect.value = "body" 43 | 44 | const {option_2, option_3} = app.entities 45 | 46 | option_2.title = "Comma separated list of emoji names" 47 | attributePicker.toggleEntityTracking(option_2.element) 48 | 49 | option_3.title = "Location" 50 | option_3.format = "selector" 51 | attributePicker.toggleEntityTracking(option_3.element) 52 | 53 | 54 | const fields = { 55 | "[name='app[title]']": "Emoji React", 56 | "[name='app[description]']": "React with your favorite Emojis!", 57 | "[name='email']": "demo@instantwordpressplugin.com" 58 | } 59 | 60 | Object 61 | .keys(fields) 62 | .forEach(name => detailsForm.querySelector(name).value = fields[name]) 63 | 64 | steps.details.imageUploader.imageURL = `${ASSET_BASE}/tada.png` 65 | } 66 | -------------------------------------------------------------------------------- /app/components/application/demos/forecast-io.js: -------------------------------------------------------------------------------- 1 | import autosize from "autosize" 2 | 3 | const script = String.raw`` 5 | 6 | export default function runDemo(app) { 7 | const {steps} = app 8 | const {attributePicker} = steps.schema 9 | const {locationSelect} = steps.schema.refs 10 | const {embedCodeInput} = steps.embedCode.refs 11 | const {detailsForm} = steps.details.refs 12 | 13 | embedCodeInput.autofocus = false 14 | embedCodeInput.value = script 15 | app.$embedCode = embedCodeInput.value 16 | autosize.update(embedCodeInput) 17 | steps.embedCode.syncButtonState() 18 | 19 | locationSelect.value = "body" 20 | 21 | const {option_7, option_8, option_9} = app.entities 22 | 23 | option_7.title = "Latitude" 24 | attributePicker.toggleEntityTracking(option_7.element) 25 | 26 | option_8.title = "Longitude" 27 | attributePicker.toggleEntityTracking(option_8.element) 28 | 29 | option_9.title = "Description" 30 | attributePicker.toggleEntityTracking(option_9.element) 31 | 32 | const fields = { 33 | "[name='app[title]']": "Forecast.io", 34 | "[name='app[description]']": "Display local weather information on your website.", 35 | "[name='email']": "demo@instantwordpressplugin.com" 36 | } 37 | 38 | Object 39 | .keys(fields) 40 | .forEach(name => detailsForm.querySelector(name).value = fields[name]) 41 | 42 | steps.details.imageUploader.imageURL = `${ASSET_BASE}/forecast.png` 43 | } 44 | -------------------------------------------------------------------------------- /app/components/application/demos/index.js: -------------------------------------------------------------------------------- 1 | export emojiReact from "./emoji-react" 2 | export nectarNinja from "./nectar-ninja" 3 | export forecastIO from "./forecast-io" 4 | -------------------------------------------------------------------------------- /app/components/application/demos/nectar-ninja.js: -------------------------------------------------------------------------------- 1 | import autosize from "autosize" 2 | 3 | const script = String.raw`` 18 | 19 | export default function runDemo(app) { 20 | const {attributePicker} = app 21 | const {embedCodeInput, pluginDetailsForm} = app.refs 22 | const {locationSelect} = attributePicker.refs 23 | 24 | embedCodeInput.autofocus = false 25 | embedCodeInput.value = script 26 | autosize.update(embedCodeInput) 27 | attributePicker.parseInput() 28 | 29 | locationSelect.value = "body" 30 | 31 | const {option_1} = app.entities 32 | 33 | option_1.title = "Twitter username" 34 | attributePicker.toggleEntityTracking(option_1.element) 35 | 36 | const fields = { 37 | "[name='email']": "demo@instantwordpressplugin.com", 38 | "[name='app[title]']": "Nectar Ninja", 39 | "[name='app[description]']": "Send website notifications via Twitter!", 40 | "[name='app[metadata][description]']": `Let users know of new features, deals or downtimes. 41 | Dead-simple, no signup required. Yes, it's free.` 42 | } 43 | 44 | Object 45 | .keys(fields) 46 | .forEach(name => pluginDetailsForm.querySelector(name).value = fields[name]) 47 | 48 | app.imageUploader.imageURL = "/external-assets/bee.png" 49 | } 50 | -------------------------------------------------------------------------------- /app/components/application/index.js: -------------------------------------------------------------------------------- 1 | import "./application.styl" 2 | import template from "./application.pug" 3 | import navigationButtonTemplate from "./navigation-button.pug" 4 | 5 | import autobind from "autobind-decorator" 6 | import BaseComponent from "components/base-component" 7 | import * as stepComponents from "components/steps" 8 | import formSerialize from "form-serialize" 9 | import createEagerSchema from "lib/create-eager-schema" 10 | import $$ from "lib/constants" 11 | 12 | const MODE_LABELS = { 13 | drupal: "Drupal", 14 | joomla: "Joomla", 15 | wordpress: "WordPress" 16 | } 17 | 18 | const STEPS_PROGRESSION = [ 19 | "intro", 20 | "embedCode", 21 | "schema", 22 | "preview", 23 | "details", 24 | "creating", 25 | "download" 26 | ] 27 | 28 | export default class Application extends BaseComponent { 29 | static template = template; 30 | 31 | constructor(mountPoint, options) { 32 | super(options) 33 | 34 | Object.assign(this, { 35 | _embedCode: "", 36 | entities: {}, 37 | mode: "wordpress", 38 | steps: {} 39 | }) 40 | 41 | const element = this.compileTemplate() 42 | const {stepsContainer} = this.refs 43 | 44 | STEPS_PROGRESSION.forEach(stepID => { 45 | const step = new stepComponents[stepID]({$root: this}) 46 | 47 | this.steps[stepID] = step 48 | 49 | stepsContainer.appendChild(step.render()) 50 | }) 51 | 52 | this.$activeStep = "intro" 53 | 54 | this.replaceElement(mountPoint, element) 55 | } 56 | 57 | get $activeStep() { 58 | return this.element.dataset.activeStep 59 | } 60 | 61 | set $activeStep(value) { 62 | const previousStep = this.$activeStep 63 | const previousStepComponent = this.$activeStepComponent 64 | const {onEnter = () => {}} = this.steps[value] 65 | 66 | this.element.dataset.activeStep = value 67 | 68 | this.renderTitle() 69 | this.renderNavigation() 70 | 71 | requestAnimationFrame(() => onEnter(value)) 72 | 73 | if (previousStepComponent) { 74 | const {onExit = () => {}} = previousStepComponent 75 | 76 | this.element.dataset.previousStep = previousStep 77 | requestAnimationFrame(() => onExit(value)) 78 | 79 | setTimeout(() => { 80 | previousStepComponent.element.scrollTop = 0 81 | }, 350) 82 | } 83 | 84 | return this.element.dataset.activeStep 85 | } 86 | 87 | get $activeStepComponent() { 88 | return this.steps[this.$activeStep] 89 | } 90 | 91 | get $modeLabel() { 92 | return MODE_LABELS[this.mode] 93 | } 94 | 95 | get $embedCode() { 96 | const {attributePicker} = this.steps.schema 97 | 98 | return attributePicker.element 99 | } 100 | 101 | set $embedCode(value) { 102 | const {attributePicker} = this.steps.schema 103 | 104 | attributePicker.parseInput(value) 105 | this.steps.schema.updateRender() 106 | 107 | return attributePicker.element 108 | } 109 | 110 | get $installJSON() { 111 | const IDs = this.getTrackedEntityIDs() 112 | const embedCodeDOM = this.$embedCode.cloneNode(true) 113 | const properties = {} 114 | 115 | IDs.forEach((id, index) => { 116 | const current = embedCodeDOM.querySelector(`[${$$.ENTITY_ID}="${id}"]`) 117 | const {delimiter, identifier, format, normalized, placeholder, title, type} = this.entities[id] 118 | 119 | properties[id] = { 120 | format, 121 | order: index + 1, 122 | placeholder, 123 | default: normalized, 124 | title: title || identifier || `Option ${index + 1}`, 125 | type 126 | } 127 | 128 | if (id !== $$.EMBED_LOCATION) { 129 | current.textContent = `${delimiter}TRACKED_ENTITY[${id}]${delimiter}` 130 | } 131 | }) 132 | 133 | const {schemaForm} = this.steps.schema.refs 134 | 135 | return createEagerSchema({ 136 | options: formSerialize(schemaForm, {hash: true}), 137 | embedCode: embedCodeDOM.textContent, 138 | properties 139 | }) 140 | } 141 | 142 | @autobind 143 | getTrackedEntityIDs() { 144 | const $ = this.entities 145 | 146 | return Object.keys($) 147 | .filter(key => $[key].tracked) 148 | .sort((keyA, keyB) => $[keyA].order - $[keyB].order) 149 | } 150 | 151 | renderNavigation() { 152 | const {navigationContainer} = this.refs 153 | const {navigationButtons = []} = this.$activeStepComponent 154 | 155 | navigationContainer.innerHTML = "" 156 | 157 | navigationButtons.forEach(({className, label, handler, href}, index) => { 158 | const button = this.serialize(navigationButtonTemplate, { 159 | className, 160 | href, 161 | label, 162 | firstButton: index === 0, 163 | lastButton: index === navigationButtons.length - 1 164 | }) 165 | 166 | if (handler) button.addEventListener("click", handler) 167 | 168 | navigationContainer.appendChild(button) 169 | }) 170 | 171 | this.updateRefs() 172 | } 173 | 174 | renderTitle() { 175 | const {title} = this.refs 176 | 177 | if (this.$activeStepComponent.title) { 178 | title.textContent = this.$activeStepComponent.title 179 | } 180 | else { 181 | title.textContent = `Instant ${MODE_LABELS[this.mode]} Plugin` 182 | } 183 | } 184 | 185 | restart() { 186 | const {steps} = this 187 | 188 | steps.embedCode.refs.embedCodeInput.value = "" 189 | this.$embedCode = "" 190 | steps.schema.updateRender() 191 | this.$activeStep = "embedCode" 192 | steps.embedCode.syncButtonState() 193 | 194 | steps.details.resetFields() 195 | 196 | // Delay iframe render to prevent UI stutter during transition. 197 | setTimeout(steps.preview.updateRender, $$.TRANSITION_DELAY) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /app/components/application/navigation-button.pug: -------------------------------------------------------------------------------- 1 | if href 2 | a.button(class=className href=href target="_blank")!=label 3 | 4 | if lastButton 5 | button.button.primary(data-ref="navigationNextButton") 6 | span.label=label 7 | span.icon 8 | include ../../assets/right-arrow.svg 9 | else if firstButton 10 | button.button.secondary(data-ref="navigationPreviousButton") 11 | span.icon 12 | include ../../assets/left-arrow.svg 13 | span.label=label 14 | else 15 | button.button.with-icon 16 | span.label=label 17 | -------------------------------------------------------------------------------- /app/components/attribute-picker/attribute-picker.pug: -------------------------------------------------------------------------------- 1 | section(data-component="attribute-picker").box.code-input 2 | -------------------------------------------------------------------------------- /app/components/attribute-picker/attribute-picker.styl: -------------------------------------------------------------------------------- 1 | @import "~styl/colors" 2 | @import "~styl/fonts" 3 | 4 | [data-component="attribute-picker"] 5 | 6 | background-color #fff 7 | color mutedTextColor 8 | cursor default 9 | display block 10 | overflow-wrap break-word 11 | user-select none 12 | white-space pre-wrap 13 | word-break break-word 14 | word-wrap break-word 15 | // Fix animation artifacts. 16 | subpixelAntialiasedFonts() 17 | 18 | span 19 | subpixelAntialiasedFonts() 20 | 21 | [data-entity-id] 22 | display inline-block 23 | position relative 24 | border-radius 2em 25 | padding 0 .5em 26 | line-height 1.3 27 | font-family inherit 28 | cursor pointer 29 | box-shadow 0 0 0 1px rgba(mutedTextColor, .5) 30 | background #fff 31 | color containedTextColor 32 | 33 | &::after 34 | content "" 35 | display block 36 | position absolute 37 | border-radius 2em 38 | top 0 39 | right 0 40 | bottom 0 41 | left 0 42 | pointer-events none 43 | 44 | &:focus 45 | outline none 46 | box-shadow 0 0 2px 1px saturation(brandOrangeDark, 75%) 47 | 48 | &:not(.tracked) 49 | &:hover 50 | box-shadow 0 0 0 1px rgba(containedTextColor, .8) 51 | 52 | &.tracked 53 | background #fff 54 | box-shadow 0 0 0 2px brandOrange 55 | 56 | &:focus 57 | box-shadow 0 0 2px 2px brandOrangeDark 58 | 59 | .details 60 | color darken(mutedTextColor, 20%) 61 | font-family sansSerifFonts 62 | font-size 1.2em 63 | margin 0 64 | text-align center 65 | white-space normal 66 | padding-left 6.5em 67 | padding-right @padding-left 68 | 69 | + .details 70 | margin-top -2.8em 71 | 72 | [data-chunk-type="param-group"] 73 | [data-chunk-type="param-key"] 74 | color darkGray 75 | 76 | &.tracked [data-chunk-type="param-key"] 77 | color brandOrange 78 | 79 | [data-chunk-type="entity-group"].tracked 80 | .hljs-attr, .entity-identifier 81 | color brandOrange 82 | 83 | [data-chunk-type="attribute-group"].tracked 84 | .hljs-attr 85 | color brandOrange 86 | 87 | @keyframes highlight-entity 88 | 0% 89 | box-shadow 0 0 0 1px rgba(brandOrange, 0) 90 | animation-timing-function ease-in 91 | 92 | 50% 93 | box-shadow 0 0 0 3px rgba(brandOrange, .5) 94 | animation-timing-function ease-out 95 | 96 | 100% 97 | box-shadow 0 0 0 5px rgba(brandOrange, 0) 98 | animation-timing-function ease-out 99 | 100 | 101 | [data-component="application"][data-active-step="schema"] 102 | [data-component="attribute-picker"] [data-entity-id] 103 | for i in (0..20) 104 | &[data-entity-order={'"' + i + '"'}]::after 105 | animation highlight-entity 1s ease-in-out 106 | animation-fill-mode forwards 107 | animation-delay .15s * i 108 | -------------------------------------------------------------------------------- /app/components/attribute-picker/index.js: -------------------------------------------------------------------------------- 1 | import "./attribute-picker.styl" 2 | import template from "./attribute-picker.pug" 3 | 4 | import autobind from "autobind-decorator" 5 | import BaseComponent from "components/base-component" 6 | import createElement from "lib/create-element" 7 | import hljs from "highlight.js" 8 | import KM from "lib/key-map" 9 | import parseURL from "lib/parse-url" 10 | import isURL from "is-url" 11 | import $$ from "lib/constants" 12 | 13 | // Actionscript is stubbed since HLJS miscategorizes small JavaScript embed codes. 14 | hljs.registerLanguage("actionscript", () => ({})) 15 | 16 | const getType = element => { 17 | const [, type] = element.className.match(/hljs-([\S]*)/) 18 | 19 | return type 20 | } 21 | const getDelimiter = (type, text) => type === "string" ? text[0] : "" 22 | const normalize = (type, text) => getDelimiter(type, text) ? text.substring(1, text.length - 1) : text 23 | 24 | export default class AttributePicker extends BaseComponent { 25 | static template = template; 26 | 27 | parseInput(value) { 28 | this.$root.entities = {} 29 | 30 | const {element} = this 31 | const serializer = createElement("div", { 32 | innerHTML: hljs.highlightAuto(value, ["html", "javascript"]).value 33 | }) 34 | 35 | // Embed codes that include non script tags like iframes use a special 36 | // option to let the plugin user choose the location. 37 | const includesHTMLTags = Array 38 | .from(serializer.querySelectorAll(".hljs-tag .hljs-name")) 39 | .map(element => element.textContent) 40 | .some(name => name !== "script") 41 | 42 | if (includesHTMLTags) { 43 | this.$root.entities[$$.EMBED_LOCATION] = { 44 | format: "element", 45 | normalized: {selector: "body", method: "prepend"}, 46 | order: 0, 47 | title: "Location", 48 | tracked: true, 49 | type: "object" 50 | } 51 | } 52 | 53 | if (!serializer.querySelector($$.ENTITY_QUERY)) { 54 | element.classList.add("empty") 55 | element.innerHTML = ` 56 |
57 | We couldn’t find any configurable strings or numbers in that embed code. 58 |
59 | ` 60 | 61 | return 62 | } 63 | 64 | element.classList.remove("empty") 65 | element.innerHTML = serializer.innerHTML 66 | 67 | // Remove strings that object properties. 68 | Array 69 | .from(element.querySelectorAll($$.JAVASCRIPT_ENTITY_QUERY)) 70 | .forEach(entityEl => { 71 | const {nextSibling} = entityEl 72 | 73 | if (!nextSibling || nextSibling.nodeType !== Node.TEXT_NODE) return 74 | if (!$$.JAVASCRIPT_PROPERTY_PATTERN.test(nextSibling.textContent)) return 75 | 76 | entityEl.classList.remove($$.STRING_CLASS) 77 | entityEl.classList.add("hljs-attr") 78 | }) 79 | 80 | // Group HTML attributes with their values. 81 | Array 82 | .from(element.querySelectorAll(".hljs-tag .hljs-string")) 83 | .forEach(entityEl => { 84 | const normalized = encodeURI(normalize("string", entityEl.textContent)) 85 | 86 | if (isURL(normalized)) return 87 | 88 | const collection = [entityEl] 89 | let sibling = entityEl 90 | 91 | const replaceWithGroup = () => { 92 | const entityGroup = createElement("span") 93 | 94 | entityGroup.setAttribute($$.CHUNK_TYPE, "attribute-group") 95 | 96 | collection.forEach(entry => { 97 | let cloneEl = entry.cloneNode(true) 98 | 99 | if (cloneEl.nodeType === Node.TEXT_NODE) { 100 | cloneEl = createElement("span", { 101 | className: "entity-text", 102 | textContent: cloneEl.textContent 103 | }) 104 | } 105 | 106 | entityGroup.appendChild(cloneEl) 107 | }) 108 | 109 | const attributeEl = entityGroup.querySelector(".hljs-attr") 110 | const identifier = attributeEl ? attributeEl.textContent : "" 111 | 112 | entityGroup.setAttribute($$.ENTITY_IDENTIFIER, identifier) 113 | 114 | entityEl.parentNode.insertBefore(entityGroup, entityEl) 115 | collection.forEach(entry => entry.parentNode.removeChild(entry)) 116 | } 117 | 118 | // Walk the DOM until we find the attribute. 119 | while (sibling = sibling.previousSibling) { // eslint-disable-line no-cond-assign 120 | collection.unshift(sibling) 121 | 122 | const {className = ""} = sibling 123 | 124 | if (/hljs-attr/.test(className)) { 125 | replaceWithGroup() 126 | break 127 | } 128 | } 129 | }) 130 | 131 | // Group JS entities with their assignment name. 132 | Array 133 | .from(element.querySelectorAll($$.JAVASCRIPT_ENTITY_QUERY)) 134 | .forEach(entityEl => { 135 | const collection = [entityEl] 136 | let sibling = entityEl 137 | 138 | const replaceWithGroup = tokenName => { 139 | const entityGroup = createElement("span") 140 | const type = { 141 | attr: "property", 142 | keyword: "assignment" 143 | }[tokenName] 144 | 145 | entityGroup.setAttribute($$.CHUNK_TYPE, "entity-group") 146 | 147 | collection.forEach(entry => { 148 | let cloneEl = entry.cloneNode(true) 149 | 150 | if (cloneEl.nodeType === Node.TEXT_NODE) { 151 | cloneEl = createElement("span", { 152 | className: "entity-text", 153 | textContent: cloneEl.textContent 154 | }) 155 | } 156 | 157 | entityGroup.appendChild(cloneEl) 158 | }) 159 | 160 | let identifier = `Unknown ${type}` 161 | 162 | if (type === "property") { 163 | const propertyEl = entityGroup.querySelector(".hljs-attr") 164 | 165 | if (propertyEl) identifier = propertyEl.textContent 166 | } 167 | else { 168 | const identifierEl = entityGroup.querySelector(".entity-text") 169 | 170 | if (identifierEl) { 171 | [, identifier] = identifierEl.textContent.match($$.JAVASCRIPT_DECLARATION_PATTERN) 172 | 173 | if (identifier) { 174 | identifierEl.innerHTML = identifierEl.innerHTML.replace($$.JAVASCRIPT_DECLARATION_PATTERN, 175 | "$1") 176 | } 177 | } 178 | } 179 | 180 | entityGroup.setAttribute($$.ENTITY_IDENTIFIER, identifier) 181 | 182 | entityEl.parentNode.insertBefore(entityGroup, entityEl) 183 | collection.forEach(entry => entry.parentNode.removeChild(entry)) 184 | } 185 | 186 | // Walk the DOM until we (hopefully) find the declaration. 187 | while (sibling = sibling.previousSibling) { // eslint-disable-line no-cond-assign 188 | collection.unshift(sibling) 189 | 190 | const {className = ""} = sibling 191 | const [, tokenName] = className.match($$.JAVASCRIPT_DECLARATION_CLASS_PATTERN) || [] 192 | 193 | if (tokenName) { 194 | replaceWithGroup(tokenName) 195 | break 196 | } 197 | } 198 | }) 199 | 200 | 201 | const getEntityElements = () => Array.from(element.querySelectorAll($$.ENTITY_QUERY)) 202 | 203 | // Parse URL components into entities. 204 | getEntityElements() 205 | .map(element => { 206 | const type = getType(element) 207 | 208 | return { 209 | element, 210 | type, 211 | entityDelimiter: getDelimiter(type, element.textContent), 212 | normalized: normalize(type, element.textContent) 213 | } 214 | }) 215 | .filter(({type, normalized}) => type === "string" && isURL(encodeURI(normalized))) 216 | .forEach(({element, entityDelimiter, normalized}) => { 217 | const groupFragment = document.createDocumentFragment() 218 | const chunks = [ 219 | {type: "delimiter", value: entityDelimiter}, 220 | ...parseURL(normalized), 221 | {type: "delimiter", value: entityDelimiter} 222 | ] 223 | 224 | function parseChunk(parentEl, {type, value}) { 225 | const chunkEl = createElement("span") 226 | 227 | chunkEl.setAttribute($$.CHUNK_TYPE, type) 228 | 229 | if (typeof value === "string") { 230 | chunkEl.textContent = value 231 | chunkEl.setAttribute($$.PRENORMALIZED, value) 232 | } 233 | 234 | if (type === "param-key") { 235 | parentEl.setAttribute($$.ENTITY_IDENTIFIER, value) 236 | } 237 | 238 | if (type === "param-group") { 239 | value.forEach(parseChunk.bind(null, chunkEl)) 240 | } 241 | else if ($$.SELECTABLE_TYPES.includes(type)) { 242 | chunkEl.className = $$.STRING_CLASS 243 | } 244 | 245 | parentEl.appendChild(chunkEl) 246 | } 247 | 248 | chunks.forEach(parseChunk.bind(null, groupFragment)) 249 | 250 | this.replaceElement(element, groupFragment) 251 | 252 | element.classList.remove($$.STRING_CLASS) 253 | }) 254 | 255 | const entityElements = getEntityElements() 256 | const entityCount = entityElements.length 257 | 258 | // Offset the existing tabindexes given our entity count. 259 | Array 260 | .from(this.element.querySelectorAll("[tabindex]")) 261 | .forEach((tabableEl, index) => tabableEl.tabIndex = entityCount + index + 1) 262 | 263 | // Populate entities. 264 | entityElements.forEach((element, index) => { 265 | const text = element.textContent 266 | const id = `option_${index + 1}` 267 | const type = getType(element) 268 | const normalized = element.getAttribute($$.PRENORMALIZED) || normalize(type, text) 269 | let identifier = "" 270 | 271 | if (normalized.length === 0) { 272 | // Skip empty strings. 273 | element.classList.remove($$.STRING_CLASS) 274 | return 275 | } 276 | 277 | if ($$.GROUP_PATTERN.test(element.parentNode.getAttribute($$.CHUNK_TYPE))) { 278 | identifier = element.parentNode.getAttribute($$.ENTITY_IDENTIFIER) 279 | } 280 | 281 | this.$root.entities[id] = { 282 | delimiter: element.getAttribute($$.PRENORMALIZED) ? "" : getDelimiter(type, text), 283 | format: "plaintext", 284 | element, 285 | identifier, 286 | normalized, 287 | placeholder: normalized, 288 | order: index, 289 | original: text, 290 | tracked: false, 291 | type 292 | } 293 | 294 | if (index === 0) element.setAttribute("data-autofocus", "") 295 | 296 | element.tabIndex = index + 1 297 | element.setAttribute($$.ENTITY_ID, id) 298 | element.setAttribute($$.ENTITY_ORDER, index) 299 | element.addEventListener("click", this.toggleEntityTracking.bind(this, element)) 300 | }) 301 | 302 | element.addEventListener("keydown", this.handleAttributeKeyDown) 303 | 304 | if (includesHTMLTags) { 305 | this.$root.entities[$$.EMBED_LOCATION].order = entityCount 306 | } 307 | 308 | this.$root.steps.schema.updateRender() 309 | } 310 | 311 | toggleEntityTracking(element) { 312 | const entity = this.$root.entities[element.getAttribute($$.ENTITY_ID)] 313 | 314 | entity.tracked = !entity.tracked 315 | const method = entity.tracked ? "add" : "remove" 316 | 317 | element.classList[method]("tracked") 318 | 319 | if ($$.GROUP_PATTERN.test(element.parentNode.getAttribute($$.CHUNK_TYPE))) { 320 | element.parentNode.classList[method]("tracked") 321 | } 322 | 323 | this.$root.steps.schema.updateRender() 324 | } 325 | 326 | 327 | @autobind 328 | handleAttributeKeyDown(event) { 329 | const entityEl = document.activeElement 330 | 331 | if (!entityEl || ![KM.enter, KM.spacebar].includes(event.keyCode)) return 332 | 333 | const {attributes} = this.steps 334 | const id = entityEl.getAttribute($$.ENTITY_ID) 335 | 336 | if (!attributes.contains(entityEl) || !id) return 337 | 338 | event.preventDefault() 339 | 340 | this.toggleEntityTracking(entityEl) 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /app/components/base-component.js: -------------------------------------------------------------------------------- 1 | import autosize from "autosize" 2 | 3 | // Ends with brackets e.g. [data-ref="foo[]"] 4 | const ARRAY_REF_PATTERN = /([a-zA-Z\d]*)(\[?\]?)/ 5 | 6 | export default class BaseComponent { 7 | static template = null; 8 | static store = null; 9 | 10 | constructor(spec = {}) { 11 | Object.assign(this, { 12 | element: null, 13 | refs: {} 14 | }, spec) 15 | } 16 | 17 | autofocus(element) { 18 | element = element || this.element 19 | 20 | const focusElement = element.querySelector("[autofocus], [data-autofocus]") 21 | 22 | if (focusElement) focusElement.focus() 23 | } 24 | 25 | // NOTE: Calling `updateRefs` multiple times from different tree depths may 26 | // allow parents to inherit a grandchild. 27 | updateRefs() { 28 | const {refs} = this 29 | 30 | Array 31 | .from(this.element.querySelectorAll("[data-ref]")) 32 | .forEach(element => { 33 | const attribute = element.getAttribute("data-ref") 34 | const [, key, arrayKey] = attribute.match(ARRAY_REF_PATTERN) 35 | 36 | if (arrayKey) { 37 | // Multiple elements 38 | if (!Array.isArray(refs[key])) refs[key] = [] 39 | 40 | refs[key].push(element) 41 | } 42 | else { 43 | // Single element 44 | refs[key] = element 45 | } 46 | 47 | element.removeAttribute("data-ref") 48 | }) 49 | } 50 | 51 | serialize(template, templateVars = {}) { 52 | // `document` is used instead of iframe's document to prevent `instanceof` reference errors. 53 | const serializer = document.createElement("div") 54 | 55 | if (typeof template === "function") { 56 | serializer.innerHTML = template.call(this, { 57 | config: this.store, 58 | ...templateVars 59 | }) 60 | } 61 | else { 62 | serializer.innerHTML = template 63 | } 64 | 65 | return serializer.firstChild 66 | } 67 | 68 | compileTemplate(templateVars = {}) { 69 | const {template} = this.constructor 70 | 71 | this.element = this.serialize(template, templateVars) 72 | this.updateRefs() 73 | 74 | autosize(this.element.querySelectorAll("textarea:not(.fixed-height)")) 75 | 76 | return this.element 77 | } 78 | 79 | insertBefore(sibling, element) { 80 | element.parentNode.insertBefore(sibling, element) 81 | } 82 | 83 | removeElement(element) { 84 | if (!element || !element.parentNode) return null 85 | 86 | return element.parentNode.removeChild(element) 87 | } 88 | 89 | render() { 90 | return this.compileTemplate() 91 | } 92 | 93 | replaceElement(current, next) { 94 | current.parentNode.insertBefore(next, current) 95 | current.parentNode.removeChild(current) 96 | 97 | if (current.getAttribute("tabindex")) { 98 | next.tabIndex = current.tabIndex 99 | } 100 | 101 | this.updateRefs() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /app/components/image-uploader/image-uploader.pug: -------------------------------------------------------------------------------- 1 | section(data-component="image-uploader" data-flow="column") 2 | .standard(filepicker data-ref="fileInputContainer") 3 | input(data-ref="hiddenURLInput" type="hidden" name=this.name) 4 | input(data-ref="fileInput" type="file" accept="image/*" id=this.id) 5 | label(class="button secondary transparent slim" for=this.id) Choose image... 6 | 7 | .standard.filepicker-with-image 8 | .image 9 | a(data-ref="imageAnchor" target="_blank") 10 | img(data-ref="previewImage") 11 | .actions 12 | button.button.secondary.small-action-button(data-ref="resetButton" type="button") Change 13 | -------------------------------------------------------------------------------- /app/components/image-uploader/image-uploader.styl: -------------------------------------------------------------------------------- 1 | @import "~styl/colors" 2 | 3 | [data-component="image-uploader"] 4 | align-items center 5 | position relative 6 | z-index 1 7 | 8 | .standard 9 | border 1px solid #cfcfcf 10 | background #eee 11 | display block 12 | width 100% 13 | padding 1.25em 14 | text-align center 15 | border-radius .1875em 16 | 17 | div, img 18 | display block 19 | 20 | .standard[filepicker] 21 | display block 22 | position relative 23 | overflow hidden 24 | cursor pointer 25 | border-style dashed 26 | text-align center 27 | 28 | &:before 29 | content "Drag an image here" 30 | color subtleText 31 | display block 32 | font-size .7272em 33 | font-weight normal 34 | letter-spacing .072em 35 | text-transform uppercase 36 | 37 | &.dragging 38 | background-color rgba(#000, .1) 39 | 40 | &:before 41 | color inherit 42 | content "Drop to upload" 43 | 44 | &.uploading:before 45 | color inherit 46 | content "Uploading..." 47 | 48 | &[data-progress].uploading:before 49 | content "Uploading (" attr(data-progress) "%)..." 50 | 51 | &.error:before 52 | content "There was an error uploading, please try again." 53 | 54 | input[type="file"] 55 | opacity 0 56 | position absolute 57 | top 0 58 | left 0 59 | 60 | label[for] 61 | font-size .7em 62 | margin-top .7em 63 | 64 | &.dragging, &.uploading 65 | 66 | label[for] 67 | pointer-events none 68 | opacity .5 69 | 70 | .image img 71 | max-width 100% 72 | width 128px 73 | 74 | .standard.filepicker-with-image 75 | cursor default 76 | 77 | .image 78 | align-items center 79 | flex-flow column nowrap 80 | margin 0 81 | 82 | a 83 | display block 84 | 85 | img 86 | display block 87 | margin auto 88 | 89 | .actions 90 | margin-top 1.25em 91 | font-size .8em 92 | 93 | &[data-state="ready"] .filepicker-with-image 94 | display none 95 | 96 | &[data-state="uploaded"] .standard[filepicker] 97 | display none 98 | -------------------------------------------------------------------------------- /app/components/image-uploader/index.js: -------------------------------------------------------------------------------- 1 | import "./image-uploader.styl" 2 | import template from "./image-uploader.pug" 3 | 4 | import BaseComponent from "components/base-component" 5 | import autobind from "autobind-decorator" 6 | import filepicker from "filepicker-js" 7 | import previewImageFile from "lib/preview-image-file" 8 | import uniqueID from "lib/unique-id" 9 | 10 | const API_KEY = "AcwVASRX5QoO107ICFDDpz" 11 | const DOMAIN = "eager-app-images.imgix.net" 12 | 13 | export default class ImageUploader extends BaseComponent { 14 | static template = template; 15 | 16 | constructor(spec = {}) { 17 | Object.assign(spec, { 18 | _imageURL: "", 19 | _uploading: false, 20 | name: spec.name || "image", 21 | id: `image-uploader-${uniqueID()}` 22 | }) 23 | 24 | super(spec) 25 | 26 | filepicker.setKey(API_KEY) 27 | } 28 | 29 | get imageURL() { 30 | return this._imageURL 31 | } 32 | 33 | set imageURL(value) { 34 | this._imageURL = value 35 | 36 | const {imageAnchor, previewImage, hiddenURLInput} = this.refs 37 | 38 | imageAnchor.href = this._imageURL 39 | previewImage.src = this._imageURL 40 | hiddenURLInput.value = this._imageURL 41 | 42 | this.element.setAttribute("data-state", value ? "uploaded" : "ready") 43 | } 44 | 45 | set imageKey(key) { 46 | this._imageKey = key 47 | this.imageURL = key ? `//${DOMAIN}/${key}` : "" 48 | 49 | return this._imageKey 50 | } 51 | 52 | get uploading() { 53 | return this._uploading 54 | } 55 | 56 | set uploading(value) { 57 | const {fileInputContainer} = this.refs 58 | const method = value ? "add" : "remove" 59 | 60 | this._uploading = value 61 | 62 | fileInputContainer.classList[method]("uploading") 63 | 64 | return this._uploading 65 | } 66 | 67 | @autobind 68 | handleChange() { 69 | const {fileInput, previewImage} = this.refs 70 | const [file] = fileInput.files 71 | 72 | if (this.uploading || !file) return 73 | 74 | this.reset() 75 | this.uploading = true 76 | 77 | const upload = filepicker.store.bind( 78 | filepicker, 79 | file, 80 | this.handleFile, 81 | this.handleError, 82 | this.handleProgress 83 | ) 84 | 85 | previewImageFile(file, ({height, src}) => { 86 | Object.assign(previewImage, {height, src}) 87 | 88 | upload() 89 | }, upload) 90 | } 91 | 92 | @autobind 93 | handleError() { 94 | const {fileInputContainer} = this.refs 95 | 96 | this.uploading = false 97 | fileInputContainer.classList.add("error") 98 | 99 | console.error("An error occurred uploading file", arguments) 100 | } 101 | 102 | @autobind 103 | handleFile({key}) { 104 | const {fileInputContainer} = this.refs 105 | 106 | fileInputContainer.classList.remove("error") 107 | fileInputContainer.removeAttribute("data-progress") 108 | 109 | this.uploading = false 110 | this.imageKey = key 111 | } 112 | 113 | @autobind 114 | handleProgress(percentage) { 115 | const {fileInputContainer} = this.refs 116 | 117 | fileInputContainer.setAttribute("data-progress", percentage) 118 | } 119 | 120 | @autobind 121 | handleDragUpload([file]) { 122 | const {fileInputContainer, previewImage} = this.refs 123 | 124 | previewImageFile(file, ({height, src}) => { 125 | Object.assign(previewImage, {height, src}) 126 | }) 127 | 128 | fileInputContainer.classList.remove("dragging", "error") 129 | fileInputContainer.removeAttribute("data-progress") 130 | this.uploading = true 131 | } 132 | 133 | render() { 134 | this.compileTemplate() 135 | 136 | const {fileInput, fileInputContainer, resetButton} = this.refs 137 | 138 | resetButton.addEventListener("click", this.reset) 139 | fileInput.addEventListener("change", this.handleChange) 140 | fileInput.addEventListener("dragenter", () => fileInputContainer.classList.add("dragging")) 141 | fileInput.addEventListener("dragleave", () => fileInputContainer.classList.remove("dragging")) 142 | fileInput.addEventListener("click", () => { 143 | if (this.imageURL) event.preventDefault() 144 | }) 145 | 146 | const dropOptions = { 147 | access: "public", 148 | dragEnter: () => fileInputContainer.classList.add("dragging"), 149 | dragLeave: () => fileInputContainer.classList.remove("dragging"), 150 | mimetype: "image/*", 151 | multiple: false, 152 | onStart: this.handleDragUpload, 153 | onSuccess: ([file]) => this.handleFile(file), 154 | onProgress: this.handleProgress, 155 | onError: this.handleError 156 | } 157 | 158 | filepicker.makeDropPane(fileInputContainer, dropOptions) 159 | 160 | this.reset() 161 | 162 | return this.element 163 | } 164 | 165 | @autobind 166 | reset() { 167 | const {fileInputContainer, previewImage} = this.refs 168 | 169 | this.imageKey = "" 170 | 171 | previewImage.removeAttribute("height") 172 | 173 | fileInputContainer.classList.remove("dragging", "error") 174 | fileInputContainer.removeAttribute("data-progress") 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /app/components/steps/creating/creating.pug: -------------------------------------------------------------------------------- 1 | section.step.with-header-offset(data-step="creating" data-flow="column") 2 | header.header(data-flow="column") 3 | h1 Packaging your plugin... 4 | 5 | .loading-dots(data-state="loading") 6 | i 7 | i 8 | i 9 | 10 | -------------------------------------------------------------------------------- /app/components/steps/creating/creating.styl: -------------------------------------------------------------------------------- 1 | .step[data-step="creating"] 2 | justify-content center 3 | 4 | .header 5 | align-items center 6 | text-align center 7 | 8 | h1 9 | margin-top 2em 10 | font-weight normal 11 | 12 | .loading-dots 13 | margin-left -1.5em 14 | 15 | -------------------------------------------------------------------------------- /app/components/steps/creating/index.js: -------------------------------------------------------------------------------- 1 | import "./creating.styl" 2 | import template from "./creating.pug" 3 | 4 | import autobind from "autobind-decorator" 5 | import BaseComponent from "components/base-component" 6 | import formSerialize from "form-serialize" 7 | import {postJson} from "simple-fetch" 8 | import $$ from "lib/constants" 9 | 10 | const MINIMUM_TRANSITION_DELAY = 2000 11 | 12 | export default class CreatingStep extends BaseComponent { 13 | static template = template; 14 | 15 | @autobind 16 | onEnter() { 17 | const {$root} = this 18 | const {detailsForm} = $root.steps.details.refs 19 | const pluginDetails = formSerialize(detailsForm, {hash: true}) 20 | const startTime = Date.now() 21 | 22 | const onComplete = ({downloadURL}) => { 23 | // Delay the transition slightly to smooth out the animation. 24 | const delay = Math.max(MINIMUM_TRANSITION_DELAY - (Date.now() - startTime), 0) 25 | 26 | setTimeout(() => { 27 | $root.downloadURL = downloadURL 28 | $root.$activeStep = "download" 29 | }, delay) 30 | } 31 | 32 | pluginDetails.app.icon = pluginDetails.app.icon || $$.DEFAULT_PLUGIN_ICON 33 | 34 | const payload = { 35 | cmsName: "wordpress", 36 | installJSON: $root.$installJSON, 37 | ...pluginDetails 38 | } 39 | 40 | postJson(`${API_BASE}/create/instant`, payload) 41 | .then(onComplete) 42 | .catch(error => console.error(error)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/components/steps/details/details.pug: -------------------------------------------------------------------------------- 1 | section.step.padded(data-step="details" data-flow="column") 2 | header.step-details 3 | p 4 | | This will appear in the WordPress Plugin Directory. 5 | 6 | form(data-ref="detailsForm").content 7 | input( 8 | data-ref="detailsFormSubmit" 9 | style="display: none;" 10 | type="submit" 11 | value="Submit") 12 | 13 | .box.form(data-flow="column") 14 | aside.legend 15 | span.accent * 16 | span Required 17 | 18 | .input 19 | label.required Plugin Title 20 | input.standard(name="app[title]" placeholder="Plugin Title" required type="text") 21 | 22 | .input 23 | label.required Creator Email 24 | input.standard(name="email" placeholder="Creator Email" required type="email") 25 | 26 | .input 27 | label 28 | span.required Short Description 29 | .help-text 30 | | A short description of the app. 31 | 32 | input.standard(maxlength="150" name="app[description]" placeholder="Description" required type="text") 33 | 34 | .input 35 | label Full Description 36 | .help-text 37 | | A long description for the WordPress Plugin page. No length limit. 38 | 39 | textarea.standard(name="app[metadata][description]" placeholder="Full description") 40 | 41 | .input 42 | label Instructions 43 | .help-text 44 | | Installation instructions for the WordPress user. 45 | 46 | textarea.standard(name="app[metadata][instructions]" placeholder="Instructions") 47 | 48 | .input 49 | label FAQ 50 | .help-text 51 | | Frequently asked questions about the plugin. 52 | 53 | textarea.standard(name="app[metadata][targets][wordpress][faq]" placeholder="FAQ") 54 | 55 | .input 56 | label Icon 57 | .help-text 58 | | A 256×256 PNG, or SVG file. 59 | 60 | section(data-ref="imageUploadMount") 61 | -------------------------------------------------------------------------------- /app/components/steps/details/details.styl: -------------------------------------------------------------------------------- 1 | @import "./styl/colors" 2 | @import "./styl/fonts" 3 | @import "./styl/font-smoothing" 4 | 5 | .step[data-step="details"] 6 | .column 7 | flex 1 1 auto 8 | width 50% 9 | 10 | > .content 11 | display flex 12 | flex 1 0 auto 13 | justify-content center 14 | 15 | .schema-column 16 | margin-left 1em 17 | -------------------------------------------------------------------------------- /app/components/steps/details/index.js: -------------------------------------------------------------------------------- 1 | import "./details.styl" 2 | import template from "./details.pug" 3 | 4 | import autobind from "autobind-decorator" 5 | import BaseComponent from "components/base-component" 6 | import ImageUploader from "components/image-uploader" 7 | import $$ from "lib/constants" 8 | 9 | export default class DetailsStep extends BaseComponent { 10 | static template = template; 11 | 12 | title = "Describe your plugin."; 13 | 14 | render() { 15 | const element = this.compileTemplate() 16 | const {detailsForm, imageUploadMount} = this.refs 17 | 18 | this.imageUploader = new ImageUploader({name: "app[icon]"}) 19 | this.replaceElement(imageUploadMount, this.imageUploader.render()) 20 | 21 | detailsForm.addEventListener("submit", event => { 22 | event.preventDefault() 23 | this.$root.$activeStep = "creating" 24 | }) 25 | 26 | this.resetFields() 27 | 28 | return element 29 | } 30 | 31 | get navigationButtons() { 32 | return [ 33 | {label: "Back", handler: this.navigatePrevious}, 34 | {label: "Download", handler: this.navigateNext} 35 | ] 36 | } 37 | 38 | @autobind 39 | navigatePrevious() { 40 | this.$root.$activeStep = "preview" 41 | } 42 | 43 | @autobind 44 | navigateNext() { 45 | const {detailsFormSubmit} = this.refs 46 | 47 | detailsFormSubmit.click() 48 | } 49 | 50 | resetFields() { 51 | const {detailsForm} = this.refs 52 | 53 | detailsForm.reset() 54 | this.imageUploader.imageURL = $$.DEFAULT_PLUGIN_ICON 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/components/steps/download/download.pug: -------------------------------------------------------------------------------- 1 | section.step.with-header-offset(data-step="download" data-flow="column") 2 | header.header(data-flow="column") 3 | img.icon(data-ref="icon") 4 | 5 | h1 Your plugin is downloading... 6 | 7 | a.download-link.more(data-ref="downloadLink" download) 8 | | Download immediately 9 | 10 | footer.footer 11 | a.made-by.button.secondary( 12 | href="https://github.com/EagerIO/InstantPlugin" 13 | tabindex="-1" 14 | target="_blank") 15 | span.label Made by 16 | include ../../../assets/eager-logo.svg 17 | -------------------------------------------------------------------------------- /app/components/steps/download/download.styl: -------------------------------------------------------------------------------- 1 | .step[data-step="download"] 2 | justify-content space-between 3 | 4 | .header 5 | align-items center 6 | justify-content center 7 | flex 1 0 auto 8 | text-align center 9 | 10 | .icon 11 | margin 2em 0 12 | max-width 100% 13 | width 128px 14 | 15 | h1 16 | margin-top 0 17 | font-weight normal 18 | 19 | .download-link 20 | text-align center 21 | text-decoration none 22 | font-size .9em 23 | opacity 0.75 24 | 25 | .content 26 | width 50% 27 | margin 1em auto 28 | 29 | .footer 30 | flex 0 0 auto 31 | justify-content center 32 | margin 2em 0 1em 33 | -------------------------------------------------------------------------------- /app/components/steps/download/index.js: -------------------------------------------------------------------------------- 1 | import "./download.styl" 2 | import template from "./download.pug" 3 | 4 | import autobind from "autobind-decorator" 5 | import BaseComponent from "components/base-component" 6 | import createElement from "lib/create-element" 7 | import $$ from "lib/constants" 8 | 9 | const TWITTER_LOGO = `` 12 | 13 | export default class DownloadStep extends BaseComponent { 14 | static template = template; 15 | 16 | @autobind 17 | onEnter() { 18 | const {downloadURL} = this.$root 19 | const {downloadLink, icon} = this.refs 20 | 21 | downloadLink.href = downloadURL 22 | icon.src = this.$root.steps.details.imageUploader.imageURL || $$.DEFAULT_PLUGIN_ICON 23 | 24 | const downloadIframe = createElement("iframe", { 25 | className: "download-iframe", 26 | src: downloadURL 27 | }) 28 | 29 | document.body.appendChild(downloadIframe) 30 | } 31 | 32 | get navigationButtons() { 33 | const href = [ 34 | "https://twitter.com/intent/tweet/?text=", 35 | encodeURIComponent("I just created a WordPress plugin in seconds."), 36 | "&url=", 37 | encodeURIComponent("http://instantwordpressplugin.com"), 38 | "&via=EagerIO"].join("") 39 | 40 | return [ 41 | {label: "Create another plugin", handler: this.navigateNext}, 42 | {label: `${TWITTER_LOGO} Share on Twitter`, href, className: "twitter-share"} 43 | ] 44 | } 45 | 46 | @autobind 47 | navigateNext() { 48 | this.$root.restart() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/components/steps/embed-code/embed-code.pug: -------------------------------------------------------------------------------- 1 | section.step.padded(data-step="embedCode" data-flow="column") 2 | header.step-details 3 | p 4 | | This is the embed code your users add to their website to install your tool or service. 5 | 6 | .demos 7 | span Or try a demo: 8 | 9 | button.demo.button.secondary( 10 | data-ref="demoButtons[]" 11 | data-demo="emojiReact" 12 | tabindex="0") Emoji React 🎉 13 | 14 | button.demo.button.secondary( 15 | data-ref="demoButtons[]" 16 | data-demo="forecastIO" 17 | tabindex="0") Forecast.io ☀️ 18 | 19 | .content(data-flow="column") 20 | textarea.box.code-input( 21 | autocapitalize="off" 22 | autocomplete="off" 23 | autocorrect="off" 24 | autofocus 25 | data-ref="embedCodeInput" 26 | placeholder="