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

', + ]; + examples.forEach((ex) => { + expect(highlight(ex)).to.equal(ex); + }) + }) + }) + + describe('if text include code block', () => { + const examples = [ + ["```\n\ncode\n\n```", "
\ncode\n\n
"], + ["```\n\ncode\n```", "
\ncode\n
"], + ["```\n\ncode\n```\ntext", "
\ncode\n

text

"], + ["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\n

text

"], + ["text\n```\ncode\n\n```", "

text

code\n\n
"], + ["text\n```\ncode\n```", "

text

code\n
"], + ["text\n```\ncode\n```\ntext", "

text

code\n

text

"], + ["```\ncode\n```", "
code\n
"], + [`\`\`\`\n${escapeTextContentForBrowser("")}\n\`\`\``, `
<script>alert('hi')</script>\n
`], + ["text\n```\ncode1\n```\n\ntext2\n```\ncode2\n```", "

text

code1\n

text2

code2\n
"] + ]; + + const simulateServerSideFormat = (text) => { + return text.replace(/\r\n?/g, "\n").split(/\n\n+/).map((p) => + `

${p.replace(/([^\n]\n)(?=[^\n])/g, '$1
')}

` + ).join('').replace(/\n/g, ''); + } + + examples.forEach((ex) => { + const input = simulateServerSideFormat(ex[0]); + const output = ex[1]; + it(`transforms ${JSON.stringify(input)} into ${JSON.stringify(output)}`, () => { + expect(highlight(input)).to.equal(output); + }); + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 5c468a2f261b63..d8f833ea4505d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1752,6 +1752,14 @@ cli-width@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" +clipboard@^1.5.5: + version "1.6.1" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.6.1.tgz#65c5b654812466b0faab82dc6ba0f1d2f8e4be53" + dependencies: + good-listener "^1.2.0" + select "^1.1.2" + tiny-emitter "^1.0.0" + cliui@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" @@ -2207,6 +2215,10 @@ delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" +delegate@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.1.2.tgz#1e1bc6f5cadda6cb6cbf7e6d05d0bcdd5712aebe" + delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -3083,6 +3095,12 @@ globule@^1.0.0: lodash "~4.16.4" minimatch "~3.0.2" +good-listener@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + dependencies: + delegate "^3.1.2" + graceful-fs@^4.1.2, graceful-fs@^4.1.4: version "4.1.9" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.9.tgz#baacba37d19d11f9d146d3578bc99958c3787e29" @@ -5036,6 +5054,12 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" +prismjs@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.6.0.tgz#118d95fb7a66dba2272e343b345f5236659db365" + optionalDependencies: + clipboard "^1.5.5" + private@^0.1.6, private@~0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/private/-/private-0.1.6.tgz#55c6a976d0f9bafb9924851350fe47b9b5fbb7c1" @@ -5780,14 +5804,18 @@ section-iterator@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" -"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" -semver@~5.3.0: +"semver@2 || 3 || 4 || 5", semver@~5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" +"semver@2.x || 3.x || 4 || 5", semver@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" + send@0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/send/-/send-0.14.2.tgz#39b0438b3f510be5dc6f667a11f71689368cdeef" @@ -6291,6 +6319,10 @@ timers-browserify@^2.0.2: dependencies: setimmediate "^1.0.4" +tiny-emitter@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.2.0.tgz#6dc845052cb08ebefc1874723b58f24a648c3b6f" + to-arraybuffer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"