├── .editorconfig ├── .gitignore ├── .parcelrc ├── .posthtmlrc.js ├── .sassrc.js ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE.md ├── PRIVACY-POLICY.md ├── README.md ├── package-lock.json ├── package.json ├── src ├── client.ts ├── comment-component.ts ├── configuration-component.ts ├── encoding.ts ├── github.ts ├── icons │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-76x76.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── manifest.webmanifest │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── utterances-300.png ├── index.html ├── index.ts ├── measure.ts ├── new-comment-component.ts ├── oauth.ts ├── page-attributes.ts ├── preferred-theme.ts ├── reactions.ts ├── repo-config.ts ├── repo-regex.ts ├── stylesheets │ ├── email-fragment.scss │ ├── index.scss │ ├── permalink-code.scss │ ├── reactions.scss │ ├── themes │ │ ├── boxy-light │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ └── utterances.scss │ │ ├── dark-blue │ │ │ ├── button.scss │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ │ ├── github-dark-orange │ │ │ ├── button.scss │ │ │ ├── code.scss │ │ │ ├── combobox.scss │ │ │ ├── comment.scss │ │ │ ├── glow.scss │ │ │ ├── heading.scss │ │ │ ├── index.scss │ │ │ ├── placeholder.scss │ │ │ ├── selection.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ │ ├── github-dark │ │ │ ├── button.scss │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ │ ├── github-light │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ └── utterances.scss │ │ ├── gruvbox-dark │ │ │ ├── button.scss │ │ │ ├── combobox.scss │ │ │ ├── comment.scss │ │ │ ├── glow.scss │ │ │ ├── heading.scss │ │ │ ├── index.scss │ │ │ ├── placeholder.scss │ │ │ ├── selection.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ │ ├── icy-dark │ │ │ ├── button.scss │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ │ └── photon-dark │ │ │ ├── button.scss │ │ │ ├── index.scss │ │ │ ├── syntax.scss │ │ │ ├── utterances.scss │ │ │ └── variables.scss │ ├── timeline-comment.scss │ ├── timeline.scss │ ├── util.scss │ ├── utterances.scss │ └── zigzag.scss ├── theme.ts ├── time-ago.ts ├── timeline-component.ts ├── utterances-api.ts ├── utterances.html └── utterances.ts ├── tsconfig.json ├── tslint.json └── utterances.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .parcel-cache 3 | chrome 4 | dist 5 | node_modules 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{ts,tsx}": [ 5 | "@parcel/transformer-typescript-tsc" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.posthtmlrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'posthtml-expressions': { 4 | root: __dirname, 5 | locals: { 6 | NODE_ENV: process.env.NODE_ENV 7 | } 8 | }, 9 | 'posthtml-include': { 10 | root: __dirname 11 | }, 12 | 'posthtml-md': { 13 | root: __dirname 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.sassrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const CWD = process.cwd() 4 | 5 | module.exports = { 6 | includePaths: [ 7 | path.resolve(CWD, 'node_modules'), 8 | path.resolve(CWD, 'src') 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "html.format.enable": false, 4 | "json.format.enable": false, 5 | "editor.detectIndentation": false, 6 | "editor.tabSize": 2 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Install yarn: https://yarnpkg.com 4 | 2. Clone the repo: 5 | ```bash 6 | git clone https://github.com/utterance/utterances 7 | ``` 8 | 3. Install the project's dependencies using yarn: 9 | ```bash 10 | cd utterances 11 | yarn install 12 | ``` 13 | 4. Start developing! 14 | ```bash 15 | yarn start 16 | ``` 17 | This command compiles the source files and starts a development webserver. Any change you make to the source TypeScript, HTML and SCSS files will automatically be recompiled. Go to http://localhost:4000/index.html to view your changes. 18 | 19 | ## Theme Development 20 | 21 | Each theme is located in a subdirectory of `src/stylesheets/themes`. Themes must have an `index.scss` and `utterances.scss` files. These are the entrypoint stylesheets for the utterances homepage and utterances widget respectively. *Todo: more instructions* 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jeremy Danyow 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 | -------------------------------------------------------------------------------- /PRIVACY-POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Utterances operates the https://utteranc.es website, which provides the SERVICE. 4 | 5 | This page is used to inform website visitors regarding our policies with the collection, use, and disclosure of Personal Information if anyone decided to use our Service. 6 | 7 | If you choose to use our Service, then you agree to the collection and use of information in relation with this policy. The Personal Information that we collect are used for providing and improving the Service. We will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at https://utteranc.es , unless otherwise defined in this Privacy Policy. 10 | 11 | ## Information Collection and Use 12 | 13 | Utterances does not collect any personal information. 14 | 15 | ## Log Data 16 | 17 | Utterances does not log, write or retain any data. 18 | 19 | ## Cookies 20 | 21 | Utterances sets a cookie to store the github api token. 22 | 23 | ## Service Providers 24 | 25 | We may employ third-party companies and individuals due to the following reasons: 26 | 27 | * Utterances uses GitHub issues to store issues and comments. 28 | 29 | ## Security 30 | 31 | We value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and we cannot guarantee its absolute security. 32 | 33 | ## Links to Other Sites 34 | 35 | Our Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by us. Therefore, we strongly advise you to review the Privacy Policy of these websites. We have no control over, and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 36 | 37 | ## Children’s Privacy 38 | 39 | Our Services do not address anyone under the age of 13. We do not knowingly collect personal identifiable information from children under 13. In the case we discover that a child under 13 has provided us with personal information, we immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact us so that we will be able to do necessary actions. 40 | 41 | ## Changes to This Privacy Policy 42 | 43 | We may update our Privacy Policy from time to time. Thus, we advise you to review this page periodically for any changes. We will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately, after they are posted on this page. 44 | 45 | ## Contact Us 46 | 47 | If you have any questions or suggestions about our Privacy Policy, do not hesitate to contact us. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # utterances 🔮 2 | 3 | A lightweight comments widget built on GitHub issues. Use GitHub issues for blog comments, wiki pages and more! 4 | 5 | - [Open source](https://github.com/utterance). 🙌 6 | - No tracking, no ads, always free. 📡🚫 7 | - No lock-in. All data stored in GitHub issues. 🔓 8 | - Styled with [Primer](http://primer.style), the css toolkit that powers GitHub. 💅 9 | - Dark theme. 🌘 10 | - Lightweight. Vanilla TypeScript. No font downloads, JavaScript frameworks or polyfills for evergreen browsers. 🐦🌲 11 | 12 | ## how it works 13 | 14 | When Utterances loads, the GitHub [issue search API](https://developer.github.com/v3/search/#search-issues) is used to find the issue associated with the page based on `url`, `pathname` or `title`. If we cannot find an issue that matches the page, no problem, [utterances-bot](https://github.com/utterances-bot) will automatically create an issue the first time someone comments. 15 | 16 | To comment, users must authorize the utterances app to post on their behalf using the GitHub [OAuth flow](https://developer.github.com/v3/oauth/#web-application-flow). Alternatively, users can comment on the GitHub issue directly. 17 | 18 | ## configuration 19 | 20 | ## sites using utterances 21 | 22 | - Haxe [documentation](https://haxe.org/manual) and [cookbook](https://code.haxe.org/) 23 | - [sadsloth.net](https://sadsloth.net/) 24 | - [danyow.net](https://danyow.net) 25 | - **[and many more...](https://github.com/topics/utterances)** 26 | 27 | Are you using utterances? [Add the `utterances` topic on your repo](https://docs.github.com/en/github/administering-a-repository/classifying-your-repository-with-topics)! 28 | 29 | # try it out 👇👇👇 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "utterances", 3 | "version": "1.0.0", 4 | "description": "A lightweight comments widget built on GitHub issues.", 5 | "private": true, 6 | "license": "MIT", 7 | "keywords": [ 8 | "github", 9 | "comments-widget", 10 | "comments", 11 | "blog" 12 | ], 13 | "homepage": "https://utteranc.es", 14 | "bugs": "https://github.com/utterance/utterances/issues", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/utterance/utterances.git" 18 | }, 19 | "browserslist": "last 3 Chrome versions,last 3 Safari versions,last 3 Firefox versions", 20 | "targets": { 21 | "main": false 22 | }, 23 | "scripts": { 24 | "preparcel": "rm -rf dist;mkdir dist", 25 | "parcel": "parcel $RUN src/*.html src/client.ts src/stylesheets/themes/*/{index,utterances}.scss", 26 | "build": "RUN=build npm run parcel --", 27 | "start": "RUN=serve npm run parcel -- --port 4000", 28 | "predeploy": "npm run build && touch dist/.nojekyll && echo 'utteranc.es' > dist/CNAME", 29 | "deploy": "gh-pages --dist dist", 30 | "reinstall": "git clean -fxd -e .env && rm -f package-lock.json && npm install", 31 | "update-deps": "npm exec --package npm-check-updates --call 'ncu -u -x @primer/css' && git clean -fxd -e .env && rm package-lock.json && npm install" 32 | }, 33 | "devDependencies": { 34 | "@parcel/packager-raw-url": "^2.3.1", 35 | "@parcel/packager-xml": "^2.3.1", 36 | "@parcel/transformer-sass": "^2.3.1", 37 | "@parcel/transformer-typescript-tsc": "^2.3.1", 38 | "@parcel/transformer-webmanifest": "^2.3.1", 39 | "@parcel/transformer-xml": "^2.3.1", 40 | "@primer/css": "^15.2.0", 41 | "autoprefixer": "^10.4.2", 42 | "gh-pages": "^3.2.3", 43 | "github-syntax-dark": "^0.5.0", 44 | "github-syntax-light": "^0.5.0", 45 | "parcel": "^2.3.1", 46 | "posthtml-expressions": "^1.9.0", 47 | "posthtml-include": "^1.7.2", 48 | "posthtml-md": "^1.1.0", 49 | "sass": "^1.49.7", 50 | "typescript": "^4.5.5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { ResizeMessage } from './measure'; 2 | import { preferredThemeId, preferredTheme } from './preferred-theme'; 3 | 4 | const url = new URL(location.href); 5 | // slice session from query string 6 | const session = url.searchParams.get('utterances') 7 | if (session) { 8 | localStorage.setItem('utterances-session', session); 9 | url.searchParams.delete('utterances'); 10 | history.replaceState(undefined, document.title, url.href); 11 | } 12 | 13 | let script = document.currentScript as HTMLScriptElement; 14 | if (script === undefined) { 15 | // Internet Explorer :( 16 | // tslint:disable-next-line:max-line-length 17 | script = document.querySelector('script[src^="https://utteranc.es/client.js"],script[src^="http://localhost:4000/client.js"]') as HTMLScriptElement; 18 | } 19 | 20 | // gather script element's attributes 21 | const attrs: Record = {}; 22 | for (let i = 0; i < script.attributes.length; i++) { 23 | const attribute = script.attributes.item(i)!; 24 | attrs[attribute.name.replace(/^data-/, '')] = attribute.value; // permit using data-theme instead of theme. 25 | } 26 | if (attrs.theme === preferredThemeId) { 27 | attrs.theme = preferredTheme; 28 | } 29 | 30 | // gather page attributes 31 | const canonicalLink = document.querySelector(`link[rel='canonical']`) as HTMLLinkElement; 32 | attrs.url = canonicalLink ? canonicalLink.href : url.origin + url.pathname + url.search; 33 | attrs.origin = url.origin; 34 | attrs.pathname = url.pathname.length < 2 ? 'index' : url.pathname.substr(1).replace(/\.\w+$/, ''); 35 | attrs.title = document.title; 36 | const descriptionMeta = document.querySelector(`meta[name='description']`) as HTMLMetaElement; 37 | attrs.description = descriptionMeta ? descriptionMeta.content : ''; 38 | // truncate descriptions that would trigger 414 "URI Too Long" 39 | const len = encodeURIComponent(attrs.description).length; 40 | if (len > 1000) { 41 | attrs.description = attrs.description.substr(0, Math.floor(attrs.description.length * 1000 / len)); 42 | } 43 | const ogtitleMeta = document.querySelector(`meta[property='og:title'],meta[name='og:title']`) as HTMLMetaElement; 44 | attrs['og:title'] = ogtitleMeta ? ogtitleMeta.content : ''; 45 | attrs.session = session || localStorage.getItem('utterances-session') || ''; 46 | 47 | // create the standard utterances styles and insert them at the beginning of the 48 | // for easy overriding. 49 | // NOTE: the craziness with "width" is for mobile safari :( 50 | document.head.insertAdjacentHTML( 51 | 'afterbegin', 52 | ``); 73 | 74 | // create the comments iframe and it's responsive container 75 | const utterancesOrigin = script.src.match(/^https:\/\/utteranc\.es|http:\/\/localhost:\d+/)![0]; 76 | const frameUrl = `${utterancesOrigin}/utterances.html`; 77 | script.insertAdjacentHTML( 78 | 'afterend', 79 | `
80 | 81 |
`); 82 | const container = script.nextElementSibling as HTMLDivElement; 83 | script.parentElement!.removeChild(script); 84 | 85 | // adjust the iframe's height when the height of it's content changes 86 | addEventListener('message', event => { 87 | if (event.origin !== utterancesOrigin) { 88 | return; 89 | } 90 | const data = event.data as ResizeMessage; 91 | if (data && data.type === 'resize' && data.height) { 92 | container.style.height = `${data.height}px`; 93 | } 94 | }); 95 | -------------------------------------------------------------------------------- /src/comment-component.ts: -------------------------------------------------------------------------------- 1 | import { CommentAuthorAssociation, IssueComment, reactionTypes } from './github'; 2 | import { timeAgo } from './time-ago'; 3 | import { scheduleMeasure } from './measure'; 4 | import { getReactionsMenuHtml, getReactionHtml, getSignInToReactMenuHtml } from './reactions'; 5 | 6 | const avatarArgs = '?v=3&s=88'; 7 | const displayAssociations: Record = { 8 | COLLABORATOR: 'Collaborator', 9 | CONTRIBUTOR: 'Contributor', 10 | MEMBER: 'Member', 11 | OWNER: 'Owner', 12 | FIRST_TIME_CONTRIBUTOR: 'First time contributor', 13 | FIRST_TIMER: 'First timer', 14 | NONE: '' 15 | }; 16 | 17 | export class CommentComponent { 18 | public readonly element: HTMLElement; 19 | 20 | constructor( 21 | public comment: IssueComment, 22 | private currentUser: string | null, 23 | locked: boolean 24 | ) { 25 | const { user, html_url, created_at, body_html, author_association, reactions } = comment; 26 | this.element = document.createElement('article'); 27 | this.element.classList.add('timeline-comment'); 28 | if (user.login === currentUser) { 29 | this.element.classList.add('current-user'); 30 | } 31 | const association = displayAssociations[author_association]; 32 | const reactionCount = reactionTypes.reduce((sum, id) => sum + reactions[id], 0); 33 | let headerReactionsMenu = ''; 34 | let footerReactionsMenu = ''; 35 | if (!locked) { 36 | if (currentUser) { 37 | headerReactionsMenu = getReactionsMenuHtml(comment.reactions.url, 'right'); 38 | footerReactionsMenu = getReactionsMenuHtml(comment.reactions.url, 'center'); 39 | } else { 40 | headerReactionsMenu = getSignInToReactMenuHtml('right'); 41 | footerReactionsMenu = getSignInToReactMenuHtml('center'); 42 | } 43 | } 44 | this.element.innerHTML = ` 45 | 46 | @${user.login} 48 | 49 |
50 |
51 | 52 | ${user.login} 53 | commented 54 | ${timeAgo(Date.now(), new Date(created_at))} 55 | 56 |
57 | ${association ? `${association}` : ''} 58 | ${headerReactionsMenu} 59 |
60 |
61 |
62 | ${body_html} 63 |
64 | 70 |
`; 71 | 72 | const markdownBody = this.element.querySelector('.markdown-body')!; 73 | const emailToggle = markdownBody.querySelector('.email-hidden-toggle a') as HTMLAnchorElement; 74 | if (emailToggle) { 75 | const emailReply = markdownBody.querySelector('.email-hidden-reply') as HTMLDivElement; 76 | emailToggle.onclick = event => { 77 | event.preventDefault(); 78 | emailReply.classList.toggle('expanded'); 79 | }; 80 | } 81 | 82 | processRenderedMarkdown(markdownBody); 83 | } 84 | 85 | public setCurrentUser(currentUser: string | null) { 86 | if (this.currentUser === currentUser) { 87 | return; 88 | } 89 | this.currentUser = currentUser; 90 | 91 | if (this.comment.user.login === this.currentUser) { 92 | this.element.classList.add('current-user'); 93 | } else { 94 | this.element.classList.remove('current-user'); 95 | } 96 | } 97 | } 98 | 99 | export function processRenderedMarkdown(markdownBody: Element) { 100 | Array.from(markdownBody.querySelectorAll(':not(.email-hidden-toggle) > a')) 101 | .forEach(a => { a.target = '_top'; a.rel = 'noopener noreferrer'; }); 102 | Array.from(markdownBody.querySelectorAll('img')) 103 | .forEach(img => img.onload = scheduleMeasure); 104 | Array.from(markdownBody.querySelectorAll('a.commit-tease-sha')) 105 | .forEach(a => a.href = 'https://github.com' + a.pathname); 106 | } 107 | -------------------------------------------------------------------------------- /src/configuration-component.ts: -------------------------------------------------------------------------------- 1 | import { preferredThemeId, preferredTheme } from './preferred-theme'; 2 | 3 | export class ConfigurationComponent { 4 | public readonly element: HTMLFormElement; 5 | private readonly script: HTMLDivElement; 6 | private readonly repo: HTMLInputElement; 7 | private readonly label: HTMLInputElement; 8 | private readonly theme: HTMLSelectElement; 9 | 10 | constructor() { 11 | this.element = document.createElement('form'); 12 | this.element.innerHTML = ` 13 |

Repository

14 |

15 | Choose the repository utterances will connect to. 16 |

17 |
    18 |
  1. Make sure the repo is public, otherwise your readers will not be able to view the issues/comments.
  2. 19 |
  3. Make sure the utterances app 20 | is installed on the repo, otherwise users will not be able to post comments. 21 |
  4. 22 |
  5. If your repo is a fork, navigate to its settings tab and confirm 23 | the issues feature is turned on.
  6. 24 |
25 |
26 |
27 |
28 | 29 |

30 | A public GitHub repository. This is where the blog 31 | post issues and issue-comments will be posted. 32 |

33 |
34 |
35 | 36 |

Blog Post ↔️ Issue Mapping

37 |

Choose the mapping between blog posts and GitHub issues.

38 |
39 |
40 | 49 |
50 |
51 | 60 |
61 |
62 | 71 |
72 |
73 | 83 |
84 |
85 | 93 |
94 |
95 | 104 |
105 |
106 | 107 |

Issue Label

108 |

109 | Choose the label that will be assigned to issues created by Utterances. 110 |

111 |
112 |
113 |
114 | 115 |

116 | Label names are case sensitive. 117 | The label must exist in your repo- 118 | Utterances cannot attach labels that do not exist. 119 | Emoji are supported in label names.✨💬✨ 120 |

121 |
122 |
123 | 124 |

Theme

125 |

126 | Choose an Utterances theme that matches your blog. 127 | Can't find a theme you like? 128 | Contribute a custom theme. 129 |

130 | 131 | 142 | 143 |

Enable Utterances

144 | 145 |

Add the following script tag to your blog's template. Position it where you want the 146 | comments to appear. Customize the layout using the .utterances and 147 | .utterances-frame selectors. 148 |

149 |
150 | 151 |
152 |
`; 153 | 154 | this.element.addEventListener('submit', event => event.preventDefault()); 155 | this.element.action = 'javascript:'; 156 | 157 | this.script = this.element.querySelector('#script') as HTMLDivElement; 158 | 159 | this.repo = this.element.querySelector('#repo') as HTMLInputElement; 160 | 161 | this.label = this.element.querySelector('#label') as HTMLInputElement; 162 | 163 | this.theme = this.element.querySelector('#theme') as HTMLSelectElement; 164 | 165 | const themeStylesheet = document.getElementById('theme-stylesheet') as HTMLLinkElement; 166 | this.theme.addEventListener('change', () => { 167 | let theme = this.theme.value; 168 | if (theme === preferredThemeId) { 169 | theme = preferredTheme 170 | } 171 | themeStylesheet.href = `/stylesheets/themes/${theme}/index.css`; 172 | const message = { 173 | type: 'set-theme', 174 | theme 175 | }; 176 | const utterances = document.querySelector('iframe')!; 177 | utterances.contentWindow!.postMessage(message, location.origin); 178 | }); 179 | 180 | const copyButton = this.element.querySelector('#copy-button') as HTMLButtonElement; 181 | copyButton.addEventListener( 182 | 'click', 183 | () => this.copyTextToClipboard(this.script.textContent as string)); 184 | 185 | this.element.addEventListener('change', () => this.outputConfig()); 186 | this.element.addEventListener('input', () => this.outputConfig()); 187 | this.outputConfig(); 188 | } 189 | 190 | private outputConfig() { 191 | const mapping = this.element.querySelector('input[name="mapping"]:checked') as HTMLInputElement; 192 | let mappingAttr: string; 193 | // tslint:disable-next-line:prefer-conditional-expression 194 | if (mapping.value === 'issue-number') { 195 | mappingAttr = this.makeConfigScriptAttribute('issue-number', '[ENTER ISSUE NUMBER HERE]'); 196 | } else if (mapping.value === 'specific-term') { 197 | mappingAttr = this.makeConfigScriptAttribute('issue-term', '[ENTER TERM HERE]'); 198 | } else { 199 | mappingAttr = this.makeConfigScriptAttribute('issue-term', mapping.value); 200 | } 201 | this.script.innerHTML = this.makeConfigScript( 202 | this.makeConfigScriptAttribute('repo', this.repo.value === '' ? '[ENTER REPO HERE]' : this.repo.value) + '\n' + 203 | mappingAttr + '\n' + 204 | (this.label.value ? this.makeConfigScriptAttribute('label', this.label.value) + '\n' : '') + 205 | this.makeConfigScriptAttribute('theme', this.theme.value) + '\n' + 206 | this.makeConfigScriptAttribute('crossorigin', 'anonymous')); 207 | } 208 | 209 | private makeConfigScriptAttribute(name: string, value: string) { 210 | // tslint:disable-next-line:max-line-length 211 | return ` ${name}="${value}"`; 212 | } 213 | 214 | private makeConfigScript(attrs: string) { 215 | // tslint:disable-next-line:max-line-length 216 | return `
<script src="https://utteranc.es/client.js"\n${attrs}\n        async>\n</script>
`; 217 | } 218 | 219 | private copyTextToClipboard(text: string) { 220 | const textArea = document.createElement('textarea'); 221 | // tslint:disable-next-line:max-line-length 222 | textArea.style.cssText = `position:fixed;top:0;left:0;width:2em;height:2em;padding:0;border:none;outline:none;box-shadow:none;background:transparent`; 223 | textArea.value = text; 224 | document.body.appendChild(textArea); 225 | textArea.select(); 226 | try { 227 | document.execCommand('copy'); 228 | // tslint:disable-next-line:no-empty 229 | } catch (err) { } 230 | document.body.removeChild(textArea); 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/encoding.ts: -------------------------------------------------------------------------------- 1 | declare function escape(str: string): string; 2 | 3 | export function decodeBase64UTF8(encoded: string) { 4 | encoded = encoded.replace(/\s/g, ''); 5 | return decodeURIComponent(escape(atob(encoded))); 6 | } 7 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import { token } from './oauth'; 2 | import { decodeBase64UTF8 } from './encoding'; 3 | import { UTTERANCES_API } from './utterances-api'; 4 | 5 | const GITHUB_API = 'https://api.github.com/'; 6 | const GITHUB_ENCODING__HTML_JSON = 'application/vnd.github.VERSION.html+json'; 7 | const GITHUB_ENCODING__HTML = 'application/vnd.github.VERSION.html'; 8 | const GITHUB_ENCODING__REST_V3 = 'application/vnd.github.v3+json'; 9 | 10 | export const PAGE_SIZE = 25; 11 | 12 | export type ReactionID = '+1' | '-1' | 'laugh' | 'hooray' | 'confused' | 'heart' | 'rocket' | 'eyes'; 13 | 14 | export const reactionTypes: ReactionID[] = ['+1', '-1', 'laugh', 'hooray', 'confused', 'heart', 'rocket', 'eyes']; 15 | 16 | let owner: string; 17 | let repo: string; 18 | const branch = 'master'; 19 | 20 | export function setRepoContext(context: { owner: string; repo: string; }) { 21 | owner = context.owner; 22 | repo = context.repo; 23 | } 24 | 25 | function githubRequest(relativeUrl: string, init?: RequestInit) { 26 | init = init || {}; 27 | init.mode = 'cors'; 28 | init.cache = 'no-cache'; // force conditional request 29 | const request = new Request(GITHUB_API + relativeUrl, init); 30 | request.headers.set('Accept', GITHUB_ENCODING__REST_V3); 31 | if (token.value !== null) { 32 | request.headers.set('Authorization', `token ${token.value}`); 33 | } 34 | return request; 35 | } 36 | 37 | const rateLimit = { 38 | standard: { 39 | limit: Number.MAX_VALUE, 40 | remaining: Number.MAX_VALUE, 41 | reset: 0 42 | }, 43 | search: { 44 | limit: Number.MAX_VALUE, 45 | remaining: Number.MAX_VALUE, 46 | reset: 0 47 | } 48 | }; 49 | 50 | function processRateLimit(response: Response) { 51 | const limit = response.headers.get('X-RateLimit-Limit')!; 52 | const remaining = response.headers.get('X-RateLimit-Remaining')!; 53 | const reset = response.headers.get('X-RateLimit-Reset')!; 54 | 55 | const isSearch = /\/search\//.test(response.url); 56 | const rate = isSearch ? rateLimit.search : rateLimit.standard; 57 | 58 | rate.limit = +limit; 59 | rate.remaining = +remaining; 60 | rate.reset = +reset; 61 | 62 | if (response.status === 403 && rate.remaining === 0) { 63 | const resetDate = new Date(0); 64 | resetDate.setUTCSeconds(rate.reset); 65 | const mins = Math.round((resetDate.getTime() - new Date().getTime()) / 1000 / 60); 66 | const apiType = isSearch ? 'search API' : 'non-search APIs'; 67 | // tslint:disable-next-line:no-console 68 | console.warn(`Rate limit exceeded for ${apiType}. Resets in ${mins} minute${mins === 1 ? '' : 's'}.`); 69 | } 70 | } 71 | 72 | export function readRelNext(response: Response) { 73 | const link = response.headers.get('link'); 74 | if (link === null) { 75 | return 0; 76 | } 77 | const match = /\?page=([2-9][0-9]*)>; rel="next"/.exec(link); 78 | if (match === null) { 79 | return 0; 80 | } 81 | return +match[1]; 82 | } 83 | 84 | function githubFetch(request: Request): Promise { 85 | return fetch(request).then(response => { 86 | if (response.status === 401) { 87 | token.value = null; 88 | } 89 | if (response.status === 403) { 90 | response.json().then(data => { 91 | if (data.message === 'Resource not accessible by integration') { 92 | window.dispatchEvent(new CustomEvent('not-installed')); 93 | } 94 | }); 95 | } 96 | 97 | processRateLimit(response); 98 | 99 | if (request.method === 'GET' 100 | && [401, 403].indexOf(response.status) !== -1 101 | && request.headers.has('Authorization') 102 | ) { 103 | request.headers.delete('Authorization'); 104 | return githubFetch(request); 105 | } 106 | return response; 107 | }); 108 | } 109 | 110 | export function loadJsonFile(path: string, html = false) { 111 | const request = githubRequest(`repos/${owner}/${repo}/contents/${path}?ref=${branch}`); 112 | if (html) { 113 | request.headers.set('accept', GITHUB_ENCODING__HTML); 114 | } 115 | return githubFetch(request).then(response => { 116 | if (response.status === 404) { 117 | throw new Error(`Repo "${owner}/${repo}" does not have a file named "${path}" in the "${branch}" branch.`); 118 | } 119 | if (!response.ok) { 120 | throw new Error(`Error fetching ${path}.`); 121 | } 122 | return html ? response.text() : response.json(); 123 | }).then(file => { 124 | if (html) { 125 | return file; 126 | } 127 | const { content } = file as FileContentsResponse; 128 | const decoded = decodeBase64UTF8(content); 129 | return JSON.parse(decoded); 130 | }); 131 | } 132 | 133 | export function loadIssueByTerm(term: string) { 134 | const q = `"${term}" type:issue in:title repo:${owner}/${repo}`; 135 | const request = githubRequest(`search/issues?q=${encodeURIComponent(q)}&sort=created&order=asc`); 136 | return githubFetch(request).then(response => { 137 | if (!response.ok) { 138 | throw new Error('Error fetching issue via search.'); 139 | } 140 | return response.json(); 141 | }).then(results => { 142 | if (results.total_count === 0) { 143 | return null; 144 | } 145 | if (results.total_count > 1) { 146 | // tslint:disable-next-line:no-console 147 | console.warn(`Multiple issues match "${q}".`); 148 | } 149 | term = term.toLowerCase(); 150 | for (const result of results.items) { 151 | if (result.title.toLowerCase().indexOf(term) !== -1) { 152 | return result; 153 | } 154 | } 155 | // tslint:disable-next-line:no-console 156 | console.warn(`Issue search results do not contain an issue with title matching "${term}". Using first result.`); 157 | return results.items[0]; 158 | }); 159 | } 160 | 161 | export function loadIssueByNumber(issueNumber: number) { 162 | const request = githubRequest(`repos/${owner}/${repo}/issues/${issueNumber}`); 163 | return githubFetch(request).then(response => { 164 | if (!response.ok) { 165 | throw new Error('Error fetching issue via issue number.'); 166 | } 167 | return response.json(); 168 | }); 169 | } 170 | 171 | function commentsRequest(issueNumber: number, page: number) { 172 | const url = `repos/${owner}/${repo}/issues/${issueNumber}/comments?page=${page}&per_page=${PAGE_SIZE}`; 173 | const request = githubRequest(url); 174 | const accept = `${GITHUB_ENCODING__HTML_JSON},${GITHUB_ENCODING__REST_V3}`; 175 | request.headers.set('Accept', accept); 176 | return request; 177 | } 178 | 179 | export function loadCommentsPage(issueNumber: number, page: number): Promise { 180 | const request = commentsRequest(issueNumber, page); 181 | return githubFetch(request).then(response => { 182 | if (!response.ok) { 183 | throw new Error('Error fetching comments.'); 184 | } 185 | return response.json(); 186 | }); 187 | } 188 | 189 | export function loadUser(): Promise { 190 | if (token.value === null) { 191 | return Promise.resolve(null); 192 | } 193 | return githubFetch(githubRequest('user')) 194 | .then(response => { 195 | if (response.ok) { 196 | return response.json(); 197 | } 198 | return null; 199 | }); 200 | } 201 | 202 | export function createIssue(issueTerm: string, documentUrl: string, title: string, description: string, label: string) { 203 | const url = `${UTTERANCES_API}/repos/${owner}/${repo}/issues${label ? `?label=${encodeURIComponent(label)}` : ''}`; 204 | const request = new Request(url, { 205 | method: 'POST', 206 | body: JSON.stringify({ 207 | title: issueTerm, 208 | body: `# ${title}\n\n${description}\n\n[${documentUrl}](${documentUrl})` 209 | }) 210 | }); 211 | request.headers.set('Accept', GITHUB_ENCODING__REST_V3); 212 | request.headers.set('Authorization', `token ${token.value}`); 213 | return fetch(request).then(response => { 214 | if (!response.ok) { 215 | throw new Error('Error creating comments container issue'); 216 | } 217 | return response.json(); 218 | }); 219 | } 220 | 221 | export function postComment(issueNumber: number, markdown: string) { 222 | const url = `repos/${owner}/${repo}/issues/${issueNumber}/comments`; 223 | const body = JSON.stringify({ body: markdown }); 224 | const request = githubRequest(url, { method: 'POST', body }); 225 | const accept = `${GITHUB_ENCODING__HTML_JSON},${GITHUB_ENCODING__REST_V3}`; 226 | request.headers.set('Accept', accept); 227 | return githubFetch(request).then(response => { 228 | if (!response.ok) { 229 | throw new Error('Error posting comment.'); 230 | } 231 | return response.json(); 232 | }); 233 | } 234 | 235 | export async function toggleReaction(url: string, content: ReactionID) { 236 | url = url.replace(GITHUB_API, ''); 237 | // We don't know if the reaction exists or not. Attempt to create it. If the GitHub 238 | // API responds that the reaction already exists, delete it. 239 | const body = JSON.stringify({ content }); 240 | const postRequest = githubRequest(url, { method: 'POST', body }); 241 | postRequest.headers.set('Accept', GITHUB_ENCODING__REST_V3); 242 | const response = await githubFetch(postRequest); 243 | const reaction: Reaction = response.ok ? await response.json() : null; 244 | if (response.status === 201) { // reaction created. 245 | return { reaction, deleted: false }; 246 | } 247 | if (response.status !== 200) { 248 | throw new Error('expected "201 reaction created" or "200 reaction already exists"'); 249 | } 250 | // reaction already exists... delete. 251 | const deleteRequest = githubRequest(`${url}/${reaction.id}`, { method: 'DELETE' }); 252 | deleteRequest.headers.set('Accept', GITHUB_ENCODING__REST_V3); 253 | await githubFetch(deleteRequest); 254 | return { reaction, deleted: true }; 255 | } 256 | 257 | export function renderMarkdown(text: string) { 258 | const body = JSON.stringify({ text, mode: 'gfm', context: `${owner}/${repo}` }); 259 | return githubFetch(githubRequest('markdown', { method: 'POST', body })) 260 | .then(response => response.text()); 261 | } 262 | 263 | interface IssueSearchResponse { 264 | total_count: number; 265 | incomplete_results: boolean; 266 | items: Issue[]; 267 | } 268 | 269 | export interface User { 270 | login: string; 271 | id: number; 272 | avatar_url: string; 273 | gravatar_id: string; 274 | url: string; 275 | html_url: string; 276 | followers_url: string; 277 | following_url: string; 278 | gists_url: string; 279 | starred_url: string; 280 | subscriptions_url: string; 281 | organizations_url: string; 282 | repos_url: string; 283 | events_url: string; 284 | received_events_url: string; 285 | type: string; 286 | } 287 | 288 | export type CommentAuthorAssociation = 289 | 'COLLABORATOR' 290 | | 'CONTRIBUTOR' 291 | | 'FIRST_TIMER' 292 | | 'FIRST_TIME_CONTRIBUTOR' 293 | | 'MEMBER' 294 | | 'NONE' 295 | | 'OWNER'; 296 | 297 | export interface Reactions { 298 | url: string; 299 | total_count: number; 300 | '+1': number; 301 | '-1': number; 302 | laugh: number; 303 | hooray: number; 304 | confused: number; 305 | heart: number; 306 | rocket: number; 307 | eyes: number; 308 | } 309 | 310 | export interface Reaction { 311 | id: number; 312 | user: User; 313 | content: ReactionID; 314 | created_at: string; 315 | } 316 | 317 | export interface Issue { 318 | url: string; 319 | repository_url: string; 320 | labels_url: string; 321 | comments_url: string; 322 | events_url: string; 323 | html_url: string; 324 | id: number; 325 | number: number; 326 | title: string; 327 | user: User; 328 | locked: boolean; 329 | labels: { 330 | url: string; 331 | name: string; 332 | color: string; 333 | }[]; 334 | state: string; 335 | assignee: null; // todo, 336 | milestone: null; // todo, 337 | comments: number; 338 | created_at: string; 339 | updated_at: string; 340 | closed_at: null; // todo, 341 | pull_request: { 342 | html_url: null; // todo, 343 | diff_url: null; // todo, 344 | patch_url: null; // todo 345 | }; 346 | body: string; 347 | score: number; 348 | reactions: Reactions; 349 | author_association: CommentAuthorAssociation; 350 | } 351 | 352 | interface FileContentsResponse { 353 | type: string; 354 | encoding: string; 355 | size: number; 356 | name: string; 357 | path: string; 358 | content: string; 359 | sha: string; 360 | url: string; 361 | git_url: string; 362 | html_url: string; 363 | download_url: string; 364 | } 365 | 366 | export interface IssueComment { 367 | id: number; 368 | url: string; 369 | html_url: string; 370 | body_html: string; 371 | user: User; 372 | created_at: string; 373 | updated_at: string; 374 | author_association: CommentAuthorAssociation; 375 | reactions: Reactions; 376 | } 377 | 378 | /* 379 | query IssueComments($owner: String!, $repo: String!, $issueQuery: String!) { 380 | search(query: $issueQuery, type: ISSUE, first: 1) { 381 | issueCount 382 | edges { 383 | node { 384 | ... on Issue { 385 | id 386 | title, 387 | comments(first: 100) { 388 | totalCount 389 | edges { 390 | node { 391 | id, 392 | createdAt, 393 | bodyHTML, 394 | author { 395 | avatarUrl, 396 | login 397 | } 398 | } 399 | } 400 | } 401 | } 402 | } 403 | } 404 | } 405 | 406 | rateLimit { 407 | cost 408 | limit 409 | remaining 410 | resetAt 411 | } 412 | 413 | repository(owner: $owner, name: $repo) { 414 | object(expression: "master:utterances.json") { 415 | ... on Blob { 416 | text 417 | } 418 | } 419 | } 420 | } 421 | 422 | { 423 | "issueQuery": "user:jdanyow repo:utterances-demo debug", 424 | "owner": "jdanyow", 425 | "repo": "utterances-demo" 426 | } 427 | */ 428 | -------------------------------------------------------------------------------- /src/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/icons/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/android-chrome-256x256.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /src/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #603cba 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/favicon-16x16.png -------------------------------------------------------------------------------- /src/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/favicon.ico -------------------------------------------------------------------------------- /src/icons/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Utterances", 3 | "icons": [ 4 | { 5 | "src": "android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image/png" 8 | }, 9 | { 10 | "src": "android-chrome-256x256.png", 11 | "sizes": "256x256", 12 | "type": "image/png" 13 | } 14 | ], 15 | "theme_color": "#ffffff", 16 | "background_color": "#ffffff", 17 | "display": "standalone" 18 | } -------------------------------------------------------------------------------- /src/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/mstile-150x150.png -------------------------------------------------------------------------------- /src/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/utterances-300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/utterance/utterances/9e79bdaaa48c0b83d224c58f132db317785103cd/src/icons/utterances-300.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | utterances 5 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 26 | 27 | 32 | 38 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 | 88 |
89 | 90 | 91 | 99 | 100 | 101 | 110 | 111 | 112 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurationComponent } from './configuration-component'; 2 | 3 | document.querySelector('h2#configuration')! 4 | .insertAdjacentElement('afterend', new ConfigurationComponent().element); 5 | -------------------------------------------------------------------------------- /src/measure.ts: -------------------------------------------------------------------------------- 1 | export interface ResizeMessage { 2 | type: 'resize'; 3 | height: number; 4 | } 5 | 6 | let hostOrigin: string; 7 | 8 | export function startMeasuring(origin: string) { 9 | hostOrigin = origin; 10 | addEventListener('resize', scheduleMeasure); 11 | addEventListener('load', scheduleMeasure); 12 | } 13 | 14 | let lastHeight = -1; 15 | 16 | function measure() { 17 | const height = document.body.scrollHeight; 18 | if (height === lastHeight) { 19 | return; 20 | } 21 | lastHeight = height; 22 | const message: ResizeMessage = { type: 'resize', height }; 23 | parent.postMessage(message, hostOrigin); 24 | } 25 | 26 | let lastMeasure = 0; 27 | 28 | export function scheduleMeasure() { 29 | const now = Date.now(); 30 | if (now - lastMeasure > 50) { 31 | lastMeasure = now; 32 | setTimeout(measure, 50); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/new-comment-component.ts: -------------------------------------------------------------------------------- 1 | import { pageAttributes as page } from './page-attributes'; 2 | import { User, renderMarkdown } from './github'; 3 | import { scheduleMeasure } from './measure'; 4 | import { processRenderedMarkdown } from './comment-component'; 5 | import { getRepoConfig } from './repo-config'; 6 | import { getLoginUrl } from './oauth'; 7 | 8 | // tslint:disable-next-line:max-line-length 9 | const anonymousAvatar = ``; 10 | // base64 encoding works in IE, Edge. UTF-8 does not. 11 | const anonymousAvatarUrl = `data:image/svg+xml;base64,${btoa(anonymousAvatar)}`; 12 | 13 | const nothingToPreview = 'Nothing to preview'; 14 | 15 | export class NewCommentComponent { 16 | public readonly element: HTMLElement; 17 | 18 | private avatarAnchor: HTMLAnchorElement; 19 | private avatar: HTMLImageElement; 20 | private form: HTMLFormElement; 21 | private textarea: HTMLTextAreaElement; 22 | private preview: HTMLDivElement; 23 | private submitButton: HTMLButtonElement; 24 | private signInAnchor: HTMLAnchorElement; 25 | 26 | private submitting = false; 27 | private renderTimeout = 0; 28 | 29 | constructor( 30 | private user: User | null, 31 | private readonly submit: (markdown: string) => Promise 32 | ) { 33 | this.element = document.createElement('article'); 34 | this.element.classList.add('timeline-comment'); 35 | 36 | this.element.innerHTML = ` 37 | 38 | 39 | 40 |
41 |
42 |
43 | 47 | 51 |
52 |
53 |
54 | 55 | 58 |
59 | 77 |
`; 78 | 79 | this.avatarAnchor = this.element.firstElementChild as HTMLAnchorElement; 80 | this.avatar = this.avatarAnchor.firstElementChild as HTMLImageElement; 81 | this.form = this.avatarAnchor.nextElementSibling as HTMLFormElement; 82 | this.textarea = this.form!.firstElementChild!.nextElementSibling!.firstElementChild as HTMLTextAreaElement; 83 | this.preview = this.form!.firstElementChild!.nextElementSibling!.lastElementChild as HTMLDivElement; 84 | this.signInAnchor = this.form!.lastElementChild!.lastElementChild! as HTMLAnchorElement; 85 | this.submitButton = this.signInAnchor.previousElementSibling! as HTMLButtonElement; 86 | 87 | this.setUser(user); 88 | this.submitButton.disabled = true; 89 | 90 | this.textarea.addEventListener('input', this.handleInput); 91 | this.form.addEventListener('submit', this.handleSubmit); 92 | this.form.addEventListener('click', this.handleClick); 93 | this.form.addEventListener('keydown', this.handleKeyDown); 94 | handleTextAreaResize(this.textarea); 95 | } 96 | 97 | public setUser(user: User | null) { 98 | this.user = user; 99 | this.submitButton.hidden = !user; 100 | this.signInAnchor.hidden = !!user; 101 | if (user) { 102 | this.avatarAnchor.href = user.html_url; 103 | this.avatar.alt = '@' + user.login; 104 | this.avatar.src = user.avatar_url + '?v=3&s=88'; 105 | this.textarea.disabled = false; 106 | this.textarea.placeholder = 'Leave a comment'; 107 | } else { 108 | this.avatarAnchor.removeAttribute('href'); 109 | this.avatar.alt = '@anonymous'; 110 | this.avatar.src = anonymousAvatarUrl; 111 | this.textarea.disabled = true; 112 | this.textarea.placeholder = 'Sign in to comment'; 113 | } 114 | } 115 | 116 | public clear() { 117 | this.textarea.value = ''; 118 | } 119 | 120 | private handleInput = () => { 121 | getRepoConfig(); // preload repo config 122 | const text = this.textarea.value; 123 | const isWhitespace = /^\s*$/.test(text); 124 | this.submitButton.disabled = isWhitespace; 125 | if (this.textarea.scrollHeight < 450 && this.textarea.offsetHeight < this.textarea.scrollHeight) { 126 | this.textarea.style.height = `${this.textarea.scrollHeight}px`; 127 | scheduleMeasure(); 128 | } 129 | 130 | clearTimeout(this.renderTimeout); 131 | if (isWhitespace) { 132 | this.preview.textContent = nothingToPreview; 133 | } else { 134 | this.preview.textContent = 'Loading preview...'; 135 | this.renderTimeout = setTimeout( 136 | () => renderMarkdown(text).then(html => this.preview.innerHTML = html) 137 | .then(() => processRenderedMarkdown(this.preview)) 138 | .then(scheduleMeasure), 139 | 500); 140 | } 141 | } 142 | 143 | private handleSubmit = async (event: Event) => { 144 | event.preventDefault(); 145 | if (this.submitting) { 146 | return; 147 | } 148 | this.submitting = true; 149 | this.textarea.disabled = true; 150 | this.submitButton.disabled = true; 151 | await this.submit(this.textarea.value).catch(() => 0); 152 | this.submitting = false; 153 | this.textarea.disabled = !this.user; 154 | this.textarea.value = ''; 155 | this.submitButton.disabled = false; 156 | this.handleClick({ ...event, target: this.form.querySelector('.tabnav-tab.tab-write') }); 157 | this.preview.textContent = nothingToPreview; 158 | } 159 | 160 | private handleClick = ({ target }: Event) => { 161 | if (!(target instanceof HTMLButtonElement) || !target.classList.contains('tabnav-tab')) { 162 | return; 163 | } 164 | if (target.getAttribute('aria-selected') === 'true') { 165 | return; 166 | } 167 | this.form.querySelector('.tabnav-tab[aria-selected="true"]')!.setAttribute('aria-selected', 'false'); 168 | target.setAttribute('aria-selected', 'true'); 169 | const isPreview = target.classList.contains('tab-preview'); 170 | this.textarea.style.display = isPreview ? 'none' : ''; 171 | this.preview.style.display = isPreview ? '' : 'none'; 172 | scheduleMeasure(); 173 | } 174 | 175 | private handleKeyDown = ({ which, ctrlKey }: KeyboardEvent) => { 176 | if (which === 13 && ctrlKey && !this.submitButton.disabled) { 177 | this.form.dispatchEvent(new CustomEvent('submit')); 178 | } 179 | } 180 | } 181 | 182 | function handleTextAreaResize(textarea: HTMLTextAreaElement) { 183 | const stopTracking = () => { 184 | removeEventListener('mousemove', scheduleMeasure); 185 | removeEventListener('mouseup', stopTracking); 186 | }; 187 | const track = () => { 188 | addEventListener('mousemove', scheduleMeasure); 189 | addEventListener('mouseup', stopTracking); 190 | }; 191 | textarea.addEventListener('mousedown', track); 192 | } 193 | -------------------------------------------------------------------------------- /src/oauth.ts: -------------------------------------------------------------------------------- 1 | import { UTTERANCES_API } from './utterances-api'; 2 | import { pageAttributes } from './page-attributes'; 3 | 4 | export const token = { value: null as null | string }; 5 | 6 | // tslint:disable-next-line:variable-name 7 | export function getLoginUrl(redirect_uri: string) { 8 | return `${UTTERANCES_API}/authorize?${new URLSearchParams({ redirect_uri })}`; 9 | } 10 | 11 | export async function loadToken(): Promise { 12 | if (token.value) { 13 | return token.value; 14 | } 15 | if (!pageAttributes.session) { 16 | return null; 17 | } 18 | const url = `${UTTERANCES_API}/token`; 19 | const response = await fetch(url, { 20 | method: 'POST', 21 | mode: 'cors', 22 | credentials: 'include', 23 | headers: { 24 | 'content-type': 'application/json' 25 | }, 26 | body: JSON.stringify(pageAttributes.session) 27 | }); 28 | if (response.ok) { 29 | const t = await response.json(); 30 | token.value = t; 31 | return t; 32 | } 33 | return null; 34 | } 35 | -------------------------------------------------------------------------------- /src/page-attributes.ts: -------------------------------------------------------------------------------- 1 | import repoRegex from './repo-regex'; 2 | 3 | function readPageAttributes() { 4 | const params = Object.fromEntries(new URL(location.href).searchParams) 5 | 6 | let issueTerm: string | null = null; 7 | let issueNumber: number | null = null; 8 | if ('issue-term' in params) { 9 | issueTerm = params['issue-term']; 10 | if (issueTerm !== undefined) { 11 | if (issueTerm === '') { 12 | throw new Error('When issue-term is specified, it cannot be blank.'); 13 | } 14 | if (['title', 'url', 'pathname', 'og:title'].indexOf(issueTerm) !== -1) { 15 | if (!params[issueTerm]) { 16 | throw new Error(`Unable to find "${issueTerm}" metadata.`); 17 | } 18 | issueTerm = params[issueTerm]; 19 | } 20 | } 21 | } else if ('issue-number' in params) { 22 | issueNumber = +params['issue-number']; 23 | if (issueNumber.toString(10) !== params['issue-number']) { 24 | throw new Error(`issue-number is invalid. "${params['issue-number']}`); 25 | } 26 | } else { 27 | throw new Error('"issue-term" or "issue-number" must be specified.'); 28 | } 29 | 30 | if (!('repo' in params)) { 31 | throw new Error('"repo" is required.'); 32 | } 33 | 34 | if (!('origin' in params)) { 35 | throw new Error('"origin" is required.'); 36 | } 37 | 38 | const matches = repoRegex.exec(params.repo); 39 | if (matches === null) { 40 | throw new Error(`Invalid repo: "${params.repo}"`); 41 | } 42 | 43 | return { 44 | owner: matches[1], 45 | repo: matches[2], 46 | issueTerm, 47 | issueNumber, 48 | origin: params.origin, 49 | url: params.url, 50 | title: params.title, 51 | description: params.description, 52 | label: params.label, 53 | theme: params.theme || 'github-light', 54 | session: params.session 55 | }; 56 | } 57 | 58 | export const pageAttributes = readPageAttributes(); 59 | -------------------------------------------------------------------------------- /src/preferred-theme.ts: -------------------------------------------------------------------------------- 1 | export const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches 2 | ? 'github-dark' 3 | : 'github-light'; 4 | 5 | export const preferredThemeId = 'preferred-color-scheme'; -------------------------------------------------------------------------------- /src/reactions.ts: -------------------------------------------------------------------------------- 1 | import { toggleReaction, ReactionID, reactionTypes } from './github'; 2 | import { getLoginUrl } from './oauth'; 3 | import { pageAttributes } from './page-attributes'; 4 | import { scheduleMeasure } from './measure'; 5 | 6 | export const reactionNames: Record = { 7 | '+1': 'Thumbs Up', 8 | '-1': 'Thumbs Down', 9 | 'laugh': 'Laugh', 10 | 'hooray': 'Hooray', 11 | 'confused': 'Confused', 12 | 'heart': 'Heart', 13 | 'rocket': 'Rocket', 14 | 'eyes': 'Eyes' 15 | }; 16 | 17 | export const reactionEmoji: Record = { 18 | '+1': '👍', 19 | '-1': '👎', 20 | 'laugh': '️😂', 21 | 'hooray': '️🎉', 22 | 'confused': '😕', 23 | 'heart': '❤️', 24 | 'rocket': '🚀', 25 | 'eyes': '👀' 26 | }; 27 | 28 | export function getReactionHtml(url: string, reaction: ReactionID, disabled: boolean, count: number) { 29 | return ` 30 | `; 42 | } 43 | 44 | export function enableReactions(authenticated: boolean) { 45 | const submitReaction = async (event: Event) => { 46 | const button = event.target instanceof HTMLElement && event.target.closest('button'); 47 | if (!button) { 48 | return; 49 | } 50 | if (!button.hasAttribute('reaction')) { 51 | return; 52 | } 53 | event.preventDefault(); 54 | if (!authenticated) { 55 | return; 56 | } 57 | button.disabled = true; 58 | const parentMenu = button.closest('details'); 59 | if (parentMenu) { 60 | parentMenu.open = false; 61 | } 62 | const url = button.formAction; 63 | const id = button.value as ReactionID; 64 | const { deleted } = await toggleReaction(url, id); 65 | const selector = `button[reaction][formaction="${url}"][value="${id}"],[reaction-count][reaction-url="${url}"]`; 66 | const elements = Array.from(document.querySelectorAll(selector)); 67 | const delta = deleted ? -1 : 1; 68 | for (const element of elements) { 69 | element.setAttribute( 70 | 'reaction-count', 71 | (parseInt(element.getAttribute('reaction-count')!, 10) + delta).toString()); 72 | } 73 | button.disabled = false; 74 | scheduleMeasure(); 75 | }; 76 | addEventListener('click', submitReaction, true); 77 | } 78 | 79 | export function getReactionsMenuHtml(url: string, align: 'center' | 'right') { 80 | const position = align === 'center' ? 'left: 50%;transform: translateX(-50%)' : 'right:6px'; 81 | const alignmentClass = align === 'center' ? '' : 'Popover-message--top-right'; 82 | const getButtonAndSpan = (id: ReactionID) => getReactionHtml(url, id, false, 0) 83 | + ``; 84 | return ` 85 |
86 | ${addReactionSvgs} 87 |
88 |
89 | Pick your reaction 90 |
91 | ${reactionTypes.slice(0, 4).map(getButtonAndSpan).join('')} 92 |
93 |
94 | ${reactionTypes.slice(4).map(getButtonAndSpan).join('')} 95 |
96 |
97 |
98 |
`; 99 | } 100 | 101 | export function getSignInToReactMenuHtml(align: 'center' | 'right') { 102 | const position = align === 'center' ? 'left: 50%;transform: translateX(-50%)' : 'right:6px'; 103 | const alignmentClass = align === 'center' ? '' : 'Popover-message--top-right'; 104 | return ` 105 |
106 | ${addReactionSvgs} 107 |
108 |
109 | Sign in to add your reaction. 110 |
111 |
112 |
`; 113 | } 114 | 115 | // tslint:disable-next-line:max-line-length 116 | const addReactionSvgs = ``; 117 | -------------------------------------------------------------------------------- /src/repo-config.ts: -------------------------------------------------------------------------------- 1 | import { loadJsonFile } from './github'; 2 | import { pageAttributes } from './page-attributes'; 3 | 4 | export interface RepoConfig { 5 | origins: string[]; 6 | } 7 | 8 | let promise: Promise; 9 | 10 | export function getRepoConfig() { 11 | if (!promise) { 12 | promise = loadJsonFile('utterances.json').then( 13 | data => { 14 | if (!Array.isArray(data.origins)) { 15 | data.origins = []; 16 | } 17 | return data; 18 | }, 19 | () => ({ 20 | origins: [pageAttributes.origin] 21 | }) 22 | ); 23 | } 24 | 25 | return promise; 26 | } 27 | -------------------------------------------------------------------------------- /src/repo-regex.ts: -------------------------------------------------------------------------------- 1 | export default /^([\w-_]+)\/([\w-_.]+)$/i; 2 | -------------------------------------------------------------------------------- /src/stylesheets/email-fragment.scss: -------------------------------------------------------------------------------- 1 | .email-fragment { 2 | white-space: pre-wrap; 3 | } 4 | 5 | .email-hidden-toggle a { 6 | display: inline-block; 7 | height: 12px; 8 | padding: 0 9px; 9 | font-size: 12px; 10 | font-weight: 600; 11 | line-height: 6px; 12 | color: $border-gray-darker; 13 | text-decoration: none; 14 | vertical-align: middle; 15 | background: $gray-200; 16 | border-radius: 1px; 17 | 18 | &:hover { 19 | background-color: $gray-300; 20 | } 21 | 22 | &:active { 23 | color: #fff; 24 | background-color: $border-blue; 25 | } 26 | } 27 | 28 | .email-hidden-reply { 29 | display: none; 30 | 31 | &.expanded { 32 | display: block; 33 | } 34 | } 35 | 36 | .email-quoted-reply, 37 | .email-signature-reply { 38 | padding: 0 15px; 39 | margin: 15px 0; 40 | color: $text-gray; 41 | border-left: 4px solid $gray-200; 42 | } 43 | -------------------------------------------------------------------------------- /src/stylesheets/index.scss: -------------------------------------------------------------------------------- 1 | @import "@primer/css/base/index"; 2 | @import "@primer/css/buttons/button"; 3 | @import "@primer/css/forms/form-control"; 4 | @import "@primer/css/forms/form-select"; 5 | @import "@primer/css/forms/form-group"; 6 | @import "@primer/css/forms/input-group"; 7 | @import "@primer/css/markdown/index"; 8 | @import "./util"; 9 | @import "./timeline"; 10 | @import "./timeline-comment"; 11 | 12 | .timeline { 13 | width: 100%; 14 | max-width: 760px; 15 | margin-left: auto; 16 | margin-right: auto; 17 | } 18 | 19 | .markdown-body { 20 | font-size: 16px; 21 | } 22 | 23 | form { 24 | font-size: $body-font-size; 25 | } 26 | 27 | fieldset { 28 | border: none; 29 | margin: 0 $spacer-3; 30 | padding: 0; 31 | } 32 | 33 | input:not([type=radio]) { 34 | margin: $spacer-1 0; 35 | } 36 | 37 | .form-checkbox label { 38 | display: block; 39 | cursor: pointer; 40 | } 41 | 42 | .code-action { 43 | float: right; 44 | margin-top: -$spacer-2; 45 | margin-bottom: $spacer-3; 46 | } 47 | -------------------------------------------------------------------------------- /src/stylesheets/permalink-code.scss: -------------------------------------------------------------------------------- 1 | // permalink code block styles 2 | 3 | .border { 4 | border: $border !important; 5 | } 6 | 7 | .border-0 { 8 | border: 0 !important; 9 | } 10 | 11 | .border-bottom { 12 | border-bottom: $border !important; 13 | } 14 | 15 | .rounded-1 { 16 | border-radius: $border-radius !important; 17 | } 18 | 19 | .lh-condensed { 20 | line-height: $lh-condensed !important; 21 | } 22 | 23 | .f6 { 24 | font-size: 12px !important; 25 | } 26 | 27 | .my-2 { 28 | margin-top: $spacer-2 !important; 29 | margin-bottom: $spacer-2 !important; 30 | } 31 | 32 | .mb-0 { 33 | margin-bottom: 0 !important; 34 | } 35 | 36 | .px-3 { 37 | padding-right: $spacer-3 !important; 38 | padding-left: $spacer-3 !important; 39 | } 40 | 41 | .py-0 { 42 | padding-top: 0 !important; 43 | padding-bottom: 0 !important; 44 | } 45 | 46 | .py-2 { 47 | padding-top: $spacer-2 !important; 48 | padding-bottom: $spacer-2 !important; 49 | } 50 | 51 | .blob-wrapper-embedded { 52 | max-height: 240px; 53 | } 54 | 55 | .blob-wrapper { 56 | border-bottom-right-radius: $border-radius; 57 | border-bottom-left-radius: $border-radius; 58 | overflow: auto; 59 | -webkit-overflow-scrolling: touch; 60 | 61 | > table { 62 | overflow: visible; 63 | } 64 | } 65 | 66 | .blob-num { 67 | width: 1%; 68 | min-width: 50px; 69 | padding-right: 10px; 70 | padding-left: 10px; 71 | font-family: $mono-font; 72 | font-size: $font-size-small; 73 | line-height: $lh-default; 74 | color: $black-fade-30; 75 | text-align: right; 76 | white-space: nowrap; 77 | vertical-align: top; 78 | cursor: pointer; 79 | user-select: none; 80 | 81 | &::before { 82 | content: attr(data-line-number); 83 | } 84 | } 85 | 86 | .blob-code-inner { 87 | overflow: visible; 88 | font-family: $mono-font; 89 | font-size: $font-size-small; 90 | color: $text-gray-dark; 91 | word-wrap: normal; 92 | white-space: pre; 93 | } 94 | 95 | .blob-code { 96 | position: relative; 97 | padding-right: 10px; 98 | padding-left: 10px; 99 | line-height: $lh-default; 100 | vertical-align: top; 101 | } 102 | 103 | .bg-white { 104 | background-color: $bg-white !important; 105 | } 106 | 107 | .bg-gray-light { 108 | background-color: $bg-gray-light !important; 109 | } 110 | -------------------------------------------------------------------------------- /src/stylesheets/reactions.scss: -------------------------------------------------------------------------------- 1 | .reaction-list { 2 | display: flex; 3 | flex-wrap: nowrap; 4 | margin-top: -1px; 5 | margin-bottom: -1px; 6 | margin-left: -1px; 7 | border-right: $border; 8 | overflow-x: hidden; 9 | overflow-y: visible; 10 | > .reaction-button:last-child { 11 | border-right: none !important; 12 | } 13 | } 14 | 15 | .reaction-list > .reaction-button { 16 | font-weight: normal; 17 | padding: $spacer-2 $spacer-3; 18 | border-radius: 0 !important; 19 | 20 | &[reaction-count="0"] { 21 | display: none; 22 | } 23 | 24 | &::after { 25 | display: inline-block; 26 | margin-left: 2px; 27 | content: attr(reaction-count); 28 | } 29 | } 30 | 31 | .reactions-popover { 32 | summary { 33 | margin: -1px 0; 34 | padding: $spacer-2 $spacer-3; 35 | color: $text-gray; 36 | white-space: nowrap; 37 | transition: opacity 0.3s ease-in-out; 38 | &:hover { 39 | color: $text-blue; 40 | } 41 | } 42 | 43 | .Popover { 44 | margin-top: $spacer-2; 45 | } 46 | 47 | .Popover-message { 48 | position: relative; 49 | padding: $spacer-1; 50 | display: flex; 51 | flex-wrap: wrap; 52 | width: 150px; 53 | background-color: $bg-white; 54 | border: $border; 55 | border-radius: $border-radius; 56 | } 57 | 58 | .BtnGroup { 59 | margin: 0 auto; 60 | &:first-of-type { 61 | margin-top: $spacer-6; 62 | } 63 | } 64 | 65 | .reaction-button { 66 | border: none; 67 | border-radius: 0 !important; 68 | transition: transform 0.15s cubic-bezier(0.2, 0, 0.13, 2); 69 | background: transparent !important; 70 | white-space: nowrap; 71 | padding: $spacer-1 $spacer-2; 72 | 73 | &:hover { 74 | transform: scale(1.2); 75 | color: $text-blue !important; 76 | } 77 | } 78 | } 79 | 80 | .reaction-name { 81 | position: absolute; 82 | top: 0; 83 | left: 0; 84 | right: 0; 85 | border-bottom: $border; 86 | padding: $spacer-2 $spacer-3; 87 | background-color: $bg-white; 88 | pointer-events: none; 89 | 90 | .reaction-button + & { 91 | display: none; 92 | } 93 | 94 | .reaction-button:hover + & { 95 | display: block; 96 | } 97 | } 98 | 99 | .reaction-button { 100 | box-shadow: none; 101 | &:focus-visible { 102 | box-shadow: none; 103 | outline: 1px dotted #000; 104 | outline-offset: -1px; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/stylesheets/themes/boxy-light/index.scss: -------------------------------------------------------------------------------- 1 | @import "../../index"; 2 | @import "./syntax"; 3 | -------------------------------------------------------------------------------- /src/stylesheets/themes/boxy-light/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-light/lib/github-light"; 2 | 3 | // adjust syntax highlighting to ensure WCAG AAA rating 4 | .pl-ent { 5 | color: #196128; 6 | } 7 | -------------------------------------------------------------------------------- /src/stylesheets/themes/boxy-light/utterances.scss: -------------------------------------------------------------------------------- 1 | $border-radius: 0; 2 | 3 | @import "../../utterances"; 4 | @import "./syntax"; 5 | 6 | .reactions-popover { 7 | summary { 8 | outline: none; 9 | } 10 | } 11 | 12 | .tabnav-tab.selected, .tabnav-tab[aria-selected=true], .tabnav-tab[aria-current], .tabnav-tab:focus { 13 | outline: none; 14 | } 15 | 16 | .btn { 17 | border-radius: $border-radius; 18 | background-image: unset; 19 | } 20 | -------------------------------------------------------------------------------- /src/stylesheets/themes/dark-blue/button.scss: -------------------------------------------------------------------------------- 1 | .btn-primary { 2 | background: #4183C4; 3 | color: #e2e2e2; 4 | } 5 | 6 | .btn-primary:hover { 7 | background: #548fc9; 8 | } 9 | 10 | .btn-primary:disabled { 11 | background: #1e4163; 12 | } -------------------------------------------------------------------------------- /src/stylesheets/themes/dark-blue/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/dark-blue/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-dark/lib/github-dark"; 2 | -------------------------------------------------------------------------------- /src/stylesheets/themes/dark-blue/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/dark-blue/variables.scss: -------------------------------------------------------------------------------- 1 | $gray-000: #181818; 2 | $gray-100: #222; 3 | $gray-200: #24292e; 4 | $gray-300: #1D1F21; 5 | $gray-400: #586069; 6 | $gray-600: #7b7b7b; 7 | $gray-700: #959da5; 8 | $bg-white: darken(#1E232B, 5%); 9 | $bg-gray: $bg-white; 10 | $bg-gray-light: darken($bg-gray, 5%); 11 | $border-gray: #1D1F21; 12 | $border-gray-dark: #1D1F21; 13 | $text-gray: #949494; 14 | $text-gray-dark: #c0c0c0; 15 | $text-blue: rgb(65, 131, 196); 16 | $bg-blue-light: #182030; 17 | $black-fade-15: rgba(#fff, 0.15); 18 | $black-fade-30: rgba(#fff, 0.3); 19 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/button.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .btn-primary:disabled { 4 | background: #376b39; 5 | } 6 | 7 | button[role=tab] { 8 | color: $gray-600; 9 | .selected { 10 | color: $text-gray-dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/code.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .bg-gray-light, 4 | .bg-white { 5 | background-color: $bg-gray !important; 6 | } 7 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/combobox.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .form-control, .form-select { 4 | color: $text-gray-dark; 5 | background-color: $bg-white !important; 6 | border-radius: 3px; 7 | } 8 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/comment.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .form-select { 4 | background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUAgMAAAD5DFXkAAAACVBMVEXMzMzMzMzMzMzgWE1NAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfiCwMFBDi4mDGnAAAAR0lEQVQI123JIRUAIQAE0eUEgghEIAV5SEEEIiAQMCnZh74RX+xKr6+bNE3ZBjxBV4KpAlt/NTjKsBRhKED1ePzkZeIwoeoCtokT7bjAVYwAAAAASUVORK5CYII=) no-repeat right 8px center; 5 | background-size: 8px 10px; 6 | } 7 | 8 | .markdown-body kbd { 9 | background-color: transparent; 10 | color: $text-gray-dark; 11 | } 12 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/glow.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | *:focus { 4 | outline: none !important; 5 | box-shadow: 0 0 0.2em 0.1em opacify($highlight, 0.1); 6 | border-radius: 1px; 7 | } 8 | 9 | .tabnav-tab, 10 | .form-control, 11 | .form-select { 12 | &.focus, 13 | &:focus { 14 | border-color: transparent; 15 | box-shadow: 0 0 0 0.11em opacify($highlight, 0.1) !important; 16 | outline-color: transparent; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/heading.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | h1, h2, h3, h4, h5, h6 { 4 | border-color: $line !important; 5 | } 6 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | @import "./glow.scss"; 6 | @import "./heading.scss"; 7 | @import "./comment.scss"; 8 | @import "./selection.scss"; 9 | @import "./placeholder.scss"; 10 | @import "./combobox.scss"; 11 | @import "./code.scss"; 12 | 13 | body { 14 | background-color: $bg-page; 15 | } 16 | 17 | iframe { 18 | -webkit-touch-callout: none; 19 | -webkit-user-select: none; 20 | -khtml-user-select: none; 21 | -moz-user-select: none; 22 | -ms-user-select: none; 23 | user-select: none; 24 | } 25 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/placeholder.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | @mixin placeholder { 4 | ::placeholder { @content; } 5 | ::-webkit-input-placeholder { @content; } 6 | ::-moz-placeholder { @content; } 7 | :-moz-placeholder { @content; } 8 | :-ms-input-placeholder { @content; } 9 | } 10 | 11 | @include placeholder { 12 | color: $text-gray-dark; 13 | } 14 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/selection.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | @mixin selection($element) { 4 | #{$element}::-moz-selection { @content; } 5 | #{$element}::selection { @content; } 6 | } 7 | 8 | @include selection("") { 9 | background: $highlight; 10 | color: $text-gray-dark !important; 11 | } 12 | 13 | @include selection("img") { 14 | background: transparentize($highlight, 0.25); 15 | } 16 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-dark/lib/github-dark"; 2 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | @import "./glow.scss"; 6 | @import "./heading.scss"; 7 | @import "./comment.scss"; 8 | @import "./selection.scss"; 9 | @import "./placeholder.scss"; 10 | @import "./combobox.scss"; 11 | @import "./code.scss"; 12 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark-orange/variables.scss: -------------------------------------------------------------------------------- 1 | $gray-000: #181818; 2 | $gray-100: #222222; 3 | $gray-200: #24292e; 4 | $gray-300: #4e4c4b; 5 | $gray-400: #586069; 6 | $gray-600: #bbbbbb; 7 | $gray-700: #959da5; 8 | $bg-white: #2d2833; 9 | $bg-gray: $gray-100; 10 | $bg-gray-light: darken($bg-gray, 5%); 11 | $border-gray: $gray-300; 12 | $border-gray-dark: $border-gray; 13 | $black-fade-15: rgba(#fff, 0.15); 14 | $black-fade-30: rgba(#fff, 0.3); 15 | $text-gray: #c9c9c9; 16 | $text-gray-dark: #f7f7f7; 17 | $text-blue: #f27052; 18 | $bg-blue-light: #212021; 19 | $bg-page: #463d4e; 20 | 21 | $highlight: rgba(242, 112, 82, 0.99); 22 | $line: #6f6e6e; 23 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark/button.scss: -------------------------------------------------------------------------------- 1 | .btn-primary { 2 | background: linear-gradient(#407045, #305530); 3 | border-color: #083; 4 | color: #e2e2e2; 5 | } 6 | 7 | .btn-primary:hover { 8 | background: linear-gradient(#508055, #407045); 9 | } 10 | 11 | .btn-primary:disabled { 12 | background: linear-gradient(#203522, #152715); 13 | border-color: #041; 14 | } -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-dark/lib/github-dark"; 2 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-dark/variables.scss: -------------------------------------------------------------------------------- 1 | $gray-000: #181818; 2 | $gray-100: #222; 3 | $gray-200: #24292e; 4 | $gray-300: #343434; 5 | $gray-400: #586069; 6 | $gray-600: #7b7b7b; 7 | $gray-700: #959da5; 8 | $bg-white: #181818; 9 | $bg-gray: #202020; 10 | $bg-gray-light: darken($bg-gray, 5%); 11 | $border-gray: $gray-300; 12 | $border-gray-dark: $border-gray; 13 | $text-gray: #949494; 14 | $text-gray-dark: #c0c0c0; 15 | $text-blue: rgb(65, 131, 196); 16 | $bg-blue-light: #182030; 17 | $black-fade-15: rgba(#fff, 0.15); 18 | $black-fade-30: rgba(#fff, 0.3); 19 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-light/index.scss: -------------------------------------------------------------------------------- 1 | @import "../../index"; 2 | @import "./syntax"; -------------------------------------------------------------------------------- /src/stylesheets/themes/github-light/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-light/lib/github-light"; 2 | 3 | // adjust syntax highlighting to ensure WCAG AAA rating 4 | .pl-ent { 5 | color: #196128; 6 | } 7 | -------------------------------------------------------------------------------- /src/stylesheets/themes/github-light/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "../../utterances"; 2 | @import "./syntax"; 3 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/button.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .btn-primary:disabled { 4 | background: $gray; 5 | } 6 | 7 | button[role=tab] { 8 | color: $light4; 9 | .selected { 10 | color: $light0; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/combobox.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .form-control, .form-select { 4 | color: $text-gray-dark; 5 | background-color: $bg-white !important; 6 | border-radius: 3px; 7 | } 8 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/comment.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .form-select { 4 | background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUAgMAAAD5DFXkAAAACVBMVEXMzMzMzMzMzMzgWE1NAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfiCwMFBDi4mDGnAAAAR0lEQVQI123JIRUAIQAE0eUEgghEIAV5SEEEIiAQMCnZh74RX+xKr6+bNE3ZBjxBV4KpAlt/NTjKsBRhKED1ePzkZeIwoeoCtokT7bjAVYwAAAAASUVORK5CYII=) no-repeat right 8px center; 5 | background-size: 8px 10px; 6 | } 7 | 8 | .markdown-body kbd { 9 | background-color: transparent; 10 | color: $text-gray-dark; 11 | } 12 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/glow.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | *:focus { 4 | outline: none !important; 5 | box-shadow: 0 0 0.2em 0.1em opacify($highlight, 0.1); 6 | border-radius: 1px; 7 | } 8 | 9 | .tabnav-tab, 10 | .form-control, 11 | .form-select { 12 | &.focus, 13 | &:focus { 14 | border-color: transparent; 15 | box-shadow: 0 0 0 0.11em opacify($highlight, 0.1) !important; 16 | outline-color: transparent; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/heading.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | h1, h2, h3, h4, h5, h6 { 4 | border-color: $line !important; 5 | } 6 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | @import "./glow.scss"; 6 | @import "./heading.scss"; 7 | @import "./comment.scss"; 8 | @import "./selection.scss"; 9 | @import "./placeholder.scss"; 10 | @import "./combobox.scss"; 11 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/placeholder.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | @mixin placeholder { 4 | ::placeholder { @content; } 5 | ::-webkit-input-placeholder { @content; } 6 | ::-moz-placeholder { @content; } 7 | :-moz-placeholder { @content; } 8 | :-ms-input-placeholder { @content; } 9 | } 10 | 11 | @include placeholder { 12 | color: $text-gray-dark; 13 | } 14 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/selection.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | @mixin selection($element) { 4 | #{$element}::-moz-selection { @content; } 5 | #{$element}::selection { @content; } 6 | } 7 | 8 | @include selection("") { 9 | background: $highlight; 10 | color: $text-gray-dark !important; 11 | } 12 | 13 | @include selection("img") { 14 | background: transparentize($highlight, 0.25); 15 | } 16 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | 3 | .pl-c /* comment, punctuation.definition.comment, string.comment */ { 4 | color: $gray; 5 | } 6 | 7 | .pl-c1 /* constant, entity.name.constant, variable.other.constant, variable.language, support, meta.property-name, support.constant, support.variable, meta.module-reference, markup.quote, markup.raw, meta.diff.header */, 8 | .pl-s .pl-v /* string variable */ { 9 | color: $light0; 10 | } 11 | 12 | .pl-e /* entity */, 13 | .pl-en /* entity.name */ { 14 | color: $light0; 15 | } 16 | 17 | .pl-smi /* variable.parameter.function, storage.modifier.package, storage.modifier.import, storage.type.java, variable.other */{ 18 | color: $bright-blue; 19 | } 20 | .pl-s .pl-s1 /* string source */ { 21 | color: $bright-orange; 22 | } 23 | 24 | .pl-ent /* entity.name.tag */ { 25 | color: $bright-red; 26 | } 27 | 28 | .pl-k /* keyword, storage, storage.type */ { 29 | color: $bright-red; 30 | } 31 | 32 | .pl-s /* string */, 33 | .pl-pds /* punctuation.definition.string, source.regexp, string.regexp.character-class */, 34 | .pl-s .pl-pse .pl-s1 /* string punctuation.section.embedded source */, 35 | .pl-sr /* string.regexp */, 36 | .pl-sr .pl-cce /* string.regexp constant.character.escape */, 37 | .pl-sr .pl-sre /* string.regexp source.ruby.embedded */, 38 | .pl-sr .pl-sra /* string.regexp string.regexp.arbitrary-repitition */ { 39 | color: $bright-green; 40 | } 41 | 42 | .pl-v /* variable */, 43 | .pl-ml /* markup.list, sublimelinter.mark.warning */ { 44 | color: $bright-orange; 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | @import "./glow.scss"; 6 | @import "./heading.scss"; 7 | @import "./comment.scss"; 8 | @import "./selection.scss"; 9 | @import "./placeholder.scss"; 10 | @import "./combobox.scss"; 11 | 12 | -------------------------------------------------------------------------------- /src/stylesheets/themes/gruvbox-dark/variables.scss: -------------------------------------------------------------------------------- 1 | /* Gruvbox Color Variables; intended to be plugged into Dark Orange below */ 2 | 3 | $dark0-hard: #1d2021; 4 | $dark0: #282828; 5 | $dark0-soft: #32302f; 6 | $dark1: #3c3836; 7 | $dark2: #504945; 8 | $dark3: #665c54; 9 | $dark4: #7c6f64; 10 | 11 | $gray: #928374; 12 | 13 | $light0-hard: #f9f5d7; 14 | $light0: #fbf1c7; 15 | $light0-soft: #f2e5bc; 16 | $light1: #ebdbb2; 17 | $light2: #d5c4a1; 18 | $light3: #bdae93; 19 | $light4: #a89984; 20 | 21 | $bright-red: #fb4934; 22 | $bright-green: #b8bb26; 23 | $bright-yellow: #fabd2f; 24 | $bright-blue: #83a598; 25 | $bright-purple: #d3869b; 26 | $bright-aqua: #8ec07c; 27 | $bright-orange: #fe8019; 28 | 29 | $neutral-red: #cc241d; 30 | $neutral-green: #98971a; 31 | $neutral-yellow: #d79921; 32 | $neutral-blue: #458588; 33 | $neutral-purple: #b16286; 34 | $neutral-aqua: #689d6a; 35 | $neutral-orange: #d65d0e; 36 | 37 | $faded-red: #9d0006; 38 | $faded-green: #79740e; 39 | $faded-yellow: #b57614; 40 | $faded-blue: #076678; 41 | $faded-purple: #8f3f71; 42 | $faded-aqua: #427b58; 43 | $faded-orange: #af3a03; 44 | 45 | /* Dark Orange Vars */ 46 | 47 | $gray-400: $light1; 48 | $bg-white: $dark0; 49 | $bg-gray: $dark1; 50 | $bg-gray-light: $dark1; 51 | $bg-blue-light: $dark0; 52 | $border-gray: $gray; 53 | $border-gray-dark: $border-gray; 54 | $black-fade-15: rgba(#fff, 0.15); 55 | $black-fade-30: rgba(#fff, 0.3); 56 | $text-gray: $light1; 57 | $text-gray-dark: $light0; 58 | $text-blue: $bright-orange; 59 | $bg-page: $dark0-hard; 60 | 61 | $highlight: rgba(168, 153, 132, 0.5); 62 | $line: #6f6e6e; 63 | -------------------------------------------------------------------------------- /src/stylesheets/themes/icy-dark/button.scss: -------------------------------------------------------------------------------- 1 | .btn-primary { 2 | background: linear-gradient(#058d92, #025854); 3 | border-color: rgb(2, 192, 192); 4 | color: #e2e2e2; 5 | } 6 | 7 | .btn-primary:hover { 8 | background: linear-gradient(#058d92, #025854); 9 | } 10 | 11 | .btn-primary:disabled { 12 | background: linear-gradient(#203535, #152727); 13 | border-color: rgb(0, 68, 65); 14 | } -------------------------------------------------------------------------------- /src/stylesheets/themes/icy-dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/icy-dark/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-dark/lib/github-dark"; 2 | -------------------------------------------------------------------------------- /src/stylesheets/themes/icy-dark/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/icy-dark/variables.scss: -------------------------------------------------------------------------------- 1 | $gray-000: #021012; 2 | $gray-100: #051a1d; 3 | $gray-200: #0a3238; 4 | $gray-300: #10444d; 5 | $gray-400: #18606d; 6 | $gray-600: #278a97; 7 | $gray-700: #39a9b8; 8 | $bg-white: #021012; 9 | $bg-gray: #06272c; 10 | $bg-gray-light: darken($bg-gray, 5%); 11 | $border-gray: $gray-300; 12 | $border-gray-dark: $border-gray; 13 | $text-gray: #fff; 14 | $text-gray-dark: #fff; 15 | $text-blue: rgb(38, 208, 231); 16 | $bg-blue-light: #182c30; 17 | $black-fade-15: rgba(#fff, 0.15); 18 | $black-fade-30: rgba(#fff, 0.3); -------------------------------------------------------------------------------- /src/stylesheets/themes/photon-dark/button.scss: -------------------------------------------------------------------------------- 1 | .btn-primary { 2 | background: $button-normal; 3 | border: 0; 4 | color: $text-gray; 5 | } 6 | 7 | .btn-primary:hover { 8 | background: $button-hover; 9 | } 10 | 11 | .btn-primary:active { 12 | background: $button-pressed; 13 | } 14 | 15 | .btn-primary:focus { 16 | box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3) 17 | } 18 | 19 | .btn-primary:disabled { 20 | background: $button-normal; 21 | opacity: 0.4; 22 | } 23 | -------------------------------------------------------------------------------- /src/stylesheets/themes/photon-dark/index.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../index"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/photon-dark/syntax.scss: -------------------------------------------------------------------------------- 1 | @import "github-syntax-dark/lib/github-dark"; 2 | -------------------------------------------------------------------------------- /src/stylesheets/themes/photon-dark/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "./variables"; 2 | @import "../../utterances"; 3 | @import "./syntax"; 4 | @import "./button.scss"; 5 | -------------------------------------------------------------------------------- /src/stylesheets/themes/photon-dark/variables.scss: -------------------------------------------------------------------------------- 1 | $gray-000: #0c0c0d; 2 | $gray-100: #0c0c0d; 3 | $gray-200: #2a2a2e; 4 | $gray-300: #38383d; 5 | $gray-400: #4a4a4f; 6 | $gray-600: #737373; 7 | $gray-700: #b1b1b3; 8 | $bg-white: darken($gray-200, 3%); 9 | $bg-gray: $gray-200; 10 | $bg-gray-light: darken($bg-gray, 5%); 11 | $border-gray: $gray-300; 12 | $border-gray-dark: $border-gray; 13 | $text-gray: #ededf0; 14 | $text-gray-dark: #d7d7db; 15 | $text-blue: #45a1ff; 16 | $bg-blue-light: #182030; 17 | $black-fade-15: rgba(#fff, 0.15); 18 | $black-fade-30: rgba(#fff, 0.3); 19 | $button-normal: #0a84ff; 20 | $button-hover: #003eaa; 21 | $button-pressed: #002275; 22 | -------------------------------------------------------------------------------- /src/stylesheets/timeline-comment.scss: -------------------------------------------------------------------------------- 1 | .timeline-comment { 2 | display: flex; 3 | align-items: flex-start; 4 | margin: $spacer-3 0; 5 | 6 | .avatar { 7 | display: none; 8 | background-color: transparent; 9 | } 10 | 11 | .avatar > img { 12 | border-radius: $border-radius; 13 | } 14 | 15 | .comment { 16 | position: relative; 17 | flex-grow: 1; 18 | flex-basis: 0; 19 | min-width: 0px; 20 | background-color: $bg-white; 21 | border: $border; 22 | border-radius: $border-radius; 23 | } 24 | 25 | .comment-header { 26 | display: flex; 27 | align-items: center; 28 | justify-content: space-between; 29 | color: $text-gray; 30 | background-color: $bg-gray; 31 | border-bottom: $border; 32 | border-top-left-radius: $border-radius; 33 | border-top-right-radius: $border-radius; 34 | } 35 | 36 | .comment-meta { 37 | padding: 10px $spacer-3; 38 | } 39 | 40 | .comment-actions { 41 | display: flex; 42 | align-items: center; 43 | } 44 | 45 | .new-comment-header { 46 | padding: $spacer-2 $spacer-2 0 $spacer-2; 47 | background-color: $bg-gray; 48 | border-bottom: $border; 49 | margin-bottom: 0; 50 | border-top-left-radius: $border-radius; 51 | border-top-right-radius: $border-radius; 52 | } 53 | 54 | .markdown-body { 55 | padding: $spacer-3 $spacer-3; 56 | font-size: $body-font-size; 57 | } 58 | 59 | .comment-body { 60 | padding: $spacer-2; 61 | 62 | textarea { 63 | appearance: none; 64 | display: block; 65 | max-height: 550px; 66 | resize: vertical; 67 | } 68 | 69 | textarea, 70 | .markdown-body { 71 | padding: $spacer-2; 72 | width: 100%; 73 | min-height: 90px; 74 | border: $border; 75 | border-radius: $border-radius; 76 | } 77 | 78 | textarea:focus { 79 | box-shadow: $btn-input-focus-shadow; 80 | } 81 | 82 | textarea:disabled { 83 | background-color: $bg-gray; 84 | } 85 | } 86 | 87 | .comment-footer { 88 | display: flex; 89 | border-top: $border; 90 | 91 | &[reaction-count="0"] { 92 | height: 0; 93 | overflow: hidden; 94 | opacity: 0; 95 | } 96 | 97 | &:not(:hover) .reactions-popover:not([open]) { 98 | height: 0; 99 | overflow: hidden; 100 | opacity: 0; 101 | summary { 102 | opacity: 0; 103 | } 104 | } 105 | } 106 | 107 | .new-comment-footer { 108 | display: flex; 109 | align-items: center; 110 | justify-content: space-between; 111 | padding: 0 $spacer-2 $spacer-2 $spacer-2; 112 | 113 | .markdown-info { 114 | font-size: $font-size-small; 115 | margin-right: $spacer-1; 116 | } 117 | } 118 | 119 | &.current-user .comment-header { 120 | background-color: $bg-blue-light; 121 | } 122 | 123 | .author-association-badge { 124 | margin-top: -1px; 125 | margin-right: $spacer-1; 126 | padding: 2px 5px; 127 | border: $border; 128 | border-radius: $border-radius; 129 | font-size: $font-size-small; 130 | font-weight: $font-weight-semibold; 131 | } 132 | 133 | .reactions-popover { 134 | display: none; 135 | } 136 | 137 | @media screen and (min-width: map-get($breakpoints, sm)) { 138 | .avatar { 139 | display: block; 140 | margin-right: $spacer-3; 141 | } 142 | 143 | .comment { 144 | @include double-caret($bg-gray, $border-gray-dark); 145 | } 146 | 147 | &.current-user .comment { 148 | @include double-caret($bg-blue-light, $border-gray-dark); 149 | } 150 | 151 | .reactions-popover { 152 | display: inline-block; 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/stylesheets/timeline.scss: -------------------------------------------------------------------------------- 1 | .timeline { 2 | margin: $spacer-3 0; 3 | padding: 0 $spacer-1; 4 | } 5 | 6 | .timeline-header { 7 | margin: 0; 8 | padding-left: $spacer-3; 9 | font-size: $body-font-size; 10 | color: $text-gray; 11 | @media screen and (min-width: map-get($breakpoints, sm)) { 12 | padding-left: 44 + $spacer-3; 13 | } 14 | } 15 | 16 | .timeline-header em { 17 | font-weight: $font-weight-normal; 18 | } 19 | 20 | .page-loader { 21 | position: relative; 22 | margin: 40px 0; 23 | 24 | .btn { 25 | position: absolute; 26 | left: 50%; 27 | transform: translateX(-50%) translateY(calc(-50% - 8px)); 28 | z-index: 1; 29 | } 30 | } -------------------------------------------------------------------------------- /src/stylesheets/util.scss: -------------------------------------------------------------------------------- 1 | .text-link { 2 | color: $text-gray; 3 | } 4 | 5 | .octicon { 6 | display: inline-block; 7 | fill: currentColor; 8 | } 9 | 10 | .v-align-bottom { 11 | vertical-align: bottom !important; 12 | } 13 | 14 | .markdown-body-scrollable { 15 | max-height: 450px; 16 | overflow-x: hidden; 17 | overflow-y: auto; 18 | -webkit-overflow-scrolling: touch; 19 | } 20 | 21 | .markdown-body .highlight-source-js > pre { 22 | -webkit-overflow-scrolling: touch; 23 | } 24 | 25 | .btn-outline { 26 | border-color: $border-gray; 27 | } 28 | 29 | .details-popover { 30 | position: relative; 31 | 32 | & > summary { 33 | list-style-type: none; 34 | &::-webkit-details-marker { 35 | display: none; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/stylesheets/utterances.scss: -------------------------------------------------------------------------------- 1 | @import "@primer/css/base/index"; 2 | @import "@primer/css/utilities/details"; 3 | @import "@primer/css/utilities/box-shadow"; 4 | @import "@primer/css/buttons/index"; 5 | @import "@primer/css/markdown/index"; 6 | @import "@primer/css/alerts/index"; 7 | @import "@primer/css/navigation/tabnav"; 8 | @import "@primer/css/forms/form-control"; 9 | @import "@primer/css/popover/index"; 10 | @import "./util"; 11 | @import "./timeline"; 12 | @import "./timeline-comment"; 13 | @import "./permalink-code"; 14 | @import "./email-fragment"; 15 | @import "./zigzag"; 16 | @import "./reactions"; 17 | 18 | html, 19 | body { 20 | background-color: transparent; 21 | } 22 | 23 | body { 24 | overflow: hidden; 25 | padding-bottom: 20px; 26 | } 27 | 28 | .form-control { 29 | font-size: $body-font-size; 30 | } 31 | 32 | .flash-not-installed { 33 | margin-bottom: $spacer-3; 34 | } 35 | 36 | textarea[disabled] { 37 | cursor: not-allowed; 38 | } -------------------------------------------------------------------------------- /src/stylesheets/zigzag.scss: -------------------------------------------------------------------------------- 1 | $zz-height: 16px; 2 | $zz-halfheight: ($zz-height / 2); 3 | $zz-thickness: 110%; // increase to make the line thicker 4 | $zz-offset: 4px; 5 | $zz-background-color: $bg-white; 6 | $zz-line-color: $border-gray; 7 | 8 | .zigzag { 9 | background: $zz-background-color; 10 | position: relative; 11 | height: $zz-height; 12 | z-index: 1; 13 | &:before, 14 | &:after { 15 | content: ""; 16 | display: block; 17 | position: absolute; 18 | left: 0; 19 | right: 0; 20 | } 21 | &:before { 22 | height: ($zz-height - $zz-offset); 23 | top: calc(#{$zz-thickness} - #{$zz-height}); 24 | background: linear-gradient(-135deg, $zz-line-color $zz-halfheight, transparent 0) 0 $zz-halfheight, linear-gradient( 135deg, $zz-line-color $zz-halfheight, transparent 0) 0 $zz-halfheight; 25 | background-position: top left; 26 | background-repeat: repeat-x; 27 | background-size: $zz-height $zz-height; 28 | } 29 | &:after { 30 | height: $zz-height; 31 | top: calc(100% - #{$zz-height}); 32 | background: linear-gradient(-135deg, $zz-background-color $zz-halfheight, transparent 0) 0 $zz-halfheight, linear-gradient( 135deg, $zz-background-color $zz-halfheight, transparent 0) 0 $zz-halfheight; 33 | background-position: top left; 34 | background-repeat: repeat-x; 35 | background-size: $zz-height $zz-height; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | export function loadTheme(theme: string, origin: string) { 2 | return new Promise(resolve => { 3 | const link = document.createElement('link'); 4 | link.rel = 'stylesheet'; 5 | link.setAttribute('crossorigin', 'anonymous'); 6 | link.onload = resolve; 7 | link.href = `/stylesheets/themes/${theme}/utterances.css`; 8 | document.head.appendChild(link); 9 | 10 | addEventListener('message', event => { 11 | if (event.origin === origin && event.data.type === 'set-theme') { 12 | link.href = `/stylesheets/themes/${event.data.theme}/utterances.css`; 13 | } 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/time-ago.ts: -------------------------------------------------------------------------------- 1 | // todo: take a dependency on some relative time library. 2 | 3 | const thresholds = [ 4 | 1000, 'second', 5 | 1000 * 60, 'minute', 6 | 1000 * 60 * 60, 'hour', 7 | 1000 * 60 * 60 * 24, 'day', 8 | 1000 * 60 * 60 * 24 * 7, 'week', 9 | 1000 * 60 * 60 * 24 * 27, 'month' 10 | ]; 11 | 12 | const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }; 13 | 14 | export function timeAgo(current: number, value: Date) { 15 | const elapsed = current - value.getTime(); 16 | if (elapsed < 5000) { 17 | return 'just now'; 18 | } 19 | let i = 0; 20 | while (i + 2 < thresholds.length && elapsed * 1.1 > thresholds[i + 2]) { 21 | i += 2; 22 | } 23 | 24 | const divisor = thresholds[i] as number; 25 | const text = thresholds[i + 1] as string; 26 | const units = Math.round(elapsed / divisor); 27 | 28 | if (units > 3 && i === thresholds.length - 2) { 29 | return `on ${value.toLocaleDateString(undefined, formatOptions)}`; 30 | } 31 | return units === 1 ? `${text === 'hour' ? 'an' : 'a'} ${text} ago` : `${units} ${text}s ago`; 32 | } 33 | -------------------------------------------------------------------------------- /src/timeline-component.ts: -------------------------------------------------------------------------------- 1 | import { User, Issue, IssueComment } from './github'; 2 | import { CommentComponent } from './comment-component'; 3 | import { scheduleMeasure } from './measure'; 4 | 5 | export class TimelineComponent { 6 | public readonly element: HTMLElement; 7 | private readonly timeline: CommentComponent[] = []; 8 | private readonly countAnchor: HTMLAnchorElement; 9 | private readonly marker: Node; 10 | private count: number = 0; 11 | 12 | constructor( 13 | private user: User | null, 14 | private issue: Issue | null 15 | ) { 16 | this.element = document.createElement('main'); 17 | this.element.classList.add('timeline'); 18 | this.element.innerHTML = ` 19 |

20 | 21 | 22 | - powered by 23 | utteranc.es 24 | 25 |

`; 26 | this.countAnchor = this.element.firstElementChild!.firstElementChild as HTMLAnchorElement; 27 | this.marker = document.createComment('marker'); 28 | this.element.appendChild(this.marker); 29 | this.setIssue(this.issue); 30 | this.renderCount(); 31 | } 32 | 33 | public setUser(user: User | null) { 34 | this.user = user; 35 | const login = user ? user.login : null; 36 | for (let i = 0; i < this.timeline.length; i++) { 37 | this.timeline[i].setCurrentUser(login); 38 | } 39 | scheduleMeasure(); 40 | } 41 | 42 | public setIssue(issue: Issue | null) { 43 | this.issue = issue; 44 | if (issue) { 45 | this.count = issue.comments; 46 | this.countAnchor.href = issue.html_url; 47 | this.renderCount(); 48 | } else { 49 | this.countAnchor.removeAttribute('href'); 50 | } 51 | } 52 | 53 | public insertComment(comment: IssueComment, incrementCount: boolean) { 54 | const component = new CommentComponent( 55 | comment, 56 | this.user ? this.user.login : null, 57 | this.issue!.locked); 58 | 59 | const index = this.timeline.findIndex(x => x.comment.id >= comment.id); 60 | if (index === -1) { 61 | this.timeline.push(component); 62 | this.element.insertBefore(component.element, this.marker); 63 | } else { 64 | const next = this.timeline[index]; 65 | const remove = next.comment.id === comment.id; 66 | this.element.insertBefore(component.element, next.element); 67 | this.timeline.splice(index, remove ? 1 : 0, component); 68 | if (remove) { 69 | next.element.remove(); 70 | } 71 | } 72 | 73 | if (incrementCount) { 74 | this.count++; 75 | this.renderCount(); 76 | } 77 | 78 | scheduleMeasure(); 79 | } 80 | 81 | public insertPageLoader(insertAfter: IssueComment, count: number, callback: () => void) { 82 | const { element: insertAfterElement } = this.timeline.find(x => x.comment.id >= insertAfter.id)!; 83 | insertAfterElement.insertAdjacentHTML('afterend', ` 84 |
85 |
86 | 90 |
91 | `); 92 | const element = insertAfterElement.nextElementSibling!; 93 | const button = element.lastElementChild! as HTMLButtonElement; 94 | const statusSpan = button.lastElementChild!; 95 | button.onclick = callback; 96 | 97 | return { 98 | setBusy() { 99 | statusSpan.textContent = 'Loading...'; 100 | button.disabled = true; 101 | }, 102 | remove() { 103 | button.onclick = null; 104 | element.remove(); 105 | } 106 | }; 107 | } 108 | 109 | private renderCount() { 110 | this.countAnchor.textContent = `${this.count} Comment${this.count === 1 ? '' : 's'}`; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/utterances-api.ts: -------------------------------------------------------------------------------- 1 | // export const UTTERANCES_API = 'http://localhost:7000'; 2 | export const UTTERANCES_API = 'https://api.utteranc.es'; 3 | -------------------------------------------------------------------------------- /src/utterances.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | utterances 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/utterances.ts: -------------------------------------------------------------------------------- 1 | import { pageAttributes as page } from './page-attributes'; 2 | import { 3 | Issue, 4 | setRepoContext, 5 | loadIssueByTerm, 6 | loadIssueByNumber, 7 | loadCommentsPage, 8 | loadUser, 9 | postComment, 10 | createIssue, 11 | PAGE_SIZE, 12 | IssueComment 13 | } from './github'; 14 | import { TimelineComponent } from './timeline-component'; 15 | import { NewCommentComponent } from './new-comment-component'; 16 | import { startMeasuring, scheduleMeasure } from './measure'; 17 | import { loadTheme } from './theme'; 18 | import { getRepoConfig } from './repo-config'; 19 | import { loadToken } from './oauth'; 20 | import { enableReactions } from './reactions'; 21 | 22 | setRepoContext(page); 23 | 24 | function loadIssue(): Promise { 25 | if (page.issueNumber !== null) { 26 | return loadIssueByNumber(page.issueNumber); 27 | } 28 | return loadIssueByTerm(page.issueTerm as string); 29 | } 30 | 31 | async function bootstrap() { 32 | await loadToken(); 33 | // tslint:disable-next-line:prefer-const 34 | let [issue, user] = await Promise.all([ 35 | loadIssue(), 36 | loadUser(), 37 | loadTheme(page.theme, page.origin) 38 | ]); 39 | 40 | startMeasuring(page.origin); 41 | 42 | const timeline = new TimelineComponent(user, issue); 43 | document.body.appendChild(timeline.element); 44 | 45 | if (issue && issue.comments > 0) { 46 | renderComments(issue, timeline); 47 | } 48 | 49 | scheduleMeasure(); 50 | 51 | if (issue && issue.locked) { 52 | return; 53 | } 54 | 55 | enableReactions(!!user); 56 | 57 | const submit = async (markdown: string) => { 58 | await assertOrigin(); 59 | if (!issue) { 60 | issue = await createIssue( 61 | page.issueTerm as string, 62 | page.url, 63 | page.title, 64 | page.description || '', 65 | page.label 66 | ); 67 | timeline.setIssue(issue); 68 | } 69 | const comment = await postComment(issue.number, markdown); 70 | timeline.insertComment(comment, true); 71 | newCommentComponent.clear(); 72 | }; 73 | 74 | const newCommentComponent = new NewCommentComponent(user, submit); 75 | timeline.element.appendChild(newCommentComponent.element); 76 | } 77 | 78 | bootstrap(); 79 | 80 | addEventListener('not-installed', function handleNotInstalled() { 81 | removeEventListener('not-installed', handleNotInstalled); 82 | document.querySelector('.timeline')!.insertAdjacentHTML('afterbegin', ` 83 |
84 | Error: utterances is not installed on ${page.owner}/${page.repo}. 85 | If you own this repo, 86 | install the app. 87 | Read more about this change in 88 | the PR. 89 |
`); 90 | scheduleMeasure(); 91 | }); 92 | 93 | async function renderComments(issue: Issue, timeline: TimelineComponent) { 94 | const renderPage = (page: IssueComment[]) => { 95 | for (const comment of page) { 96 | timeline.insertComment(comment, false); 97 | } 98 | }; 99 | 100 | const pageCount = Math.ceil(issue.comments / PAGE_SIZE); 101 | // always load the first page. 102 | const pageLoads = [loadCommentsPage(issue.number, 1)]; 103 | // if there are multiple pages, load the last page. 104 | if (pageCount > 1) { 105 | pageLoads.push(loadCommentsPage(issue.number, pageCount)); 106 | } 107 | // if the last page is small, load the penultimate page. 108 | if (pageCount > 2 && issue.comments % PAGE_SIZE < 3 && 109 | issue.comments % PAGE_SIZE !== 0) { 110 | pageLoads.push(loadCommentsPage(issue.number, pageCount - 1)); 111 | } 112 | // await all loads to reduce jank. 113 | const pages = await Promise.all(pageLoads); 114 | for (const page of pages) { 115 | renderPage(page); 116 | } 117 | // enable loading hidden pages. 118 | let hiddenPageCount = pageCount - pageLoads.length; 119 | let nextHiddenPage = 2; 120 | const renderLoader = (afterPage: IssueComment[]) => { 121 | if (hiddenPageCount === 0) { 122 | return; 123 | } 124 | const load = async () => { 125 | loader.setBusy(); 126 | const page = await loadCommentsPage(issue.number, nextHiddenPage); 127 | loader.remove(); 128 | renderPage(page); 129 | hiddenPageCount--; 130 | nextHiddenPage++; 131 | renderLoader(page); 132 | }; 133 | const afterComment = afterPage.pop()!; 134 | const loader = timeline.insertPageLoader(afterComment, hiddenPageCount * PAGE_SIZE, load); 135 | }; 136 | renderLoader(pages[0]); 137 | } 138 | 139 | export async function assertOrigin() { 140 | const { origins } = await getRepoConfig(); 141 | const { origin, owner, repo } = page; 142 | if (origins.indexOf(origin) !== -1) { 143 | return; 144 | } 145 | 146 | document.querySelector('.timeline')!.lastElementChild!.insertAdjacentHTML('beforebegin', ` 147 |
148 | Error: ${origin} is not permitted to post to ${owner}/${repo}. 149 | Confirm this is the correct repo for this site's comments. If you own this repo, 150 | 151 | update the utterances.json 152 | 153 | to include ${origin} in the list of origins.

154 | Suggested configuration:
155 |
${JSON.stringify({ origins: [origin] }, null, 2)}
156 |
`); 157 | scheduleMeasure(); 158 | throw new Error('Origin not permitted.'); 159 | } 160 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ESNext", 5 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 6 | "types": [], 7 | "strict": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitAny": true, 10 | "noImplicitReturns": true, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "removeComments": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "rules": { 4 | "quotemark": [true, "single"], 5 | "object-literal-sort-keys": false, 6 | "ordered-imports": [false], 7 | "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-module", "check-separator", "check-type"], 8 | "interface-name": [true, "never-prefix"], 9 | "no-shadowed-variable": false, 10 | "no-string-literal": false, 11 | "trailing-comma": [false], 12 | "array-type": [true, "array"], 13 | "arrow-parens": false, 14 | "max-classes-per-file": false, 15 | "prefer-for-of": false, 16 | "no-implicit-dependencies": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utterances.json: -------------------------------------------------------------------------------- 1 | { 2 | "origins": ["https://utteranc.es"] 3 | } 4 | --------------------------------------------------------------------------------