website/_plugins/highlighter.rb (239 lines of code) (raw):
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
require 'open3'
require 'json'
require 'cgi'
module Jekyll
module Converters
class Markdown::FlowMarkdown
class << self
attr_accessor :tempdir
end
def initialize(config)
@config = config
@flow_cache = {}
@markdown = Jekyll::Converters::Markdown::KramdownParser.new config
end
def convert(content)
@markdown.convert(render(content))
end
def render(content)
content.gsub(Kramdown::Parser::GFM::FENCED_CODEBLOCK_MATCH) {|match|
_, _, lang, _, code = Regexp.last_match.captures
if lang == 'js'
render_block(code)
else
match
end
}
end
def render_block(content)
unless @flow_cache[content]
tokens = serialize_tokens(get_tokens(content))
errors = serialize_errors(get_errors(content))
html_ranges = create_html_ranges(tokens, errors)
printed_code = print_code(content, html_ranges)
hover_errors = print_hover_errors(errors)
json = JSON.generate({
'value' => content,
'tokens' => tokens,
'errors' => errors,
})
json = CGI.escapeHTML(json)
@flow_cache[content] = print_code_section(printed_code, hover_errors, json, { 'line_numbers' => true })
end
@flow_cache[content]
end
def match_pragma(content)
content.match(/^(\/\/ *@flow|\/\*\s*@flow)/)
end
def run_flow(command, stdin, success_codes)
out, err, status = Open3.capture3(
"#{@config['flow']['path'] || 'flow'} #{command}",
:stdin_data => stdin
)
if status.exited? and success_codes.include?(status.exitstatus)
response = JSON.parse(out)
else
raise "flow ast failed (exit #{status.exitstatus}): #{err}"
end
end
def get_tokens(content)
response = run_flow("ast --tokens", content, [0])
response['comments'].each do |comment|
normalize_comment comment
end
tokens = response['tokens']
tokens += response['comments']
tokens.sort_by do |token|
token['range'][0]
end
end
def normalize_comment(comment)
comment['context'] = 'comment'
if comment['type'] == 'Line'
comment['value'] = '//' + comment['value']
elsif comment['type'] == 'Block'
comment['value'] = '/*' + comment['value'] + "*/"
end
end
def get_errors(content)
return [] unless match_pragma(content)
response = run_flow(
"check-contents --json --root #{self.class.tempdir}",
content,
[0, 2]
)
response['errors']
end
def serialize_tokens(tokens)
result = []
tokens.each do |token|
next if token['type'] == 'T_EOF'
result.push({
'type' => token['type'],
'context' => token['context'],
'value' => token['value'],
'line' => token['loc']['start']['line'],
'start' => token['range'][0],
'end' => token['range'][1],
})
end
result
end
def serialize_errors(errors)
errors.each_with_index.map do |error, error_index|
error_id = error_index + 1
messages = error['message'].each_with_index.map do |message, message_index|
message_id = message_index + 1
message_loc = message['loc'] || {}
{
'id' => "E#{error_id}M#{message_id}",
'description' => message['descr'],
'context' => message['context'],
'source' => message_loc['source'],
'start' => message_loc['start'],
'end' => message_loc['end'],
}
end
operation = nil
if !error['operation'].nil?
op = error['operation']
operation = {
'description' => "#{op['descr']}\n",
'context' => op['context'],
'source' => op['loc']['source'],
'start' => op['loc']['start'],
'end' => op['loc']['end'],
}
end
{
'id' => "E#{error_id}",
'messages' => messages,
'operation' => operation,
}
end
end
def create_html_ranges(tokens, errors)
before = {}
after = {}
tokens.each do |token|
start_pos = token['start']
end_pos = token['end']
open = "<span class=\"#{token['context']} #{token['type']}\">"
close = '</span>'
before[start_pos] = (before[start_pos] || '') + open
after[end_pos] = close + (after[end_pos] || '')
end
errors.each do |error|
error['messages'].each do |message|
next if message['start'] == nil || message['end'] == nil
start_pos = message['start']['offset']
end_pos = message['end']['offset']
open = '<span class="flow-error-target" data-error-id="' + error['id'] + '" data-message-id="' + message['id'] + '">'
close = '</span>'
before[start_pos] = (before[start_pos] || '') + open
after[end_pos] = close + (after[end_pos] || '')
end
end
{ 'before' => before, 'after' => after }
end
def print_code(source, html_ranges)
chars = source.chars
printed = ""
before = html_ranges['before']
after = html_ranges['after']
chars.each_with_index do |char, index|
if before[index] != nil
printed += before[index]
end
printed += CGI.escapeHTML(char)
if after[index + 1] != nil
printed += after[index + 1]
end
end
printed
end
def print_code_section(content, hover_errors, json, options)
<<-CODE
<div class="editor highlight">
<div class="editor-content">
#{print_editor_code(content, options['line_numbers'])}
</div>
<div class="editor-messages">#{hover_errors}</div>
<div hidden class="editor-data">#{json}</div>
</div>
CODE
end
def print_editor_code(content, line_numbers)
if line_numbers
lines = content.lines.count
<<-CODE
<table>
<tbody>
<tr>
<td class="editor-gutter">
<pre class="editor-line">#{(1..lines).to_a.join("\n")}</pre>
</td>
<td class="editor-code"><pre><code>#{content}</code></pre></td>
</tr>
</tbody>
</table>
CODE
else
<<-CODE
<pre><code>#{content}</code></pre>
CODE
end
end
def print_hover_errors(errors)
errors = errors.each.map do |error|
messages = error['messages'].each.map do |message|
if message['context'] != nil
'<span data-message-id="' + message['id'] + '">' + message['description'] + '</span>'
else
message['description']
end
end
'<span class="flow-error" data-error-id="' + error['id'] + '">' + messages.join(' ') + '</span>'
end
errors.join("\n")
end
end
end
Hooks.register :site, :pre_render do |site|
tempdir = Dir.mktmpdir
Converters::Markdown::FlowMarkdown.tempdir = tempdir
File.open(File.join(tempdir, '.flowconfig'), 'w') {|f|
f.write(
<<-EOF.gsub(/^ {10}/, '')
[options]
max_header_tokens=1
EOF
)
}
end
Hooks.register :site, :post_render do |site|
tempdir = Converters::Markdown::FlowMarkdown.tempdir
flow = site.config['flow']['path'] || 'flow'
`#{flow} stop #{tempdir}`
FileUtils.remove_entry_secure tempdir
end
end