diff --git a/app/assets/javascripts/components/highlight.jsx b/app/assets/javascripts/components/highlight.jsx new file mode 100644 index 00000000000000..a67ac2351683d3 --- /dev/null +++ b/app/assets/javascripts/components/highlight.jsx @@ -0,0 +1,158 @@ +// @flow +import Prism from 'prismjs'; +import escapeTextContentForBrowser from 'escape-html'; +import apacheconf from "prismjs/components/prism-apacheconf" +import bash from "prismjs/components/prism-bash" +import brainfuck from "prismjs/components/prism-brainfuck" +import c from "prismjs/components/prism-c" +import coffeescript from "prismjs/components/prism-coffeescript" +import cpp from "prismjs/components/prism-cpp" +import csharp from "prismjs/components/prism-csharp" +import d from "prismjs/components/prism-d" +import diff from "prismjs/components/prism-diff" +import docker from "prismjs/components/prism-docker" +import elixir from "prismjs/components/prism-elixir" +import erlang from "prismjs/components/prism-erlang" +import go from "prismjs/components/prism-go" +import graphql from "prismjs/components/prism-graphql" +import haml from "prismjs/components/prism-haml" +import handlebars from "prismjs/components/prism-handlebars" +import haskell from "prismjs/components/prism-haskell" +import java from "prismjs/components/prism-java" +import json from "prismjs/components/prism-json" +import jsx from "prismjs/components/prism-jsx" +import julia from "prismjs/components/prism-julia" +import kotlin from "prismjs/components/prism-kotlin" +import lua from "prismjs/components/prism-lua" +import markdown from "prismjs/components/prism-markdown" +import nginx from "prismjs/components/prism-nginx" +import objectivec from "prismjs/components/prism-objectivec" +import ocaml from "prismjs/components/prism-ocaml" +import perl from "prismjs/components/prism-perl" +import php from "prismjs/components/prism-php" +import processing from "prismjs/components/prism-processing" +import python from "prismjs/components/prism-python" +import r from "prismjs/components/prism-r" +import ruby from "prismjs/components/prism-ruby" +import rust from "prismjs/components/prism-rust" +import sass from "prismjs/components/prism-sass" +import scala from "prismjs/components/prism-scala" +import scheme from "prismjs/components/prism-scheme" +import scss from "prismjs/components/prism-scss" +import smalltalk from "prismjs/components/prism-smalltalk" +import sql from "prismjs/components/prism-sql" +import swift from "prismjs/components/prism-swift" +import typescript from "prismjs/components/prism-typescript" +import yaml from "prismjs/components/prism-yaml" + +Prism.languages.extend({bash, brainfuck, c, cpp, csharp, d, diff, docker, elixir, erlang, go, graphql, haml, handlebars, haskell, java, json, jsx, julia, kotlin, lua, markdown, nginx, objectivec, ocaml, perl, php, processing, python, r, ruby, rust, sass, scala, scheme, scss, smalltalk, sql, swift, typescript, yaml}); + +Prism.languages.rb = Prism.languages.ruby; + +type Code = {| + text: string, + language: ?string, +|} + +export const createCodeElement = (doc: Document, {text, language}: Code): HTMLPreElement => { + const pre = doc.createElement('pre'); + if(language) pre.className = `language-${language}`; + const code = doc.createElement('code'); + const syntax = Prism.languages[language]; + const highlighted = syntax ? Prism.highlight(text, syntax) : escapeTextContentForBrowser(text); + code.innerHTML = highlighted; + pre.appendChild(code); + return pre; +} + +const removeNode = (node: Node) => { + node.parentNode.removeChild(node); + return true +} + +const trimBR = (node: Node) => { + while(node.firstChild && node.firstChild.tagName === 'BR') removeNode(node.firstChild); + while(node.lastChild && node.lastChild.tagName === 'BR') removeNode(node.lastChild); +} + +const previousSiblingsParagraph = (doc: Document, node: Node) => { + const p = doc.createElement('p'); + while(node.parentNode.firstChild !== node) p.appendChild(node.parentNode.firstChild); + trimBR(p); + return p; +} + +const accumulateOrTerminateCode = (doc: Document, current: Node, code: Code, top: ?Node): ?Code => { + if(current.textContent !== '```') { + code.text += current.tagName === 'BR' && code.text !== '' ? "\n" : current.textContent; + return removeNode(current) && code; + } + let p = current; + while(p.parentNode !== top) p = p.parentNode; + if(top.tagName === 'P') { + // p can not include block element + //
text1
text2 =>text1
text
+ const prev = previousSiblingsParagraph(doc, current) + if(prev.childNodes.length !== 0) top.parentNode.insertBefore(prev, top) + top.parentNode.insertBefore(createCodeElement(doc, code), top) + removeNode(current); + } else { + top.insertBefore(createCodeElement(doc, code), p); + removeNode(current); + } + return null; +} + +const findStartDelimiter = (current: Node): ?Code => { + const match = current.textContent.match(/^```([a-z]*)$/) + if(match) { + removeNode(current); + return {language: match[1], text: ''}; + } + return null; +} + +const handleLeaf = (doc: Document, current: Node, code: ?Code, top: ?Node): ?Code => { + if(code) return accumulateOrTerminateCode(doc, current, code, top); + if(!(current instanceof window.Text)) return null; + return findStartDelimiter(current) +} + +const transform = (doc, current: Node, code: ?Code, top: ?Node): ?Code => { + if(current.childNodes.length === 0) return handleLeaf(doc, current, code, top); + + // childNodes might be modified inside loop + const arr = Array.from(current.childNodes); + for(let i = 0, l = arr.length; i < l; i++) { + const outside = !code; + code = transform(doc, arr[i], code, top); + if(outside && code) top = current; + } + + if(current.tagName === 'P') { + if(code) code.text += code.text === '' ? "\n" : "\n\n"; + trimBR(current) + if(current.childNodes.length === 0) removeNode(current); + } + return code; +} + +const parse = (text: string): ?Document => { + try { + const doc = new window.DOMParser().parseFromString(text, 'text/html'); + if(doc.getElementsByTagName("parsererror").length) return null; + return doc; + } catch (e) { + return null; + } +} + +const highlight = (text: string): string => { + const doc = parse(text); + if(!doc) return escapeTextContentForBrowser(text); + const incomplete = transform(doc, doc.body, null, null); + if(incomplete) return text; + return doc.body.innerHTML; +} + +export default highlight; diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx index 2002d22233903a..0d8816638ff8b9 100644 --- a/app/assets/javascripts/components/reducers/statuses.jsx +++ b/app/assets/javascripts/components/reducers/statuses.jsx @@ -34,13 +34,14 @@ import { } from '../actions/favourites'; import { SEARCH_FETCH_SUCCESS } from '../actions/search'; import Immutable from 'immutable'; +import highlight from '../highlight'; const normalizeStatus = (state, status) => { if (!status) { return state; } - const normalStatus = { ...status }; + const normalStatus = { ...status, content: highlight(status.content) }; normalStatus.account = status.account.id; if (status.reblog && status.reblog.id) { diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx index c13feceffef809..a845200fa039d0 100644 --- a/app/assets/javascripts/extras.jsx +++ b/app/assets/javascripts/extras.jsx @@ -1,4 +1,5 @@ import emojify from './components/emoji' +import highlight from './components/highlight' $(() => { $.each($('.emojify'), (_, content) => { @@ -37,4 +38,9 @@ $(() => { $(e.target).parent().attr('style', null); } }); + + $.each($('.highlight'), (_, content) => { + const $content = $(content); + $content.html(highlight($content.html())); + }); }); diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 765b43f9390eff..abd19f5699d0f0 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1,4 +1,5 @@ @import 'variables'; +@import 'prism'; .app-body{ -webkit-overflow-scrolling: touch; @@ -286,6 +287,15 @@ overflow: hidden; white-space: pre-wrap; + pre { + @extend pre[class*="language-"]; + border-width: 2px; + position: relative; + padding: 0.4em 0.8em; + > code { + font-size: 0.8em; + } + } .emojione { width: 18px; height: 18px; diff --git a/app/assets/stylesheets/prism.css b/app/assets/stylesheets/prism.css new file mode 100644 index 00000000000000..fcaecd0eb4b370 --- /dev/null +++ b/app/assets/stylesheets/prism.css @@ -0,0 +1,200 @@ +/* http://prismjs.com/download.html?themes=prism-twilight&languages=markup+css+clike+javascript */ +/** + * prism.js Twilight theme + * Based (more or less) on the Twilight theme originally of Textmate fame. + * @author Remy Bach + */ +code[class*="language-"], +pre[class*="language-"] { + color: white; + background: none; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + text-shadow: 0 -.1em .2em black; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*="language-"], +:not(pre) > code[class*="language-"] { + background: hsl(0, 0%, 8%); /* #141414 */ +} + +/* Code blocks */ +pre[class*="language-"] { + border-radius: .5em; + border: .3em solid hsl(0, 0%, 33%); /* #282A2B */ + box-shadow: 1px 1px .5em black inset; + margin: .5em 0; + overflow: auto; + padding: 1em; +} + +pre[class*="language-"]::-moz-selection { + /* Firefox */ + background: hsl(200, 4%, 16%); /* #282A2B */ +} + +pre[class*="language-"]::selection { + /* Safari */ + background: hsl(200, 4%, 16%); /* #282A2B */ +} + +/* Text Selection colour */ +pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: hsla(0, 0%, 93%, 0.15); /* #EDEDED */ +} + +pre[class*="language-"]::selection, pre[class*="language-"] ::selection, +code[class*="language-"]::selection, code[class*="language-"] ::selection { + text-shadow: none; + background: hsla(0, 0%, 93%, 0.15); /* #EDEDED */ +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + border-radius: .3em; + border: .13em solid hsl(0, 0%, 33%); /* #545454 */ + box-shadow: 1px 1px .3em -.1em black inset; + padding: .15em .2em .05em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: hsl(0, 0%, 47%); /* #777777 */ +} + +.token.punctuation { + opacity: .7; +} + +.namespace { + opacity: .7; +} + +.token.tag, +.token.boolean, +.token.number, +.token.deleted { + color: hsl(14, 58%, 55%); /* #CF6A4C */ +} + +.token.keyword, +.token.property, +.token.selector, +.token.constant, +.token.symbol, +.token.builtin { + color: hsl(53, 89%, 79%); /* #F9EE98 */ +} + +.token.attr-name, +.token.attr-value, +.token.string, +.token.char, +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable, +.token.inserted { + color: hsl(76, 21%, 52%); /* #8F9D6A */ +} + +.token.atrule { + color: hsl(218, 22%, 55%); /* #7587A6 */ +} + +.token.regex, +.token.important { + color: hsl(42, 75%, 65%); /* #E9C062 */ +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} + +pre[data-line] { + padding: 1em 0 1em 3em; + position: relative; +} + +/* Markup */ +.language-markup .token.tag, +.language-markup .token.attr-name, +.language-markup .token.punctuation { + color: hsl(33, 33%, 52%); /* #AC885B */ +} + +/* Make the tokens sit above the line highlight so the colours don't look faded. */ +.token { + position: relative; + z-index: 1; +} + +.line-highlight { + background: hsla(0, 0%, 33%, 0.25); /* #545454 */ + background: linear-gradient(to right, hsla(0, 0%, 33%, .1) 70%, hsla(0, 0%, 33%, 0)); /* #545454 */ + border-bottom: 1px dashed hsl(0, 0%, 33%); /* #545454 */ + border-top: 1px dashed hsl(0, 0%, 33%); /* #545454 */ + left: 0; + line-height: inherit; + margin-top: 0.75em; /* Same as .prism’s padding-top */ + padding: inherit 0; + pointer-events: none; + position: absolute; + right: 0; + white-space: pre; + z-index: 0; +} + +.line-highlight:before, +.line-highlight[data-end]:after { + background-color: hsl(215, 15%, 59%); /* #8794A6 */ + border-radius: 999px; + box-shadow: 0 1px white; + color: hsl(24, 20%, 95%); /* #F5F2F0 */ + content: attr(data-start); + font: bold 65%/1.5 sans-serif; + left: .6em; + min-width: 1em; + padding: 0 .5em; + position: absolute; + text-align: center; + text-shadow: none; + top: .4em; + vertical-align: .3em; +} + +.line-highlight[data-end]:after { + bottom: .4em; + content: attr(data-end); + top: auto; +} + diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index e3cc522be20245..a620cc62a62d01 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -12,7 +12,7 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary>= "#{status.spoiler_text} " %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - %div.e-content{ style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) + %div.e-content.highlight{ style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) - unless status.media_attachments.empty? diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 52905ff5e0aa15..ceac702caffe05 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -17,7 +17,7 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary>= "#{status.spoiler_text} " %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - %div.e-content{ style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) + %div.e-content.highlight{ style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) - unless status.media_attachments.empty? .status__attachments diff --git a/package.json b/package.json index 0ced631a9cb9f3..41f81f59165b97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mastodon", - "license" : "AGPL-3.0", + "license": "AGPL-3.0", "scripts": { "start": "babel-node ./streaming/index.js --presets es2015,stage-2", "storybook": "start-storybook -p 9001 -c storybook", @@ -41,6 +41,7 @@ "node-sass": "^4.5.0", "npmlog": "^4.0.2", "pg": "^6.1.2", + "prismjs": "^1.6.0", "react": "^15.4.2", "react-addons-perf": "^15.4.2", "react-addons-pure-render-mixin": "^15.4.2", diff --git a/spec/javascript/components/highlight.test.jsx b/spec/javascript/components/highlight.test.jsx new file mode 100644 index 00000000000000..f21c5f22526930 --- /dev/null +++ b/spec/javascript/components/highlight.test.jsx @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import { jsdom } from 'jsdom'; +import { stub, spy } from 'sinon'; +import Prism from 'prismjs'; + +import highlight, { + createCodeElement, +} from '../../../app/assets/javascripts/components/highlight' + +import escapeTextContentForBrowser from 'escape-html'; + +describe('createCodeElement', () => { + let document; + beforeEach(() => { + document = jsdom(''); + }) + + describe('gieven supported language', () => { + it('returns pre element whose innerHTML is syntax highlighted', () => { + stub(Prism, 'highlight').returns('highlighted'); + const code = {language: 'html', text: 'test'}; + const elem = createCodeElement(document, code) + expect(elem.tagName).to.equal('PRE'); + expect(elem.firstChild.tagName).to.equal('CODE'); + expect(elem.firstChild.innerHTML).to.equal('highlighted'); + }) + }) + + describe('given unsupported language', () => { + it('returns pre element whose innerHTML is trimmed escaped text', () => { + const code = {language: 'aaaaaa', text: ''}; + const elem = createCodeElement(document, code) + expect(elem.tagName).to.equal('PRE'); + expect(elem.firstChild.tagName).to.equal('CODE'); + expect(elem.firstChild.innerHTML).to.equal('<script></script>'); + }) + }) +}) + +describe('highlight', () => { + describe('if text does not include complete code block', () => { + it('returns given text', () => { + const examples = [ + 'text', + 'text', + 'text
```
code
\ncode\n\n"],
+ ["```\n\ncode\n```", "\ncode\n"],
+ ["```\n\ncode\n```\ntext", "\ncode\ntext
"], + ["text\n```\n\ncode\n\n```", "text
\ncode\n\n"],
+ ["text\n```\n\ncode\n```", "text
\ncode\n"],
+ ["text\n```\n\ncode\n```\ntext", "text
\ncode\ntext
"], + ["text\n```\ncode\n\n```", "text
code\n\n"],
+ ["text\n```\ncode\n```", "text
code\n"],
+ ["text\n```\ncode\n```\ntext", "text
code\ntext
"], + ["```\ncode\n```", "code\n"],
+ [`\`\`\`\n${escapeTextContentForBrowser("")}\n\`\`\``, `<script>alert('hi')</script>\n`],
+ ["text\n```\ncode1\n```\n\ntext2\n```\ncode2\n```", "text
code1\ntext2
code2\n"]
+ ];
+
+ const simulateServerSideFormat = (text) => {
+ return text.replace(/\r\n?/g, "\n").split(/\n\n+/).map((p) =>
+ `${p.replace(/([^\n]\n)(?=[^\n])/g, '$1
')}