├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── ACKNOWLEDGEMENTS.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── client-service-worker ├── README.md ├── ___dumber-gist-worker.js ├── __boot-up-worker.html ├── __remove-expired-worker.html ├── favicon.ico └── index.html ├── client ├── .eslintrc.json ├── .htmlhintrc ├── .npmrc ├── .sass-lint.yml ├── README.md ├── _index.html ├── favicon.ico ├── gulpfile.js ├── host-names.js ├── jsconfig.json ├── package.json ├── src-worker │ ├── au1-deps-finder.js │ ├── cache-primitives.js │ ├── concat-with-sourcemaps.js │ ├── deps-resolver.js │ ├── dumber-cache.js │ ├── dumber-session.js │ ├── index.js │ ├── jsdelivr.js │ ├── transpiler.js │ ├── transpilers │ │ ├── au2.js │ │ ├── js.js │ │ ├── less.js │ │ ├── sass.js │ │ ├── svelte.js │ │ └── text.js │ └── turbo-resolver │ │ ├── registries │ │ └── npm-http.js │ │ └── resolver.js ├── src │ ├── _dialog.scss │ ├── _gist-editor.scss │ ├── _variables.scss │ ├── action-dispatcher.js │ ├── app.html │ ├── app.js │ ├── app.scss │ ├── dialogs │ │ ├── confirmation-dialog.html │ │ ├── confirmation-dialog.js │ │ ├── context-menu.html │ │ ├── context-menu.js │ │ ├── create-file-dialog.html │ │ ├── create-file-dialog.js │ │ ├── edit-name-dialog.html │ │ ├── edit-name-dialog.js │ │ ├── fuzzy-filter.js │ │ ├── list-gists-dialog.html │ │ ├── list-gists-dialog.js │ │ ├── new-gist-dialog.html │ │ ├── new-gist-dialog.js │ │ ├── open-file-dialog.html │ │ ├── open-file-dialog.js │ │ ├── waiting-dialog.html │ │ └── waiting-dialog.js │ ├── edit │ │ ├── code-editor.html │ │ ├── code-editor.js │ │ ├── code-editor.scss │ │ ├── dialogs │ │ │ ├── editor-config-dialog.html │ │ │ └── editor-config-dialog.js │ │ ├── edit-session.js │ │ ├── editor-tabs.html │ │ ├── editor-tabs.js │ │ ├── file-tree.js │ │ └── opened-files.js │ ├── embedded-browser │ │ ├── browser-bar.html │ │ ├── browser-bar.js │ │ ├── browser-dev-tools.html │ │ ├── browser-dev-tools.js │ │ ├── browser-frame.html │ │ ├── browser-frame.js │ │ ├── console-log.js │ │ ├── dialogs │ │ │ ├── browser-config-dialog.html │ │ │ └── browser-config-dialog.js │ │ ├── log-line.html │ │ ├── log-line.js │ │ ├── logs.html │ │ └── logs.js │ ├── file-drop-indicator.html │ ├── file-drop-indicator.js │ ├── gist-app.html │ ├── gist-app.js │ ├── github │ │ ├── access-token.js │ │ ├── api-client.js │ │ ├── gists.js │ │ ├── oauth.js │ │ ├── persist-session.js │ │ ├── rate-limit.js │ │ └── user.js │ ├── helper.js │ ├── history-tracker.js │ ├── init-params.js │ ├── main.js │ ├── navigation │ │ ├── dialogs │ │ │ ├── select-skeleton-dialog.html │ │ │ └── select-skeleton-dialog.js │ │ ├── file-navigator.html │ │ ├── file-navigator.js │ │ ├── file-node.html │ │ ├── file-node.js │ │ ├── gist-info.html │ │ ├── gist-info.js │ │ ├── quick-start.html │ │ ├── quick-start.js │ │ └── quick-start.scss │ ├── notifications.html │ ├── notifications.js │ ├── notifications.scss │ ├── panel-resizer.html │ ├── panel-resizer.js │ ├── remove-expired-session.js │ ├── resources │ │ ├── dialog-close.html │ │ ├── dialog-close.js │ │ ├── file-icon.js │ │ ├── hidden-submit.html │ │ ├── hidden-submit.scss │ │ ├── index.js │ │ ├── left-click.js │ │ └── show-local-date.js │ ├── session-id.js │ ├── skeletons │ │ ├── aurelia.js │ │ ├── aurelia2.js │ │ ├── backbone.js │ │ ├── inferno.js │ │ ├── none.js │ │ ├── preact.js │ │ ├── react.js │ │ ├── skeleton-generator.js │ │ ├── svelte.js │ │ └── vue.js │ ├── top-bar │ │ ├── dialogs │ │ │ ├── confirm-draft-dialog.html │ │ │ ├── confirm-draft-dialog.js │ │ │ ├── confirm-fork-dialog.html │ │ │ ├── confirm-fork-dialog.js │ │ │ ├── confirm-open-dialog.html │ │ │ ├── confirm-open-dialog.js │ │ │ ├── confirm-share-dialog.html │ │ │ ├── confirm-share-dialog.js │ │ │ ├── open-gist-dialog.html │ │ │ ├── open-gist-dialog.js │ │ │ ├── share-gist-dialog.html │ │ │ ├── share-gist-dialog.js │ │ │ ├── short-cuts-dialog.html │ │ │ └── short-cuts-dialog.js │ │ ├── gist-bar.html │ │ ├── gist-bar.js │ │ ├── github-account.html │ │ └── github-account.js │ ├── url-handler.js │ └── worker-service.js ├── test-worker │ ├── au1-deps-finder.spec.js │ ├── cache-primitives.helper.js │ ├── cache-primitives.jsdelivr.spec.js │ ├── cache-primitives.local-cache.spec.js │ ├── cache-primitives.remote-cache.spec.js │ ├── deps-resolver.spec.js │ ├── dumber-cache.spec.js │ ├── dumber-session.spec.js │ ├── jsdelivr.spec.js │ ├── mock.js │ ├── setup.js │ ├── transpiler.spec.js │ ├── transpilers │ │ ├── au2.spec.js │ │ ├── js.spec.js │ │ ├── less.spec.js │ │ ├── sass.spec.js │ │ ├── svelte.spec.js │ │ └── text.spec.js │ └── turbo-resolver │ │ ├── registries │ │ └── npm-http.spec.js │ │ └── resolver.spec.js └── test │ ├── dialogs │ └── fuzzy-filter.spec.js │ ├── edit │ ├── edit-session.create-file.spec.js │ ├── edit-session.delete-file.spec.js │ ├── edit-session.delete-folder.spec.js │ ├── edit-session.import-data.spec.js │ ├── edit-session.spec.js │ ├── edit-session.update-file.spec.js │ ├── edit-session.update-path.spec.js │ ├── file-tree.spec.js │ └── opened-files.spec.js │ ├── history-tracker.spec.js │ ├── session-id.spec.js │ ├── setup.js │ └── worker-service.spec.js ├── copy-to-server.sh ├── deploy.sh ├── development.md ├── jsconfig.json ├── nginx.dev.conf ├── nginx.prod.conf ├── package.json └── server ├── .eslintrc.json ├── README.md ├── dumber-cache └── app.js ├── github-oauth └── app.js ├── package.json └── request.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | defaults: 12 | run: 13 | shell: bash 14 | working-directory: ./client 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | - run: npm install 20 | - run: npm run build --if-present 21 | - run: xvfb-run -a npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_STORE 3 | Thumbs.db 4 | 5 | # Editors 6 | .idea 7 | .vscode 8 | 9 | # Dependencies 10 | node_modules 11 | package-lock.json 12 | pnpm-lock.yaml 13 | yarn.lock 14 | bun.lockb 15 | 16 | # Compiled files 17 | dist 18 | /client/index.html 19 | /client-service-worker/__dumber-gist-worker.js 20 | .nyc_output 21 | coverage 22 | 23 | # Phusion Passenger 24 | /server/dumber-cache/public 25 | /server/dumber-cache/tmp 26 | /server/github-oauth/tmp 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # for pnpm, use flat node_modules 2 | shamefully-hoist=true 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Chunpeng Huo 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 | # Dumber Gist 2 | 3 | ![CI](https://github.com/dumberjs/dumber-gist/workflows/CI/badge.svg) 4 | 5 | A lightweight online IDE to write JS SPA prototypes in your own GitHub gists. 6 | 7 | https://gist.dumber.app 8 | 9 | Wiki: https://github.com/dumberjs/dumber-gist/wiki 10 | 11 | ----- 12 | Dumber Gist is written in [Aurelia](https://aurelia.io), inspired by [gist-run](https://github.com/gist-run), runs [dumber](https://dumber.js.org) bundler purely in browser. 13 | 14 | More information is in [development.md](https://github.com/dumberjs/dumber-gist/blob/master/development.md). 15 | -------------------------------------------------------------------------------- /client-service-worker/README.md: -------------------------------------------------------------------------------- 1 | # Dumber gist service worker 2 | 3 | For `https://random-id.gist.dumber.app/`, `__boot-up-worker.html` is loaded by an invisible iframe to setup a service worker. 4 | 5 | Once service worker is in place, a user app `index.html` will be added to cache and then be served as entry of the embedded app in the second visible iframe under exactly same host name `https://random-id.gist.dumber.app/`. 6 | -------------------------------------------------------------------------------- /client-service-worker/__boot-up-worker.html: -------------------------------------------------------------------------------- 1 | 2 | 33 | -------------------------------------------------------------------------------- /client-service-worker/__remove-expired-worker.html: -------------------------------------------------------------------------------- 1 | 2 | 17 | -------------------------------------------------------------------------------- /client-service-worker/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dumberjs/dumber-gist/08824a52b3b77fbfa534ed3f778a58f0677da60f/client-service-worker/favicon.ico -------------------------------------------------------------------------------- /client-service-worker/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 |

Sorry, you are trying to view an embedded app (iframe) from a new tab/window for (1) a previously closed dumber-gist window, or (2) in Safari (which sandboxes all iframes).

9 |

Dumber Gist runs purely in browser, the embedded app is "created" by a Service Worker, it doesn't exist in the back-end. This URL is randomly generated and never reused.

10 | 11 | 12 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/no-explicit-any": 0, 13 | "@typescript-eslint/no-empty-function": 0, 14 | "no-console": 0, 15 | "getter-return": 0, 16 | "no-prototype-builtins": 0 17 | }, 18 | "env": { 19 | "es2017": true, 20 | "browser": true, 21 | "node": true, 22 | "worker": true 23 | }, 24 | "globals": { 25 | "requirejs": true, 26 | "HOST_NAMES": true, 27 | "DUMBER_MODULE_LOADER_DIST": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/.htmlhintrc: -------------------------------------------------------------------------------- 1 | { 2 | "tagname-lowercase": true, 3 | "attr-lowercase": true, 4 | "attr-value-double-quotes": true, 5 | "attr-no-duplication": true, 6 | "doctype-first": false, 7 | "tag-pair": true, 8 | "spec-char-escape": true, 9 | "id-unique": true, 10 | "src-not-empty": true, 11 | "title-require": true 12 | } 13 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | # for pnpm, use flat node_modules 2 | shamefully-hoist=true 3 | -------------------------------------------------------------------------------- /client/.sass-lint.yml: -------------------------------------------------------------------------------- 1 | files: 2 | include: 'src/**/*.scss' 3 | rules: 4 | property-sort-order: 0 5 | no-color-keywords: 0 6 | quotes: 0 7 | hex-notation: 0 8 | single-line-per-selector: 0 9 | no-important: 0 10 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Dumber gist client side 2 | 3 | App is in `src/`, worker (dumber bundler) is in `src-worker/`. 4 | 5 | Following scripts are in `package.json`. 6 | 7 | ## Dev build 8 | 9 | npm run build 10 | 11 | Continuously build in watch mode: 12 | 13 | npm start 14 | 15 | ## Production build 16 | 17 | npm run build:prod 18 | 19 | ## Unit tests in browser 20 | 21 | npm test 22 | 23 | ## Unit tests in nodejs 24 | 25 | ./nodejs-test test/a-test-file 26 | ./nodejs-test test-worker/a-test-file 27 | -------------------------------------------------------------------------------- /client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dumberjs/dumber-gist/08824a52b3b77fbfa534ed3f778a58f0677da60f/client/favicon.ico -------------------------------------------------------------------------------- /client/host-names.js: -------------------------------------------------------------------------------- 1 | const domainSubfix = process.env.NODE_ENV === 'production' ? 'app' : 'local'; 2 | const DUMBER_DOMAIN = process.env.DUMBER_DOMAIN || `dumber.${domainSubfix}`; 3 | const JSDELIVR_CDN_DOMAIN = process.env.JSDELIVR_CDN_DOMAIN || 'cdn.jsdelivr.net'; 4 | const JSDELIVR_DATA_DOMAIN = process.env.JSDELIVR_DATA_DOMAIN || 'data.jsdelivr.com'; 5 | 6 | module.exports = { 7 | domain : DUMBER_DOMAIN, 8 | host: `gist.${DUMBER_DOMAIN}`, 9 | clientUrl: `https://gist.${DUMBER_DOMAIN}`, 10 | cacheUrl: `https://cache.${DUMBER_DOMAIN}`, 11 | oauthUrl: `https://github-oauth.gist.${DUMBER_DOMAIN}`, 12 | jsdelivrDataDomain: JSDELIVR_DATA_DOMAIN, 13 | jsdelivrCdnDomain: JSDELIVR_CDN_DOMAIN 14 | }; 15 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": false, 4 | "experimentalDecorators": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "skipLibCheck": true, 8 | "target": "ES2020", 9 | "esModuleInterop": true, 10 | "resolveJsonModule": true, 11 | "importHelpers": true, 12 | "sourceMap": true, 13 | "allowJs": true, 14 | "baseUrl": "src" 15 | }, 16 | "include": [ 17 | "test", 18 | "src", 19 | "test-worker", 20 | "src-worker" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "dist", 25 | "scripts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumber-gist", 3 | "description": "Dumber gist client side app", 4 | "keywords": [], 5 | "private": true, 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@aurelia/plugin-conventions": "latest", 9 | "@typescript-eslint/eslint-plugin": "^7.8.0", 10 | "@typescript-eslint/parser": "^7.8.0", 11 | "aurelia-bootstrapper": "^2.4.1", 12 | "aurelia-combo": "^1.1.4", 13 | "aurelia-deps-finder": "^2.1.7", 14 | "aurelia-dialog-lite": "^1.0.1", 15 | "aurelia-testing": "^1.1.0", 16 | "base64-arraybuffer": "^1.0.2", 17 | "bcx-aurelia-dnd": "^1.6.0", 18 | "bcx-aurelia-reorderable-repeat": "^1.5.1", 19 | "bcx-validation": "^2.1.0", 20 | "browser-do": "^5.0.0", 21 | "clipboard": "^2.0.11", 22 | "codemirror": "^5.65.7", 23 | "cross-env": "^7.0.3", 24 | "eslint": "^8.56.0", 25 | "esm": "^3.2.25", 26 | "graphlib": "^2.1.8", 27 | "gulp": "^4.0.2", 28 | "gulp-dart-sass": "^1.1.0", 29 | "gulp-dumber": "^3.0.0", 30 | "gulp-if": "^3.0.0", 31 | "gulp-plumber": "^1.2.1", 32 | "gulp-swc": "^2.2.0", 33 | "gulp-terser": "^2.1.0", 34 | "htmlhint": "^1.1.4", 35 | "is-utf8": "^0.2.1", 36 | "less": "^4.2.1", 37 | "localforage": "^1.10.0", 38 | "lodash": "^4.17.21", 39 | "merge2": "^1.4.1", 40 | "moment": "^2.30.1", 41 | "nyc": "^15.1.0", 42 | "sass": "~1.49.11", 43 | "sass-lint": "^1.13.1", 44 | "sass.js": "^0.11.1", 45 | "semver": "^7.6.3", 46 | "source-map": "^0.8.0-beta.0", 47 | "svelte": "^5.16.0", 48 | "ts-plugin-inferno": "^6.1.0", 49 | "tslib": "^2.8.1", 50 | "typescript": "^5.7.2", 51 | "zora": "^6.0.0" 52 | }, 53 | "overrides": { 54 | "chokidar": "^3.5.3", 55 | "glob-stream": "^7.0.0", 56 | "glob-parent": "^6.0.2", 57 | "micromatch": "^4.0.5" 58 | }, 59 | "scripts": { 60 | "css-lint": "sass-lint -c .sass-lint.yml 'src/**/*.scss'", 61 | "js-lint": "eslint src test src-worker test-worker", 62 | "html-lint": "htmlhint -c .htmlhintrc src", 63 | "lint": "npm run js-lint && npm run css-lint && npm run html-lint", 64 | "coverage": "npm run test && nyc report --reporter=lcov --reporter=text", 65 | "start": "cross-env NODE_OPTIONS=--max_old_space_size=3584 gulp", 66 | "build": "cross-env NODE_OPTIONS=--max_old_space_size=3584 gulp build", 67 | "build:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max_old_space_size=3584 gulp build", 68 | "clear-cache": "gulp clear-cache", 69 | "pretest": "npm run lint && npm run build:test", 70 | "build:test": "cross-env NODE_ENV=test NODE_OPTIONS=--max_old_space_size=3584 gulp build", 71 | "test": "npm run test:app && npm run test:worker", 72 | "test:app": "browser-do --tap < dist/entry-bundle.js", 73 | "browser-test:app": "npm run build:test && browser-do --tap --browser chrome < dist/entry-bundle.js", 74 | "test:worker": "browser-do --tap < dist/bundler-worker.js", 75 | "browser-test:worker": "npm run build:test && browser-do --tap --browser chrome < dist/bundler-worker.js" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /client/src-worker/au1-deps-finder.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import findDeps from 'aurelia-deps-finder'; 3 | import { 4 | JSDELIVR_PREFIX, 5 | CachePrimitives 6 | } from './cache-primitives'; 7 | 8 | @inject(CachePrimitives) 9 | export class Au1DepsFinder { 10 | constructor(primitives) { 11 | this.primitives = primitives; 12 | this.findDeps = this.findDeps.bind(this); 13 | this.readFile = this.readFile.bind(this); 14 | } 15 | 16 | async readFile(filename) { 17 | let packageWithVersion; 18 | let filePath; 19 | if (filename.startsWith(JSDELIVR_PREFIX)) { 20 | const part = filename.slice(JSDELIVR_PREFIX.length); 21 | let idx = part.indexOf('/'); 22 | if (idx > 0) { 23 | // For @scoped/npm-package/file/path.js 24 | if (part.startsWith('@')) idx = part.indexOf('/', idx + 1); 25 | if (idx > 0) { 26 | packageWithVersion = part.slice(0, idx); 27 | filePath = part.slice(idx + 1); 28 | } 29 | } 30 | } 31 | 32 | if (!packageWithVersion) { 33 | throw new Error('do not care'); 34 | } 35 | 36 | if (! await this.primitives.doesJsdelivrFileExist(packageWithVersion, filePath)) { 37 | throw new Error('File does not exist'); 38 | } 39 | } 40 | 41 | findDeps(filename, contents) { 42 | return findDeps(filename, contents, {readFile: this.readFile}); 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /client/src-worker/dumber-cache.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {CachePrimitives} from './cache-primitives'; 3 | 4 | function globalPostMessage() { 5 | return postMessage.apply(global, arguments); 6 | } 7 | 8 | @inject(CachePrimitives) 9 | export class DumberCache { 10 | constructor(primitives, _postMessage) { 11 | this.primitives = primitives; 12 | this._postMessage = _postMessage || globalPostMessage; 13 | this.getCache = this.getCache.bind(this); 14 | this.setCache = this.setCache.bind(this); 15 | this.clearCache = this.clearCache.bind(this); 16 | } 17 | 18 | async getCache(hash, meta) { 19 | try { 20 | return await this.primitives.getLocalCache(hash); 21 | } catch (e) { 22 | this._postMessage({type:'miss-cache', meta}); 23 | throw e; 24 | } 25 | } 26 | 27 | async setCache(hash, object) { 28 | try { 29 | await this.primitives.setLocalCache(hash, object); 30 | } catch (e) { 31 | // ignore 32 | } 33 | 34 | if (object.packageName) { 35 | // Globally share traced result for npm packages 36 | try { 37 | // Note this is noop for user not signed in. 38 | await this.primitives.setRemoteCache(hash, object); 39 | } catch (e) { 40 | // ignore 41 | } 42 | } 43 | } 44 | 45 | // To satisfy dumber 46 | async clearCache() {} 47 | } 48 | -------------------------------------------------------------------------------- /client/src-worker/index.js: -------------------------------------------------------------------------------- 1 | // metadata Polyfill from Aurelia 1 2 | import 'aurelia-polyfills'; 3 | // initializeTC39Metadata from Aurelia 2 4 | import '@aurelia/kernel'; 5 | 6 | import {DumberSession} from './dumber-session'; 7 | import {Container} from 'aurelia-dependency-injection'; 8 | 9 | (function patchConsole() { 10 | function patch(method) { 11 | const old = console[method]; 12 | console[method] = function() { 13 | const args = Array.prototype.slice.call(arguments, 0); 14 | if ( 15 | typeof args[0] === 'string' && 16 | args[0].startsWith('[dumber] ') 17 | ) { 18 | postMessage({ 19 | type: 'dumber-console', 20 | method: method, 21 | args: args.map((a, i) => { 22 | // Remove the leading '[dumber] ' 23 | if (i === 0) return a.slice(9); 24 | return a && a.toString ? a.toString() : a; 25 | }) 26 | }); 27 | } 28 | if (old) return old.apply(console, arguments); 29 | }; 30 | } 31 | 32 | const methods = ['log', 'error', 'warn', 'dir', 'debug', 'info', 'trace']; 33 | methods.forEach(m => patch(m)); 34 | })(); 35 | 36 | const container = new Container(); 37 | const session = container.get(DumberSession); 38 | 39 | onmessage = async function(event) { 40 | var action = event.data; 41 | const {id, type} = action; 42 | if (!type) return; 43 | 44 | try { 45 | let data; 46 | 47 | if (type === 'bundle') { 48 | data = await session.bundle(action.files); 49 | } else if (type === 'update-token') { 50 | // sync github token from main window 51 | global.__github_token = action.token; 52 | } else { 53 | throw new Error(`Unknown action: ${JSON.stringify(action)}`); 54 | } 55 | 56 | postMessage({type: 'ack', id, data}); 57 | } catch (e) { 58 | console.error('[dumber] ' + e.message); 59 | postMessage({type: 'err', id, error: e.message}); 60 | } 61 | }; 62 | 63 | postMessage({type: 'bundler-worker-up'}); 64 | -------------------------------------------------------------------------------- /client/src-worker/jsdelivr.js: -------------------------------------------------------------------------------- 1 | // Copied from dumber, enhanced with local and remote cache 2 | import {inject} from 'aurelia-dependency-injection'; 3 | import {JSDELIVR_PREFIX, CachePrimitives} from './cache-primitives'; 4 | 5 | @inject(CachePrimitives) 6 | export class Jsdelivr { 7 | constructor(primitives) { 8 | this.primitives = primitives; 9 | this.create = this.create.bind(this); 10 | } 11 | 12 | // use jsdelivr to find npm package files 13 | async create(packageConfig) { 14 | const name = packageConfig.name; 15 | let version = packageConfig.version; 16 | const dumberForcedMain = packageConfig.main; 17 | 18 | let packagePath; 19 | if (packageConfig.location) { 20 | const m = packageConfig.location.match(/^(.+)@(\d[^@]*)$/); 21 | if (m) { 22 | packagePath = m[1]; 23 | version = m[2]; 24 | } else { 25 | packagePath = packageConfig.location; 26 | } 27 | } else { 28 | packagePath = name; 29 | } 30 | 31 | if (version) { 32 | packagePath += '@' + version; 33 | } 34 | 35 | const packageJson = JSON.parse( 36 | (await this.primitives.getJsdelivrFile(packagePath, 'package.json')).contents 37 | ); 38 | 39 | if (!version) { 40 | // fillup version 41 | version = packageJson.version; 42 | packagePath += '@' + version; 43 | } else if (version !== packageJson.version) { 44 | packagePath = packagePath.slice(0, -version.length) + packageJson.version; 45 | version = packageJson.version; 46 | } 47 | 48 | const exists = async filePath => { 49 | if (filePath.startsWith('./')) filePath = filePath.slice(2); 50 | return this.primitives.doesJsdelivrFileExist(packagePath, filePath); 51 | }; 52 | 53 | const fileReader = async filePath => { 54 | if (filePath.startsWith('./')) filePath = filePath.slice(2); 55 | 56 | // Patch package.json with name and forced main 57 | if (filePath === 'package.json') { 58 | const meta = JSON.parse(JSON.stringify(packageJson)); 59 | if (meta.name !== name) { 60 | meta.name = name; 61 | } 62 | if (dumberForcedMain) { 63 | meta.dumberForcedMain = dumberForcedMain; 64 | } 65 | return { 66 | path: JSDELIVR_PREFIX + packagePath + '/' + filePath, 67 | contents: JSON.stringify(meta) 68 | }; 69 | } 70 | 71 | if (! await exists(filePath)) { 72 | throw new Error('no file "' + filePath + '" in ' + packagePath); 73 | } 74 | 75 | return this.primitives.getNpmPackageFile(packagePath, filePath); 76 | }; 77 | 78 | fileReader.packageConfig = packageConfig; 79 | fileReader.exists = exists; 80 | return fileReader; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /client/src-worker/transpiler.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {inject} from 'aurelia-dependency-injection'; 3 | import {SvelteTranspiler} from './transpilers/svelte'; 4 | import {Au2Transpiler} from './transpilers/au2'; 5 | import {JsTranspiler} from './transpilers/js'; 6 | import {SassTranspiler} from './transpilers/sass'; 7 | import {LessTranspiler} from './transpilers/less'; 8 | import {TextTranspiler} from './transpilers/text'; 9 | 10 | @inject( 11 | SvelteTranspiler, 12 | Au2Transpiler, 13 | JsTranspiler, 14 | SassTranspiler, 15 | LessTranspiler, 16 | TextTranspiler 17 | ) 18 | export class Transpiler { 19 | constructor(...transpilers) { 20 | this.transpilers = transpilers; 21 | } 22 | 23 | findTranspiler(file, files) { 24 | return this.transpilers.find(t => t.match(file, files)); 25 | } 26 | 27 | async transpile(file, files, opts) { 28 | const transpiler = this.findTranspiler(file, files); 29 | let result; 30 | 31 | if (transpiler) { 32 | result = await transpiler.transpile(file, files, opts); 33 | } 34 | 35 | if (result) { 36 | let moduleId = path.relative('src', result.filename); 37 | return {...result, moduleId}; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src-worker/transpilers/au2.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {JsTranspiler} from './js'; 3 | import _ from 'lodash'; 4 | 5 | const EXTS = ['.html', '.js', '.ts']; 6 | 7 | export class Au2Transpiler { 8 | constructor() { 9 | this.jsTranspiler = new JsTranspiler(); 10 | } 11 | 12 | match(file, files) { 13 | const ext = path.extname(file.filename); 14 | if (!EXTS.includes(ext)) return false; 15 | 16 | const packageJson = _.find(files, {filename: 'package.json'}); 17 | if (packageJson) { 18 | try { 19 | const meta = JSON.parse(packageJson.content); 20 | // package "aurelia" is for aurelia 2 21 | return _.has(meta, 'dependencies.aurelia'); 22 | } catch (e) { 23 | // ignore 24 | } 25 | } 26 | } 27 | 28 | _lazyLoad() { 29 | if (!this._promise) { 30 | this._promise = import('@aurelia/plugin-conventions'); 31 | } 32 | 33 | return this._promise; 34 | } 35 | 36 | async transpile(file, files) { 37 | if (!this.match(file, files)) throw new Error('Cannot use Au2Transpiler for file: ' + file.filename); 38 | 39 | const au2 = await this._lazyLoad(); 40 | 41 | const au2Options = au2.preprocessOptions({ 42 | useProcessedFilePairFilename: true, 43 | stringModuleWrap: id => `text!${id}`, 44 | hmr: false 45 | }); 46 | 47 | const result = au2.preprocess( 48 | { 49 | path: file.filename, 50 | contents: file.content 51 | }, 52 | au2Options, 53 | (unit, filePath) => { 54 | let resolved = path.resolve(path.dirname(unit.path), filePath); 55 | // in browser env, path.resolve('src', './app.html') yields '/src/app.html' 56 | // Remove leading "/" 57 | resolved = resolved.substring(1); 58 | return !!_.find(files, {filename: resolved}); 59 | }, 60 | (unit, filePath) => { 61 | let resolved = path.resolve(path.dirname(unit.path), filePath); 62 | // in browser env, path.resolve('src', './app.html') yields '/src/app.html' 63 | // Remove leading "/" 64 | resolved = resolved.substring(1); 65 | const file = _.find(files, {filename: resolved}); 66 | if (file) { 67 | return file.content; 68 | } 69 | return ""; 70 | }, 71 | ); 72 | 73 | if (result) { 74 | const ext = path.extname(file.filename); 75 | const newFilename = file.filename + (ext === '.html' ? '.js': ''); 76 | 77 | return this.jsTranspiler.transpile( 78 | // ignore result.map for now 79 | {filename: newFilename, content: result.code}, 80 | files 81 | ); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /client/src-worker/transpilers/js.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import _ from 'lodash'; 3 | import transformInferno from 'ts-plugin-inferno'; 4 | import * as ts from 'typescript'; 5 | import {stripSourceMappingUrl} from 'dumber/lib/shared.js'; 6 | const EXTS = ['.js', '.ts', '.jsx', '.tsx']; 7 | 8 | function isAurelia1(files) { 9 | const packageJson = _.find(files, {filename: 'package.json'}); 10 | if (packageJson) { 11 | try { 12 | const meta = JSON.parse(packageJson.content); 13 | // package "aurelia" is for aurelia 2 14 | return _.has(meta, 'dependencies["aurelia-bootstrapper"]'); 15 | } catch (e) { 16 | // ignore 17 | } 18 | } 19 | } 20 | 21 | export class JsTranspiler { 22 | match(file) { 23 | const ext = path.extname(file.filename); 24 | return EXTS.includes(ext); 25 | } 26 | 27 | async transpile(file, files, opts = {}) { 28 | if (!this.match(file, files)) throw new Error('Cannot use JsTranspiler for file: ' + filename); 29 | 30 | const {filename, content} = file; 31 | const ext = path.extname(filename); 32 | 33 | const jsxPragma = opts.jsxPragma || 'React.createElement'; 34 | const jsxFrag = opts.jsxFrag || 'React.Fragment'; 35 | // Only au1 uses legacy decorators. 36 | const au1 = isAurelia1(files); 37 | 38 | const options = { 39 | fileName: filename, 40 | compilerOptions: { 41 | allowJs: true, 42 | checkJs: false, 43 | experimentalDecorators: au1, 44 | emitDecoratorMetadata: au1, 45 | inlineSources: true, 46 | // Don't compile to ModuleKind.AMD because 47 | // dumber can stub some commonjs globals. 48 | // The stubbing only applies to commonjs or ESM code. 49 | // Use ESNext so that dumber can normalise import (by babel), 50 | // so that we don't need esModuleInterop in tsc. 51 | module: ts.ModuleKind.ESNext, 52 | target: ts.ScriptTarget.ES2020, 53 | sourceMap: true 54 | } 55 | }; 56 | 57 | if (jsxPragma.startsWith('Inferno')) { 58 | // We didn't use jsxPragma and jsxFrag for Inferno. 59 | // ts-transform-inferno does all the work. 60 | options.transformers = { 61 | before: [transformInferno()] 62 | } 63 | } else { 64 | options.compilerOptions.jsx = ts.JsxEmit.React; 65 | options.compilerOptions.jsxFactory = jsxPragma; 66 | options.compilerOptions.jsxFragmentFactory = jsxFrag; 67 | } 68 | 69 | const result = ts.transpileModule(content, options); 70 | 71 | const {outputText, sourceMapText} = result; 72 | const newFilename = filename.slice(0, -ext.length) + '.js'; 73 | let newContent = stripSourceMappingUrl(outputText); 74 | if (!newContent) { 75 | // For ts type definition file, make a empty es module 76 | newContent = 'exports.__esModule = true;\n'; 77 | } 78 | 79 | const sourceMap = JSON.parse(sourceMapText); 80 | sourceMap.file = newFilename; 81 | sourceMap.sources = [filename]; 82 | sourceMap.sourceRoot = ''; 83 | 84 | return { 85 | filename: newFilename, 86 | content: newContent, 87 | sourceMap 88 | }; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src-worker/transpilers/less.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import _ from 'lodash'; 3 | 4 | export class LessTranspiler { 5 | match(file) { 6 | const ext = path.extname(file.filename); 7 | return ext === '.less'; 8 | } 9 | 10 | _lazyLoad() { 11 | if (!this._promise) { 12 | this._promise = Promise.all([ 13 | import('less/lib/less'), 14 | import('less/lib/less/environment/abstract-file-manager'), 15 | import('less/lib/less-browser/plugin-loader') 16 | ]).then(results => results.map(r => r.default)) 17 | .then(results => { 18 | const [ createLess, 19 | AbstractFileManager, 20 | PluginLoader ] = results; 21 | 22 | class FileManager extends AbstractFileManager { 23 | files = {}; 24 | 25 | resetFiles() { 26 | this.files = {}; 27 | } 28 | 29 | setFiles(files) { 30 | this.files = files; 31 | } 32 | 33 | alwaysMakePathsAbsolute() { 34 | return false; // test true/false 35 | } 36 | 37 | supports() { 38 | return true; 39 | } 40 | 41 | loadFile(filename, currentDirectory, options) { 42 | if (currentDirectory && !this.isPathAbsolute(filename)) { 43 | filename = currentDirectory + filename; 44 | } 45 | 46 | filename = options.ext ? this.tryAppendExtension(filename, options.ext) : filename; 47 | 48 | return new Promise((resolve, reject) => { 49 | if (this.files[filename]) { 50 | resolve({filename, contents: this.files[filename]}); 51 | } else { 52 | reject({filename, message: `less.js file manager cannot find file "${filename}"`}); 53 | } 54 | }); 55 | } 56 | } 57 | 58 | const fileManager = new FileManager(); 59 | const less = createLess(null, [fileManager]); 60 | less.PluginLoader = PluginLoader; 61 | return {less, fileManager}; 62 | }); 63 | } 64 | 65 | return this._promise; 66 | } 67 | 68 | async transpile(file, files) { 69 | const {filename, content} = file; 70 | if (!this.match(file)) throw new Error('Cannot use LessTranspiler for file: ' + filename); 71 | 72 | const {less, fileManager} = await this._lazyLoad(); 73 | 74 | const ext = path.extname(filename); 75 | 76 | const cssFiles = {}; 77 | _.each(files, f => { 78 | const ext = path.extname(f.filename); 79 | if (ext === '.less' || ext === '.css') { 80 | const relative = path.relative(path.dirname(filename), f.filename); 81 | cssFiles[relative] = f.content; 82 | } 83 | }); 84 | 85 | fileManager.setFiles(cssFiles); 86 | 87 | const result = await less.render(content, { 88 | // TODO find a way to enable source map in browser. 89 | // The default less environment did not implement 90 | // getSourceMapGenerator. 91 | // The default less-browser env (we did not use) did 92 | // not implement it either. 93 | // It's possible to turn on, but less uses source-map 94 | // version 0.6.x, not latested 0.7.x that dumber uses. 95 | 96 | // sourceMap: { 97 | // outputSourceFiles: true 98 | // } 99 | }); 100 | 101 | const {css} = result; 102 | const newFilename = filename.slice(0, -ext.length) + '.css'; 103 | // const sourceMap = JSON.parse(map); 104 | // sourceMap.file = newFilename; 105 | 106 | return { 107 | filename: newFilename, 108 | content: css 109 | // sourceMap: sourceMap 110 | }; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /client/src-worker/transpilers/svelte.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {JsTranspiler} from './js'; 3 | import {LessTranspiler} from './less'; 4 | import {SassTranspiler} from './sass'; 5 | 6 | export class SvelteTranspiler { 7 | constructor() { 8 | this.jsTranspiler = new JsTranspiler(); 9 | this.sassTranspiler = new SassTranspiler(); 10 | this.lessTranspiler = new LessTranspiler(); 11 | 12 | this.transpileCss = this.transpileCss.bind(this); 13 | this.transpileJs = this.transpileJs.bind(this); 14 | } 15 | 16 | match(file) { 17 | const ext = path.extname(file.filename); 18 | return ext === '.svelte'; 19 | } 20 | 21 | _lazyLoad() { 22 | if (!this._promise) { 23 | this._promise = import('svelte/compiler'); 24 | } 25 | 26 | return this._promise; 27 | } 28 | 29 | async transpileCss({content, attributes, filename}, files) { 30 | let ext = '.css'; 31 | if (attributes.lang === 'scss' || (attributes.type && attributes.type.startsWith('text/scss'))) { 32 | ext = '.scss'; 33 | } else if (attributes.lang === 'sass' || (attributes.type && attributes.type.startsWith('text/sass'))) { 34 | ext = '.sass'; 35 | } else if (attributes.lang === 'less' || (attributes.type && attributes.type.startsWith('text/less'))) { 36 | ext = '.less'; 37 | } 38 | 39 | const file = {filename: filename + ext, content}; 40 | if (ext === '.scss' || ext === '.sass') { 41 | const result = await this.sassTranspiler.transpile( 42 | file, 43 | [...files, file] 44 | ); 45 | return { 46 | code: result.content, 47 | map: result.sourceMap 48 | } 49 | } else if (ext === '.less') { 50 | const result = await this.lessTranspiler.transpile( 51 | file, 52 | [...files, file] 53 | ); 54 | return { 55 | code: result.content, 56 | map: result.sourceMap 57 | } 58 | } else { // css pass through 59 | return {code: content}; 60 | } 61 | } 62 | 63 | async transpileJs({content, filename}) { 64 | const result = await this.jsTranspiler.transpile( 65 | // Just go through typescript syntax for any js. 66 | {filename: filename + '.ts', content} 67 | ); 68 | return { 69 | code: result.content, 70 | map: result.sourceMap 71 | }; 72 | } 73 | 74 | async transpile(file, files) { 75 | if (!this.match(file)) throw new Error('Cannot use SvelteTranspiler for file: ' + file.filename); 76 | 77 | const {compile, preprocess} = await this._lazyLoad(); 78 | 79 | const {filename, content} = file; 80 | 81 | const newFilename = file.filename + '.js'; 82 | const preprocessed = await preprocess( 83 | content, 84 | { 85 | style: opts => this.transpileCss(opts, files), 86 | script: this.transpileJs 87 | }, 88 | {filename: filename} 89 | ); 90 | 91 | const compiled = compile(preprocessed.toString(), { 92 | outputFilename: newFilename 93 | }); 94 | 95 | let {code, map} = compiled.js; 96 | map.file = newFilename; 97 | map.sources = [filename]; 98 | map.sourceRoot = ''; 99 | 100 | return { 101 | filename: newFilename, 102 | content: code, 103 | sourceMap: map 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /client/src-worker/transpilers/text.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const EXTS = ['.html', '.css', '.svg', '.xml', '.json']; 4 | 5 | export class TextTranspiler { 6 | match(file) { 7 | const ext = path.extname(file.filename); 8 | return EXTS.indexOf(ext) !== -1; 9 | } 10 | 11 | async transpile(file) { 12 | if (!this.match(file)) throw new Error('Cannot use TextTranspiler for file: ' + file.filename); 13 | return file; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src-worker/turbo-resolver/registries/npm-http.js: -------------------------------------------------------------------------------- 1 | export class NpmHttpRegistry { 2 | constructor() { 3 | // Cannot use https://registry.npmjs.org which 4 | // doesn't have CORS enabled 5 | // this.registryUrl = 'https://registry.npmjs.cf'; 6 | // 2022-04-15 npmjs.cf has duplicated cors header issue. 7 | // npmjs.org seems works with CORS now. 8 | this.registryUrl = 'https://registry.npmjs.org'; 9 | this.cache = {}; 10 | this.fetching = {}; 11 | } 12 | 13 | resetCache() { 14 | this.cache = {}; 15 | } 16 | 17 | async fetch(name){ 18 | if(this.cache[name]) { 19 | return this.cache[name]; 20 | } 21 | 22 | if (!this.fetching[name]) { 23 | this.fetching[name] = fetch(`${this.registryUrl}/${name}`).then(async response => { 24 | if(!response.ok){ 25 | // npm can send a json error 26 | const dataError = (await (response.json().then(j => j && j.error).catch( () => null))); 27 | const error = `Could not load npm registry for ${name}: ${dataError || response.statusText}`; 28 | 29 | console.error(error); 30 | throw new Error(error); 31 | } 32 | 33 | return response.json(); 34 | }).then(json => { 35 | this.cache[name] = json; 36 | delete this.fetching[name]; 37 | return json; 38 | }); 39 | } 40 | 41 | return this.fetching[name]; 42 | } 43 | 44 | batchFetch(names){ 45 | return Promise.all(names.map(name => this.fetch(name))); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/_dialog.scss: -------------------------------------------------------------------------------- 1 | .dialog-lite-overlay { 2 | justify-content: flex-start; 3 | padding: 1rem 0; 4 | z-index: 1051; 5 | } 6 | 7 | .dialog { 8 | display: flex; 9 | flex-direction: column; 10 | box-shadow: 0 5px 15px $dark-shadow; 11 | border: 1px solid $gray-700; 12 | border-radius: $border-radius; 13 | padding: 3; 14 | width: 420px; 15 | max-height: 100%; 16 | color: $gray-100; 17 | border-image-source: initial; 18 | border-image-slice: initial; 19 | border-image-width: initial; 20 | border-image-outset: initial; 21 | border-image-repeat: initial; 22 | background-color: $gray-800; 23 | 24 | &.wide-dialog { 25 | @media (min-width: $small) { 26 | width: 600px; 27 | } 28 | } 29 | 30 | @media (max-width: ($tiny - .02)) { 31 | width: 310px; 32 | } 33 | 34 | > .dialog-header { 35 | display: flex; 36 | flex-wrap: nowrap; 37 | flex: 0 0 auto; 38 | padding: .75rem 1rem; 39 | font-size: 1.2rem; 40 | border-top-left-radius: $border-radius; 41 | border-top-right-radius: $border-radius; 42 | 43 | > .dialog-close { 44 | cursor: pointer; 45 | display: inline-block; 46 | margin: -.75rem -1rem -.75rem auto; 47 | margin-left: auto; 48 | color: $gray-400; 49 | font-size: 2.6rem; 50 | font-weight: 300; 51 | line-height: 1; 52 | width: 2.8rem; 53 | vertical-align: middle; 54 | text-align: center; 55 | border-radius: $border-radius; 56 | 57 | &:hover { 58 | color: $white; 59 | } 60 | } 61 | } 62 | 63 | > .dialog-body { 64 | display: block; 65 | flex: 0 1 auto; 66 | padding: .75rem 1rem; 67 | overflow-x: hidden; 68 | overflow-y: auto; 69 | -webkit-overflow-scrolling: touch; 70 | } 71 | 72 | .dialog-header + .dialog-body { 73 | padding-top: .25rem; 74 | } 75 | 76 | > .dialog-footer { 77 | display: flex; 78 | flex: 0 0 auto; 79 | padding: 1rem; 80 | background-color: $gray-900; 81 | text-align: right; 82 | border-bottom-left-radius: $border-radius; 83 | border-bottom-right-radius: $border-radius; 84 | 85 | .btn { 86 | min-width: 6rem; 87 | } 88 | 89 | .btn + .btn { 90 | margin-left: 10px; 91 | } 92 | 93 | button:disabled { 94 | cursor: default; 95 | opacity: .45; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /client/src/_gist-editor.scss: -------------------------------------------------------------------------------- 1 | .cm-s-gist-editor.CodeMirror { 2 | background: $gray-900; 3 | color: $gray-300; 4 | } 5 | 6 | .cm-s-gist-editor div.CodeMirror-selected { 7 | background: $gray-700; 8 | } 9 | 10 | .cm-s-gist-editor .CodeMirror-line::selection, 11 | .cm-s-gist-editor .CodeMirror-line > span::selection, 12 | .cm-s-gist-editor .CodeMirror-line > span > span::selection { 13 | background: rgba(56, 56, 56, 0.99); 14 | } 15 | 16 | .cm-s-gist-editor .CodeMirror-line::-moz-selection, 17 | .cm-s-gist-editor .CodeMirror-line > span::-moz-selection, 18 | .cm-s-gist-editor .CodeMirror-line > span > span::-moz-selection { 19 | background: rgba(56, 56, 56, 0.99); 20 | } 21 | 22 | .cm-s-gist-editor .CodeMirror-gutters { 23 | background: $gray-900; 24 | border-right: 2px dashed $gray-800; 25 | } 26 | 27 | .cm-s-gist-editor .CodeMirror-guttermarker { 28 | color: $gray-300; 29 | } 30 | 31 | .cm-s-gist-editor .CodeMirror-guttermarker-subtle { 32 | color: azure; 33 | } 34 | 35 | .cm-s-gist-editor .CodeMirror-linenumber { 36 | color: $gray-700; 37 | } 38 | 39 | .cm-s-gist-editor .CodeMirror-cursor { 40 | border-left: 2px solid $white; 41 | } 42 | 43 | .cm-s-gist-editor span.cm-keyword { 44 | color: $yellow-dark; 45 | font-weight: bold; 46 | } 47 | 48 | .cm-s-gist-editor span.cm-atom { 49 | color: #77F; 50 | } 51 | 52 | .cm-s-gist-editor span.cm-number { 53 | color: violet; 54 | } 55 | 56 | .cm-s-gist-editor span.cm-def { 57 | color: #fffabc; 58 | } 59 | 60 | .cm-s-gist-editor span.cm-variable { 61 | color: #abcdef; 62 | } 63 | 64 | .cm-s-gist-editor span.cm-variable-2 { 65 | color: #cacbcc; 66 | } 67 | 68 | .cm-s-gist-editor span.cm-variable-3, .cm-s-gist-editor span.cm-type { 69 | color: #def; 70 | } 71 | 72 | .cm-s-gist-editor span.cm-property { color: 73 | #fedcba; 74 | } 75 | 76 | .cm-s-gist-editor span.cm-operator { 77 | color: $yellow; 78 | } 79 | 80 | .cm-s-gist-editor span.cm-comment { 81 | color: #7a7b7c; font-style: italic; 82 | } 83 | 84 | .cm-s-gist-editor span.cm-string { 85 | color: $green; 86 | } 87 | 88 | .cm-s-gist-editor span.cm-meta { 89 | color: #C9F; 90 | } 91 | 92 | .cm-s-gist-editor span.cm-qualifier { 93 | color: $yellow; 94 | } 95 | 96 | .cm-s-gist-editor span.cm-builtin { 97 | color: #30aabc; 98 | } 99 | 100 | .cm-s-gist-editor span.cm-bracket { 101 | color: #8a8a8a; 102 | } 103 | 104 | .cm-s-gist-editor span.cm-tag { 105 | color: $yellow-dark; 106 | } 107 | 108 | .cm-s-gist-editor span.cm-attribute { 109 | color: $yellow; 110 | } 111 | 112 | .cm-s-gist-editor span.cm-error { 113 | color: #FF0000; 114 | } 115 | 116 | .cm-s-gist-editor span.cm-header { 117 | color: aquamarine; font-weight: bold; 118 | } 119 | 120 | .cm-s-gist-editor span.cm-link { 121 | color: blueviolet; 122 | } 123 | 124 | .cm-s-gist-editor .CodeMirror-activeline-background { 125 | background: #314151; 126 | } 127 | -------------------------------------------------------------------------------- /client/src/_variables.scss: -------------------------------------------------------------------------------- 1 | $charcoal: #2B2D33; 2 | 3 | $blue: #30A1C1; 4 | // $indigo: #6610f2; 5 | // $purple: #6f42c1; 6 | $pink: #D06566; 7 | $red: #DD6163; 8 | $orange: #D56D59; 9 | $yellow: #FEC624; 10 | $green: #34C371; 11 | // $teal: #20c997; 12 | $cyan: #5D99BD; 13 | 14 | 15 | $white: #fff; 16 | $gray-100: #f8f9fa; 17 | $gray-200: #e9ecef; 18 | $gray-300: #dee2e6; 19 | $gray-400: #ced4da; 20 | $gray-500: #adb5bd; 21 | $gray-600: #868e96; 22 | $gray-700: #495057; 23 | $gray-800: #343a40; 24 | $gray-900: #212529; 25 | $gray-1000: #101215; 26 | $black: #000; 27 | $shadow: transparentize($black, .95); 28 | $dark-shadow: transparentize($black, .7); 29 | $deep-dark-shadow: transparentize($black, .5); 30 | $bright-shadow: transparentize($white, .85); 31 | 32 | $red-light: lighten($red, 15%); 33 | $cyan-light: lighten($cyan, 38%); 34 | $yellow-light: lighten($yellow, 35%); 35 | $blue-light: lighten($blue, 45%); 36 | $green-light: lighten($green, 45%); 37 | 38 | $red-dark: darken($red, 20%); 39 | $cyan-dark: darken($cyan, 20%); 40 | $yellow-dark: darken($yellow, 20%); 41 | $blue-dark: darken($blue, 20%); 42 | $green-dark: darken($green, 20%); 43 | 44 | $tiny: 450px; 45 | $small: 800px; 46 | 47 | $border-radius: 3px; 48 | -------------------------------------------------------------------------------- /client/src/app.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/app.js: -------------------------------------------------------------------------------- 1 | import {inject, computedFrom} from 'aurelia-framework'; 2 | import {Oauth} from './github/oauth'; 3 | import {User} from './github/user'; 4 | import {UrlHandler} from './url-handler'; 5 | 6 | @inject(Oauth, User, UrlHandler) 7 | export class App { 8 | constructor(oauth, user, urlHandler) { 9 | this.oauth = oauth; 10 | this.user = user; 11 | this.urlHandler = urlHandler; 12 | urlHandler.start(); 13 | } 14 | 15 | @computedFrom('oauth.initialised', 'urlHandler.initialised', 'user.loading') 16 | get loading() { 17 | return !this.oauth.initialised || 18 | !this.urlHandler.initialised || 19 | this.user.loading; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/dialogs/confirmation-dialog.html: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /client/src/dialogs/confirmation-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | import {combo} from 'aurelia-combo'; 4 | 5 | @inject(DialogController) 6 | export class ConfirmationDialog { 7 | constructor(controller) { 8 | this.controller = controller; 9 | this.controller.overlayDismiss = false; 10 | } 11 | 12 | activate(model) { 13 | this.question = model.question; 14 | this.confirmationLabel = model.confirmationLabel || 'Yes'; 15 | this.cancelationLabel = model.cancelationLabel || 'No'; 16 | } 17 | 18 | @combo('enter') 19 | ok() { 20 | this.controller.ok(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/src/dialogs/context-menu.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/dialogs/context-menu.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | 4 | @inject(DialogController) 5 | export class ContextMenu { 6 | constructor(controller) { 7 | this.controller = controller; 8 | } 9 | 10 | activate(model) { 11 | this.items = model.items; 12 | const {top, left, bottom, right} = model; 13 | const style = {}; 14 | if (top) style.top = top + 'px'; 15 | if (left) style.left = left + 'px'; 16 | if (bottom) style.bottom = bottom + 'px'; 17 | if (right) style.right = right + 'px'; 18 | this.style = style; 19 | } 20 | 21 | clickItem(item) { 22 | if (item.href) { 23 | return true; // let browser handle the link 24 | } 25 | 26 | if (item.code) { 27 | this.controller.ok(item.code); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/dialogs/create-file-dialog.html: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /client/src/dialogs/create-file-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import Validation from 'bcx-validation'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {inject, computedFrom} from 'aurelia-framework'; 5 | import _ from 'lodash'; 6 | import path from 'path'; 7 | 8 | const BINARY_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.wasm']; 9 | 10 | @inject(DialogController, Validation, EditSession) 11 | export class CreateFileDialog { 12 | triedOnce = false; 13 | name = ''; 14 | 15 | constructor(controller, validation, session) { 16 | this.controller = controller; 17 | this.validator = validation.generateValidator({ 18 | name: [ 19 | 'mandatory', 20 | { 21 | validate: /^[a-zA-Z0-9_/.-]+$/, 22 | message: 'only accept letters, numbers, dash(-), underscore(_), dot(.), or slash(/) in file path' 23 | }, 24 | name => { 25 | if (_.find(session.files, {filename: name})) { 26 | return `there is an existing file "${name}"`; 27 | } 28 | if (_.find(session.files, f => f.filename.startsWith(name + '/'))) { 29 | return `there is an existing folder "${name}"`; 30 | } 31 | }, 32 | name => { 33 | if (BINARY_EXTS.includes(path.extname(name))) { 34 | return `GitHub gist only supports text file, try svg if you need images`; 35 | } 36 | } 37 | ] 38 | }) 39 | } 40 | 41 | activate(model) { 42 | this.name = _.get(model, 'filePath') || ''; 43 | if (this.name) this.name += '/'; 44 | } 45 | 46 | save() { 47 | this.triedOnce = true; 48 | if (this.errors) return; 49 | this.controller.ok(_.trim(this.name, '/')); 50 | } 51 | 52 | @computedFrom('triedOnce', 'name') 53 | get errors() { 54 | if (this.triedOnce) { 55 | const errors = this.validator(this); 56 | return _.capitalize(_.get(errors, 'name', []).join(', ')); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/dialogs/edit-name-dialog.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /client/src/dialogs/edit-name-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import Validation from 'bcx-validation'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {inject, computedFrom} from 'aurelia-framework'; 5 | import _ from 'lodash'; 6 | import path from 'path'; 7 | 8 | const BINARY_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.ico', '.wasm']; 9 | 10 | @inject(DialogController, Validation, EditSession) 11 | export class EditNameDialog { 12 | triedOnce = false; 13 | name = ''; 14 | 15 | constructor(controller, validation, session) { 16 | this.controller = controller; 17 | this.validation = validation; 18 | this.session = session; 19 | } 20 | 21 | activate(model) { 22 | this.name = model.filePath; 23 | this._originalFilePath = model.filePath; 24 | this.isFolder = model.isFolder; 25 | 26 | this.validator = this.validation.generateValidator({ 27 | name: [ 28 | 'mandatory', 29 | { 30 | validate: /^[a-zA-Z0-9_/.-]+$/, 31 | message: 'only accept letters, numbers, dash(-), underscore(_), dot(.), or slash(/) in file path' 32 | }, 33 | name => { 34 | if (name === this._originalFilePath) return; 35 | if (_.find(this.session.files, {filename: name})) { 36 | return `there is an existing file "${name}"`; 37 | } 38 | if (_.find(this.session.files, f => f.filename.startsWith(name + '/'))) { 39 | return `there is an existing folder "${name}"`; 40 | } 41 | }, 42 | name => { 43 | if (BINARY_EXTS.includes(path.extname(name))) { 44 | return `GitHub gist only supports text file, try svg if you need images`; 45 | } 46 | } 47 | ] 48 | }); 49 | } 50 | 51 | attached() { 52 | let startIdx = this.name.lastIndexOf('/') + 1; 53 | const part = this.name.split('.')[0]; 54 | this.input.setSelectionRange(startIdx, part.length); 55 | } 56 | 57 | save() { 58 | if (!this.isChanged) return; 59 | this.triedOnce = true; 60 | if (this.errors) return; 61 | this.controller.ok(_.trim(this.name, '/')); 62 | } 63 | 64 | @computedFrom('triedOnce', 'name') 65 | get errors() { 66 | if (this.triedOnce) { 67 | const errors = this.validator(this); 68 | return _.capitalize(_.get(errors, 'name', []).join(', ')); 69 | } 70 | } 71 | 72 | @computedFrom('name', '_originalFilePath') 73 | get isChanged() { 74 | return this.name !== this._originalFilePath; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/src/dialogs/fuzzy-filter.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export function fuzzyFilter(filter, list) { 4 | filter = _.trim(filter); 5 | const ii = filter.length; 6 | 7 | const filtered = []; 8 | _.each(list, fn => { 9 | let idx = 0; 10 | 11 | const matches = []; 12 | for (let i = 0; i < ii; i++) { 13 | const c = filter[i]; 14 | const nextIdx = fn.indexOf(c, idx); 15 | if (nextIdx === -1) return; 16 | if (_.get(_.last(matches), 'end')=== nextIdx) { 17 | _.last(matches).end += 1; 18 | } else { 19 | matches.push({start: nextIdx, end: nextIdx + 1}); 20 | } 21 | idx = nextIdx + 1; 22 | } 23 | 24 | // even (0,2,4...) is unmatched, odd is matched. 25 | const segments = []; 26 | let start = 0; 27 | for (let j = 0, jj = matches.length; j < jj; j++) { 28 | const m = matches[j]; 29 | // unmatched 30 | segments.push(fn.slice(start, m.start)); 31 | // matched 32 | segments.push(fn.slice(m.start, m.end)); 33 | start = m.end; 34 | } 35 | if (start < fn.length) { 36 | segments.push(fn.slice(start)); 37 | } 38 | 39 | filtered.push(segments); 40 | }); 41 | 42 | return _.sortBy(filtered, 'length'); 43 | } 44 | -------------------------------------------------------------------------------- /client/src/dialogs/list-gists-dialog.html: -------------------------------------------------------------------------------- 1 | 46 | -------------------------------------------------------------------------------- /client/src/dialogs/new-gist-dialog.html: -------------------------------------------------------------------------------- 1 | 39 | -------------------------------------------------------------------------------- /client/src/dialogs/new-gist-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import Validation from 'bcx-validation'; 3 | import {inject, computedFrom} from 'aurelia-framework'; 4 | import _ from 'lodash'; 5 | 6 | @inject(DialogController, Validation) 7 | export class NewGistDialog { 8 | triedOnce = false; 9 | model = { 10 | description: '', 11 | isPublic: true 12 | }; 13 | 14 | constructor(controller, validation) { 15 | this.controller = controller; 16 | this.validator = validation.generateValidator({ 17 | description: 'mandatory' 18 | }); 19 | } 20 | 21 | activate(model) { 22 | this.model.description = model.description; 23 | this.model.isPublic = model.isPublic; 24 | } 25 | 26 | save() { 27 | this.triedOnce = true; 28 | if (this.errors) return; 29 | this.controller.ok(this.model); 30 | } 31 | 32 | @computedFrom('triedOnce', 'model.description', 'model.isPublic') 33 | get errors() { 34 | if (this.triedOnce) { 35 | const errors = this.validator(this.model); 36 | return _.capitalize(_.get(errors, 'description', []).join(', ')); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/src/dialogs/open-file-dialog.html: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /client/src/dialogs/open-file-dialog.js: -------------------------------------------------------------------------------- 1 | import {combo} from 'aurelia-combo'; 2 | import {DialogController} from 'aurelia-dialog-lite'; 3 | import {inject, observable} from 'aurelia-framework'; 4 | import {fuzzyFilter} from './fuzzy-filter'; 5 | 6 | @inject(DialogController) 7 | export class OpenFileDialog { 8 | @observable filter = ''; 9 | selectedIdx = -1; 10 | filteredFilenames = []; 11 | 12 | constructor(controller) { 13 | this.controller = controller; 14 | } 15 | 16 | activate(model) { 17 | this.filenames = model.filenames; 18 | this.filteredFilenames = fuzzyFilter(this.filter, this.filenames); 19 | } 20 | 21 | keyDownInFilter(e) { 22 | if (e.key === 'ArrowUp') { // up 23 | e.target.blur(); 24 | this.selectPrevious(); 25 | return false; 26 | } else if (e.key === 'ArrowDown') { // down 27 | e.target.blur(); 28 | this.selectNext(); 29 | return false; 30 | } else if (e.key === 'Enter') { // return 31 | this.open(this.selectedIdx); 32 | return false; 33 | } 34 | 35 | return true; 36 | } 37 | 38 | @combo('up') 39 | selectPrevious() { 40 | if (this.selectedIdx === -1) { 41 | this.selectedIdx = 0; 42 | } else if (this.selectedIdx > 0) { 43 | this.selectedIdx -= 1; 44 | } 45 | this.scrollIfNeeded(); 46 | } 47 | 48 | @combo('down') 49 | selectNext() { 50 | if (this.selectedIdx === -1) { 51 | this.selectedIdx = 0; 52 | } else if ( 53 | this.selectedIdx >= 0 && 54 | this.selectedIdx < this.filteredFilenames.length - 1 55 | ) { 56 | this.selectedIdx += 1; 57 | } 58 | this.scrollIfNeeded(); 59 | } 60 | 61 | @combo('enter') 62 | submitIfSelected() { 63 | this.open(this.selectedIdx); 64 | } 65 | 66 | scrollIfNeeded() { 67 | setTimeout(() => { 68 | const selected = this.list.querySelector('.available-item.selected'); 69 | if (selected) { 70 | if (selected.scrollIntoViewIfNeeded) { 71 | selected.scrollIntoViewIfNeeded(); 72 | } else if (selected.scrollIntoView) { 73 | selected.scrollIntoView(); 74 | } 75 | } 76 | }); 77 | } 78 | 79 | open(selectedIdx) { 80 | if (selectedIdx >= 0) { 81 | const segments = this.filteredFilenames[selectedIdx]; 82 | if (segments) { 83 | this.controller.ok(segments.join('')); 84 | } 85 | } 86 | } 87 | 88 | filterChanged(filter) { 89 | this.filteredFilenames = fuzzyFilter(filter, this.filenames); 90 | if (filter && this.filteredFilenames.length) { 91 | this.selectedIdx = 0; 92 | } else { 93 | this.selectedIdx = -1; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/src/dialogs/waiting-dialog.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /client/src/dialogs/waiting-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | import _ from 'lodash'; 4 | 5 | const DEFAULT_DELAY = 200; 6 | 7 | @inject(DialogController) 8 | export class WaitingDialog { 9 | slow = false; 10 | 11 | constructor(controller) { 12 | this.controller = controller; 13 | // No auto dismiss 14 | controller.escDismiss = false; 15 | controller.overlayDismiss = false; 16 | } 17 | 18 | activate(model) { 19 | let {delay} = model; 20 | if (typeof delay !== 'number' || delay < 0) delay = 0; 21 | 22 | this.title = _.get(model, 'title') || 'Loading ...'; 23 | this.slowSetter = setTimeout(() => { 24 | this.slow = true; 25 | }, model.delay || DEFAULT_DELAY); 26 | } 27 | 28 | deactivate() { 29 | clearTimeout(this.slowSetter); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/edit/code-editor.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /client/src/edit/dialogs/editor-config-dialog.html: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /client/src/edit/dialogs/editor-config-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | import {combo} from 'aurelia-combo'; 4 | 5 | @inject(DialogController) 6 | export class EditorConfigDialog { 7 | model = { 8 | vimMode: false, 9 | lineWrapping: false, 10 | }; 11 | 12 | constructor(controller) { 13 | this.controller = controller; 14 | } 15 | 16 | activate(model) { 17 | Object.assign(this.model, model); 18 | } 19 | 20 | @combo('enter') 21 | ok() { 22 | this.controller.ok(this.model); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/edit/editor-tabs.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /client/src/edit/editor-tabs.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable, bindingMode, BindingEngine} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {OpenedFiles} from './opened-files'; 4 | import {DialogService} from 'aurelia-dialog-lite'; 5 | import {EditorConfigDialog} from './dialogs/editor-config-dialog'; 6 | import _ from 'lodash'; 7 | 8 | @inject(EventAggregator, BindingEngine, OpenedFiles, DialogService) 9 | export class EditorTabs { 10 | @bindable insideIframe; 11 | @bindable({defaultBindingMode: bindingMode.twoWay}) vimMode = false; 12 | @bindable({defaultBindingMode: bindingMode.twoWay}) lineWrapping = false; 13 | 14 | constructor(ea, bindingEngine, openedFiles, dialogService) { 15 | this.ea = ea; 16 | this.bindingEngine = bindingEngine; 17 | this.openedFiles = openedFiles; 18 | this.dialogService = dialogService; 19 | this.showActiveTab = _.debounce(this.showActiveTab.bind(this)); 20 | } 21 | 22 | attached() { 23 | this.subscribers = [ 24 | this.bindingEngine.propertyObserver(this.openedFiles, 'focusedIndex').subscribe(this.showActiveTab) 25 | ]; 26 | } 27 | 28 | detached() { 29 | this.subscribers.forEach(s => s.dispose()); 30 | } 31 | 32 | config() { 33 | if (this.insideIframe) return; 34 | if (this.dialogService.hasActiveDialog) return; 35 | 36 | this.dialogService.open({ 37 | viewModel: EditorConfigDialog, 38 | model: { 39 | vimMode: this.vimMode, 40 | lineWrapping: this.lineWrapping 41 | } 42 | }).then( 43 | output => { 44 | this.vimMode = output.vimMode; 45 | this.lineWrapping = output.lineWrapping; 46 | }, 47 | () => {} 48 | ); 49 | } 50 | 51 | closeFile(filename) { 52 | this.ea.publish('close-file', filename); 53 | } 54 | 55 | showActiveTab() { 56 | const active = this.el.querySelector('.tab.active'); 57 | if (active) { 58 | if (active.scrollIntoViewIfNeeded) { 59 | active.scrollIntoViewIfNeeded(); 60 | } else { 61 | active.scrollIntoView(); 62 | } 63 | } 64 | } 65 | 66 | afterReordering(filenames, change) { 67 | if (this.openedFiles.focusedIndex !== change.toIndex) { 68 | this.openedFiles.focusedIndex = change.toIndex; 69 | } else { 70 | // Force a mutation 71 | this.openedFiles.focusedIndex = change.toIndex - 1; 72 | this.openedFiles.focusedIndex = change.toIndex; 73 | } 74 | } 75 | } 76 | 77 | export class LastPartValueConverter { 78 | toView(filename) { 79 | return _.last(filename.split('/')); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/edit/file-tree.js: -------------------------------------------------------------------------------- 1 | import {inject, BindingEngine} from 'aurelia-framework'; 2 | import {EditSession} from './edit-session'; 3 | import _ from 'lodash'; 4 | import path from 'path'; 5 | 6 | @inject(EditSession, BindingEngine) 7 | export class FileTree { 8 | tree = []; 9 | 10 | constructor(session, bindingEngine) { 11 | this._updateTree = this._updateTree.bind(this); 12 | this.session = session; 13 | bindingEngine.propertyObserver(session, 'mutation').subscribe(this._updateTree); 14 | this._updateTree(); 15 | } 16 | 17 | _updateTree() { 18 | const tree = []; 19 | 20 | _(this.session.files) 21 | .map(f => { 22 | const filename = path.normalize(f.filename); 23 | const parts = filename.split('/'); 24 | const len = parts.length; 25 | return {filename, parts, len, f}; 26 | }) 27 | .sortBy(f => -f.len) 28 | .each(({filename, parts, len, f}) => { 29 | let branch = tree; 30 | _.each(parts, (p, i) => { 31 | if (i === len - 1) { 32 | // file 33 | branch.push({ 34 | filePath: filename, 35 | dirPath: parts.slice(0, i).join('/'), 36 | name: p, 37 | isChanged: f.isChanged, 38 | file: f 39 | }); 40 | } else { 41 | // dir 42 | const existingFolder = _.find(branch, b => b.files && b.name === p); 43 | if (existingFolder) { 44 | branch = existingFolder.files; 45 | } else { 46 | const newFolder = { 47 | filePath: parts.slice(0, i + 1).join('/'), 48 | dirPath: parts.slice(0, i).join('/'), 49 | name: p, 50 | files: [] 51 | }; 52 | branch.push(newFolder); 53 | branch = newFolder.files; 54 | } 55 | } 56 | }); 57 | }); 58 | 59 | markIsChanged(tree); 60 | this.tree = tree; 61 | } 62 | } 63 | 64 | function markIsChanged(tree) { 65 | let isChanged = false; 66 | 67 | _.each(tree, branch => { 68 | if (branch.files) { 69 | branch.isChanged = markIsChanged(branch.files); 70 | } 71 | 72 | if (branch.isChanged) { 73 | isChanged = true; 74 | } 75 | }); 76 | 77 | return isChanged; 78 | } 79 | -------------------------------------------------------------------------------- /client/src/edit/opened-files.js: -------------------------------------------------------------------------------- 1 | import {inject, computedFrom, BindingEngine} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {EditSession} from './edit-session'; 4 | import _ from 'lodash'; 5 | 6 | @inject(EventAggregator, EditSession, BindingEngine) 7 | export class OpenedFiles { 8 | filenames = []; 9 | focusedIndex = -1; 10 | 11 | constructor(ea, session, bindingEngine) { 12 | this.ea = ea; 13 | this.session = session; 14 | this._reset = this._reset.bind(this); 15 | 16 | ea.subscribe('loaded-gist', this._reset); 17 | bindingEngine.propertyObserver(session, 'mutation').subscribe(() => { 18 | // if (mutation <= 0) { 19 | // // just loaded new gist 20 | // this._reset(); 21 | // } else { 22 | this._cleanUp(); 23 | // } 24 | }); 25 | } 26 | 27 | openFile(filename) { 28 | const file = _.find(this.session.files, {filename}); 29 | if (!file) return; 30 | 31 | const idx = this.filenames.indexOf(filename); 32 | if (idx === -1) { 33 | this.filenames.push(filename); 34 | this.focusedIndex = this.filenames.length - 1; 35 | } else { 36 | this.focusedIndex = idx; 37 | } 38 | 39 | this.ea.publish('opened-file', filename); 40 | } 41 | 42 | closeFile(filename) { 43 | const file = _.find(this.session.files, {filename}); 44 | if (!file) return; 45 | 46 | const idx = this.filenames.indexOf(filename); 47 | 48 | if (idx !== -1) { 49 | this.filenames.splice(idx, 1); 50 | if (this.focusedIndex > idx) { 51 | this.focusedIndex -= 1; 52 | } else if (this.focusedIndex === idx) { 53 | if (this.focusedIndex >= this.filenames.length) { 54 | this.focusedIndex = this.filenames.length - 1; 55 | } else { 56 | // force reload 57 | this.focusedIndex += 1; 58 | this.focusedIndex -= 1; 59 | } 60 | } 61 | } 62 | 63 | this.ea.publish('closed-file', filename); 64 | } 65 | 66 | afterRenameFile(oldFilename, newFilename) { 67 | const idx = this.filenames.indexOf(oldFilename); 68 | 69 | if (idx !== -1) { 70 | this.filenames.splice(idx, 1, newFilename); 71 | if (this.focusedIndex === idx) { 72 | // force reload 73 | this.focusedIndex += 1; 74 | this.focusedIndex -= 1; 75 | } 76 | } 77 | } 78 | 79 | @computedFrom('filenames', 'focusedIndex', 'session.mutation') 80 | get editingFile() { 81 | if (this.focusedIndex >= 0) { 82 | const fn = this.filenames[this.focusedIndex]; 83 | if (fn) { 84 | return _.find(this.session.files, {filename: fn}); 85 | } 86 | } 87 | } 88 | 89 | _reset() { 90 | this.filenames = []; 91 | this.focusedIndex = -1; 92 | } 93 | 94 | _cleanUp() { 95 | const toRemove = []; 96 | _.each(this.filenames, (fn, i) => { 97 | if (!_.find(this.session.files, {filename: fn})) { 98 | // unshift to make sure sort from bigger to smaller index 99 | toRemove.unshift(i); 100 | } 101 | }); 102 | 103 | toRemove.forEach(i => this.filenames.splice(i, 1)); 104 | 105 | if (this.filenames.length - 1 < this.focusedIndex) { 106 | this.focusedIndex = this.filenames.length - 1; 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/src/embedded-browser/browser-bar.html: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /client/src/embedded-browser/browser-bar.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable, bindingMode} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {DialogService} from 'aurelia-dialog-lite'; 4 | import {BrowserConfigDialog} from './dialogs/browser-config-dialog'; 5 | import {EditSession} from '../edit/edit-session'; 6 | import {HistoryTracker} from '../history-tracker'; 7 | 8 | @inject(EventAggregator, DialogService, EditSession, HistoryTracker) 9 | export class BrowserBar { 10 | @bindable isBundling; 11 | @bindable insideIframe; 12 | @bindable({defaultBindingMode: bindingMode.twoWay}) autoRefresh; 13 | 14 | constructor(ea, dialogService, session, historyTracker) { 15 | this.ea = ea; 16 | this.dialogService = dialogService; 17 | this.session = session; 18 | this.historyTracker = historyTracker; 19 | } 20 | 21 | bundleOrReload() { 22 | this.ea.publish('bundle-or-reload'); 23 | } 24 | 25 | goBack() { 26 | if (!this.isBundling && this.historyTracker.canGoBack) { 27 | this.ea.publish('history-back'); 28 | } 29 | } 30 | 31 | goForward() { 32 | if (!this.isBundling && this.historyTracker.canGoForward) { 33 | this.ea.publish('history-forward'); 34 | } 35 | } 36 | 37 | config() { 38 | if (this.dialogService.hasActiveDialog) return; 39 | 40 | this.dialogService.open({ 41 | viewModel: BrowserConfigDialog, 42 | model: { 43 | config: { 44 | autoRefresh: this.autoRefresh, 45 | }, 46 | insideIframe: this.insideIframe 47 | } 48 | }).then( 49 | output => this.autoRefresh = output.autoRefresh, 50 | () => {} 51 | ); 52 | } 53 | 54 | keyDownInInput(e) { 55 | if (e.key === 'Enter') { // return key 56 | this.ea.publish('history-reload'); 57 | } 58 | 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/embedded-browser/browser-dev-tools.html: -------------------------------------------------------------------------------- 1 | 54 | -------------------------------------------------------------------------------- /client/src/embedded-browser/browser-dev-tools.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable, computedFrom} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {ConsoleLog} from './console-log'; 4 | import {EditSession} from '../edit/edit-session'; 5 | import {HistoryTracker} from '../history-tracker'; 6 | import _ from 'lodash'; 7 | 8 | @inject(EventAggregator, ConsoleLog, EditSession, HistoryTracker) 9 | export class BrowserDevTools { 10 | @bindable height; 11 | @bindable toggleDevTools; 12 | // Synchronize filters on two logs 13 | filter = ''; 14 | 15 | constructor(ea, consoleLog, session, historyTracker) { 16 | this.ea = ea; 17 | this.consoleLog = consoleLog; 18 | this.session = session; 19 | this.historyTracker = historyTracker; 20 | } 21 | 22 | activeTool = 'console'; 23 | 24 | activateTool(tool) { 25 | this.activeTool = tool; 26 | this.toggleDevTools({open: true}); 27 | } 28 | 29 | resetAppLogs() { 30 | this.consoleLog.resetAppLogs(); 31 | } 32 | 33 | resetDumberLogs() { 34 | this.consoleLog.resetDumberLogs(); 35 | } 36 | 37 | runApp() { 38 | if (!this.isRunningUnitTests) return; 39 | this.historyTracker.currentUrl = '/'; 40 | this.ea.publish('history-reload'); 41 | } 42 | 43 | runUnitTests() { 44 | if (this.isRunningUnitTests) return; 45 | this.historyTracker.currentUrl = '/run-tests.html'; 46 | this.ea.publish('history-reload'); 47 | } 48 | 49 | @computedFrom('session.mutation') 50 | get hasUnitTests() { 51 | return _.some(this.session.files, {filename: 'run-tests.html'}); 52 | } 53 | 54 | @computedFrom('historyTracker.currentUrl') 55 | get isRunningUnitTests() { 56 | return _.startsWith(this.historyTracker.currentUrl, '/run-tests.html'); 57 | } 58 | 59 | @computedFrom('consoleLog.appLogs.length') 60 | get appErrorsCount() { 61 | return _.filter(this.consoleLog.appLogs, {method: 'error'}).length; 62 | } 63 | 64 | @computedFrom('consoleLog.dumberLogs.length') 65 | get dumberErrorsCount() { 66 | return _.filter(this.consoleLog.dumberLogs, {method: 'error'}).length; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/embedded-browser/browser-frame.html: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /client/src/embedded-browser/console-log.js: -------------------------------------------------------------------------------- 1 | export class ConsoleLog { 2 | appLogs = []; 3 | dumberLogs = []; 4 | 5 | resetAppLogs() { 6 | this.appLogs.splice(0, this.appLogs.length); 7 | } 8 | 9 | resetDumberLogs() { 10 | this.dumberLogs.splice(0, this.dumberLogs.length); 11 | } 12 | } -------------------------------------------------------------------------------- /client/src/embedded-browser/dialogs/browser-config-dialog.html: -------------------------------------------------------------------------------- 1 | 36 | -------------------------------------------------------------------------------- /client/src/embedded-browser/dialogs/browser-config-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {inject} from 'aurelia-framework'; 4 | import {Helper} from '../../helper'; 5 | import {combo} from 'aurelia-combo'; 6 | 7 | @inject(DialogController, EventAggregator, Helper) 8 | export class BrowserConfigDialog { 9 | model = { 10 | autoRefresh: false 11 | }; 12 | 13 | constructor(controller, ea, helper) { 14 | this.controller = controller; 15 | this.ea = ea; 16 | this.helper = helper; 17 | } 18 | 19 | activate(model) { 20 | Object.assign(this.model, model.config); 21 | this.insideIframe = model.insideIframe; 22 | } 23 | 24 | resetCache() { 25 | if (this.insideIframe) return; 26 | 27 | this.helper.confirm(`Reset all local caches for bundler and npm registry?`) 28 | .then( 29 | () => this.ea.publish('reset-cache'), 30 | () => {} 31 | ); 32 | } 33 | 34 | @combo('enter') 35 | ok() { 36 | this.controller.ok(this.model); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/embedded-browser/log-line.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/src/embedded-browser/log-line.js: -------------------------------------------------------------------------------- 1 | import {bindable, computedFrom} from 'aurelia-framework'; 2 | 3 | export class LogLine { 4 | @bindable method; 5 | @bindable args; 6 | 7 | @computedFrom('method') 8 | get lineColor() { 9 | const m = this.method; 10 | 11 | if (m === 'error') return 'text-error-light'; 12 | if (m === 'warn') return 'text-warning'; 13 | if (m === 'info') return 'text-cyan'; 14 | if (m === 'debug') return 'text-muted'; 15 | if (m === 'system') return 'text-muted bg-dark'; 16 | return 'text-white-light'; 17 | } 18 | 19 | @computedFrom('method') 20 | get icon() { 21 | const m = this.method; 22 | 23 | if (m === 'error') return 'fa-fw fas fa-exclamation-circle'; 24 | if (m === 'warn') return 'fa-fw fas fa-exclamation-circle'; 25 | if (m === 'info') return 'fa-fw fas fa-info-circle'; 26 | if (m === 'debug') return 'fa-fw fas fa-bug'; 27 | if (m === 'system') return 'fa-fw fas fa-power-off'; 28 | return 'fa-fw fas fa-list-alt'; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/embedded-browser/logs.html: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /client/src/embedded-browser/logs.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable, computedFrom, BindingEngine, bindingMode} from 'aurelia-framework'; 2 | import _ from 'lodash'; 3 | 4 | @inject(BindingEngine) 5 | export class Logs { 6 | @bindable height; 7 | @bindable logs; 8 | @bindable resetLogs; 9 | @bindable({defaultBindingMode: bindingMode.twoWay}) filter = ''; 10 | userScrolled = false; 11 | 12 | constructor(bindingEngine) { 13 | this.bindingEngine = bindingEngine; 14 | this.scrollToBottomIfNeeded = _.debounce(this.scrollToBottomIfNeeded.bind(this), 100); 15 | this.updateUserScrolled = this.updateUserScrolled.bind(this); 16 | } 17 | 18 | attached() { 19 | this.subscribers = [ 20 | this.bindingEngine.propertyObserver(this.logs, 'length').subscribe(this.scrollToBottomIfNeeded) 21 | ]; 22 | this.el.addEventListener('scroll', this.updateUserScrolled); 23 | this.scrollToBottom(); 24 | } 25 | 26 | detached() { 27 | _.each(this.subscribers, s => s.dispose()); 28 | this.el.removeEventListener('scroll', this.updateUserScrolled); 29 | } 30 | 31 | heightChanged() { 32 | this.scrollToBottomIfNeeded(); 33 | } 34 | 35 | updateUserScrolled() { 36 | // Give 20 pixels margin. 37 | this.userScrolled = this.el.scrollTop + this.el.clientHeight < this.el.scrollHeight - 20; 38 | } 39 | 40 | scrollToBottomIfNeeded() { 41 | if (this.logs.length === 0) { 42 | // Reset 43 | this.userScrolled = false; 44 | } 45 | 46 | if (!this.userScrolled) { 47 | this.scrollToBottom(); 48 | } 49 | } 50 | 51 | scrollToBottom() { 52 | if (this.el) this.el.scrollTop = this.el.scrollHeight; 53 | } 54 | 55 | @computedFrom('filter', 'logs.length') 56 | get filteredLogs() { 57 | const {filter, logs} = this; 58 | if (filter === 'error') { 59 | return _.filter(logs, {method: 'error'}); 60 | } else if (filter === 'warning') { 61 | return _.filter(logs, {method: 'warn'}); 62 | } else if (filter === 'logs') { 63 | return _.filter(logs, l => ( 64 | l.method !== 'error' && l.method !== 'warn' 65 | )); 66 | } else { 67 | return logs; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/src/file-drop-indicator.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/file-drop-indicator.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import isUtf8 from 'is-utf8'; 4 | import _ from 'lodash'; 5 | 6 | @inject(EventAggregator) 7 | export class FileDropIndicator { 8 | duringFileDrop = false; 9 | 10 | constructor(ea) { 11 | this.ea = ea; 12 | } 13 | 14 | attached() { 15 | // no need to clean up in detached() because 16 | // it's not going to be detached. 17 | this._setupFileDrop(); 18 | } 19 | 20 | _setupFileDrop() { 21 | let toFinish; 22 | const cancelFinish = () => { 23 | if (toFinish) { 24 | clearTimeout(toFinish); 25 | toFinish = null; 26 | } 27 | }; 28 | 29 | const finish = () => { 30 | cancelFinish(); 31 | toFinish = setTimeout(() => this.duringFileDrop = false, 50); 32 | }; 33 | 34 | const scanFiles = item => { 35 | if (item.isDirectory) { 36 | const reader = item.createReader(); 37 | reader.readEntries(entries => { 38 | entries.forEach(entry => { 39 | scanFiles(entry); 40 | }) 41 | }); 42 | } else if (item.isFile) { 43 | item.file(file => { 44 | const filename = _.trim(item.fullPath, '/'); 45 | const reader = new FileReader(); 46 | reader.onload = e => { 47 | const content = e.target.result; 48 | if (isUtf8(Buffer.from(content))) { 49 | // Create but not open it in editor 50 | this.ea.publish('import-file', {filename, content}); 51 | } else { 52 | this.ea.publish('error', `Cannot import binary file "${filename}" because gist only supports text file.`); 53 | } 54 | }; 55 | reader.onerror = err => { 56 | this.ea.publish('error', `Failed to import "${filename}" : ${err.message}`); 57 | }; 58 | reader.readAsText(file); 59 | }); 60 | } 61 | }; 62 | 63 | document.addEventListener('dragenter', e => { 64 | e.stopPropagation(); 65 | e.preventDefault(); 66 | cancelFinish(); 67 | this.duringFileDrop = true; 68 | }); 69 | 70 | document.addEventListener('dragover', e => { 71 | e.stopPropagation(); 72 | e.preventDefault(); 73 | finish(); 74 | }); 75 | 76 | document.addEventListener('drop', e => { 77 | e.stopPropagation(); 78 | e.preventDefault(); 79 | finish(); 80 | const {items} = e.dataTransfer; 81 | for (let i = 0; i < items.length; i++) { 82 | const item = items[i].webkitGetAsEntry(); 83 | scanFiles(item); 84 | } 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /client/src/github/access-token.js: -------------------------------------------------------------------------------- 1 | import {inject, computedFrom} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import _ from 'lodash'; 4 | const storageKey = 'github-oauth-token'; 5 | 6 | @inject(EventAggregator) 7 | export class AccessToken { 8 | _token = null; 9 | 10 | constructor(ea) { 11 | this.ea = ea; 12 | this.init(); 13 | } 14 | 15 | async init() { 16 | return new Promise(resolve => { 17 | // delay init so others can listen to update-token event. 18 | setTimeout(() => { 19 | try { 20 | const json = localStorage.getItem(storageKey); 21 | if (json) { 22 | this._token = JSON.parse(json); 23 | this.ea.publish('update-token', this._token); 24 | } 25 | } catch (e) { 26 | // ignore 27 | // localStorage could be unavailable in iframe. 28 | } 29 | resolve(); 30 | }); 31 | }); 32 | } 33 | 34 | @computedFrom('_token') 35 | get value() { 36 | return this._token ? this._token.access_token : null; 37 | } 38 | 39 | @computedFrom('_token') 40 | get scope() { 41 | return this._token ? this._token.scope : null; 42 | } 43 | 44 | setToken(token) { 45 | if (_.isEqual(token, this._token)) return; 46 | this._token = token; 47 | this.ea.publish('update-token', this._token); 48 | try { 49 | if (token) { 50 | localStorage.setItem(storageKey, JSON.stringify(token)); 51 | } else { 52 | localStorage.removeItem(storageKey) 53 | } 54 | } catch (e) { 55 | // ignore 56 | // localStorage could be unavailable in iframe. 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/src/github/api-client.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {AccessToken} from './access-token'; 3 | import {RateLimit} from './rate-limit'; 4 | 5 | const base = 'https://api.github.com'; 6 | 7 | @inject(AccessToken, RateLimit) 8 | export class ApiClient { 9 | constructor(accessToken, rateLimit) { 10 | this.accessToken = accessToken; 11 | this.rateLimit = rateLimit; 12 | } 13 | 14 | fetch(path, init) { 15 | let url = `${base}/${path}`; 16 | init = init || {}; 17 | init.headers = init.headers || {}; 18 | if (this.accessToken.value) { 19 | init.headers.Authorization = `token ${this.accessToken.value}`; 20 | } 21 | return fetch(url, init) 22 | .then(response => { 23 | this.rateLimit.readHeaders(response); 24 | return response; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/github/oauth.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import queryString from 'querystring'; 4 | import {AccessToken} from './access-token'; 5 | import {SessionId} from '../session-id'; 6 | import {PersistSession} from './persist-session'; 7 | import {Helper} from '../helper'; 8 | 9 | // github oauth client id/secret 10 | export const client_id = 11 | process.env.NODE_ENV === 'production' ? 12 | '366fabacbad89519ff19' : 13 | 'a505c051c5291a3f3618'; 14 | const oauthUri = 'https://github.com/login/oauth/authorize'; 15 | const tokenUri = `${HOST_NAMES.oauthUrl}/access_token`; 16 | 17 | @inject(EventAggregator, AccessToken, SessionId, PersistSession, Helper) 18 | export class Oauth { 19 | initialised = false; 20 | 21 | constructor(ea, accessToken, sessionId, persistSession, helper) { 22 | this.ea = ea; 23 | this.accessToken = accessToken; 24 | this.sessionId = sessionId; 25 | this.persistSession = persistSession; 26 | this.helper = helper; 27 | } 28 | 29 | async login() { 30 | this.helper.waitFor( 31 | 'Signing to GitHub ...', 32 | new Promise((resolve, reject) => { 33 | this._login().then( 34 | () => { 35 | // Wait for the re-route. 36 | setTimeout(resolve, 5000); 37 | }, 38 | reject 39 | ); 40 | }), 41 | { delay: 0 } 42 | ) 43 | .then(() => {}, () => {}); 44 | } 45 | 46 | async _login() { 47 | // Save session data to be restored after login 48 | await this.persistSession.saveSession(); 49 | 50 | const args = { 51 | client_id, 52 | redirect_uri: `${HOST_NAMES.clientUrl}?sessionId=${this.sessionId.id}`, 53 | scope: 'gist', 54 | state: this.sessionId.id 55 | }; 56 | 57 | const url = `${oauthUri}?${queryString.stringify(args)}`; 58 | 59 | // Avoid window.open by using current window for GitHub login. 60 | window.location = url; 61 | } 62 | 63 | logout() { 64 | this.accessToken.setToken(null); 65 | this.ea.publish('info', 'Signed out from GitHub'); 66 | } 67 | 68 | async init(code) { 69 | if (code) await this.exchangeAccessToken(code); 70 | this.initialised = true; 71 | } 72 | 73 | async exchangeAccessToken(code) { 74 | let args = { 75 | redirect_uri: `${HOST_NAMES.clientUrl}?sessionId=${this.sessionId.id}`, 76 | code, 77 | state: this.sessionId.id 78 | }; 79 | 80 | return fetch(tokenUri, { 81 | mode: 'cors', 82 | method: 'POST', 83 | body: JSON.stringify(args), 84 | headers: { 85 | 'Content-Type': 'application/json' 86 | } 87 | }) 88 | .then(response => response.text()) 89 | .then(body => queryString.parse(body)) 90 | .then(token => this.accessToken.setToken(token)) 91 | .catch(async err => { 92 | this.accessToken.setToken(null); 93 | throw err; 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/src/github/persist-session.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {SessionId} from '../session-id'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {InitParams} from '../init-params'; 5 | import _ from 'lodash'; 6 | 7 | const KEY = 'dumber-gist-session:'; 8 | 9 | // Save session data before user attempt login, 10 | // restore them after logged in (or cancelled login). 11 | @inject(SessionId, InitParams, EditSession) 12 | export class PersistSession { 13 | constructor(sessionId, params, editSession) { 14 | this.id = sessionId.id; 15 | this.previousId = params.sessionId; 16 | this.editSession = editSession; 17 | } 18 | 19 | _sessionData() { 20 | return { 21 | description: this.editSession.description, 22 | files: _.map(this.editSession.files, f => ({ 23 | filename: f.filename, 24 | content: f.content, 25 | isChanged: !!f.isChanged 26 | })), 27 | gist: this.editSession.gist 28 | }; 29 | } 30 | 31 | tryRestoreSession() { 32 | try { 33 | let data = localStorage.getItem(KEY); 34 | if (!data) return; 35 | localStorage.removeItem(KEY); 36 | data = JSON.parse(data); 37 | 38 | const sessionData = data[this.previousId]; 39 | if (!sessionData) return; 40 | 41 | this.editSession.importData(sessionData); 42 | } catch (e) { 43 | // localStorage could be unavailable in iframe 44 | console.warn(e); 45 | } 46 | } 47 | 48 | saveSession() { 49 | try { 50 | localStorage.setItem(KEY, JSON.stringify({ 51 | [this.id]: this._sessionData() 52 | })); 53 | } catch (e) { 54 | // localStorage could be unavailable in iframe 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/github/rate-limit.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import moment from 'moment'; 4 | 5 | @inject(EventAggregator) 6 | export class RateLimit { 7 | limit = 99999; 8 | remaining = 99999; 9 | reset = null; 10 | 11 | constructor(ea) { 12 | this.ea = ea; 13 | } 14 | 15 | readHeaders(response) { 16 | this.limit = parseInt(response.headers.get('X-RateLimit-Limit'), 10), 17 | this.remaining = parseInt(response.headers.get('X-RateLimit-Remaining'), 10), 18 | this.reset = new Date(parseInt(response.headers.get('X-RateLimit-Reset'), 10) * 1000); 19 | 20 | const resetIn = moment(this.reset).fromNow(); 21 | 22 | if (this.remaining === 0) { 23 | this.ea.publish('error', { 24 | title: 'GitHub API rate limit', 25 | message: `You have reached GitHub API rate limit, it will reset ${resetIn}. Read more at https://developer.github.com/v3/#rate-limiting` 26 | }); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/src/github/user.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {AccessToken} from './access-token'; 4 | import {ApiClient} from './api-client' 5 | 6 | @inject(AccessToken, ApiClient, EventAggregator) 7 | export class User { 8 | loading = false; 9 | authenticated = false; 10 | login = null; 11 | avatar_url = null; 12 | 13 | constructor(accessToken, api, ea) { 14 | this.accessToken = accessToken; 15 | this.api = api; 16 | 17 | this.load = this.load.bind(this); 18 | this._subscriber = ea.subscribe('update-token', this.load); 19 | } 20 | 21 | async setAnonymous() { 22 | this.authenticated = false; 23 | this.login = null; 24 | this.avatar_url = null; 25 | this.accessToken.setToken(null); 26 | } 27 | 28 | async load() { 29 | if (this.accessToken.value) { 30 | this.loading = true; 31 | 32 | try { 33 | const response = await this.api.fetch('user') 34 | const user = response.ok ? 35 | await response.json() : 36 | null; 37 | if (user) { 38 | this.authenticated = true; 39 | this.login = user.login; 40 | this.avatar_url = user.avatar_url; 41 | } else { 42 | await this.setAnonymous(); 43 | } 44 | } catch(e) { 45 | // ignore 46 | } 47 | 48 | this.loading = false; 49 | } else { 50 | await this.setAnonymous(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/helper.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {DialogService} from 'aurelia-dialog-lite'; 3 | import {EventAggregator} from 'aurelia-event-aggregator'; 4 | import {ConfirmationDialog} from './dialogs/confirmation-dialog'; 5 | import {WaitingDialog} from './dialogs/waiting-dialog'; 6 | 7 | @inject(DialogService, EventAggregator) 8 | export class Helper { 9 | constructor(dialogService, ea) { 10 | this.dialogService = dialogService; 11 | this.ea = ea; 12 | } 13 | 14 | confirm(question, opts = {}) { 15 | return this.dialogService.open({ 16 | viewModel: ConfirmationDialog, 17 | model: {question, ...opts} 18 | }); 19 | } 20 | 21 | waitFor(title, promise, opts = {}) { 22 | return this.dialogService.create({ 23 | viewModel: WaitingDialog, 24 | model: {...opts, title} 25 | }).then(controller => { 26 | // Close the waiting dialog, 27 | // and return original promise. 28 | promise.then(() => controller.ok(), () => controller.ok()); 29 | return promise; 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/history-tracker.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | 4 | @inject(EventAggregator) 5 | export class HistoryTracker { 6 | constructor(ea) { 7 | this.resetUrl = this.resetUrl.bind(this); 8 | this.reset = this.reset.bind(this); 9 | 10 | ea.subscribe('loaded-gist', this.resetUrl); 11 | ea.subscribe('imported-data', this.resetUrl); 12 | this.resetUrl(); 13 | this.reset(); 14 | } 15 | 16 | reset() { 17 | this.stack = [{title: '', url: this.currentUrl}]; 18 | this.currentIndex = 0; 19 | this._update(); 20 | } 21 | 22 | resetUrl() { 23 | this.currentUrl = '/'; 24 | } 25 | 26 | pushState(title, url) { 27 | this.stack.splice(++this.currentIndex, Infinity, {title, url}); 28 | this._updateAll(); 29 | } 30 | 31 | replaceState(title, url) { 32 | if (this.currentIndex > -1) { 33 | this.stack.splice(this.currentIndex, 1, {title, url}); 34 | this._updateAll(); 35 | } else { 36 | this.pushState(title, url); 37 | } 38 | } 39 | 40 | go(delta) { 41 | let nextIndex = this.currentIndex + delta; 42 | if (nextIndex < 0) nextIndex = 0; 43 | if (nextIndex >= this.stack.length) { 44 | nextIndex = this.stack.length - 1; 45 | } 46 | this.currentIndex = nextIndex; 47 | this._updateAll(); 48 | } 49 | 50 | _updateAll() { 51 | this.currentUrl = this.currentIndex > -1 ? 52 | this.stack[this.currentIndex].url : '/'; 53 | 54 | this._update(); 55 | } 56 | 57 | _update() { 58 | this.canGoForward = this.stack.length > 1 && 59 | this.stack.length - 1 > this.currentIndex; 60 | 61 | this.canGoBack = this.currentIndex > 0; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/init-params.js: -------------------------------------------------------------------------------- 1 | import queryString from 'querystring'; 2 | 3 | export class InitParams { 4 | constructor() { 5 | const params = queryString.parse(location.search.slice(1)); 6 | Object.assign(this, params); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | export function configure(aurelia) { 2 | aurelia.use.feature('resources'); 3 | aurelia.use.standardConfiguration(); 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | aurelia.use.developmentLogging('warn'); 7 | } else { 8 | aurelia.use.developmentLogging('info'); 9 | } 10 | 11 | aurelia.use.plugin('aurelia-dialog-lite', { 12 | escDismiss: true, 13 | overlayDismiss: true 14 | }); 15 | aurelia.use.plugin('aurelia-combo'); 16 | aurelia.use.plugin('bcx-aurelia-reorderable-repeat'); 17 | 18 | aurelia.start().then(() => aurelia.setRoot()); 19 | } 20 | -------------------------------------------------------------------------------- /client/src/navigation/dialogs/select-skeleton-dialog.html: -------------------------------------------------------------------------------- 1 | 86 | -------------------------------------------------------------------------------- /client/src/navigation/file-navigator.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /client/src/navigation/file-navigator.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable, computedFrom} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {FileTree} from '../edit/file-tree'; 5 | import {DndService} from 'bcx-aurelia-dnd'; 6 | import _ from 'lodash'; 7 | 8 | @inject(EventAggregator, DndService, EditSession, FileTree) 9 | export class FileNavigator { 10 | @bindable insideIframe; 11 | collapseFlags = {}; 12 | 13 | constructor(ea, dndService, session, fileTree) { 14 | this.ea = ea; 15 | this.dndService = dndService; 16 | this.session = session; 17 | this.fileTree = fileTree; 18 | } 19 | 20 | attached() { 21 | this.dndService.addTarget(this); 22 | } 23 | 24 | detached() { 25 | this.dndService.removeTarget(this); 26 | } 27 | 28 | dndCanDrop(model) { 29 | return model.type === 'move-file'; 30 | } 31 | 32 | dndDrop() { 33 | const {node} = this.dnd.model; 34 | this.ea.publish('update-path', { 35 | oldFilePath: node.filePath, 36 | newFilePath: node.name 37 | }); 38 | } 39 | 40 | openAny() { 41 | this.ea.publish('open-any'); 42 | } 43 | 44 | createFile() { 45 | this.ea.publish('create-file'); 46 | } 47 | 48 | @computedFrom('dnd', 'dnd.isProcessing', 'dnd.canDrop', 'dnd.isHoveringShallowly') 49 | get dndClass() { 50 | const {dnd} = this; 51 | if (!dnd || !dnd.isProcessing) return ''; 52 | if (!dnd.canDrop || !dnd.isHoveringShallowly) return ''; 53 | return 'can-drop'; 54 | } 55 | 56 | @computedFrom('session.gist') 57 | get isNew() { 58 | return !_.get(this.session, 'gist.id'); 59 | } 60 | 61 | @computedFrom('session.gist') 62 | get isPrivate() { 63 | return !_.get(this.session, 'gist.public'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/navigation/file-node.html: -------------------------------------------------------------------------------- 1 | 39 | -------------------------------------------------------------------------------- /client/src/navigation/gist-info.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /client/src/navigation/gist-info.js: -------------------------------------------------------------------------------- 1 | import {EventAggregator} from 'aurelia-event-aggregator'; 2 | import {inject, computedFrom, BindingEngine} from 'aurelia-framework'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {User} from '../github/user'; 5 | import _ from 'lodash'; 6 | 7 | @inject(EventAggregator, BindingEngine, EditSession, User) 8 | export class GistInfo { 9 | constructor(ea, bindingEngine, session, user) { 10 | this.ea = ea; 11 | this.bindingEngine = bindingEngine; 12 | this.session = session; 13 | this.user = user; 14 | this.fitSize = this.fitSize.bind(this); 15 | } 16 | 17 | attached() { 18 | this.subscribers = [ 19 | this.bindingEngine.propertyObserver(this.session, 'description').subscribe(this.fitSize) 20 | ]; 21 | this.fitSize(); 22 | } 23 | 24 | detached() { 25 | _.each(this.subscribers, s => s.dispose()); 26 | } 27 | 28 | listGists() { 29 | const {owner} = this; 30 | if (!owner) return; 31 | this.ea.publish('list-gists', owner.login); 32 | } 33 | 34 | @computedFrom('session.gist') 35 | get owner() { 36 | return _.get(this.session, 'gist.owner'); 37 | } 38 | 39 | @computedFrom('session.gist', 'user.login') 40 | get ownedByMe() { 41 | return _.get(this.session, 'gist.owner.login') === _.get(this.user, 'login'); 42 | } 43 | 44 | // https://stephanwagner.me/auto-resizing-textarea-with-vanilla-javascript 45 | // We don't need `element.offsetHeight - element.clientHeight` because 46 | // our textarea has no border. 47 | fitSize() { 48 | if (!this.textarea) return; 49 | // Auto fit text area height to content size 50 | this.textarea.style.height = 'auto'; 51 | this.textarea.style.height = this.textarea.scrollHeight + 'px'; 52 | } 53 | 54 | keyDownInDescription(e) { 55 | if (e.key === 'Enter') { 56 | // Prevent enter key's default behavior: 57 | // creating new line 58 | return false; 59 | } 60 | 61 | return true; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/navigation/quick-start.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /client/src/navigation/quick-start.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {DialogService} from 'aurelia-dialog-lite'; 3 | import {EditSession} from '../edit/edit-session'; 4 | import {SelectSkeletonDialog} from './dialogs/select-skeleton-dialog'; 5 | import {SkeletonGenerator} from '../skeletons/skeleton-generator'; 6 | 7 | @inject(DialogService, EditSession, SkeletonGenerator) 8 | export class QuickStart { 9 | constructor(dialogService, session, skeletonGenerator) { 10 | this.dialogService = dialogService; 11 | this.session = session; 12 | this.skeletonGenerator = skeletonGenerator; 13 | } 14 | 15 | start() { 16 | if (this.dialogService.hasActiveDialog) return; 17 | 18 | if (this.session.files.length > 0) return; 19 | this.dialogService.open({ 20 | viewModel: SelectSkeletonDialog 21 | }).then( 22 | selection => this.skeletonGenerator.generate(selection), 23 | () => {} 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/navigation/quick-start.scss: -------------------------------------------------------------------------------- 1 | @import "../variables"; 2 | 3 | .quick-start { 4 | margin: .5rem; 5 | color: $gray-300; 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/notifications.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /client/src/notifications.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import _ from 'lodash'; 4 | 5 | const TIME_TO_STAY = 3000; 6 | 7 | const eventMap = { 8 | error: 'danger', 9 | 'info-clean': 'clean', 10 | 'info-dark': 'dark' 11 | }; 12 | 13 | @inject(EventAggregator) 14 | export class Notifications { 15 | notifications = []; 16 | removers = {}; 17 | _nextId = 0; 18 | 19 | constructor(ea) { 20 | this.ea = ea; 21 | this.handleNoti = this.handleNoti.bind(this); 22 | } 23 | 24 | attached() { 25 | const {handleNoti} = this; 26 | this.subscribers = [ 27 | this.ea.subscribe('success', handleNoti), 28 | this.ea.subscribe('info', handleNoti), 29 | this.ea.subscribe('error', handleNoti), 30 | this.ea.subscribe('warning', handleNoti), 31 | this.ea.subscribe('info-clean', handleNoti), 32 | this.ea.subscribe('info-dark', handleNoti), 33 | ]; 34 | } 35 | 36 | detached() { 37 | _.each(this.subscribers, s => s.dispose()); 38 | } 39 | 40 | nextId() { 41 | this._nextId += 1; 42 | return this._nextId; 43 | } 44 | 45 | handleNoti(message, event) { 46 | const style = eventMap[event] || event; 47 | if (typeof message === 'string') { 48 | this.addNoti(style, message); 49 | } else if (typeof message === 'object') { 50 | this.addNoti(style, message.title, message.message); 51 | } 52 | } 53 | 54 | addNoti(style, title, message) { 55 | if (!message) { 56 | message = title; 57 | title = ''; 58 | } 59 | 60 | if (style === 'warning' || style === 'danger') { 61 | let existing = _.find(this.notifications, {style, title, message}); 62 | 63 | if (existing) { 64 | // don't show same warning/error again. 65 | existing.count = (existing.count || 1) + 1; 66 | 67 | if (style !== 'danger') { 68 | // reset remover 69 | this.stopFutureRemove(existing.id); 70 | this.setFutureRemove(existing.id); 71 | } 72 | return; 73 | } 74 | } 75 | 76 | let icon = 'fas fa-info-circle'; 77 | if (style === 'success') icon = 'far fa-check'; 78 | else if (style === 'warning') icon = 'fas fa-exclamation-circle'; 79 | else if (style === 'danger') icon = 'fas fa-exclamation-triangle'; 80 | 81 | const id = this.nextId(); 82 | this.notifications.push({id, style, icon, title, message}); 83 | 84 | if (style !== 'danger') { 85 | this.setFutureRemove(id); 86 | } 87 | } 88 | 89 | removeNotification(id) { 90 | const idx = _.findIndex(this.notifications, {id}); 91 | if (idx >= 0) { 92 | this.notifications.splice(idx, 1); 93 | delete this.removers[id]; 94 | } 95 | } 96 | 97 | stopFutureRemove(id) { 98 | if (this.removers[id]) { 99 | clearTimeout(this.removers[id]); 100 | delete this.removers[id]; 101 | } 102 | } 103 | 104 | setFutureRemove(id) { 105 | if (this.removers[id]) return; 106 | this.removers[id] = setTimeout(() => { 107 | this.removeNotification(id); 108 | }, TIME_TO_STAY); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /client/src/notifications.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | @keyframes notifadein { 4 | 0% { 5 | opacity: 0; 6 | max-height: 0; 7 | } 8 | 9 | 100% { 10 | opacity: 1; 11 | max-height: 300px; 12 | } 13 | } 14 | 15 | @keyframes notifadeout { 16 | 0% { 17 | opacity: 1; 18 | max-height: 300px; 19 | } 20 | 21 | 100% { 22 | opacity: 0; 23 | max-height: 0; 24 | } 25 | } 26 | 27 | notifications { 28 | display: block; 29 | position: fixed; 30 | bottom: 20px; 31 | left: 20px; 32 | width: 320px; 33 | z-index: 9999; 34 | } 35 | 36 | @media (max-width: $tiny) { 37 | notifications { 38 | bottom: 5px; 39 | right: 5px; 40 | left: 5px; 41 | width: auto; 42 | } 43 | } 44 | 45 | .notification-wrapper { 46 | position: relative; 47 | max-height: 310px; 48 | 49 | &.au-enter { 50 | opacity: 0; 51 | max-height: 0; 52 | } 53 | 54 | &.au-enter-active { 55 | animation: notifadein 0.2s; 56 | } 57 | 58 | &.au-leave-active { 59 | animation: notifadeout 0.2s; 60 | } 61 | } 62 | 63 | .notification { 64 | margin: .3rem; 65 | border-radius: 3px; 66 | 67 | position: relative; 68 | background-color: $charcoal; 69 | box-shadow: 0 0 .5rem $dark-shadow; 70 | font-weight: 500; 71 | color: $white; 72 | 73 | &.clean, &.clean .notification-count.badge { 74 | background-color: $white; 75 | color: $charcoal; 76 | } 77 | 78 | &.success, &.success .notification-count.badge { 79 | background-color: $green; 80 | } 81 | 82 | &.info, &.info .notification-count.badge { 83 | background-color: $blue; 84 | } 85 | 86 | &.warning, &.warning .notification-count.badge { 87 | background-color: #ff9900; 88 | } 89 | 90 | &.danger, &.danger .notification-count.badge { 91 | background-color: $red; 92 | } 93 | 94 | &.dark, &.dark .notification-count.badge { 95 | background-color: #23262b; 96 | } 97 | 98 | &:hover .close-button { 99 | display: block; 100 | } 101 | 102 | .close-button { 103 | display: none; 104 | position: absolute; 105 | cursor: pointer; 106 | top: .2rem; 107 | right: .5rem; 108 | font-size: 1.8rem; 109 | line-height: 1; 110 | 111 | &::after { 112 | content: '×'; 113 | } 114 | } 115 | 116 | .notification-title { 117 | padding: .5rem 1.5rem 0 .8rem; 118 | font-size: 1.2rem; 119 | line-height: 1.8rem; 120 | } 121 | 122 | .notification-message { 123 | overflow-x: hidden; 124 | overflow-y: auto; 125 | max-height: 200px; 126 | padding: .5rem 1.5rem .5rem .8rem; 127 | margin: 0; 128 | word-wrap: break-word; 129 | font-size: 1rem; 130 | line-height: 1.4rem; 131 | } 132 | 133 | .notification-title + .notification-message { 134 | padding-top: 0; 135 | } 136 | 137 | .notification-count.badge { 138 | display: inline-block; 139 | position: absolute; 140 | color: $white; 141 | top: 0; 142 | left: 0; 143 | margin-top: -1rem; 144 | margin-left: -.5rem; 145 | box-shadow: 0 0 0 2px $gray-200; 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /client/src/panel-resizer.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/panel-resizer.js: -------------------------------------------------------------------------------- 1 | import {inject, bindable} from 'aurelia-framework'; 2 | import {DndService} from 'bcx-aurelia-dnd'; 3 | 4 | @inject(DndService, bindable) 5 | export class PanelResizer { 6 | @bindable panel = ''; 7 | 8 | constructor(dndService) { 9 | this.dndService = dndService; 10 | } 11 | 12 | attached() { 13 | this.dndService.addSource(this, {noPreview: true}); 14 | } 15 | 16 | detached() { 17 | this.dndService.removeSource(this); 18 | } 19 | 20 | dndModel() { 21 | return {type: 'resize-panel', panel: this.panel}; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/remove-expired-session.js: -------------------------------------------------------------------------------- 1 | // Remove expired service worker on used random session id. 2 | // The expired ids were saved by session-id callback on 'unload' event. 3 | export class RemoveExpiredSession { 4 | constructor() { 5 | this.removeExpired = this.removeExpired.bind(this); 6 | this.running = false; 7 | } 8 | 9 | start() { 10 | setInterval(this.removeExpired, 5 * 1000); 11 | } 12 | 13 | async removeExpired() { 14 | if (this.running) return; 15 | this.running = true; 16 | 17 | try { 18 | let i = 0; 19 | let key; 20 | while ((key = localStorage.key(i)) !== null) { 21 | if (key.startsWith('expired:')) { 22 | const expired = key.slice(8); 23 | await this._removeServiceWorker(expired); 24 | localStorage.removeItem(key); 25 | } 26 | i += 1; 27 | } 28 | } catch (e) { 29 | // localStorage or service worker could be unavailable in iframe 30 | } 31 | 32 | this.running = false; 33 | } 34 | 35 | async _removeServiceWorker(expiredId) { 36 | // An invisible iframe to clean up expired session. 37 | const iframe = document.createElement('iframe'); 38 | iframe.setAttribute('src', `https://${expiredId}.${HOST_NAMES.host}/__remove-expired-worker.html`); 39 | iframe.setAttribute('style', 'display: none'); 40 | document.body.appendChild(iframe); 41 | 42 | return new Promise((resolve, reject) => { 43 | const handleMessage = e => { 44 | if (!e.data) return; 45 | // Use '_type' instead of 'type' to avoid cross-talk 46 | // with worker-service. 47 | const {_type, result} = e.data; 48 | if (_type === 'worker-removed') { 49 | if (result) { 50 | console.info(`Removed an expired previously used service worker on ${expiredId}.${HOST_NAMES.host}`); 51 | } 52 | resolve(); 53 | } else { 54 | reject(new Error(result)); 55 | } 56 | 57 | removeEventListener('message', handleMessage); 58 | iframe.remove(); 59 | }; 60 | 61 | addEventListener('message', handleMessage); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /client/src/resources/dialog-close.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/resources/dialog-close.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {DialogController} from 'aurelia-dialog-lite'; 3 | 4 | @inject(DialogController) 5 | export class DialogClose { 6 | constructor(controller) { 7 | this.controller = controller; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/resources/file-icon.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export class FileIconValueConverter { 4 | toView(value) { 5 | if (!value) return 'fa-fw fas fa-file'; 6 | const ext = path.extname(value); 7 | if (ext === '.html') return 'fa-fw fab fa-html5 text-error'; 8 | if (ext === '.js' || ext === '.ts') return 'fa-fw fab fa-js-square text-warning'; 9 | if (ext === '.jsx' || ext === '.tsx') return 'fa-fw fab fa-react text-warning'; 10 | if (ext === '.json' || ext === '.json5') return 'fa-fw fas fa-file-code text-warning'; 11 | if (ext === '.css' || ext === '.scss' || ext === '.less') return 'fa-fw fab fa-css3 text-success'; 12 | if (ext === '.svg' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.gif' || ext === '.ico') return 'fa-fw fas fa-image text-success'; 13 | if (ext === '.svelte') return 'fa-fw fab fa-stripe-s text-error'; 14 | 15 | return 'fas fa-file text-primary'; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/resources/hidden-submit.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /client/src/resources/hidden-submit.scss: -------------------------------------------------------------------------------- 1 | .hidden-submit { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | visibility: hidden; 6 | width: 1px; 7 | height: 1px; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/resources/index.js: -------------------------------------------------------------------------------- 1 | export function configure(config) { 2 | config.globalResources([ 3 | './left-click', 4 | './file-icon', 5 | './hidden-submit.html', 6 | './show-local-date', 7 | './dialog-close' 8 | ]); 9 | } 10 | -------------------------------------------------------------------------------- /client/src/resources/left-click.js: -------------------------------------------------------------------------------- 1 | // handle primary button click or a touch 2 | function handleLeftClickEvent(event) { 3 | // mouse event primary button is 0, 4 | // touch event button is undefined. 5 | if (event.button) return; 6 | // only call real callback when it is either left mouse click or touch event. 7 | this.leftClickEventCallSource(event); 8 | } 9 | 10 | export class LeftClickBindingBehavior { 11 | bind(binding) { 12 | if (!binding.callSource || !binding.targetEvent) throw new Error('leftClick binding behavior only supports event.'); 13 | binding.leftClickEventCallSource = binding.callSource; 14 | binding.callSource = handleLeftClickEvent; 15 | } 16 | 17 | unbind(binding) { 18 | binding.callSource = binding.leftClickEventCallSource; 19 | binding.leftClickEventCallSource = null; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/resources/show-local-date.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export class ShowLocalDateValueConverter { 4 | toView(value) { 5 | return moment(value).format('YYYY/MM/DD hh:mm:ss a'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/session-id.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export class SessionId { 4 | constructor() { 5 | this.id = this._generateId(); 6 | this.expireWhenExit(); 7 | } 8 | 9 | expireWhenExit() { 10 | if (process.NODE_ENV === 'test' || !process.browser) return; 11 | window.addEventListener('unload', () => { 12 | try { 13 | localStorage.setItem('expired:' + this.id, (new Date()).toString()); 14 | } catch (e) { 15 | // ignore 16 | // localStorage could be unavailable in iframe 17 | } 18 | }); 19 | } 20 | 21 | // id is the unique identifier for every dumber-gist instance. 22 | // Then worker and app are behind https://${id}.gist.dumber.app. 23 | _generateId() { 24 | // Random id (32 chars) for every dumber-gist instance to avoid 25 | // cross talk. 26 | return crypto.randomBytes(16).toString('hex'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/skeletons/backbone.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const main = `import App from './app'; 22 | const app = new App({el: '#root'}); 23 | app.render(); 24 | `; 25 | 26 | const app = `import * as Backbone from 'backbone'; 27 | import _ from 'underscore'; 28 | 29 | export default Backbone.View.extend({ 30 | messageTemplate: _.template("

<%- message %>

"), 31 | render: function() { 32 | this.$el.html( 33 | this.messageTemplate({message: 'Hello Backbone!'}) 34 | ); 35 | } 36 | }); 37 | `; 38 | 39 | const jasmineTest = `import App from '../src/app'; 40 | 41 | describe('Component App', () => { 42 | it('should render message', () => { 43 | const div = document.createElement('div'); 44 | const app = new App({el: div}); 45 | app.render(); 46 | expect(div.textContent).toEqual('Hello Backbone!'); 47 | }); 48 | }); 49 | `; 50 | 51 | const mochaTest = `import {expect} from 'chai'; 52 | import App from '../src/app'; 53 | 54 | describe('Component App', () => { 55 | it('should render message', () => { 56 | const div = document.createElement('div'); 57 | const app = new App({el: div}); 58 | app.render(); 59 | expect(div.textContent).to.equal('Hello Backbone!'); 60 | }); 61 | }); 62 | `; 63 | 64 | export default function({transpiler, testFramework}) { 65 | const ext = transpiler === 'typescript' ? '.ts' : '.js'; 66 | const files = [ 67 | { 68 | filename: 'package.json', 69 | dependencies: {'backbone': 'latest'} 70 | }, 71 | { 72 | filename: 'index.html', 73 | content: indexHtml(ext) 74 | }, 75 | { 76 | filename: `src/main${ext}`, 77 | content: main 78 | }, 79 | { 80 | filename: `src/app${ext}`, 81 | content: app 82 | } 83 | ]; 84 | 85 | if (testFramework === 'jasmine') { 86 | files.push({ 87 | filename: `test/app.spec${ext}`, 88 | content: jasmineTest 89 | }); 90 | } else if (testFramework === 'mocha') { 91 | files.push({ 92 | filename: `test/app.spec${ext}`, 93 | content: mochaTest 94 | }); 95 | } 96 | 97 | return files; 98 | } 99 | -------------------------------------------------------------------------------- /client/src/skeletons/inferno.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const index = `import { render } from 'inferno'; 22 | import App from './App'; 23 | 24 | render(, document.getElementById('root')); 25 | `; 26 | 27 | const app = `import { Component } from 'inferno'; 28 | // Try to create a css/scss/sass/less file then import it here. 29 | 30 | export default class App extends Component { 31 | render() { 32 | return ( 33 |
34 |

Hello Inferno!

35 |
36 | ); 37 | } 38 | } 39 | `; 40 | 41 | const jasmineTest = `import { render } from 'inferno'; 42 | import App from '../src/App'; 43 | 44 | describe('Component App', () => { 45 | it('should render message', () => { 46 | const div = document.createElement('div'); 47 | render(, div); 48 | expect(div.textContent).toEqual('Hello Inferno!'); 49 | }); 50 | }); 51 | `; 52 | 53 | const mochaTest = `import { render } from 'inferno'; 54 | import {expect} from 'chai'; 55 | import App from '../src/App'; 56 | 57 | describe('Component App', () => { 58 | it('should render message', () => { 59 | const div = document.createElement('div'); 60 | render(, div); 61 | expect(div.textContent).to.equal('Hello Inferno!'); 62 | }); 63 | }); 64 | `; 65 | 66 | export default function({transpiler, testFramework}) { 67 | const ext = transpiler === 'typescript' ? '.tsx' : '.jsx'; 68 | const files = [ 69 | { 70 | filename: 'package.json', 71 | dependencies: {'inferno': 'latest'} 72 | }, 73 | { 74 | filename: 'index.html', 75 | content: indexHtml(ext) 76 | }, 77 | { 78 | filename: `src/index${ext}`, 79 | content: index 80 | }, 81 | { 82 | filename: `src/App${ext}`, 83 | content: app 84 | } 85 | ]; 86 | 87 | if (testFramework === 'jasmine') { 88 | files.push({ 89 | filename: `test/app.spec${ext}`, 90 | content: jasmineTest 91 | }); 92 | } else if (testFramework === 'mocha') { 93 | files.push({ 94 | filename: `test/app.spec${ext}`, 95 | content: mochaTest 96 | }); 97 | } 98 | 99 | return files; 100 | } 101 | -------------------------------------------------------------------------------- /client/src/skeletons/none.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 | 16 | 17 | 18 | `; 19 | 20 | const main = `import app from './app'; 21 | document.body.appendChild(app); 22 | `; 23 | 24 | const app = `const app = document.createElement('p'); 25 | app.textContent = 'Hello Dumber Gist!'; 26 | export default app; 27 | `; 28 | 29 | const jasmineTest = `import app from '../src/app'; 30 | 31 | describe('Component app', () => { 32 | it('should render message', () => { 33 | expect(app.textContent).toBe('Hello Dumber Gist!'); 34 | }); 35 | }); 36 | `; 37 | 38 | const mochaTest = `import {expect} from 'chai'; 39 | import app from '../src/app'; 40 | 41 | describe('Component app', () => { 42 | it('should render message', () => { 43 | expect(app.textContent).to.equal('Hello Dumber Gist!'); 44 | }); 45 | }); 46 | `; 47 | 48 | export default function({transpiler, testFramework}) { 49 | const ext = transpiler === 'typescript' ? '.ts' : '.js'; 50 | const files = [ 51 | { 52 | filename: 'package.json' 53 | }, 54 | { 55 | filename: 'index.html', 56 | content: indexHtml(ext) 57 | }, 58 | { 59 | filename: `src/main${ext}`, 60 | content: main 61 | }, 62 | { 63 | filename: `src/app${ext}`, 64 | content: app 65 | } 66 | ]; 67 | 68 | if (testFramework === 'jasmine') { 69 | files.push({ 70 | filename: `test/app.spec${ext}`, 71 | content: jasmineTest 72 | }); 73 | } else if (testFramework === 'mocha') { 74 | files.push({ 75 | filename: `test/app.spec${ext}`, 76 | content: mochaTest 77 | }); 78 | } 79 | 80 | return files; 81 | } 82 | -------------------------------------------------------------------------------- /client/src/skeletons/preact.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const index = `import { h, render } from 'preact'; 22 | import App from './app'; 23 | 24 | render(, document.getElementById('root')); 25 | `; 26 | 27 | const app = `import { h, Component } from 'preact'; 28 | // Try to create a css/scss/sass/less file then import it here. 29 | // 30 | // Note to preact-cli users: preact-cli turns on css-mdules by default. But for simplicity, dumber-gist did not turn on css-modules. 31 | // https://github.com/css-modules/css-modules 32 | // 33 | // However, dumber bundler supports css-modules through gulp. 34 | // https://github.com/dumberjs/gulp-dumber-css-module 35 | 36 | export default class App extends Component { 37 | render() { 38 | return ( 39 |
40 |

Hello Preact!

41 |
42 | ); 43 | } 44 | } 45 | `; 46 | 47 | const jasmineTest = `import { h, render } from 'preact'; 48 | import App from '../src/app'; 49 | 50 | describe('Component App', () => { 51 | it('should render message', () => { 52 | const div = document.createElement('div'); 53 | render(, div); 54 | expect(div.textContent).toEqual('Hello Preact!'); 55 | }); 56 | }); 57 | `; 58 | 59 | const mochaTest = `import { h, render } from 'preact'; 60 | import {expect} from 'chai'; 61 | import App from '../src/app'; 62 | 63 | describe('Component App', () => { 64 | it('should render message', () => { 65 | const div = document.createElement('div'); 66 | render(, div); 67 | expect(div.textContent).to.equal('Hello Preact!'); 68 | }); 69 | }); 70 | `; 71 | 72 | 73 | export default function({transpiler, testFramework}) { 74 | const ext = transpiler === 'typescript' ? '.tsx' : '.jsx'; 75 | const files = [ 76 | { 77 | filename: 'package.json', 78 | dependencies: {'preact': 'latest'} 79 | }, 80 | { 81 | filename: 'index.html', 82 | content: indexHtml(ext) 83 | }, 84 | { 85 | filename: `src/index${ext}`, 86 | content: index 87 | }, 88 | { 89 | filename: `src/app${ext}`, 90 | content: app 91 | } 92 | ]; 93 | 94 | if (testFramework === 'jasmine') { 95 | files.push({ 96 | filename: `test/app.spec${ext}`, 97 | content: jasmineTest 98 | }); 99 | } else if (testFramework === 'mocha') { 100 | files.push({ 101 | filename: `test/app.spec${ext}`, 102 | content: mochaTest 103 | }); 104 | } 105 | 106 | return files; 107 | } 108 | -------------------------------------------------------------------------------- /client/src/skeletons/react.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const index = ext => `import React from "react"; 22 | import { createRoot } from 'react-dom/client'; 23 | 24 | import App from "./App"; 25 | 26 | const container = document.getElementById("root"); 27 | const root = createRoot(container${ext === '.tsx' ? '!' : ''}); 28 | root.render(); 29 | `; 30 | 31 | const app = `import React from "react"; 32 | // Try to create a css/scss/sass/less file then import it here 33 | 34 | export default function App() { 35 | return ( 36 |
37 |

Hello React!

38 |
39 | ); 40 | } 41 | `; 42 | 43 | const testSetup = `global.IS_REACT_ACT_ENVIRONMENT = true;`; 44 | 45 | const jasmineTest = `import React, {act} from 'react'; 46 | import ReactDOMClient from 'react-dom/client'; 47 | import App from '../src/App'; 48 | 49 | describe('Component App', () => { 50 | it('should render message', async () => { 51 | const container = document.createElement('div'); 52 | 53 | await act( async () => { 54 | ReactDOMClient.createRoot(container).render(); 55 | }); 56 | const h1 = container.querySelector('h1'); 57 | expect(h1.textContent).toBe('Hello React!'); 58 | }); 59 | }); 60 | `; 61 | 62 | const mochaTest = `import React, {act} from 'react'; 63 | import ReactDOMClient from 'react-dom/client'; 64 | import { expect } from 'chai'; 65 | import App from '../src/App'; 66 | 67 | describe('Component App', () => { 68 | it('should render message', async () => { 69 | const container = document.createElement('div'); 70 | 71 | await act( async () => { 72 | ReactDOMClient.createRoot(container).render(); 73 | }); 74 | const h1 = container.querySelector('h1'); 75 | expect(h1.textContent).to.equal('Hello React!'); 76 | }); 77 | }); 78 | `; 79 | 80 | export default function({transpiler, testFramework}) { 81 | const ext = transpiler === 'typescript' ? '.tsx' : '.jsx'; 82 | const files = [ 83 | { 84 | filename: 'package.json', 85 | dependencies: {'react': 'latest', 'react-dom': 'latest'} 86 | }, 87 | { 88 | filename: 'index.html', 89 | content: indexHtml(ext) 90 | }, 91 | { 92 | filename: `src/index${ext}`, 93 | content: index(ext) 94 | }, 95 | { 96 | filename: `src/App${ext}`, 97 | content: app 98 | } 99 | ]; 100 | 101 | 102 | if (testFramework !== 'none') { 103 | files.push({ 104 | filename: `test/setup${ext}`, 105 | content: testSetup 106 | }); 107 | 108 | if (testFramework === 'jasmine') { 109 | files.push({ 110 | filename: `test/app.spec${ext}`, 111 | content: jasmineTest 112 | }); 113 | } else if (testFramework === 'mocha') { 114 | files.push({ 115 | filename: `test/app.spec${ext}`, 116 | content: mochaTest 117 | }); 118 | } 119 | } 120 | 121 | return files; 122 | } 123 | -------------------------------------------------------------------------------- /client/src/skeletons/svelte.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const index = `import App from './App.svelte'; 22 | import { mount } from 'svelte'; 23 | 24 | const app = mount(App, { 25 | target: document.getElementById('root'), 26 | props: { 27 | name: 'Svelte' 28 | } 29 | }); 30 | 31 | export default app; 32 | `; 33 | 34 | const app = ext => ` 37 | 38 |
39 |

Hello {name}!

40 |
41 | 42 | 47 | 52 | `; 53 | 54 | const jasmineTest = `import App from '../src/App.svelte'; 55 | import { mount } from 'svelte'; 56 | 57 | describe('Component App', () => { 58 | it('should render message', () => { 59 | const div = document.createElement('div'); 60 | mount(App, { 61 | target: div, 62 | props: { 63 | name: 'Svelte' 64 | } 65 | }); 66 | expect(div.textContent).toEqual('Hello Svelte!'); 67 | }); 68 | }); 69 | `; 70 | 71 | const mochaTest = `import {expect} from 'chai'; 72 | import App from '../src/App.svelte'; 73 | import { mount } from 'svelte'; 74 | 75 | describe('Component App', () => { 76 | it('should render message', () => { 77 | const div = document.createElement('div'); 78 | mount(App, { 79 | target: div, 80 | props: { 81 | name: 'Svelte' 82 | } 83 | }); 84 | expect(div.textContent).to.equal('Hello Svelte!'); 85 | }); 86 | }); 87 | ` 88 | 89 | export default function({transpiler, testFramework}) { 90 | const ext = transpiler === 'typescript' ? '.ts' : '.js'; 91 | const files = [ 92 | { 93 | filename: 'package.json', 94 | dependencies: {'svelte': 'latest'} 95 | }, 96 | { 97 | filename: 'index.html', 98 | content: indexHtml(ext) 99 | }, 100 | { 101 | filename: `src/index${ext}`, 102 | content: index 103 | }, 104 | { 105 | filename: `src/App.svelte`, 106 | content: app(ext) 107 | } 108 | ]; 109 | 110 | if (testFramework === 'jasmine') { 111 | files.push({ 112 | filename: `test/app.spec${ext}`, 113 | content: jasmineTest 114 | }); 115 | } else if (testFramework === 'mocha') { 116 | files.push({ 117 | filename: `test/app.spec${ext}`, 118 | content: mochaTest 119 | }); 120 | } 121 | 122 | return files; 123 | } 124 | -------------------------------------------------------------------------------- /client/src/skeletons/vue.js: -------------------------------------------------------------------------------- 1 | const indexHtml = ext => ` 2 | 3 | 4 | 5 | Dumber Gist 6 | 7 | 8 | 14 | 15 |
16 | 17 | 18 | 19 | `; 20 | 21 | const main = `import Vue from 'vue'; 22 | import App from './App'; 23 | 24 | new Vue({ 25 | components: {App}, 26 | template: '' 27 | }).$mount('#vue-root'); 28 | `; 29 | 30 | const app = `// Try to create a css/scss/sass/less file then import it here 31 | export default { 32 | template: \` 33 |
34 |

{{ msg }}

35 |
36 | \`, 37 | data() { 38 | return { 39 | msg: 'Hello Vue!' 40 | }; 41 | } 42 | }; 43 | `; 44 | 45 | const jasmineTest = `import { mount } from '@vue/test-utils'; 46 | import App from '../src/App'; 47 | 48 | describe('Component App', () => { 49 | it('should render message', () => { 50 | const wrapper = mount(App); 51 | expect(wrapper.text()).toBe('Hello Vue!'); 52 | }); 53 | }); 54 | `; 55 | 56 | const mochaTest = `import {expect} from 'chai'; 57 | import { mount } from '@vue/test-utils'; 58 | import App from '../src/App'; 59 | 60 | describe('Component App', () => { 61 | it('should render message', () => { 62 | const wrapper = mount(App); 63 | expect(wrapper.text()).to.equal('Hello Vue!'); 64 | }); 65 | }); 66 | `; 67 | 68 | export default function({transpiler, testFramework}) { 69 | const ext = transpiler === 'typescript' ? '.ts' : '.js'; 70 | const files = [ 71 | { 72 | filename: 'package.json', 73 | dependencies: {'vue': '^2.0.0'} 74 | }, 75 | { 76 | filename: 'index.html', 77 | content: indexHtml(ext) 78 | }, 79 | { 80 | filename: `src/main${ext}`, 81 | content: main 82 | }, 83 | { 84 | filename: `src/App${ext}`, 85 | content: app 86 | } 87 | ]; 88 | 89 | if (testFramework === 'jasmine') { 90 | files.push({ 91 | filename: `test/app.spec${ext}`, 92 | content: jasmineTest 93 | }); 94 | } else if (testFramework === 'mocha') { 95 | files.push({ 96 | filename: `test/app.spec${ext}`, 97 | content: mochaTest 98 | }); 99 | } 100 | 101 | return files; 102 | } 103 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-draft-dialog.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-draft-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | 4 | @inject(DialogController) 5 | export class ConfirmDraftDialog { 6 | constructor(controller) { 7 | this.controller = controller; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-fork-dialog.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-fork-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | import {User} from '../../github/user'; 4 | import _ from 'lodash'; 5 | 6 | @inject(DialogController, User) 7 | export class ConfirmForkDialog { 8 | constructor(controller, user) { 9 | this.controller = controller; 10 | this.user = user; 11 | } 12 | 13 | activate(model) { 14 | this.gist = model.gist; 15 | this.ownedByMe = _.get(this.gist, 'owner.login') === _.get(this.user, 'login'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-open-dialog.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-open-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | 4 | @inject(DialogController) 5 | export class ConfirmOpenDialog { 6 | constructor(controller) { 7 | this.controller = controller; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-share-dialog.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/confirm-share-dialog.js: -------------------------------------------------------------------------------- 1 | import {DialogController} from 'aurelia-dialog-lite'; 2 | import {inject} from 'aurelia-framework'; 3 | 4 | @inject(DialogController) 5 | export class ConfirmShareDialog { 6 | constructor(controller) { 7 | this.controller = controller; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/open-gist-dialog.html: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/open-gist-dialog.js: -------------------------------------------------------------------------------- 1 | import {inject, computedFrom} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {DialogController} from 'aurelia-dialog-lite'; 4 | import Validation from 'bcx-validation'; 5 | import {Gists} from '../../github/gists'; 6 | import {Helper} from '../../helper'; 7 | import _ from 'lodash'; 8 | 9 | const gistIdRegex = /^[0-9a-f]{7,32}$/; 10 | const gistUrlRegex = /^https:\/\/gist.github.com\/([0-9a-zA-Z](([0-9a-zA-Z-]*)?[0-9a-zA-Z])?\/)?[0-9a-f]{7,32}$/; 11 | 12 | const idRegex = /[0-9a-f]+$/; 13 | 14 | @inject(EventAggregator, DialogController, Validation, Gists, Helper) 15 | export class OpenGistDialog { 16 | triedOnce = false; 17 | gistUrl = ''; 18 | githubUser = ''; 19 | 20 | constructor(ea, controller, validation, gists, helper) { 21 | this.ea = ea; 22 | this.controller = controller; 23 | this.gists = gists; 24 | this.helper = helper; 25 | 26 | this.validator = validation.generateValidator({ 27 | gistUrl: [ 28 | 'mandatory', 29 | url => { 30 | url = url.trim(); 31 | if (gistIdRegex.test(url)) return; 32 | if (gistUrlRegex.test(url)) return; 33 | return 'Unrecognizable gist id or URL' 34 | } 35 | ] 36 | }); 37 | } 38 | 39 | open() { 40 | this.triedOnce = true; 41 | if (!this.errors) { 42 | const m = this.gistUrl.trim().match(idRegex); 43 | if (m) { 44 | const id = m[0]; 45 | 46 | return this.helper.waitFor( 47 | `Loading Gist ${id.slice(0, 7)} ...`, 48 | this.gists.load(id) 49 | ).then( 50 | gist => this.controller.ok(gist), 51 | err => this.ea.publish('error', err.message) 52 | ); 53 | } 54 | } 55 | 56 | // Try list gists of githubUser 57 | const githubUser = this.githubUser.trim(); 58 | if (githubUser) { 59 | this.controller.cancel(); 60 | this.ea.publish('list-gists', githubUser); 61 | } 62 | } 63 | 64 | @computedFrom('triedOnce', 'gistUrl') 65 | get errors() { 66 | if (this.triedOnce) { 67 | const errors = this.validator(this); 68 | return _.capitalize(_.get(errors, 'gistUrl', []).join(', ')); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/share-gist-dialog.html: -------------------------------------------------------------------------------- 1 | 77 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/share-gist-dialog.js: -------------------------------------------------------------------------------- 1 | import ClipboardJS from 'clipboard'; 2 | import {DialogController} from 'aurelia-dialog-lite'; 3 | import {EventAggregator} from 'aurelia-event-aggregator'; 4 | import {inject, BindingEngine} from 'aurelia-framework'; 5 | import {User} from '../../github/user'; 6 | import _ from 'lodash'; 7 | 8 | @inject(EventAggregator, DialogController, BindingEngine, User) 9 | export class ShareGistDialog { 10 | selectedFiles = []; 11 | 12 | constructor(ea, controller, bindingEngine, user) { 13 | this.ea = ea; 14 | this.controller = controller; 15 | this.bindingEngine = bindingEngine; 16 | this.user = user; 17 | this._update = this._update.bind(this); 18 | } 19 | 20 | activate(model) { 21 | this.gist = model.gist; 22 | this.originalFiles = _.map(this.gist.files, 'filename'); 23 | this.subscribers = [ 24 | // Cannot just @computedFrom('selectedFiles') as computedFrom 25 | // only subscribes propertyObserver, we need to respond to 26 | // collectionObserver. 27 | // Aurelia 2 should be able to remove this glue code. 28 | this.bindingEngine.collectionObserver(this.selectedFiles).subscribe(this._update) 29 | ]; 30 | this._update(); 31 | } 32 | 33 | attached() { 34 | this.copyUrl = new ClipboardJS(this.copyUrlBtn, {text: () => this.url}); 35 | 36 | this.copyUrl.on('success', () => { 37 | this.ea.publish('success', 'URL copied'); 38 | }); 39 | 40 | this.copyIframed = new ClipboardJS(this.copyIframedBtn, {text: () => this.iframed}); 41 | 42 | this.copyIframed.on('success', () => { 43 | this.ea.publish('success', 'iframe snippet copied'); 44 | }); 45 | } 46 | 47 | deactivate() { 48 | this.subscribers.forEach(s => s.dispose()); 49 | } 50 | 51 | detached() { 52 | if (this.copyUrl) { 53 | this.copyUrl.destroy(); 54 | this.copyUrl = null; 55 | } 56 | 57 | if (this.copyIframed) { 58 | this.copyIframed.destroy(); 59 | this.copyIframed = null; 60 | } 61 | } 62 | 63 | _update() { 64 | let url = `${HOST_NAMES.clientUrl}/?gist=${this.gist.id}`; 65 | 66 | const {selectedFiles} = this; 67 | if (selectedFiles.length) { 68 | url += _(selectedFiles) 69 | .map(f => `&open=${encodeURIComponent(f)}`) 70 | .join(''); 71 | } 72 | 73 | this.url = url; 74 | this.iframed = ``; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/short-cuts-dialog.html: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /client/src/top-bar/dialogs/short-cuts-dialog.js: -------------------------------------------------------------------------------- 1 | import {EventAggregator} from 'aurelia-event-aggregator'; 2 | import {DialogController} from 'aurelia-dialog-lite'; 3 | import {inject} from 'aurelia-framework'; 4 | 5 | @inject(DialogController, EventAggregator) 6 | export class ShortCutsDialog { 7 | shortcuts = [ 8 | { 9 | action: 'Find and open a file', 10 | event: 'open-any', 11 | notes: "Ctrl-P and Cmd-P may not work when the focus is in the embedded app, or blocked by your system's default. Try Alt-P.", 12 | keys: ['Alt-P', '⌥ P', 'Ctrl-P', '⌃ P', 'Cmd-P', '⌘ P'] 13 | }, 14 | { 15 | action: 'Manually reload the embedded browser', 16 | event: 'bundle-or-reload', 17 | keys: ['Alt-R', '⌥ R'] 18 | }, 19 | { 20 | action: 'Create a new file', 21 | event: 'create-file', 22 | keys: ['Alt-N', '⌥ N', 'Ctrl-Alt-N', '⌃ ⌥ N'], 23 | notes: "Alt-N may not work when the focus is in the code editor. Try Ctrl-Alt-N.", 24 | }, 25 | { 26 | action: 'Close the active editing file', 27 | event: 'close-active-file', 28 | keys: ['Alt-W', '⌥ W'] 29 | } 30 | ]; 31 | 32 | constructor(controller, ea) { 33 | this.controller = controller; 34 | this.ea = ea; 35 | } 36 | 37 | evoke(action) { 38 | this.controller.cancel(); 39 | this.ea.publish(action); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/top-bar/gist-bar.html: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /client/src/top-bar/github-account.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /client/src/top-bar/github-account.js: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {DialogService} from 'aurelia-dialog-lite'; 4 | import {Oauth} from '../github/oauth'; 5 | import {User} from '../github/user'; 6 | import {ContextMenu} from '../dialogs/context-menu'; 7 | import {ShortCutsDialog} from './dialogs/short-cuts-dialog'; 8 | 9 | @inject(EventAggregator, DialogService, Oauth, User) 10 | export class GithubAccount { 11 | constructor(ea, dialogService, oauth, user) { 12 | this.ea = ea; 13 | this.dialogService = dialogService; 14 | this.oauth = oauth; 15 | this.user = user; 16 | } 17 | 18 | login() { 19 | this.oauth.login(); 20 | } 21 | 22 | userMenu() { 23 | if (!this.user.login) return; 24 | if (this.dialogService.hasActiveDialog) return; 25 | 26 | const rect = this.el.getBoundingClientRect(); 27 | this.dialogService.open({ 28 | viewModel: ContextMenu, 29 | model: { 30 | right: 5, 31 | top: rect.bottom + 4, 32 | items: [ 33 | {html: `Signed in as ${this.user.login}`}, 34 | {separator: true}, 35 | {title: 'Your gists', code: 'gists'}, 36 | {title: 'Sign out', code: 'logout'} 37 | ] 38 | } 39 | }).then( 40 | code => { 41 | if (code === 'logout') { 42 | return this.oauth.logout(); 43 | } else if (code === 'gists') { 44 | this.ea.publish('list-gists', this.user.login); 45 | } 46 | }, 47 | () => {} 48 | ); 49 | } 50 | 51 | helpMenu() { 52 | if (this.dialogService.hasActiveDialog) return; 53 | 54 | const rect = this.el.getBoundingClientRect(); 55 | this.dialogService.open({ 56 | viewModel: ContextMenu, 57 | model: { 58 | right: 5, 59 | top: rect.bottom + 4, 60 | items: [ 61 | {title: 'Dumber Gist', icon: "fab fa-github", href: 'https://github.com/dumberjs/dumber-gist'}, 62 | {separator: true}, 63 | {title: 'GitHub Wiki', href: 'https://github.com/dumberjs/dumber-gist/wiki'}, 64 | {title: 'GitHub Issues', href: 'https://github.com/dumberjs/dumber-gist/issues'}, 65 | {separator: true}, 66 | {title: 'List of Short-cuts', code: 'short-cuts'} 67 | ] 68 | } 69 | }).then( 70 | code => { 71 | if (code === 'short-cuts') return this.listShortCuts(); 72 | }, 73 | () => {} 74 | ); 75 | } 76 | 77 | listShortCuts() { 78 | if (this.dialogService.hasActiveDialog) return; 79 | this.dialogService.open({viewModel: ShortCutsDialog}) 80 | .then(() => {}, () => {}); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /client/test-worker/au1-deps-finder.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {Au1DepsFinder} from '../src-worker/au1-deps-finder'; 3 | import {JSDELIVR_PREFIX} from '../src-worker/cache-primitives'; 4 | 5 | test('Au1DepsFinder find deps for local js file', async t => { 6 | const primitives = { 7 | async doesJsdelivrFileExist() { return false; } 8 | } 9 | const f = new Au1DepsFinder(primitives); 10 | const deps = await f.findDeps('src/foo.js', 'exports.Foo = class Foo {}'); 11 | t.deepEqual(deps, []); 12 | }); 13 | 14 | test('Au1DepsFinder find deps for local html file', async t => { 15 | const primitives = { 16 | async doesJsdelivrFileExist() { return false; } 17 | } 18 | const f = new Au1DepsFinder(primitives); 19 | const deps = await f.findDeps('src/foo.html', ''); 20 | t.deepEqual(deps, ['text!./a.css']); 21 | }); 22 | 23 | test('Au1DepsFinder find deps for npm js file', async t => { 24 | const primitives = { 25 | async doesJsdelivrFileExist(packageWithVersion, filePath) { 26 | if (packageWithVersion === 'foo@1.0.0' && filePath === 'dist/bar.html') { 27 | return true; 28 | } 29 | } 30 | } 31 | const f = new Au1DepsFinder(primitives); 32 | const deps = await f.findDeps(`${JSDELIVR_PREFIX}foo@1.0.0/dist/bar.js`, 'exports.Bar = class Bar {}'); 33 | t.deepEqual(deps, ['text!./bar.html']); 34 | 35 | const deps2 = await f.findDeps(`${JSDELIVR_PREFIX}foo@1.0.0/dist/bar2.js`, 'exports.Bar = class Bar2 {}'); 36 | t.deepEqual(deps2, []); 37 | }); 38 | 39 | test('Au1DepsFinder find deps for scoped npm js file', async t => { 40 | const primitives = { 41 | async doesJsdelivrFileExist(packageWithVersion, filePath) { 42 | if (packageWithVersion === '@scoped/foo@1.0.0' && filePath === 'dist/bar.html') { 43 | return true; 44 | } 45 | } 46 | } 47 | const f = new Au1DepsFinder(primitives); 48 | const deps = await f.findDeps(`${JSDELIVR_PREFIX}@scoped/foo@1.0.0/dist/bar.js`, 'exports.Bar = class Bar {}'); 49 | t.deepEqual(deps, ['text!./bar.html']); 50 | 51 | const deps2 = await f.findDeps(`${JSDELIVR_PREFIX}@scoped/foo@1.0.0/dist/bar2.js`, 'exports.Bar = class Bar2 {}'); 52 | t.deepEqual(deps2, []); 53 | }); 54 | 55 | test('Au1DepsFinder find deps for npm html file', async t => { 56 | const primitives = { 57 | async doesJsdelivrFileExist() { return false; } 58 | } 59 | const f = new Au1DepsFinder(primitives); 60 | const deps = await f.findDeps(`${JSDELIVR_PREFIX}foo@1.0.0/dist/foo.html`, ''); 61 | t.deepEqual(deps, ['text!./a.css', './b']); 62 | }); 63 | -------------------------------------------------------------------------------- /client/test-worker/cache-primitives.helper.js: -------------------------------------------------------------------------------- 1 | import {CachePrimitives} from '../src-worker/cache-primitives'; 2 | 3 | function mockLocalforage(db = {}) { 4 | return { 5 | async getItem(key) { 6 | return db[key]; 7 | }, 8 | async setItem(key, value) { 9 | db[key] = value; 10 | return value; 11 | }, 12 | async clear() { 13 | Object.getOwnPropertyNames(db).forEach(function (prop) { 14 | delete db[prop]; 15 | }); 16 | } 17 | }; 18 | } 19 | 20 | function mkResponse (text) { 21 | return { 22 | ok: true, 23 | text: async () => text 24 | }; 25 | } 26 | 27 | function mkBufferResponse (arrayBuffer) { 28 | return { 29 | ok: true, 30 | arrayBuffer: async () => arrayBuffer 31 | }; 32 | } 33 | 34 | function mkJsonResponse (obj) { 35 | return { 36 | ok: true, 37 | json: async () => obj 38 | }; 39 | } 40 | 41 | function mkFailedResponse () { 42 | return { 43 | ok: false 44 | }; 45 | } 46 | 47 | function mockFetch(remote = {}) { 48 | return async (url, opts = {}) => { 49 | if (opts.method === 'POST') { 50 | const {hash, object} = JSON.parse(opts.body); 51 | remote[url + '/' + hash.slice(0, 2) + '/' + hash.slice(2)] = object; 52 | } else if (remote[url]) { 53 | const result = remote[url]; 54 | if (typeof result === 'string') { 55 | return mkResponse(result); 56 | } else if (typeof result === 'object') { 57 | if (typeof result.byteLength === 'number') { 58 | return mkBufferResponse(result); 59 | } 60 | return mkJsonResponse(result); 61 | } 62 | } 63 | return mkFailedResponse(); 64 | } 65 | } 66 | 67 | export default function create(db = {}, remote = {}) { 68 | return new CachePrimitives(mockLocalforage(db), mockFetch(remote)); 69 | } 70 | -------------------------------------------------------------------------------- /client/test-worker/cache-primitives.remote-cache.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import create from './cache-primitives.helper'; 3 | 4 | const cacheUrl = HOST_NAMES.cacheUrl; 5 | 6 | test('getRemoteCacheWithPath rejects missing cache, gets valid cache', async t => { 7 | const remote = { 8 | [`${cacheUrl}/npm/foo@1.0.0/index.js`]: {foo: 1} 9 | }; 10 | const p = create({}, remote); 11 | t.deepEqual( 12 | await p.getRemoteCacheWithPath('foo@1.0.0/index.js'), 13 | {foo: 1} 14 | ); 15 | try { 16 | await p.getRemoteCacheWithPath('bar@1.0.0/index.js'); 17 | t.fail('should not pass'); 18 | } catch (e) { 19 | t.ok(true, e.message); 20 | } 21 | }); 22 | 23 | test('getRemoteCache rejects missing cache, gets valid cache', async t => { 24 | const remote = { 25 | [`${cacheUrl}/ha/sh`]: {foo: 1} 26 | }; 27 | const p = create({}, remote); 28 | t.deepEqual( 29 | await p.getRemoteCache('hash'), 30 | {foo: 1} 31 | ); 32 | try { 33 | await p.getRemoteCache('hash2'); 34 | t.fail('should not pass'); 35 | } catch (e) { 36 | t.ok(true, e.message); 37 | } 38 | }); 39 | 40 | // test('setRemoteCache does not set cache if user is not signed in', async t => { 41 | // const remote = {}; 42 | // const p = create({}, remote); 43 | // await p.setRemoteCache('12345', {a: 1}); 44 | // t.deepEqual(remote, {}); 45 | // try { 46 | // await p.getRemoteCache('12345'); 47 | // t.fail('should not pass'); 48 | // } catch (e) { 49 | // t.ok(true, e.message); 50 | // } 51 | // }); 52 | 53 | test('setRemoteCache sets cache', async t => { 54 | // global.__github_token = {access_token: '1'}; 55 | try { 56 | const remote = {}; 57 | const p = create({}, remote); 58 | await p.setRemoteCache('12345', {a: 1}); 59 | t.deepEqual(remote, { 60 | [`${cacheUrl}/12/345`]: {a: 1} 61 | }); 62 | t.deepEqual( 63 | await p.getRemoteCache('12345'), 64 | {a: 1} 65 | ); 66 | } catch (e) { 67 | delete global.__github_token; 68 | throw e; 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /client/test-worker/mock.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dumberjs/dumber-gist/08824a52b3b77fbfa534ed3f778a58f0677da60f/client/test-worker/mock.js -------------------------------------------------------------------------------- /client/test-worker/setup.js: -------------------------------------------------------------------------------- 1 | // import "core-js/stable"; 2 | -------------------------------------------------------------------------------- /client/test-worker/transpilers/au2.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {Au2Transpiler} from '../../src-worker/transpilers/au2'; 3 | 4 | const au2PackageJson = { 5 | filename: 'package.json', content: '{"dependencies":{"aurelia":"dev"}}' 6 | }; 7 | 8 | test('Au2Transpiler matches js/ts/html if using aurelia2', t => { 9 | const jt = new Au2Transpiler(); 10 | t.ok(jt.match({filename: 'src/foo.js', content: ''}, [au2PackageJson])); 11 | t.ok(jt.match({filename: 'src/foo.ts', content: ''}, [au2PackageJson])); 12 | t.ok(jt.match({filename: 'src/foo.html', content: ''}, [au2PackageJson])); 13 | t.notOk(jt.match({filename: 'src/foo.css', content: ''}, [au2PackageJson])); 14 | }); 15 | 16 | test('Au2Transpiler does not any files if not using aurelia2', t => { 17 | const jt = new Au2Transpiler(); 18 | t.notOk(jt.match({filename: 'src/foo.html', content: ''})); 19 | t.notOk(jt.match({filename: 'src/foo.css', content: ''})); 20 | t.notOk(jt.match({filename: 'src/foo.json', content: ''})); 21 | t.notOk(jt.match({filename: 'src/foo.less', content: ''})); 22 | t.notOk(jt.match({filename: 'src/foo.scss', content: ''})); 23 | t.notOk(jt.match({filename: 'src/foo.js', content: ''})); 24 | t.notOk(jt.match({filename: 'src/foo.ts', content: ''})); 25 | }); 26 | 27 | test('Au2Transpiler transpiles js file', async t => { 28 | const jt = new Au2Transpiler(); 29 | const code = `export class Foo { 30 | } 31 | `; 32 | const htmlPair = {filename: 'src/foo.html', content: ''}; 33 | 34 | const file = await jt.transpile({ 35 | filename: 'src/foo.js', 36 | content: code 37 | }, [htmlPair, au2PackageJson]); 38 | 39 | t.equal(file.filename, 'src/foo.js'); 40 | t.ok(file.content.includes('customElement')); 41 | // t.notOk(file.content.includes("sourceMappingURL")); 42 | // t.equal(file.sourceMap.file, 'src/foo.js'); 43 | // t.deepEqual(file.sourceMap.sources, ['src/foo.js']); 44 | // t.deepEqual(file.sourceMap.sourcesContent, [code]); 45 | }); 46 | 47 | test('Au2Transpiler transpiles ts file', async t => { 48 | const jt = new Au2Transpiler(); 49 | const code = `export class Foo { 50 | public name: string; 51 | } 52 | `; 53 | const htmlPair = {filename: 'src/foo.html', content: ''}; 54 | 55 | const file = await jt.transpile({ 56 | filename: 'src/foo.ts', 57 | content: code 58 | }, [htmlPair, au2PackageJson]); 59 | 60 | t.equal(file.filename, 'src/foo.js'); 61 | t.ok(file.content.includes('customElement')); 62 | // t.notOk(file.content.includes("sourceMappingURL")); 63 | // t.equal(file.sourceMap.file, 'src/foo.js'); 64 | // t.deepEqual(file.sourceMap.sources, ['src/foo.ts']); 65 | // t.deepEqual(file.sourceMap.sourcesContent, [code]); 66 | }); 67 | 68 | test('Au2Transpiler transpiles html file', async t => { 69 | const jt = new Au2Transpiler(); 70 | const code = ` 71 |

\${name}

72 | `; 73 | const file = await jt.transpile({ 74 | filename: 'src/foo.html', 75 | content: code 76 | }, [au2PackageJson]); 77 | 78 | t.equal(file.filename, 'src/foo.html.js'); 79 | t.ok(file.content.includes('name = "foo"')); 80 | t.ok(file.content.includes('import "./foo.css"')); 81 | // t.notOk(file.content.includes("sourceMappingURL")); 82 | // t.equal(file.sourceMap.file, 'src/foo.html.js'); 83 | // t.deepEqual(file.sourceMap.sources, ['src/foo.html']); 84 | // t.deepEqual(file.sourceMap.sourcesContent, [code]); 85 | }); 86 | 87 | -------------------------------------------------------------------------------- /client/test-worker/transpilers/less.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {LessTranspiler} from '../../src-worker/transpilers/less'; 3 | 4 | test('LessTranspiler matches less files', t => { 5 | const jt = new LessTranspiler(); 6 | t.ok(jt.match({filename: 'src/foo.less', content: ''})); 7 | }); 8 | 9 | test('LessTranspiler does not match other files', t => { 10 | const jt = new LessTranspiler(); 11 | t.notOk(jt.match({filename: 'src/foo.scss', content: ''})); 12 | t.notOk(jt.match({filename: 'src/foo.css', content: ''})); 13 | t.notOk(jt.match({filename: 'src/foo.html', content: ''})); 14 | }); 15 | 16 | test('LessTranspiler transpile less file', async t => { 17 | const jt = new LessTranspiler(); 18 | const code = '.a { .b { color: red; } }'; 19 | const f = { 20 | filename: 'src/foo.less', 21 | content: code 22 | }; 23 | const file = await jt.transpile(f, [f]); 24 | 25 | t.equal(file.filename, 'src/foo.css'); 26 | t.ok(file.content.includes('.a .b')); 27 | // t.equal(file.sourceMap.file, 'src/foo.css'); 28 | // t.deepEqual(file.sourceMap.sources, ['src/foo.less']); 29 | // t.deepEqual(file.sourceMap.sourcesContent, [code]); 30 | }); 31 | 32 | test('LessTranspiler transpile empty less file', async t => { 33 | const jt = new LessTranspiler(); 34 | const code = '\n\t\n'; 35 | const f = { 36 | filename: 'src/foo.less', 37 | content: code 38 | }; 39 | const file = await jt.transpile(f, [f]); 40 | 41 | t.equal(file.filename, 'src/foo.css'); 42 | t.equal(file.content, ''); 43 | t.notOk(file.sourceMap); 44 | }); 45 | 46 | test('LessTranspiler reject broken less file', async t => { 47 | const jt = new LessTranspiler(); 48 | const code = '.a {'; 49 | const f = { 50 | filename: 'src/foo.less', 51 | content: code 52 | }; 53 | try { 54 | await jt.transpile(f, [f]); 55 | t.fail('should not pass'); 56 | } catch (e) { 57 | t.ok(true, e.message); 58 | } 59 | }); 60 | 61 | test('LessTranspiler cannot tranpile other file', async t => { 62 | const jt = new LessTranspiler(); 63 | try { 64 | await jt.transpile({ 65 | filename: 'src/foo.js', 66 | content: '' 67 | }); 68 | t.fail('should not pass'); 69 | } catch (e) { 70 | t.ok(true, e.message); 71 | } 72 | }); 73 | 74 | test('LessTranspiler transpile less file with less import', async t => { 75 | const jt = new LessTranspiler(); 76 | const foo = '@import "variables";\n.a { .b { color: red; } }'; 77 | const variables = '.c { color: green }'; 78 | const f = { 79 | filename: 'src/foo.less', 80 | content: foo 81 | }; 82 | const f2 = { 83 | filename: 'src/variables.less', 84 | content: variables 85 | } 86 | const file = await jt.transpile(f, [f, f2]); 87 | 88 | t.equal(file.filename, 'src/foo.css'); 89 | t.ok(file.content.includes('.a .b')); 90 | t.ok(file.content.includes('.c')); 91 | // t.equal(file.sourceMap.file, 'src/foo.css'); 92 | // t.deepEqual(file.sourceMap.sources, ['src/foo.less', 'src/variables.less']); 93 | // t.deepEqual(file.sourceMap.sourcesContent, [foo, variables]); 94 | }); 95 | -------------------------------------------------------------------------------- /client/test-worker/transpilers/text.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {TextTranspiler} from '../../src-worker/transpilers/text'; 3 | 4 | test('TextTranspiler matches html/css/svg/xml/json files', t => { 5 | const jt = new TextTranspiler(); 6 | t.ok(jt.match({filename: 'src/foo.html', content: ''})); 7 | t.ok(jt.match({filename: 'src/foo.css', content: ''})); 8 | t.ok(jt.match({filename: 'src/foo.svg', content: ''})); 9 | t.ok(jt.match({filename: 'src/foo.xml', content: ''})); 10 | t.ok(jt.match({filename: 'src/foo.json', content: ''})); 11 | }); 12 | 13 | test('TextTranspiler does not match other files', t => { 14 | const jt = new TextTranspiler(); 15 | t.notOk(jt.match({filename: 'src/foo.js', content: ''})); 16 | t.notOk(jt.match({filename: 'src/foo.jsx', content: ''})); 17 | t.notOk(jt.match({filename: 'src/foo.ts', content: ''})); 18 | t.notOk(jt.match({filename: 'src/foo.tsx', content: ''})); 19 | t.notOk(jt.match({filename: 'src/foo.scss', content: ''})); 20 | }); 21 | 22 | test('TextTranspiler passes through supported file', async t => { 23 | const jt = new TextTranspiler(); 24 | const code = 'lorem'; 25 | const file = await jt.transpile({ 26 | filename: 'src/foo.html', 27 | content: code 28 | }); 29 | 30 | t.equal(file.filename, 'src/foo.html'); 31 | t.equal(file.content, code); 32 | t.notOk(file.sourceMap); 33 | }); 34 | 35 | test('TextTranspiler cannot tranpile other file', async t => { 36 | const jt = new TextTranspiler(); 37 | try { 38 | await jt.transpile({ 39 | filename: 'src/foo.js', 40 | content: '' 41 | }); 42 | t.fail('should not pass'); 43 | } catch (e) { 44 | t.ok(true, e.message); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /client/test-worker/turbo-resolver/registries/npm-http.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {NpmHttpRegistry} from '../../../src-worker/turbo-resolver/registries/npm-http'; 3 | 4 | const r = new NpmHttpRegistry(); 5 | 6 | test('NpmHttpRegistry fetches one package', async t => { 7 | const pack = await r.fetch('npm'); 8 | t.equal(pack.name, 'npm'); 9 | }); 10 | 11 | test('NpmHttpRegistry complains about unknown package', async t => { 12 | try { 13 | await r.fetch('@no/such/thing'); 14 | t.fail('should not pass'); 15 | } catch (e) { 16 | t.ok(true, e.message); 17 | } 18 | }); 19 | 20 | test('NpmHttpRegistry fetches multiple packages', async t => { 21 | const r = new NpmHttpRegistry(); 22 | const packs = await r.batchFetch(['npm', 'yarn']); 23 | t.deepEqual(packs.map(p => p.name), ['npm', 'yarn']); 24 | }); 25 | -------------------------------------------------------------------------------- /client/test-worker/turbo-resolver/resolver.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import _ from 'lodash'; 3 | import {NpmHttpRegistry} from '../../src-worker/turbo-resolver/registries/npm-http'; 4 | import {Resolver} from '../../src-worker/turbo-resolver/resolver'; 5 | 6 | test('Resolver resolves au1 deps', async t => { 7 | const r = new Resolver(new NpmHttpRegistry()); 8 | 9 | const result = await r.resolve({ 10 | 'aurelia-bootstrapper': '^2.0.0' 11 | }); 12 | 13 | t.deepEqual(Object.keys(result.appDependencies), ['aurelia-bootstrapper']); 14 | t.ok(_.some(Object.keys(result.resDependencies), k => k.startsWith('aurelia-framework'))); 15 | }); 16 | 17 | test('Resolver resolves vue2 deps', async t => { 18 | const r = new Resolver(new NpmHttpRegistry()); 19 | 20 | const result = await r.resolve({ 21 | 'vue': '^2.0.0' 22 | }); 23 | 24 | t.deepEqual(Object.keys(result.appDependencies), ['vue']); 25 | t.equal(Object.keys(result.resDependencies).length, 0); 26 | }); 27 | 28 | test('Resolver resolves invalid deps', async t => { 29 | const r = new Resolver(new NpmHttpRegistry()); 30 | try { 31 | await r.resolve({'an-invalid-module-name': 'latest'}); 32 | t.fail('should not pass'); 33 | } catch (e) { 34 | t.equal(e.message, 'Could not load npm registry for an-invalid-module-name: Not found'); 35 | } 36 | }); 37 | 38 | test('Resolver resolves invalid version', async t => { 39 | const r = new Resolver(new NpmHttpRegistry()); 40 | try { 41 | await r.resolve({'vue': '^2000.0.0'}); 42 | t.fail('should not pass'); 43 | } catch (e) { 44 | t.equal(e.message, 'npm package "vue" was not found with requested version: "^2000.0.0".'); 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /client/test/dialogs/fuzzy-filter.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {fuzzyFilter} from '../../src/dialogs/fuzzy-filter'; 3 | 4 | test('fuzzyFilter returns original list', t => { 5 | t.deepEqual( 6 | fuzzyFilter('', ['foo', 'bar', 'lo']), 7 | [['foo'], ['bar'], ['lo']] 8 | ); 9 | }); 10 | 11 | test('fuzzyFilter returns sorted filtered list', t => { 12 | t.deepEqual( 13 | fuzzyFilter('o', ['foo', 'bar', 'lo']), 14 | [['l', 'o'], ['f', 'o', 'o']] 15 | ); 16 | }); 17 | 18 | test('fuzzyFilter returns sorted filtered list, case2', t => { 19 | const list = ['package.json', 'src/main.js', 'src/app.html', 'src/app.js', 'index.html']; 20 | t.deepEqual( 21 | fuzzyFilter(' aj ', list), 22 | [ 23 | ['p', 'a', 'ckage.', 'j', 'son'], 24 | ['src/m', 'a', 'in.', 'j', 's'], 25 | ['src/', 'a', 'pp.', 'j', 's'] 26 | ] 27 | ); 28 | }); 29 | 30 | test('fuzzyFilter returns sorted filtered list, case3', t => { 31 | const list = ['package.json', 'src/main.js', 'src/app.html', 'src/app.js', 'index.html']; 32 | t.deepEqual( 33 | fuzzyFilter('smain', list), 34 | [ 35 | ['', 's', 'rc/', 'main', '.js'], 36 | ] 37 | ); 38 | }); 39 | 40 | test('fuzzyFilter returns sorted filtered list, case4', t => { 41 | const list = ['package.json', 'src/main.js', 'src/app.html', 'src/app.js', 'index.html']; 42 | t.deepEqual( 43 | fuzzyFilter('js', list), 44 | [ 45 | ['src/main.', 'js'], 46 | ['src/app.', 'js'], 47 | ['package.', 'js', 'on'] 48 | ] 49 | ); 50 | }); 51 | -------------------------------------------------------------------------------- /client/test/edit/edit-session.import-data.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {EditSession} from '../../src/edit/edit-session'; 3 | 4 | function makeEa(published) { 5 | return { 6 | publish(event, data) { 7 | published.push([event, data]); 8 | } 9 | }; 10 | } 11 | 12 | function makeWorkerService(actions) { 13 | return { 14 | async perform(action) { 15 | actions.push(action); 16 | 17 | if (action.type === 'bundle') { 18 | return ['bundled-files']; 19 | } 20 | } 21 | }; 22 | } 23 | 24 | const consoleLog = { 25 | dumberLogs: { 26 | push() {} 27 | } 28 | } 29 | 30 | test('EditSession imports data', t => { 31 | const actions = []; 32 | const published = []; 33 | 34 | const ea = makeEa(published); 35 | const workerService = makeWorkerService(actions); 36 | const es = new EditSession(ea, workerService, consoleLog); 37 | 38 | const gist = { 39 | description: 'desc', 40 | files: [ 41 | { 42 | filename: 'src/main.js', 43 | content: 'main' 44 | }, 45 | { 46 | filename: 'index.html', 47 | content: 'index-html' 48 | }, 49 | { 50 | filename: 'package.json', 51 | content: '{"dependencies":{}}' 52 | } 53 | ] 54 | }; 55 | 56 | const newGist = { 57 | description: 'new-gist-desc', 58 | files: [ 59 | { 60 | filename: 'src/main.js', 61 | content: 'main2' 62 | } 63 | ] 64 | }; 65 | 66 | es.loadGist(gist); 67 | es.importData({ 68 | description: 'new-desc', 69 | files: [], 70 | gist: newGist 71 | }); 72 | es.mutationChanged(); 73 | t.deepEqual(published, [ 74 | ['loaded-gist', undefined], 75 | ['imported-data', undefined] 76 | ]); 77 | t.equal(es.description, 'new-desc'); 78 | t.equal(es.files.length, 0); 79 | t.equal(es.gist, newGist); 80 | t.notOk(es.isRendered); 81 | t.ok(es.isChanged); 82 | }); 83 | 84 | test('EditSession imports data with only files', t => { 85 | const actions = []; 86 | const published = []; 87 | 88 | const ea = makeEa(published); 89 | const workerService = makeWorkerService(actions); 90 | const es = new EditSession(ea, workerService, consoleLog); 91 | 92 | const gist = { 93 | description: 'desc', 94 | files: [ 95 | { 96 | filename: 'src/main.js', 97 | content: 'main' 98 | }, 99 | { 100 | filename: 'index.html', 101 | content: 'index-html' 102 | }, 103 | { 104 | filename: 'package.json', 105 | content: '{"dependencies":{}}' 106 | } 107 | ] 108 | }; 109 | 110 | es.loadGist(gist); 111 | es.importData({ 112 | files: [ 113 | { 114 | filename: 'foo.js', 115 | content: 'foo', 116 | isChanged: true 117 | }, 118 | { 119 | filename: 'index.html', 120 | content: 'html', 121 | isChanged: false 122 | } 123 | ] 124 | }); 125 | es.mutationChanged(); 126 | t.deepEqual(published, [ 127 | ['loaded-gist', undefined], 128 | ['imported-data', undefined] 129 | ]); 130 | t.equal(es.description, 'desc'); 131 | t.deepEqual(es.files, [ 132 | { 133 | filename: 'foo.js', 134 | content: 'foo', 135 | isChanged: true 136 | }, 137 | { 138 | filename: 'index.html', 139 | content: 'html', 140 | isChanged: false 141 | } 142 | ]); 143 | t.equal(es.gist, gist); 144 | t.notOk(es.isRendered); 145 | t.ok(es.isChanged); 146 | }); 147 | -------------------------------------------------------------------------------- /client/test/session-id.spec.js: -------------------------------------------------------------------------------- 1 | import {test} from 'zora'; 2 | import {SessionId} from '../src/session-id'; 3 | 4 | test('SessionId generates random sessionId', t => { 5 | const s = new SessionId({}); 6 | t.ok(s.id.match(/^[0-9a-f]{32}$/)); 7 | }); 8 | -------------------------------------------------------------------------------- /client/test/setup.js: -------------------------------------------------------------------------------- 1 | import 'aurelia-polyfills'; 2 | import {initialize} from 'aurelia-pal-browser'; 3 | initialize(); 4 | -------------------------------------------------------------------------------- /copy-to-server.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash --login 2 | 3 | # Copy js before html files 4 | scp client/dist/* huocp@a.gist.dumber.app:dumber-gist/client/dist/ 5 | scp client/favicon.ico huocp@a.gist.dumber.app:dumber-gist/client/ 6 | scp client/index.html huocp@a.gist.dumber.app:dumber-gist/client/ 7 | 8 | scp client-service-worker/__dumber-gist-worker.js huocp@a.gist.dumber.app:dumber-gist/client-service-worker/ 9 | scp client-service-worker/__boot-up-worker.html huocp@a.gist.dumber.app:dumber-gist/client-service-worker/ 10 | scp client-service-worker/__remove-expired-worker.html huocp@a.gist.dumber.app:dumber-gist/client-service-worker/ 11 | scp client-service-worker/favicon.ico huocp@a.gist.dumber.app:dumber-gist/client-service-worker/ 12 | scp client-service-worker/index.html huocp@a.gist.dumber.app:dumber-gist/client-service-worker/ 13 | 14 | scp server/dumber-cache/app.js huocp@a.gist.dumber.app:dumber-gist/server/dumber-cache/ 15 | scp server/github-oauth/app.js huocp@a.gist.dumber.app:dumber-gist/server/github-oauth/ 16 | scp server/request.js huocp@a.gist.dumber.app:dumber-gist/server/ 17 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash --login 2 | 3 | cd client 4 | npm run build:prod 5 | 6 | cd .. 7 | 8 | echo "Deploy files to Digital Ocean ..." 9 | 10 | ./copy-to-server.sh 11 | 12 | echo "Deployed!" 13 | echo "To restart cache or oauth server, ssh to dumber.app and touch dumber-gist/server/dumber-cache/tmp/restart.txt and dumber-gist/server/github-oauth/tmp/restart.txt" 14 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "baseUrl": "src" 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | "output", 9 | "dist", 10 | "scripts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumber-gist", 3 | "private": true, 4 | "version": "0.19.0", 5 | "description": "The private is set to true to avoid publishing to npm", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "standard-changelog": "^6.0.0" 9 | }, 10 | "scripts": { 11 | "version": "standard-changelog && git add CHANGELOG.md", 12 | "postversion": "git push && git push --tags && ./deploy.sh" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "eslint:recommended", 4 | "parserOptions": { 5 | "ecmaVersion": 8 6 | }, 7 | "rules": { 8 | "no-console": 0, 9 | "getter-return": 0, 10 | "no-prototype-builtins": 0 11 | }, 12 | "env": { 13 | "es2017": true, 14 | "node": true 15 | }, 16 | "globals": { 17 | } 18 | } -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Dumber gist back-end support 2 | 3 | This back-end only does caching and GitHub OAuth. 4 | 5 | GitHub oauth server for dumber-gist 6 | 7 | ## Running in dev mode 8 | 9 | Use local nginx+passenger to start both apps. TODO guide. 10 | 11 | ## Or run them manually 12 | 13 | Start github-oauth server at port 500 14 | 15 | node github-oauth/app 16 | 17 | Start dumber-cache server at port 5001 18 | 19 | node dumber-cache/app 20 | 21 | -------------------------------------------------------------------------------- /server/dumber-cache/app.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const path = require('path'); 3 | const fs = require('fs').promises; 4 | const {receiveData, fetch} = require('../request'); 5 | 6 | const PORT = 5001; 7 | const CACHE_DIR = path.join(__dirname, 'public'); 8 | 9 | const domainSubfix = process.env.NODE_ENV === 'production' ? 'app' : 'local'; 10 | const DUMBER_DOMAIN = process.env.DUMBER_DOMAIN || `dumber.${domainSubfix}`; 11 | const HOST = `https://gist.${DUMBER_DOMAIN}`; 12 | const JSDELIVR_CDN_DOMAIN = process.env.JSDELIVR_CDN_DOMAIN || 'cdn.jsdelivr.net'; 13 | 14 | const knownTokens = {}; 15 | 16 | async function getUser(token) { 17 | if (!token) throw new Error('Invalid user'); 18 | if (knownTokens[token]) return knownTokens[token]; 19 | 20 | const {statusCode, body} = await fetch('https://api.github.com/user', { 21 | headers: { 22 | Authorization: `token ${token}`, 23 | 'User-Agent': `dumber-gist/0.0.1 (${HOST})` 24 | } 25 | }); 26 | 27 | if (statusCode !== 200) { 28 | throw new Error('Invalid user: ' + body); 29 | } 30 | 31 | try { 32 | const user = JSON.parse(body); 33 | knownTokens[token] = user.login; 34 | return user.login; 35 | } catch (e) { 36 | throw new Error('Invalid user: ' + e.message); 37 | } 38 | } 39 | 40 | function cachedFilePath(hash) { 41 | const folder = hash.slice(0, 2); 42 | const fileName = hash.slice(2); 43 | return path.resolve(CACHE_DIR, folder, fileName); 44 | } 45 | 46 | async function setCache(hash, object) { 47 | object.__dumber_hash = hash; 48 | const filePath = cachedFilePath(hash); 49 | await fs.mkdir(path.dirname(filePath), {recursive: true}); 50 | 51 | try { 52 | // x means fail if file already exists 53 | await fs.writeFile(filePath, JSON.stringify(object), {flag: 'wx'}); 54 | } catch (err) { 55 | if (err && !err.code === 'EEXIST') { 56 | console.error(`Failed to write cache ${filePath}: ${err.code} ${err.message}`); 57 | } 58 | return; 59 | } 60 | 61 | if (object.path.startsWith(`//${JSDELIVR_CDN_DOMAIN}/npm/`)) { 62 | // slice to "npm/..." 63 | const npmPath = path.resolve(CACHE_DIR, object.path.slice(19)); 64 | await fs.mkdir(path.dirname(npmPath), {recursive: true}); 65 | await fs.link(filePath, npmPath); 66 | } 67 | } 68 | 69 | // Note CORS headers are now added by nginx 70 | async function handleRequest(req, res) { 71 | const origin = req.headers.origin; 72 | res.setHeader('Content-Type', 'text/plain'); 73 | 74 | if (origin === HOST) { 75 | if (req.method === 'OPTIONS') { 76 | res.writeHead(200); 77 | res.end(); 78 | return; 79 | } 80 | 81 | // Only set-cache action is handled by nodejs + passenger. 82 | // get-cache is handled by nginx directly. 83 | if (req.method === 'POST') { 84 | try { 85 | const d = await receiveData(req) 86 | // const {token, hash, object} = d; 87 | const {hash, object} = d; 88 | if (!hash || !object) { 89 | throw new Error('In complete request'); 90 | } 91 | 92 | // Skip user check, allow anonymous contribution to cache 93 | // await getUser(token); 94 | setCache(hash, object); 95 | 96 | res.writeHead(200); 97 | res.end(); 98 | } catch (error) { 99 | res.writeHead(500); 100 | res.end(error.message, 'utf8'); 101 | } 102 | return; 103 | } 104 | } 105 | 106 | res.writeHead(404); 107 | res.end(`Not Found: ${req.method} ${req.url}\n`, 'utf8'); 108 | } 109 | 110 | console.log('Start dumber-gist cache server ...'); 111 | http.createServer(handleRequest).listen(PORT); 112 | -------------------------------------------------------------------------------- /server/github-oauth/app.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const {receiveData, fetch} = require('../request'); 3 | 4 | const PORT = 5000; 5 | const CLIENT_ID = process.env.DR_CLIENT_ID; 6 | const CLIENT_SECRET = process.env.DR_CLIENT_SECRET; 7 | 8 | const domainSubfix = process.env.NODE_ENV === 'production' ? 'app' : 'local'; 9 | const domain = process.env.DUMBER_DOMAIN || `dumber.${domainSubfix}`; 10 | const HOST = `https://gist.${domain}`; 11 | 12 | if (!CLIENT_ID || !CLIENT_SECRET) { 13 | throw new Error('no client_id or client_secret'); 14 | } 15 | 16 | // Note CORS headers are now added by nginx 17 | async function handleRequest(req, res) { 18 | const origin = req.headers.origin; 19 | res.setHeader('Content-Type', 'text/plain'); 20 | 21 | if (origin === HOST) { 22 | if (req.method === 'OPTIONS') { 23 | res.writeHead(200); 24 | res.end(); 25 | return; 26 | } 27 | 28 | if (req.method === 'POST' && /^\/access_token$/.test(req.url)) { 29 | try { 30 | const data = await receiveData(req); 31 | const params = { 32 | client_id: CLIENT_ID, 33 | client_secret: CLIENT_SECRET, 34 | code: data.code, 35 | redirect_uri: data.redirect_uri, 36 | state: data.state 37 | }; 38 | 39 | const result = await fetch('https://github.com/login/oauth/access_token', { 40 | method: 'POST', 41 | params 42 | }); 43 | res.writeHead(200, result.headers); 44 | res.end(result.body, 'utf8'); 45 | } catch(error) { 46 | res.writeHead(500); 47 | res.end(error.message, 'utf8'); 48 | } 49 | return; 50 | } 51 | } 52 | 53 | res.writeHead(404); 54 | res.end(`Not Found: ${req.method} ${req.url}\n`, 'utf8'); 55 | } 56 | 57 | console.log('Start dumber-gist github-oauth server ...'); 58 | http.createServer(handleRequest).listen(PORT); 59 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumber-gist-server", 3 | "version": "0.1.0", 4 | "description": "GitHub OAuth server, and cache server for dumber-gist", 5 | "private": true, 6 | "main": "github-oauth.js", 7 | "scripts": { 8 | "lint": "eslint dumber-cache github-oauth request.js" 9 | }, 10 | "license": "MIT", 11 | "devDependencies": { 12 | "eslint": "^8.56.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/request.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const querystring = require('querystring'); 3 | 4 | exports.receiveData = function(request) { 5 | return new Promise((resolve, reject) => { 6 | let body = ''; 7 | 8 | request.on('data', chunk => body += chunk); 9 | request.on('end', () => { 10 | try { 11 | resolve(JSON.parse(body)); 12 | } catch (e) { 13 | reject(e); 14 | } 15 | }); 16 | request.on('error', reject); 17 | }); 18 | }; 19 | 20 | exports.fetch = function(uri, opts = {}) { 21 | const options = { 22 | method: opts.method || 'GET', 23 | headers: {} 24 | }; 25 | 26 | if (opts.headers) { 27 | Object.assign(options.headers, opts.headers); 28 | } 29 | 30 | const form = querystring.stringify(opts.params || {}); 31 | if (options.method === 'POST') { 32 | Object.assign(options.headers, { 33 | 'Content-Type': 'application/x-www-form-urlencoded', 34 | 'Content-Length': form.length 35 | }); 36 | } 37 | 38 | return new Promise((resolve, reject) => { 39 | const req = https.request(uri, options, res => { 40 | res.setEncoding('utf8'); 41 | const { statusCode, headers } = res; 42 | let body = ''; 43 | 44 | res.on('data', chunk => body += chunk); 45 | res.on('end', () => resolve({ statusCode, headers, body })); 46 | res.on('error', reject); 47 | }); 48 | 49 | if (options.method === 'POST') { 50 | req.write(form); 51 | } 52 | req.end(); 53 | }); 54 | }; 55 | --------------------------------------------------------------------------------