├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src ├── constants.js ├── gitment.js ├── icons.js ├── test.js ├── theme │ └── default.js └── utils.js ├── style └── default.css ├── test ├── gitment.browser.html ├── gitment.html └── style.css ├── webpack.config.js └── webpack.dev.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | 4 | node_modules 5 | 6 | dist 7 | test/config.js 8 | index.html -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .gitignore 4 | .npmignore 5 | .babelrc 6 | 7 | node_modules 8 | 9 | src 10 | test 11 | index.html 12 | webpack.config.js 13 | webpack.dev.config.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 imsun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitment 2 | 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | 6 | [npm-image]: https://img.shields.io/npm/v/gitment.svg 7 | [npm-url]: https://www.npmjs.com/package/gitment 8 | 9 | Gitment is a comment system based on GitHub Issues, 10 | which can be used in the frontend without any server-side implementation. 11 | 12 | [Demo Page](https://imsun.github.io/gitment/) 13 | 14 | [中文简介](https://imsun.net/posts/gitment-introduction/) 15 | 16 | - [Features](#features) 17 | - [Get Started](#get-started) 18 | - [Methods](#methods) 19 | - [Customize](#customize) 20 | - [About Security](#about-security) 21 | 22 | ## Features 23 | 24 | - GitHub Login 25 | - Markdown / GFM support 26 | - Syntax highlighting 27 | - Notifications from GitHub 28 | - Easy to customize 29 | - No server-side implementation 30 | 31 | ## Get Started 32 | 33 | ### 1. Install 34 | 35 | ```html 36 | 37 | ``` 38 | 39 | ```html 40 | 41 | ``` 42 | 43 | or via npm: 44 | 45 | ```sh 46 | $ npm i --save gitment 47 | ``` 48 | 49 | ```javascript 50 | import 'gitment/style/default.css' 51 | import Gitment from 'gitment' 52 | ``` 53 | 54 | ### 2. Register An OAuth Application 55 | 56 | [Click here](https://github.com/settings/applications/new) to register an OAuth application, and you will get a client ID and a client secret. 57 | 58 | Make sure the callback URL is right. Generally it's the origin of your site, like [https://imsun.net](https://imsun.net). 59 | 60 | ### 3. Render Gitment 61 | 62 | ```javascript 63 | const gitment = new Gitment({ 64 | id: 'Your page ID', // optional 65 | owner: 'Your GitHub ID', 66 | repo: 'The repo to store comments', 67 | oauth: { 68 | client_id: 'Your client ID', 69 | client_secret: 'Your client secret', 70 | }, 71 | // ... 72 | // For more available options, check out the documentation below 73 | }) 74 | 75 | gitment.render('comments') 76 | // or 77 | // gitment.render(document.getElementById('comments')) 78 | // or 79 | // document.body.appendChild(gitment.render()) 80 | ``` 81 | 82 | ### 4. Initialize Your Comments 83 | 84 | After the page is published, you should visit your page, login with your GitHub account(make sure you're repo's owner), and click the initialize button, to create a related issue in your repo. 85 | After that, others can leave their comments. 86 | 87 | ## Methods 88 | 89 | ### constructor(options) 90 | 91 | #### options: 92 | 93 | Type: `object` 94 | 95 | - owner: Your GitHub ID. Required. 96 | - repo: The repository to store your comments. Make sure you're repo's owner. Required. 97 | - oauth: An object contains your client ID and client secret. Required. 98 | - client_id: GitHub client ID. Required. 99 | - client_secret: GitHub client secret. Required. 100 | - id: An optional string to identify your page. Default `location.href`. 101 | - title: An optional title for your page, used as issue's title. Default `document.title`. 102 | - link: An optional link for your page, used in issue's body. Default `location.href`. 103 | - desc: An optional description for your page, used in issue's body. Default `''`. 104 | - labels: An optional array of labels your want to add when creating the issue. Default `[]`. 105 | - theme: An optional Gitment theme object. Default `gitment.defaultTheme`. 106 | - perPage: An optional number to which comments will be paginated. Default `20`. 107 | - maxCommentHeight: An optional number to limit comments' max height, over which comments will be folded. Default `250`. 108 | 109 | ### gitment.render([element]) 110 | 111 | #### element 112 | 113 | Type: `HTMLElement` or `string` 114 | 115 | The DOM element to which comments will be rendered. Can be an HTML element or element's id. When omitted, this function will create a new `div` element. 116 | 117 | This function returns the element to which comments be rendered. 118 | 119 | ### gitment.renderHeader([element]) 120 | 121 | Same like `gitment.render([element])`. But only renders the header. 122 | 123 | ### gitment.renderComments([element]) 124 | 125 | Same like `gitment.render([element])`. But only renders comments list. 126 | 127 | 128 | ### gitment.renderEditor([element]) 129 | 130 | Same like `gitment.render([element])`. But only renders the editor. 131 | 132 | 133 | ### gitment.renderFooter([element]) 134 | 135 | Same like `gitment.render([element])`. But only renders the footer. 136 | 137 | ### gitment.init() 138 | 139 | Initialize a new page. Returns a `Promise` and resolves when initialized. 140 | 141 | ### gitment.update() 142 | 143 | Update data and views. Returns a `Promise` and resolves when data updated. 144 | 145 | ### gitment.post() 146 | 147 | Post comment in the editor. Returns a `Promise` and resolves when posted. 148 | 149 | ### gitment.markdown(text) 150 | 151 | #### text 152 | 153 | Type: `string` 154 | 155 | Returns a `Promise` and resolves rendered text. 156 | 157 | ### gitment.login() 158 | 159 | Jump to GitHub OAuth page to login. 160 | 161 | ### gitment.logout() 162 | 163 | Log out current user. 164 | 165 | ### goto(page) 166 | 167 | #### page 168 | 169 | Type: `number` 170 | 171 | Jump to the target page of comments. Notice that `page` starts from `1`. Returns a `Promise` and resolves when comments loaded. 172 | 173 | ### gitment.like() 174 | 175 | Like current page. Returns a `Promise` and resolves when liked. 176 | 177 | ### gitment.unlike() 178 | 179 | Unlike current page. Returns a `Promise` and resolves when unliked. 180 | 181 | ### gitment.likeAComment(commentId) 182 | 183 | #### commentId 184 | 185 | Type: `string` 186 | 187 | Like a comment. Returns a `Promise` and resolves when liked. 188 | 189 | ### gitment.unlikeAComment(commentId) 190 | 191 | #### commentId 192 | 193 | Type: `string` 194 | 195 | Unlike a comment. Returns a `Promise` and resolves when unliked. 196 | 197 | ## Customize 198 | 199 | Gitment is easy to customize. You can use your own CSS or write a theme. 200 | (The difference is that customized CSS can't modify DOM structure) 201 | 202 | ### Use Customized CSS 203 | 204 | Gitment does't use any atomic CSS, making it easier and more flexible to customize. 205 | You can inspect the DOM structure in the browser and write your own styles. 206 | 207 | ### Write A Theme 208 | 209 | A Gitment theme is an object contains several render functions. 210 | 211 | By default Gitment has five render functions: `render`, `renderHeader`, `renderComments`, `renderEditor`, `renderFooter`. 212 | The last four render independent components and `render` functions render them together. 213 | All of them can be used independently. 214 | 215 | You can override any render function above or write your own render function. 216 | 217 | For example, you can override the `render` function to put an editor before the comment list, and render a new component. 218 | 219 | ```javascript 220 | const myTheme = { 221 | render(state, instance) { 222 | const container = document.createElement('div') 223 | container.lang = "en-US" 224 | container.className = 'gitment-container gitment-root-container' 225 | 226 | // your custom component 227 | container.appendChild(instance.renderSomething(state, instance)) 228 | 229 | container.appendChild(instance.renderHeader(state, instance)) 230 | container.appendChild(instance.renderEditor(state, instance)) 231 | container.appendChild(instance.renderComments(state, instance)) 232 | container.appendChild(instance.renderFooter(state, instance)) 233 | return container 234 | }, 235 | renderSomething(state, instance) { 236 | const container = document.createElement('div') 237 | container.lang = "en-US" 238 | if (state.user.login) { 239 | container.innerText = `Hello, ${state.user.login}` 240 | } 241 | return container 242 | } 243 | } 244 | 245 | const gitment = new Gitment({ 246 | // ... 247 | theme: myTheme, 248 | }) 249 | 250 | gitment.render(document.body) 251 | // or 252 | // gitment.renderSomthing(document.body) 253 | ``` 254 | 255 | Each render function should receive a state object and a gitment instance, and return an HTML element. 256 | It will be wrapped attached to the Gitment instance with the same name. 257 | 258 | Gitment uses [MobX](https://github.com/mobxjs/mobx) to detect states used in render functions. 259 | Once used states change, Gitment will call the render function to get a new element and render it. 260 | Unused states' changing won't affect rendered elements. 261 | 262 | Available states: 263 | 264 | - user: `object`. User info returned from [GitHub Users API](https://developer.github.com/v3/users/#get-the-authenticated-user) with two more keys. 265 | - isLoggingIn: `bool`. Indicates if user is logging in. 266 | - fromCache: `bool`. Gitment will cache user's information. Its value indicates if current user info is from cache. 267 | - error: `Error Object`. Will be null if no error occurs. 268 | - meta: `object`. Issue's info returned from [GitHub Issues API](https://developer.github.com/v3/issues/#list-issues). 269 | - comments: `array`. Array of comment returned from [GitHub Issue Comments API](/repos/:owner/:repo/issues/:number/comments). Will be `undefined` when comments not loaded. 270 | - reactions: `array`. Array of reactions added to current page, returned from [GitHub Issues' Reactions API](https://developer.github.com/v3/reactions/#list-reactions-for-an-issue). 271 | - commentReactions: `object`. Object of reactions added to comments, with comment ID as key, returned from [GitHub Issue Comments' Reactions API](/repos/:owner/:repo/issues/comments/:id/reactions). 272 | - currentPage: `number`. Which page of comments is user on. Starts from `1`. 273 | 274 | ## About Security 275 | 276 | ### Is it safe to make my client secret public? 277 | 278 | Client secret is necessary for OAuth, without which users can't login or comment with their GitHub accounts. 279 | Although GitHub does't recommend to hard code client secret in the frontend, you can still do that because GitHub will verify your callback URL. 280 | In theory, no one else can use your secret except your site. 281 | 282 | If you find a way to hack it, please [open an issue](https://github.com/imsun/gitment/issues/new). 283 | 284 | ### Why does Gitment send a request to gh-oauth.imsun.net? 285 | 286 | [https://gh-oauth.imsun.net](https://gh-oauth.imsun.net) is an simple open-source service to proxy [one request](https://developer.github.com/v3/oauth/#2-github-redirects-back-to-your-site) during users logging in. 287 | Because GitHub doesn't attach a CORS header to it. 288 | 289 | This service won't record or store anything. It only attaches a CORS header to that request and provides proxy. 290 | So that users can login in the frontend without any server-side implementation. 291 | 292 | For more details, checkout [this project](https://github.com/imsun/gh-oauth-server). 293 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitment", 3 | "version": "0.0.3", 4 | "description": "A comment system based on GitHub Issues", 5 | "main": "./dist/gitment.js", 6 | "author": { 7 | "name": "Shiquan Sun", 8 | "url": "https://github.com/imsun" 9 | }, 10 | "keywords": [ 11 | "comment system", 12 | "GitHub Issues" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/imsun/gitment" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/imsun/gitment/issues" 20 | }, 21 | "homepage": "https://github.com/imsun/gitment", 22 | "scripts": { 23 | "build": "babel src --out-dir dist --ignore test.js --source-maps & NODE_ENV=production webpack --config webpack.config.js --progress --profile --colors", 24 | "dev": "webpack-dev-server --config webpack.dev.config.js --host 0.0.0.0 --progress --profile --colors" 25 | }, 26 | "devDependencies": { 27 | "babel-cli": "^6.24.0", 28 | "babel-core": "^6.24.0", 29 | "babel-loader": "^6.4.1", 30 | "babel-preset-es2015": "^6.24.0", 31 | "webpack": "^2.3.2", 32 | "webpack-dev-server": "^2.4.2" 33 | }, 34 | "dependencies": { 35 | "mobx": "^3.1.7" 36 | }, 37 | "license": "MIT" 38 | } 39 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const LS_ACCESS_TOKEN_KEY = 'gitment-comments-token' 2 | export const LS_USER_KEY = 'gitment-user-info' 3 | 4 | export const NOT_INITIALIZED_ERROR = new Error('Comments Not Initialized') 5 | -------------------------------------------------------------------------------- /src/gitment.js: -------------------------------------------------------------------------------- 1 | import { autorun, observable } from 'mobx' 2 | 3 | import { LS_ACCESS_TOKEN_KEY, LS_USER_KEY, NOT_INITIALIZED_ERROR } from './constants' 4 | import { getTargetContainer, http, Query } from './utils' 5 | import defaultTheme from './theme/default' 6 | 7 | const scope = 'public_repo' 8 | 9 | function extendRenderer(instance, renderer) { 10 | instance[renderer] = (container) => { 11 | const targetContainer = getTargetContainer(container) 12 | const render = instance.theme[renderer] || instance.defaultTheme[renderer] 13 | 14 | autorun(() => { 15 | const e = render(instance.state, instance) 16 | if (targetContainer.firstChild) { 17 | targetContainer.replaceChild(e, targetContainer.firstChild) 18 | } else { 19 | targetContainer.appendChild(e) 20 | } 21 | }) 22 | 23 | return targetContainer 24 | } 25 | } 26 | 27 | class Gitment { 28 | get accessToken() { 29 | return localStorage.getItem(LS_ACCESS_TOKEN_KEY) 30 | } 31 | set accessToken(token) { 32 | localStorage.setItem(LS_ACCESS_TOKEN_KEY, token) 33 | } 34 | 35 | get loginLink() { 36 | const oauthUri = 'https://github.com/login/oauth/authorize' 37 | const redirect_uri = this.oauth.redirect_uri || window.location.href 38 | 39 | const oauthParams = Object.assign({ 40 | scope, 41 | redirect_uri, 42 | }, this.oauth) 43 | 44 | return `${oauthUri}${Query.stringify(oauthParams)}` 45 | } 46 | 47 | constructor(options = {}) { 48 | this.defaultTheme = defaultTheme 49 | this.useTheme(defaultTheme) 50 | 51 | Object.assign(this, { 52 | id: window.location.href, 53 | title: window.document.title, 54 | link: window.location.href, 55 | desc: '', 56 | labels: [], 57 | theme: defaultTheme, 58 | oauth: {}, 59 | perPage: 20, 60 | maxCommentHeight: 250, 61 | }, options) 62 | 63 | this.useTheme(this.theme) 64 | 65 | const user = {} 66 | try { 67 | const userInfo = localStorage.getItem(LS_USER_KEY) 68 | if (this.accessToken && userInfo) { 69 | Object.assign(user, JSON.parse(userInfo), { 70 | fromCache: true, 71 | }) 72 | } 73 | } catch (e) { 74 | localStorage.removeItem(LS_USER_KEY) 75 | } 76 | 77 | this.state = observable({ 78 | user, 79 | error: null, 80 | meta: {}, 81 | comments: undefined, 82 | reactions: [], 83 | commentReactions: {}, 84 | currentPage: 1, 85 | }) 86 | 87 | const query = Query.parse() 88 | if (query.code) { 89 | const { client_id, client_secret } = this.oauth 90 | const code = query.code 91 | delete query.code 92 | const search = Query.stringify(query) 93 | const replacedUrl = `${window.location.origin}${window.location.pathname}${search}${window.location.hash}` 94 | history.replaceState({}, '', replacedUrl) 95 | 96 | Object.assign(this, { 97 | id: replacedUrl, 98 | link: replacedUrl, 99 | }, options) 100 | 101 | this.state.user.isLoggingIn = true 102 | http.post('https://gh-oauth.imsun.net', { 103 | code, 104 | client_id, 105 | client_secret, 106 | }, '') 107 | .then(data => { 108 | this.accessToken = data.access_token 109 | this.update() 110 | }) 111 | .catch(e => { 112 | this.state.user.isLoggingIn = false 113 | alert(e) 114 | }) 115 | } else { 116 | this.update() 117 | } 118 | } 119 | 120 | init() { 121 | return this.createIssue() 122 | .then(() => this.loadComments()) 123 | .then(comments => { 124 | this.state.error = null 125 | return comments 126 | }) 127 | } 128 | 129 | useTheme(theme = {}) { 130 | this.theme = theme 131 | 132 | const renderers = Object.keys(this.theme) 133 | renderers.forEach(renderer => extendRenderer(this, renderer)) 134 | } 135 | 136 | update() { 137 | return Promise.all([this.loadMeta(), this.loadUserInfo()]) 138 | .then(() => Promise.all([ 139 | this.loadComments().then(() => this.loadCommentReactions()), 140 | this.loadReactions(), 141 | ])) 142 | .catch(e => this.state.error = e) 143 | } 144 | 145 | markdown(text) { 146 | return http.post('/markdown', { 147 | text, 148 | mode: 'gfm', 149 | }) 150 | } 151 | 152 | createIssue() { 153 | const { id, owner, repo, title, link, desc, labels } = this 154 | 155 | return http.post(`/repos/${owner}/${repo}/issues`, { 156 | title, 157 | labels: labels.concat(['gitment', id]), 158 | body: `${link}\n\n${desc}`, 159 | }) 160 | .then((meta) => { 161 | this.state.meta = meta 162 | return meta 163 | }) 164 | } 165 | 166 | getIssue() { 167 | if (this.state.meta.id) return Promise.resolve(this.state.meta) 168 | 169 | return this.loadMeta() 170 | } 171 | 172 | post(body) { 173 | return this.getIssue() 174 | .then(issue => http.post(issue.comments_url, { body }, '')) 175 | .then(data => { 176 | this.state.meta.comments++ 177 | const pageCount = Math.ceil(this.state.meta.comments / this.perPage) 178 | if (this.state.currentPage === pageCount) { 179 | this.state.comments.push(data) 180 | } 181 | return data 182 | }) 183 | } 184 | 185 | loadMeta() { 186 | const { id, owner, repo } = this 187 | return http.get(`/repos/${owner}/${repo}/issues`, { 188 | creator: owner, 189 | labels: id, 190 | }) 191 | .then(issues => { 192 | if (!issues.length) return Promise.reject(NOT_INITIALIZED_ERROR) 193 | this.state.meta = issues[0] 194 | return issues[0] 195 | }) 196 | } 197 | 198 | loadComments(page = this.state.currentPage) { 199 | return this.getIssue() 200 | .then(issue => http.get(issue.comments_url, { page, per_page: this.perPage }, '')) 201 | .then((comments) => { 202 | this.state.comments = comments 203 | return comments 204 | }) 205 | } 206 | 207 | loadUserInfo() { 208 | if (!this.accessToken) { 209 | this.logout() 210 | return Promise.resolve({}) 211 | } 212 | 213 | return http.get('/user') 214 | .then((user) => { 215 | this.state.user = user 216 | localStorage.setItem(LS_USER_KEY, JSON.stringify(user)) 217 | return user 218 | }) 219 | } 220 | 221 | loadReactions() { 222 | if (!this.accessToken) { 223 | this.state.reactions = [] 224 | return Promise.resolve([]) 225 | } 226 | 227 | return this.getIssue() 228 | .then((issue) => { 229 | if (!issue.reactions.total_count) return [] 230 | return http.get(issue.reactions.url, {}, '') 231 | }) 232 | .then((reactions) => { 233 | this.state.reactions = reactions 234 | return reactions 235 | }) 236 | } 237 | 238 | loadCommentReactions() { 239 | if (!this.accessToken) { 240 | this.state.commentReactions = {} 241 | return Promise.resolve([]) 242 | } 243 | 244 | const comments = this.state.comments 245 | const comentReactions = {} 246 | 247 | return Promise.all(comments.map((comment) => { 248 | if (!comment.reactions.total_count) return [] 249 | 250 | const { owner, repo } = this 251 | return http.get(`/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions`, {}) 252 | })) 253 | .then(reactionsArray => { 254 | comments.forEach((comment, index) => { 255 | comentReactions[comment.id] = reactionsArray[index] 256 | }) 257 | this.state.commentReactions = comentReactions 258 | 259 | return comentReactions 260 | }) 261 | } 262 | 263 | login() { 264 | window.location.href = this.loginLink 265 | } 266 | 267 | logout() { 268 | localStorage.removeItem(LS_ACCESS_TOKEN_KEY) 269 | localStorage.removeItem(LS_USER_KEY) 270 | this.state.user = {} 271 | } 272 | 273 | goto(page) { 274 | this.state.currentPage = page 275 | this.state.comments = undefined 276 | return this.loadComments(page) 277 | } 278 | 279 | like() { 280 | if (!this.accessToken) { 281 | alert('Login to Like') 282 | return Promise.reject() 283 | } 284 | 285 | const { owner, repo } = this 286 | 287 | return http.post(`/repos/${owner}/${repo}/issues/${this.state.meta.number}/reactions`, { 288 | content: 'heart', 289 | }) 290 | .then(reaction => { 291 | this.state.reactions.push(reaction) 292 | this.state.meta.reactions.heart++ 293 | }) 294 | } 295 | 296 | unlike() { 297 | if (!this.accessToken) return Promise.reject() 298 | 299 | 300 | const { user, reactions } = this.state 301 | const index = reactions.findIndex(reaction => reaction.user.login === user.login) 302 | return http.delete(`/reactions/${reactions[index].id}`) 303 | .then(() => { 304 | reactions.splice(index, 1) 305 | this.state.meta.reactions.heart-- 306 | }) 307 | } 308 | 309 | likeAComment(commentId) { 310 | if (!this.accessToken) { 311 | alert('Login to Like') 312 | return Promise.reject() 313 | } 314 | 315 | const { owner, repo } = this 316 | const comment = this.state.comments.find(comment => comment.id === commentId) 317 | 318 | return http.post(`/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`, { 319 | content: 'heart', 320 | }) 321 | .then(reaction => { 322 | this.state.commentReactions[commentId].push(reaction) 323 | comment.reactions.heart++ 324 | }) 325 | } 326 | 327 | unlikeAComment(commentId) { 328 | if (!this.accessToken) return Promise.reject() 329 | 330 | const reactions = this.state.commentReactions[commentId] 331 | const comment = this.state.comments.find(comment => comment.id === commentId) 332 | const { user } = this.state 333 | const index = reactions.findIndex(reaction => reaction.user.login === user.login) 334 | 335 | return http.delete(`/reactions/${reactions[index].id}`) 336 | .then(() => { 337 | reactions.splice(index, 1) 338 | comment.reactions.heart-- 339 | }) 340 | } 341 | } 342 | 343 | module.exports = Gitment 344 | -------------------------------------------------------------------------------- /src/icons.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Modified from https://github.com/evil-icons/evil-icons 3 | */ 4 | 5 | export const close = '' 6 | export const github = '' 7 | export const heart = '' 8 | export const spinner = '' 9 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | var Gitment = Gitment || require('./gitment') 2 | 3 | const config = window.config 4 | 5 | if (!config) { 6 | throw new Error('You need your own config to run this test.') 7 | } 8 | 9 | const gitment = new Gitment(config) 10 | 11 | gitment.render('container') 12 | 13 | window.gitment = gitment 14 | 15 | try { 16 | window.http = require('./utils').http 17 | } catch (e) {} 18 | -------------------------------------------------------------------------------- /src/theme/default.js: -------------------------------------------------------------------------------- 1 | import { github as githubIcon, heart as heartIcon, spinner as spinnerIcon } from '../icons' 2 | import { NOT_INITIALIZED_ERROR } from '../constants' 3 | 4 | function renderHeader({ meta, user, reactions }, instance) { 5 | const container = document.createElement('div') 6 | container.lang = "en-US" 7 | container.className = 'gitment-container gitment-header-container' 8 | 9 | const likeButton = document.createElement('span') 10 | const likedReaction = reactions.find(reaction => ( 11 | reaction.content === 'heart' && reaction.user.login === user.login 12 | )) 13 | likeButton.className = 'gitment-header-like-btn' 14 | likeButton.innerHTML = ` 15 | ${heartIcon} 16 | ${ likedReaction 17 | ? 'Unlike' 18 | : 'Like' 19 | } 20 | ${ meta.reactions && meta.reactions.heart 21 | ? ` • ${meta.reactions.heart} Liked` 22 | : '' 23 | } 24 | ` 25 | 26 | if (likedReaction) { 27 | likeButton.classList.add('liked') 28 | likeButton.onclick = () => instance.unlike() 29 | } else { 30 | likeButton.classList.remove('liked') 31 | likeButton.onclick = () => instance.like() 32 | } 33 | container.appendChild(likeButton) 34 | 35 | const commentsCount = document.createElement('span') 36 | commentsCount.innerHTML = ` 37 | ${ meta.comments 38 | ? ` • ${meta.comments} Comments` 39 | : '' 40 | } 41 | ` 42 | container.appendChild(commentsCount) 43 | 44 | const issueLink = document.createElement('a') 45 | issueLink.className = 'gitment-header-issue-link' 46 | issueLink.href = meta.html_url 47 | issueLink.target = '_blank' 48 | issueLink.innerText = 'Issue Page' 49 | container.appendChild(issueLink) 50 | 51 | return container 52 | } 53 | 54 | function renderComments({ meta, comments, commentReactions, currentPage, user, error }, instance) { 55 | const container = document.createElement('div') 56 | container.lang = "en-US" 57 | container.className = 'gitment-container gitment-comments-container' 58 | 59 | if (error) { 60 | const errorBlock = document.createElement('div') 61 | errorBlock.className = 'gitment-comments-error' 62 | 63 | if (error === NOT_INITIALIZED_ERROR 64 | && user.login 65 | && user.login.toLowerCase() === instance.owner.toLowerCase()) { 66 | const initHint = document.createElement('div') 67 | const initButton = document.createElement('button') 68 | initButton.className = 'gitment-comments-init-btn' 69 | initButton.onclick = () => { 70 | initButton.setAttribute('disabled', true) 71 | instance.init() 72 | .catch(e => { 73 | initButton.removeAttribute('disabled') 74 | alert(e) 75 | }) 76 | } 77 | initButton.innerText = 'Initialize Comments' 78 | initHint.appendChild(initButton) 79 | errorBlock.appendChild(initHint) 80 | } else { 81 | errorBlock.innerText = error 82 | } 83 | container.appendChild(errorBlock) 84 | return container 85 | } else if (comments === undefined) { 86 | const loading = document.createElement('div') 87 | loading.innerText = 'Loading comments...' 88 | loading.className = 'gitment-comments-loading' 89 | container.appendChild(loading) 90 | return container 91 | } else if (!comments.length) { 92 | const emptyBlock = document.createElement('div') 93 | emptyBlock.className = 'gitment-comments-empty' 94 | emptyBlock.innerText = 'No Comment Yet' 95 | container.appendChild(emptyBlock) 96 | return container 97 | } 98 | 99 | const commentsList = document.createElement('ul') 100 | commentsList.className = 'gitment-comments-list' 101 | 102 | comments.forEach(comment => { 103 | const createDate = new Date(comment.created_at) 104 | const updateDate = new Date(comment.updated_at) 105 | const commentItem = document.createElement('li') 106 | commentItem.className = 'gitment-comment' 107 | commentItem.innerHTML = ` 108 | 109 | 110 | 111 |
112 |
113 | 114 | ${comment.user.login} 115 | 116 | commented on 117 | ${createDate.toDateString()} 118 | ${ createDate.toString() !== updateDate.toString() 119 | ? ` • edited` 120 | : '' 121 | } 122 |
${heartIcon} ${comment.reactions.heart || ''}
123 |
124 |
${comment.body_html}
125 |
126 | ` 127 | const likeButton = commentItem.querySelector('.gitment-comment-like-btn') 128 | const likedReaction = commentReactions[comment.id] 129 | && commentReactions[comment.id].find(reaction => ( 130 | reaction.content === 'heart' && reaction.user.login === user.login 131 | )) 132 | if (likedReaction) { 133 | likeButton.classList.add('liked') 134 | likeButton.onclick = () => instance.unlikeAComment(comment.id) 135 | } else { 136 | likeButton.classList.remove('liked') 137 | likeButton.onclick = () => instance.likeAComment(comment.id) 138 | } 139 | 140 | // dirty 141 | // use a blank image to trigger height calculating when element rendered 142 | const imgTrigger = document.createElement('img') 143 | const markdownBody = commentItem.querySelector('.gitment-comment-body') 144 | imgTrigger.className = 'gitment-hidden' 145 | imgTrigger.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" 146 | imgTrigger.onload = () => { 147 | if (markdownBody.clientHeight > instance.maxCommentHeight) { 148 | markdownBody.classList.add('gitment-comment-body-folded') 149 | markdownBody.style.maxHeight = instance.maxCommentHeight + 'px' 150 | markdownBody.title = 'Click to Expand' 151 | markdownBody.onclick = () => { 152 | markdownBody.classList.remove('gitment-comment-body-folded') 153 | markdownBody.style.maxHeight = '' 154 | markdownBody.title = '' 155 | markdownBody.onclick = null 156 | } 157 | } 158 | } 159 | commentItem.appendChild(imgTrigger) 160 | 161 | commentsList.appendChild(commentItem) 162 | }) 163 | 164 | container.appendChild(commentsList) 165 | 166 | if (meta) { 167 | const pageCount = Math.ceil(meta.comments / instance.perPage) 168 | if (pageCount > 1) { 169 | const pagination = document.createElement('ul') 170 | pagination.className = 'gitment-comments-pagination' 171 | 172 | if (currentPage > 1) { 173 | const previousButton = document.createElement('li') 174 | previousButton.className = 'gitment-comments-page-item' 175 | previousButton.innerText = 'Previous' 176 | previousButton.onclick = () => instance.goto(currentPage - 1) 177 | pagination.appendChild(previousButton) 178 | } 179 | 180 | for (let i = 1; i <= pageCount; i++) { 181 | const pageItem = document.createElement('li') 182 | pageItem.className = 'gitment-comments-page-item' 183 | pageItem.innerText = i 184 | pageItem.onclick = () => instance.goto(i) 185 | if (currentPage === i) pageItem.classList.add('gitment-selected') 186 | pagination.appendChild(pageItem) 187 | } 188 | 189 | if (currentPage < pageCount) { 190 | const nextButton = document.createElement('li') 191 | nextButton.className = 'gitment-comments-page-item' 192 | nextButton.innerText = 'Next' 193 | nextButton.onclick = () => instance.goto(currentPage + 1) 194 | pagination.appendChild(nextButton) 195 | } 196 | 197 | container.appendChild(pagination) 198 | } 199 | } 200 | 201 | return container 202 | } 203 | 204 | function renderEditor({ user, error }, instance) { 205 | const container = document.createElement('div') 206 | container.lang = "en-US" 207 | container.className = 'gitment-container gitment-editor-container' 208 | 209 | const shouldDisable = user.login && !error ? '' : 'disabled' 210 | const disabledTip = user.login ? '' : 'Login to Comment' 211 | container.innerHTML = ` 212 | ${ user.login 213 | ? ` 214 | 215 | ` 216 | : user.isLoggingIn 217 | ? `
${spinnerIcon}
` 218 | : ` 219 | ${githubIcon} 220 | ` 221 | } 222 | 223 |
224 |
225 | 229 | 237 |
238 |
239 |
240 | 241 |
242 |
243 |
244 |
245 |
246 |
247 | 253 | ` 254 | if (user.login) { 255 | container.querySelector('.gitment-editor-logout-link').onclick = () => instance.logout() 256 | } 257 | 258 | const writeField = container.querySelector('.gitment-editor-write-field') 259 | const previewField = container.querySelector('.gitment-editor-preview-field') 260 | 261 | const textarea = writeField.querySelector('textarea') 262 | textarea.oninput = () => { 263 | textarea.style.height = 'auto' 264 | const style = window.getComputedStyle(textarea, null) 265 | const height = parseInt(style.height, 10) 266 | const clientHeight = textarea.clientHeight 267 | const scrollHeight = textarea.scrollHeight 268 | if (clientHeight < scrollHeight) { 269 | textarea.style.height = (height + scrollHeight - clientHeight) + 'px' 270 | } 271 | } 272 | 273 | const [writeTab, previewTab] = container.querySelectorAll('.gitment-editor-tab') 274 | writeTab.onclick = () => { 275 | writeTab.classList.add('gitment-selected') 276 | previewTab.classList.remove('gitment-selected') 277 | writeField.classList.remove('gitment-hidden') 278 | previewField.classList.add('gitment-hidden') 279 | 280 | textarea.focus() 281 | } 282 | previewTab.onclick = () => { 283 | previewTab.classList.add('gitment-selected') 284 | writeTab.classList.remove('gitment-selected') 285 | previewField.classList.remove('gitment-hidden') 286 | writeField.classList.add('gitment-hidden') 287 | 288 | const preview = previewField.querySelector('.gitment-editor-preview') 289 | const content = textarea.value.trim() 290 | if (!content) { 291 | preview.innerText = 'Nothing to preview' 292 | return 293 | } 294 | 295 | preview.innerText = 'Loading preview...' 296 | instance.markdown(content) 297 | .then(html => preview.innerHTML = html) 298 | } 299 | 300 | const submitButton = container.querySelector('.gitment-editor-submit') 301 | submitButton.onclick = () => { 302 | submitButton.innerText = 'Submitting...' 303 | submitButton.setAttribute('disabled', true) 304 | instance.post(textarea.value.trim()) 305 | .then(data => { 306 | textarea.value = '' 307 | textarea.style.height = 'auto' 308 | submitButton.removeAttribute('disabled') 309 | submitButton.innerText = 'Comment' 310 | }) 311 | .catch(e => { 312 | alert(e) 313 | submitButton.removeAttribute('disabled') 314 | submitButton.innerText = 'Comment' 315 | }) 316 | } 317 | 318 | return container 319 | } 320 | 321 | function renderFooter() { 322 | const container = document.createElement('div') 323 | container.lang = "en-US" 324 | container.className = 'gitment-container gitment-footer-container' 325 | container.innerHTML = ` 326 | Powered by 327 | 328 | Gitment 329 | 330 | ` 331 | return container 332 | } 333 | 334 | function render(state, instance) { 335 | const container = document.createElement('div') 336 | container.lang = "en-US" 337 | container.className = 'gitment-container gitment-root-container' 338 | container.appendChild(instance.renderHeader(state, instance)) 339 | container.appendChild(instance.renderComments(state, instance)) 340 | container.appendChild(instance.renderEditor(state, instance)) 341 | container.appendChild(instance.renderFooter(state, instance)) 342 | return container 343 | } 344 | 345 | export default { render, renderHeader, renderComments, renderEditor, renderFooter } 346 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { LS_ACCESS_TOKEN_KEY } from './constants' 2 | 3 | export const isString = s => toString.call(s) === '[object String]' 4 | 5 | export function getTargetContainer(container) { 6 | let targetContainer 7 | if (container instanceof Element) { 8 | targetContainer = container 9 | } else if (isString(container)) { 10 | targetContainer = document.getElementById(container) 11 | } else { 12 | targetContainer = document.createElement('div') 13 | } 14 | 15 | return targetContainer 16 | } 17 | 18 | export const Query = { 19 | parse(search = window.location.search) { 20 | if (!search) return {} 21 | const queryString = search[0] === '?' ? search.substring(1) : search 22 | const query = {} 23 | queryString.split('&') 24 | .forEach(queryStr => { 25 | const [key, value] = queryStr.split('=') 26 | if (key) query[key] = value 27 | }) 28 | 29 | return query 30 | }, 31 | stringify(query, prefix = '?') { 32 | const queryString = Object.keys(query) 33 | .map(key => `${key}=${encodeURIComponent(query[key] || '')}`) 34 | .join('&') 35 | return queryString ? prefix + queryString : '' 36 | }, 37 | } 38 | 39 | function ajaxFactory(method) { 40 | return function(apiPath, data = {}, base = 'https://api.github.com') { 41 | const req = new XMLHttpRequest() 42 | const token = localStorage.getItem(LS_ACCESS_TOKEN_KEY) 43 | 44 | let url = `${base}${apiPath}` 45 | let body = null 46 | if (method === 'GET' || method === 'DELETE') { 47 | url += Query.stringify(data) 48 | } 49 | 50 | const p = new Promise((resolve, reject) => { 51 | req.addEventListener('load', () => { 52 | const contentType = req.getResponseHeader('content-type') 53 | const res = req.responseText 54 | if (!/json/.test(contentType)) { 55 | resolve(res) 56 | return 57 | } 58 | const data = req.responseText ? JSON.parse(res) : {} 59 | if (data.message) { 60 | reject(new Error(data.message)) 61 | } else { 62 | resolve(data) 63 | } 64 | }) 65 | req.addEventListener('error', error => reject(error)) 66 | }) 67 | req.open(method, url, true) 68 | 69 | req.setRequestHeader('Accept', 'application/vnd.github.squirrel-girl-preview, application/vnd.github.html+json') 70 | if (token) { 71 | req.setRequestHeader('Authorization', `token ${token}`) 72 | } 73 | if (method !== 'GET' && method !== 'DELETE') { 74 | body = JSON.stringify(data) 75 | req.setRequestHeader('Content-Type', 'application/json') 76 | } 77 | 78 | req.send(body) 79 | return p 80 | } 81 | } 82 | 83 | export const http = { 84 | get: ajaxFactory('GET'), 85 | post: ajaxFactory('POST'), 86 | delete: ajaxFactory('DELETE'), 87 | put: ajaxFactory('PUT'), 88 | } 89 | -------------------------------------------------------------------------------- /style/default.css: -------------------------------------------------------------------------------- 1 | .gitment-container { 2 | font-family: sans-serif; 3 | font-size: 14px; 4 | line-height: 1.5; 5 | color: #333; 6 | word-wrap: break-word; 7 | } 8 | 9 | .gitment-container * { 10 | box-sizing: border-box; 11 | } 12 | 13 | .gitment-container *:disabled { 14 | cursor: not-allowed; 15 | } 16 | 17 | .gitment-container a, 18 | .gitment-container a:visited { 19 | cursor: pointer; 20 | text-decoration: none; 21 | } 22 | 23 | .gitment-container a:hover { 24 | text-decoration: underline; 25 | } 26 | 27 | .gitment-container .gitment-hidden { 28 | display: none; 29 | } 30 | 31 | .gitment-container .gitment-spinner-icon { 32 | fill: #333; 33 | 34 | -webkit-animation: gitment-spin 1s steps(12) infinite; 35 | animation: gitment-spin 1s steps(12) infinite; 36 | } 37 | 38 | @-webkit-keyframes gitment-spin { 39 | 100% { 40 | -webkit-transform: rotate(360deg); 41 | transform: rotate(360deg) 42 | } 43 | } 44 | 45 | @keyframes gitment-spin { 46 | 100% { 47 | -webkit-transform: rotate(360deg); 48 | transform: rotate(360deg) 49 | } 50 | } 51 | 52 | .gitment-root-container { 53 | margin: 19px 0; 54 | } 55 | 56 | .gitment-header-container { 57 | margin: 19px 0; 58 | } 59 | 60 | .gitment-header-like-btn, 61 | .gitment-comment-like-btn { 62 | cursor: pointer; 63 | } 64 | 65 | .gitment-comment-like-btn { 66 | float: right; 67 | } 68 | 69 | .gitment-comment-like-btn.liked { 70 | color: #F44336; 71 | } 72 | 73 | .gitment-header-like-btn svg { 74 | vertical-align: middle; 75 | height: 30px; 76 | } 77 | 78 | .gitment-comment-like-btn svg { 79 | vertical-align: middle; 80 | height: 20px; 81 | } 82 | 83 | .gitment-header-like-btn.liked svg, 84 | .gitment-comment-like-btn.liked svg { 85 | fill: #F44336; 86 | } 87 | 88 | a.gitment-header-issue-link, 89 | a.gitment-header-issue-link:visited { 90 | float: right; 91 | line-height: 30px; 92 | color: #666; 93 | } 94 | 95 | a.gitment-header-issue-link:hover { 96 | color: #666; 97 | } 98 | 99 | .gitment-comments-loading, 100 | .gitment-comments-error, 101 | .gitment-comments-empty { 102 | text-align: center; 103 | margin: 50px 0; 104 | } 105 | 106 | .gitment-comments-list { 107 | list-style: none; 108 | padding-left: 0; 109 | margin: 0 0 38px; 110 | } 111 | 112 | .gitment-comment, 113 | .gitment-editor-container { 114 | position: relative; 115 | min-height: 60px; 116 | padding-left: 60px; 117 | margin: 19px 0; 118 | } 119 | 120 | .gitment-comment-avatar, 121 | .gitment-editor-avatar { 122 | float: left; 123 | margin-left: -60px; 124 | } 125 | 126 | .gitment-comment-avatar, 127 | .gitment-comment-avatar-img, 128 | .gitment-comment-avatar, 129 | .gitment-editor-avatar-img, 130 | .gitment-editor-avatar svg { 131 | width: 44px; 132 | height: 44px; 133 | border-radius: 3px; 134 | } 135 | 136 | .gitment-editor-avatar .gitment-github-icon { 137 | fill: #fff; 138 | background-color: #333; 139 | } 140 | 141 | .gitment-comment-main, 142 | .gitment-editor-main { 143 | position: relative; 144 | border: 1px solid #CFD8DC; 145 | border-radius: 0; 146 | } 147 | 148 | .gitment-editor-main::before, 149 | .gitment-editor-main::after, 150 | .gitment-comment-main::before, 151 | .gitment-comment-main::after { 152 | position: absolute; 153 | top: 11px; 154 | left: -16px; 155 | display: block; 156 | width: 0; 157 | height: 0; 158 | pointer-events: none; 159 | content: ""; 160 | border-color: transparent; 161 | border-style: solid solid outset; 162 | } 163 | 164 | .gitment-editor-main::before, 165 | .gitment-comment-main::before { 166 | border-width: 8px; 167 | border-right-color: #CFD8DC; 168 | } 169 | 170 | .gitment-editor-main::after, 171 | .gitment-comment-main::after { 172 | margin-top: 1px; 173 | margin-left: 2px; 174 | border-width: 7px; 175 | border-right-color: #fff; 176 | } 177 | 178 | .gitment-comment-header { 179 | margin: 12px 15px; 180 | color: #666; 181 | background-color: #fff; 182 | border-radius: 3px; 183 | } 184 | 185 | .gitment-editor-header { 186 | padding: 0; 187 | margin: 0; 188 | border-bottom: 1px solid #CFD8DC; 189 | } 190 | 191 | a.gitment-comment-name, 192 | a.gitment-comment-name:visited { 193 | font-weight: 600; 194 | color: #666; 195 | } 196 | 197 | .gitment-editor-tabs { 198 | margin-bottom: -1px; 199 | margin-left: -1px; 200 | } 201 | 202 | .gitment-editor-tab { 203 | display: inline-block; 204 | padding: 11px 12px; 205 | font-size: 14px; 206 | line-height: 20px; 207 | color: #666; 208 | text-decoration: none; 209 | background-color: transparent; 210 | border-width: 0 1px; 211 | border-style: solid; 212 | border-color: transparent; 213 | border-radius: 0; 214 | 215 | white-space: nowrap; 216 | cursor: pointer; 217 | user-select: none; 218 | 219 | outline: none; 220 | } 221 | 222 | .gitment-editor-tab.gitment-selected { 223 | color: #333; 224 | background-color: #fff; 225 | border-color: #CFD8DC; 226 | } 227 | 228 | .gitment-editor-login { 229 | float: right; 230 | margin-top: -30px; 231 | margin-right: 15px; 232 | } 233 | 234 | a.gitment-footer-project-link, 235 | a.gitment-footer-project-link:visited, 236 | a.gitment-editor-login-link, 237 | a.gitment-editor-login-link:visited { 238 | color: #2196F3; 239 | } 240 | 241 | a.gitment-editor-logout-link, 242 | a.gitment-editor-logout-link:visited { 243 | color: #666; 244 | } 245 | 246 | a.gitment-editor-logout-link:hover { 247 | color: #2196F3; 248 | text-decoration: none; 249 | } 250 | 251 | .gitment-comment-body { 252 | position: relative; 253 | margin: 12px 15px; 254 | overflow: hidden; 255 | border-radius: 3px; 256 | } 257 | 258 | .gitment-comment-body-folded { 259 | cursor: pointer; 260 | } 261 | 262 | .gitment-comment-body-folded::before { 263 | display: block !important; 264 | content: ""; 265 | position: absolute; 266 | width: 100%; 267 | left: 0; 268 | top: 0; 269 | bottom: 50px; 270 | pointer-events: none; 271 | background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9)); 272 | background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9)); 273 | } 274 | 275 | .gitment-comment-body-folded::after { 276 | display: block !important; 277 | content: "Click to Expand" !important; 278 | text-align: center; 279 | color: #666; 280 | position: absolute; 281 | width: 100%; 282 | height: 50px; 283 | line-height: 50px; 284 | left: 0; 285 | bottom: 0; 286 | pointer-events: none; 287 | background: rgba(255, 255, 255, .9); 288 | } 289 | 290 | .gitment-editor-body { 291 | margin: 0; 292 | } 293 | 294 | .gitment-comment-body > *:first-child, 295 | .gitment-editor-preview > *:first-child { 296 | margin-top: 0 !important; 297 | } 298 | 299 | .gitment-comment-body > *:last-child, 300 | .gitment-editor-preview > *:last-child { 301 | margin-bottom: 0 !important; 302 | } 303 | 304 | .gitment-editor-body textarea { 305 | display: block; 306 | width: 100%; 307 | min-height: 150px; 308 | max-height: 500px; 309 | padding: 16px; 310 | resize: vertical; 311 | 312 | max-width: 100%; 313 | margin: 0; 314 | font-size: 14px; 315 | line-height: 1.6; 316 | 317 | background-color: #fff; 318 | 319 | color: #333; 320 | vertical-align: middle; 321 | border: none; 322 | border-radius: 0; 323 | outline: none; 324 | box-shadow: none; 325 | 326 | overflow: visible; 327 | } 328 | 329 | .gitment-editor-body textarea:focus { 330 | background-color: #fff; 331 | } 332 | 333 | .gitment-editor-preview { 334 | min-height: 150px; 335 | 336 | padding: 16px; 337 | background-color: transparent; 338 | 339 | width: 100%; 340 | font-size: 14px; 341 | 342 | line-height: 1.5; 343 | word-wrap: break-word; 344 | } 345 | 346 | .gitment-editor-footer { 347 | padding: 0; 348 | margin-top: 10px; 349 | } 350 | 351 | .gitment-editor-footer::after { 352 | display: table; 353 | clear: both; 354 | content: ""; 355 | } 356 | 357 | a.gitment-editor-footer-tip { 358 | display: inline-block; 359 | padding-top: 10px; 360 | font-size: 12px; 361 | color: #666; 362 | } 363 | 364 | a.gitment-editor-footer-tip:hover { 365 | color: #2196F3; 366 | text-decoration: none; 367 | } 368 | 369 | .gitment-comments-pagination { 370 | list-style: none; 371 | text-align: right; 372 | border-radius: 0; 373 | margin: -19px 0 19px 0; 374 | } 375 | 376 | .gitment-comments-page-item { 377 | display: inline-block; 378 | cursor: pointer; 379 | border: 1px solid #CFD8DC; 380 | margin-left: -1px; 381 | padding: .25rem .5rem; 382 | } 383 | 384 | .gitment-comments-page-item:hover { 385 | background-color: #f5f5f5; 386 | } 387 | 388 | .gitment-comments-page-item.gitment-selected { 389 | background-color: #f5f5f5; 390 | } 391 | 392 | .gitment-editor-submit, 393 | .gitment-comments-init-btn { 394 | color: #fff; 395 | background-color: #00BCD4; 396 | 397 | position: relative; 398 | display: inline-block; 399 | padding: 7px 13px; 400 | font-size: 14px; 401 | font-weight: 600; 402 | line-height: 20px; 403 | white-space: nowrap; 404 | vertical-align: middle; 405 | cursor: pointer; 406 | -webkit-user-select: none; 407 | -moz-user-select: none; 408 | -ms-user-select: none; 409 | user-select: none; 410 | background-size: 110% 110%; 411 | border: none; 412 | -webkit-appearance: none; 413 | -moz-appearance: none; 414 | appearance: none; 415 | } 416 | 417 | .gitment-editor-submit:hover, 418 | .gitment-comments-init-btn:hover { 419 | background-color: #00ACC1; 420 | } 421 | 422 | .gitment-comments-init-btn:disabled, 423 | .gitment-editor-submit:disabled { 424 | color: rgba(255,255,255,0.75); 425 | background-color: #4DD0E1; 426 | box-shadow: none; 427 | } 428 | 429 | .gitment-editor-submit { 430 | float: right; 431 | } 432 | 433 | .gitment-footer-container { 434 | margin-top: 30px; 435 | margin-bottom: 20px; 436 | text-align: right; 437 | font-size: 12px; 438 | } 439 | 440 | /* 441 | * Markdown CSS 442 | * Copied from https://github.com/sindresorhus/github-markdown-css 443 | */ 444 | .gitment-markdown { 445 | -ms-text-size-adjust: 100%; 446 | -webkit-text-size-adjust: 100%; 447 | line-height: 1.5; 448 | color: #333; 449 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 450 | font-size: 16px; 451 | line-height: 1.5; 452 | word-wrap: break-word; 453 | } 454 | 455 | .gitment-markdown .pl-c { 456 | color: #969896; 457 | } 458 | 459 | .gitment-markdown .pl-c1, 460 | .gitment-markdown .pl-s .pl-v { 461 | color: #0086b3; 462 | } 463 | 464 | .gitment-markdown .pl-e, 465 | .gitment-markdown .pl-en { 466 | color: #795da3; 467 | } 468 | 469 | .gitment-markdown .pl-smi, 470 | .gitment-markdown .pl-s .pl-s1 { 471 | color: #333; 472 | } 473 | 474 | .gitment-markdown .pl-ent { 475 | color: #63a35c; 476 | } 477 | 478 | .gitment-markdown .pl-k { 479 | color: #a71d5d; 480 | } 481 | 482 | .gitment-markdown .pl-s, 483 | .gitment-markdown .pl-pds, 484 | .gitment-markdown .pl-s .pl-pse .pl-s1, 485 | .gitment-markdown .pl-sr, 486 | .gitment-markdown .pl-sr .pl-cce, 487 | .gitment-markdown .pl-sr .pl-sre, 488 | .gitment-markdown .pl-sr .pl-sra { 489 | color: #183691; 490 | } 491 | 492 | .gitment-markdown .pl-v, 493 | .gitment-markdown .pl-smw { 494 | color: #ed6a43; 495 | } 496 | 497 | .gitment-markdown .pl-bu { 498 | color: #b52a1d; 499 | } 500 | 501 | .gitment-markdown .pl-ii { 502 | color: #f8f8f8; 503 | background-color: #b52a1d; 504 | } 505 | 506 | .gitment-markdown .pl-c2 { 507 | color: #f8f8f8; 508 | background-color: #b52a1d; 509 | } 510 | 511 | .gitment-markdown .pl-c2::before { 512 | content: "^M"; 513 | } 514 | 515 | .gitment-markdown .pl-sr .pl-cce { 516 | font-weight: bold; 517 | color: #63a35c; 518 | } 519 | 520 | .gitment-markdown .pl-ml { 521 | color: #693a17; 522 | } 523 | 524 | .gitment-markdown .pl-mh, 525 | .gitment-markdown .pl-mh .pl-en, 526 | .gitment-markdown .pl-ms { 527 | font-weight: bold; 528 | color: #1d3e81; 529 | } 530 | 531 | .gitment-markdown .pl-mq { 532 | color: #008080; 533 | } 534 | 535 | .gitment-markdown .pl-mi { 536 | font-style: italic; 537 | color: #333; 538 | } 539 | 540 | .gitment-markdown .pl-mb { 541 | font-weight: bold; 542 | color: #333; 543 | } 544 | 545 | .gitment-markdown .pl-md { 546 | color: #bd2c00; 547 | background-color: #ffecec; 548 | } 549 | 550 | .gitment-markdown .pl-mi1 { 551 | color: #55a532; 552 | background-color: #eaffea; 553 | } 554 | 555 | .gitment-markdown .pl-mc { 556 | color: #ef9700; 557 | background-color: #ffe3b4; 558 | } 559 | 560 | .gitment-markdown .pl-mi2 { 561 | color: #d8d8d8; 562 | background-color: #808080; 563 | } 564 | 565 | .gitment-markdown .pl-mdr { 566 | font-weight: bold; 567 | color: #795da3; 568 | } 569 | 570 | .gitment-markdown .pl-mo { 571 | color: #1d3e81; 572 | } 573 | 574 | .gitment-markdown .pl-ba { 575 | color: #595e62; 576 | } 577 | 578 | .gitment-markdown .pl-sg { 579 | color: #c0c0c0; 580 | } 581 | 582 | .gitment-markdown .pl-corl { 583 | text-decoration: underline; 584 | color: #183691; 585 | } 586 | 587 | .gitment-markdown .octicon { 588 | display: inline-block; 589 | vertical-align: text-top; 590 | fill: currentColor; 591 | } 592 | 593 | .gitment-markdown a { 594 | background-color: transparent; 595 | -webkit-text-decoration-skip: objects; 596 | } 597 | 598 | .gitment-markdown a:active, 599 | .gitment-markdown a:hover { 600 | outline-width: 0; 601 | } 602 | 603 | .gitment-markdown strong { 604 | font-weight: inherit; 605 | } 606 | 607 | .gitment-markdown strong { 608 | font-weight: bolder; 609 | } 610 | 611 | .gitment-markdown h1 { 612 | font-size: 2em; 613 | margin: 0.67em 0; 614 | } 615 | 616 | .gitment-markdown img { 617 | border-style: none; 618 | } 619 | 620 | .gitment-markdown svg:not(:root) { 621 | overflow: hidden; 622 | } 623 | 624 | .gitment-markdown code, 625 | .gitment-markdown kbd, 626 | .gitment-markdown pre { 627 | font-family: monospace, monospace; 628 | font-size: 1em; 629 | } 630 | 631 | .gitment-markdown hr { 632 | box-sizing: content-box; 633 | height: 0; 634 | overflow: visible; 635 | } 636 | 637 | .gitment-markdown input { 638 | font: inherit; 639 | margin: 0; 640 | } 641 | 642 | .gitment-markdown input { 643 | overflow: visible; 644 | } 645 | 646 | .gitment-markdown [type="checkbox"] { 647 | box-sizing: border-box; 648 | padding: 0; 649 | } 650 | 651 | .gitment-markdown * { 652 | box-sizing: border-box; 653 | } 654 | 655 | .gitment-markdown input { 656 | font-family: inherit; 657 | font-size: inherit; 658 | line-height: inherit; 659 | } 660 | 661 | .gitment-markdown a { 662 | color: #0366d6; 663 | text-decoration: none; 664 | } 665 | 666 | .gitment-markdown a:hover { 667 | text-decoration: underline; 668 | } 669 | 670 | .gitment-markdown strong { 671 | font-weight: 600; 672 | } 673 | 674 | .gitment-markdown hr { 675 | height: 0; 676 | margin: 15px 0; 677 | overflow: hidden; 678 | background: transparent; 679 | border: 0; 680 | border-bottom: 1px solid #dfe2e5; 681 | } 682 | 683 | .gitment-markdown hr::before { 684 | display: table; 685 | content: ""; 686 | } 687 | 688 | .gitment-markdown hr::after { 689 | display: table; 690 | clear: both; 691 | content: ""; 692 | } 693 | 694 | .gitment-markdown table { 695 | border-spacing: 0; 696 | border-collapse: collapse; 697 | } 698 | 699 | .gitment-markdown td, 700 | .gitment-markdown th { 701 | padding: 0; 702 | } 703 | 704 | .gitment-markdown h1, 705 | .gitment-markdown h2, 706 | .gitment-markdown h3, 707 | .gitment-markdown h4, 708 | .gitment-markdown h5, 709 | .gitment-markdown h6 { 710 | margin-top: 0; 711 | margin-bottom: 0; 712 | } 713 | 714 | .gitment-markdown h1 { 715 | font-size: 32px; 716 | font-weight: 600; 717 | } 718 | 719 | .gitment-markdown h2 { 720 | font-size: 24px; 721 | font-weight: 600; 722 | } 723 | 724 | .gitment-markdown h3 { 725 | font-size: 20px; 726 | font-weight: 600; 727 | } 728 | 729 | .gitment-markdown h4 { 730 | font-size: 16px; 731 | font-weight: 600; 732 | } 733 | 734 | .gitment-markdown h5 { 735 | font-size: 14px; 736 | font-weight: 600; 737 | } 738 | 739 | .gitment-markdown h6 { 740 | font-size: 12px; 741 | font-weight: 600; 742 | } 743 | 744 | .gitment-markdown p { 745 | margin-top: 0; 746 | margin-bottom: 10px; 747 | } 748 | 749 | .gitment-markdown blockquote { 750 | margin: 0; 751 | } 752 | 753 | .gitment-markdown ul, 754 | .gitment-markdown ol { 755 | padding-left: 0; 756 | margin-top: 0; 757 | margin-bottom: 0; 758 | } 759 | 760 | .gitment-markdown ol ol, 761 | .gitment-markdown ul ol { 762 | list-style-type: lower-roman; 763 | } 764 | 765 | .gitment-markdown ul ul ol, 766 | .gitment-markdown ul ol ol, 767 | .gitment-markdown ol ul ol, 768 | .gitment-markdown ol ol ol { 769 | list-style-type: lower-alpha; 770 | } 771 | 772 | .gitment-markdown dd { 773 | margin-left: 0; 774 | } 775 | 776 | .gitment-markdown code { 777 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 778 | font-size: 12px; 779 | } 780 | 781 | .gitment-markdown pre { 782 | margin-top: 0; 783 | margin-bottom: 0; 784 | font: 12px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 785 | } 786 | 787 | .gitment-markdown .octicon { 788 | vertical-align: text-bottom; 789 | } 790 | 791 | .gitment-markdown .pl-0 { 792 | padding-left: 0 !important; 793 | } 794 | 795 | .gitment-markdown .pl-1 { 796 | padding-left: 4px !important; 797 | } 798 | 799 | .gitment-markdown .pl-2 { 800 | padding-left: 8px !important; 801 | } 802 | 803 | .gitment-markdown .pl-3 { 804 | padding-left: 16px !important; 805 | } 806 | 807 | .gitment-markdown .pl-4 { 808 | padding-left: 24px !important; 809 | } 810 | 811 | .gitment-markdown .pl-5 { 812 | padding-left: 32px !important; 813 | } 814 | 815 | .gitment-markdown .pl-6 { 816 | padding-left: 40px !important; 817 | } 818 | 819 | .gitment-markdown::before { 820 | display: table; 821 | content: ""; 822 | } 823 | 824 | .gitment-markdown::after { 825 | display: table; 826 | clear: both; 827 | content: ""; 828 | } 829 | 830 | .gitment-markdown>*:first-child { 831 | margin-top: 0 !important; 832 | } 833 | 834 | .gitment-markdown>*:last-child { 835 | margin-bottom: 0 !important; 836 | } 837 | 838 | .gitment-markdown a:not([href]) { 839 | color: inherit; 840 | text-decoration: none; 841 | } 842 | 843 | .gitment-markdown .anchor { 844 | float: left; 845 | padding-right: 4px; 846 | margin-left: -20px; 847 | line-height: 1; 848 | } 849 | 850 | .gitment-markdown .anchor:focus { 851 | outline: none; 852 | } 853 | 854 | .gitment-markdown p, 855 | .gitment-markdown blockquote, 856 | .gitment-markdown ul, 857 | .gitment-markdown ol, 858 | .gitment-markdown dl, 859 | .gitment-markdown table, 860 | .gitment-markdown pre { 861 | margin-top: 0; 862 | margin-bottom: 16px; 863 | } 864 | 865 | .gitment-markdown hr { 866 | height: 0.25em; 867 | padding: 0; 868 | margin: 24px 0; 869 | background-color: #e1e4e8; 870 | border: 0; 871 | } 872 | 873 | .gitment-markdown blockquote { 874 | padding: 0 1em; 875 | color: #6a737d; 876 | border-left: 0.25em solid #dfe2e5; 877 | } 878 | 879 | .gitment-markdown blockquote>:first-child { 880 | margin-top: 0; 881 | } 882 | 883 | .gitment-markdown blockquote>:last-child { 884 | margin-bottom: 0; 885 | } 886 | 887 | .gitment-markdown kbd { 888 | display: inline-block; 889 | padding: 3px 5px; 890 | font-size: 11px; 891 | line-height: 10px; 892 | color: #444d56; 893 | vertical-align: middle; 894 | background-color: #fafbfc; 895 | border: solid 1px #c6cbd1; 896 | border-bottom-color: #959da5; 897 | border-radius: 0; 898 | box-shadow: inset 0 -1px 0 #959da5; 899 | } 900 | 901 | .gitment-markdown h1, 902 | .gitment-markdown h2, 903 | .gitment-markdown h3, 904 | .gitment-markdown h4, 905 | .gitment-markdown h5, 906 | .gitment-markdown h6 { 907 | margin-top: 24px; 908 | margin-bottom: 16px; 909 | font-weight: 600; 910 | line-height: 1.25; 911 | } 912 | 913 | .gitment-markdown h1 .octicon-link, 914 | .gitment-markdown h2 .octicon-link, 915 | .gitment-markdown h3 .octicon-link, 916 | .gitment-markdown h4 .octicon-link, 917 | .gitment-markdown h5 .octicon-link, 918 | .gitment-markdown h6 .octicon-link { 919 | color: #1b1f23; 920 | vertical-align: middle; 921 | visibility: hidden; 922 | } 923 | 924 | .gitment-markdown h1:hover .anchor, 925 | .gitment-markdown h2:hover .anchor, 926 | .gitment-markdown h3:hover .anchor, 927 | .gitment-markdown h4:hover .anchor, 928 | .gitment-markdown h5:hover .anchor, 929 | .gitment-markdown h6:hover .anchor { 930 | text-decoration: none; 931 | } 932 | 933 | .gitment-markdown h1:hover .anchor .octicon-link, 934 | .gitment-markdown h2:hover .anchor .octicon-link, 935 | .gitment-markdown h3:hover .anchor .octicon-link, 936 | .gitment-markdown h4:hover .anchor .octicon-link, 937 | .gitment-markdown h5:hover .anchor .octicon-link, 938 | .gitment-markdown h6:hover .anchor .octicon-link { 939 | visibility: visible; 940 | } 941 | 942 | .gitment-markdown h1 { 943 | padding-bottom: 0.3em; 944 | font-size: 2em; 945 | border-bottom: 1px solid #eaecef; 946 | } 947 | 948 | .gitment-markdown h2 { 949 | padding-bottom: 0.3em; 950 | font-size: 1.5em; 951 | border-bottom: 1px solid #eaecef; 952 | } 953 | 954 | .gitment-markdown h3 { 955 | font-size: 1.25em; 956 | } 957 | 958 | .gitment-markdown h4 { 959 | font-size: 1em; 960 | } 961 | 962 | .gitment-markdown h5 { 963 | font-size: 0.875em; 964 | } 965 | 966 | .gitment-markdown h6 { 967 | font-size: 0.85em; 968 | color: #6a737d; 969 | } 970 | 971 | .gitment-markdown ul, 972 | .gitment-markdown ol { 973 | padding-left: 2em; 974 | } 975 | 976 | .gitment-markdown ul ul, 977 | .gitment-markdown ul ol, 978 | .gitment-markdown ol ol, 979 | .gitment-markdown ol ul { 980 | margin-top: 0; 981 | margin-bottom: 0; 982 | } 983 | 984 | .gitment-markdown li>p { 985 | margin-top: 16px; 986 | } 987 | 988 | .gitment-markdown li+li { 989 | margin-top: 0.25em; 990 | } 991 | 992 | .gitment-markdown dl { 993 | padding: 0; 994 | } 995 | 996 | .gitment-markdown dl dt { 997 | padding: 0; 998 | margin-top: 16px; 999 | font-size: 1em; 1000 | font-style: italic; 1001 | font-weight: 600; 1002 | } 1003 | 1004 | .gitment-markdown dl dd { 1005 | padding: 0 16px; 1006 | margin-bottom: 16px; 1007 | } 1008 | 1009 | .gitment-markdown table { 1010 | display: block; 1011 | width: 100%; 1012 | overflow: auto; 1013 | } 1014 | 1015 | .gitment-markdown table th { 1016 | font-weight: 600; 1017 | } 1018 | 1019 | .gitment-markdown table th, 1020 | .gitment-markdown table td { 1021 | padding: 6px 13px; 1022 | border: 1px solid #dfe2e5; 1023 | } 1024 | 1025 | .gitment-markdown table tr { 1026 | background-color: #fff; 1027 | border-top: 1px solid #c6cbd1; 1028 | } 1029 | 1030 | .gitment-markdown table tr:nth-child(2n) { 1031 | background-color: #f5f5f5; 1032 | } 1033 | 1034 | .gitment-markdown img { 1035 | max-width: 100%; 1036 | box-sizing: content-box; 1037 | background-color: #fff; 1038 | } 1039 | 1040 | .gitment-markdown code { 1041 | padding: 0; 1042 | padding-top: 0.2em; 1043 | padding-bottom: 0.2em; 1044 | margin: 0; 1045 | font-size: 85%; 1046 | background-color: rgba(27,31,35,0.05); 1047 | border-radius: 0; 1048 | } 1049 | 1050 | .gitment-markdown code::before, 1051 | .gitment-markdown code::after { 1052 | letter-spacing: -0.2em; 1053 | content: "\00a0"; 1054 | } 1055 | 1056 | .gitment-markdown pre { 1057 | word-wrap: normal; 1058 | } 1059 | 1060 | .gitment-markdown pre>code { 1061 | padding: 0; 1062 | margin: 0; 1063 | font-size: 100%; 1064 | word-break: normal; 1065 | white-space: pre; 1066 | background: transparent; 1067 | border: 0; 1068 | } 1069 | 1070 | .gitment-markdown .highlight { 1071 | margin-bottom: 16px; 1072 | } 1073 | 1074 | .gitment-markdown .highlight pre { 1075 | margin-bottom: 0; 1076 | word-break: normal; 1077 | } 1078 | 1079 | .gitment-markdown .highlight pre, 1080 | .gitment-markdown pre { 1081 | padding: 16px; 1082 | overflow: auto; 1083 | font-size: 85%; 1084 | line-height: 1.45; 1085 | background-color: #f5f5f5; 1086 | border-radius: 0; 1087 | } 1088 | 1089 | .gitment-markdown pre code { 1090 | display: inline; 1091 | max-width: auto; 1092 | padding: 0; 1093 | margin: 0; 1094 | overflow: visible; 1095 | line-height: inherit; 1096 | word-wrap: normal; 1097 | background-color: transparent; 1098 | border: 0; 1099 | } 1100 | 1101 | .gitment-markdown pre code::before, 1102 | .gitment-markdown pre code::after { 1103 | content: normal; 1104 | } 1105 | 1106 | .gitment-markdown .full-commit .btn-outline:not(:disabled):hover { 1107 | color: #005cc5; 1108 | border-color: #005cc5; 1109 | } 1110 | 1111 | .gitment-markdown kbd { 1112 | display: inline-block; 1113 | padding: 3px 5px; 1114 | font: 11px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 1115 | line-height: 10px; 1116 | color: #444d56; 1117 | vertical-align: middle; 1118 | background-color: #fcfcfc; 1119 | border: solid 1px #c6cbd1; 1120 | border-bottom-color: #959da5; 1121 | border-radius: 0; 1122 | box-shadow: inset 0 -1px 0 #959da5; 1123 | } 1124 | 1125 | .gitment-markdown :checked+.radio-label { 1126 | position: relative; 1127 | z-index: 1; 1128 | border-color: #0366d6; 1129 | } 1130 | 1131 | .gitment-markdown .task-list-item { 1132 | list-style-type: none; 1133 | } 1134 | 1135 | .gitment-markdown .task-list-item+.task-list-item { 1136 | margin-top: 3px; 1137 | } 1138 | 1139 | .gitment-markdown .task-list-item input { 1140 | margin: 0 0.2em 0.25em -1.6em; 1141 | vertical-align: middle; 1142 | } 1143 | 1144 | .gitment-markdown hr { 1145 | border-bottom-color: #eee; 1146 | } 1147 | -------------------------------------------------------------------------------- /test/gitment.browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gitment 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/gitment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gitment 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0 20px; 3 | margin: 0 auto; 4 | font-family: sans-serif; 5 | } 6 | 7 | @media (min-width: 780px) { 8 | body { 9 | max-width: 760px; 10 | } 11 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | context: path.join(__dirname, 'src'), 5 | entry: './gitment.js', 6 | devtool: 'source-map', 7 | output: { 8 | path: path.join(__dirname, 'dist'), 9 | filename: 'gitment.browser.js', 10 | libraryTarget: 'var', 11 | library: 'Gitment', 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | exclude: /^node_mocules/, 18 | loaders: ['babel-loader'], 19 | }, 20 | ], 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | context: path.join(__dirname, 'src'), 5 | entry: './test.js', 6 | devtool: 'source-map', 7 | output: { 8 | path: path.join(__dirname, 'dist'), 9 | filename: 'test.js', 10 | publicPath: '/dist/', 11 | }, 12 | module: { 13 | loaders: [ 14 | { 15 | test: /\.js$/, 16 | exclude: /^node_mocules/, 17 | loaders: ['babel-loader'], 18 | }, 19 | ], 20 | }, 21 | devServer: { 22 | port: 3000, 23 | contentBase: './', 24 | }, 25 | } 26 | --------------------------------------------------------------------------------