├── .rspec ├── assets ├── favicon.png ├── octodown.gif ├── highlight.css └── atom.css ├── Gemfile ├── lib ├── octodown │ ├── version.rb │ ├── renderer │ │ ├── renderable.rb │ │ ├── raw.rb │ │ ├── github_markdown.rb │ │ ├── html.rb │ │ └── server.rb │ ├── support │ │ ├── renderable.rb │ │ ├── services │ │ │ ├── document_presenter.rb │ │ │ └── riposter.rb │ │ ├── logger.rb │ │ ├── persistent_tempfile.rb │ │ ├── relative_root_filter.rb │ │ └── file_chooser.rb │ └── template │ │ └── octodown.html.erb └── octodown.rb ├── .gitignore ├── spec ├── support │ └── test.md ├── lib │ ├── support │ │ └── relative_root_filter_spec.rb │ └── renderer │ │ ├── html_spec.rb │ │ ├── github_markdown_spec.rb │ │ └── server_spec.rb ├── spec_helper.rb └── integration_spec.rb ├── .rubocop.yml ├── Rakefile ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── CHANGELOG.md ├── LICENSE.txt ├── tasks └── styles.rake ├── .rubocop_todo.yml ├── octodown.gemspec ├── bin └── octodown └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ianks/octodown/HEAD/assets/favicon.png -------------------------------------------------------------------------------- /assets/octodown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ianks/octodown/HEAD/assets/octodown.gif -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'pry' 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/octodown/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Octodown 4 | VERSION = '1.9.2' 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.bundle 10 | *.so 11 | *.o 12 | *.a 13 | mkmf.log 14 | /distro 15 | Gemfile.lock 16 | -------------------------------------------------------------------------------- /spec/support/test.md: -------------------------------------------------------------------------------- 1 | Hello world! 2 | ============ 3 | ![some-img](https://foo.com/bar.img) 4 | ![some-img](https://foo.com/bar.img) 5 | 6 | You are now reading markdown. 7 | How lucky you are! 8 | 9 | ```ruby 10 | def some_code() 11 | puts 'Yeah, this is some code.' 12 | end 13 | ``` 14 | 15 | 16 | [some-file](test.txt) 17 | -------------------------------------------------------------------------------- /lib/octodown/renderer/renderable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Octodown 4 | module Renderer 5 | module Renderable 6 | def self.included(base) 7 | base.extend ClassMethods 8 | end 9 | 10 | module ClassMethods 11 | def render(*args) 12 | new(*args).content 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/octodown/support/renderable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Octodown 4 | module Renderer 5 | module Renderable 6 | def self.included(base) 7 | base.extend ClassMethods 8 | end 9 | 10 | module ClassMethods 11 | def render(*args) 12 | new(*args).content 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_from: .rubocop_todo.yml 3 | 4 | Metrics/BlockLength: 5 | Exclude: 6 | - "spec/**/*.rb" 7 | - "bin/octodown" 8 | 9 | Style/Documentation: 10 | Enabled: false 11 | 12 | Style/HashSyntax: 13 | Enabled: false 14 | 15 | Style/Lambda: 16 | Enabled: false 17 | 18 | Style/HashTransformValues: 19 | Enabled: true 20 | 21 | Style/HashTransformKeys: 22 | Enabled: true 23 | 24 | Style/HashEachMethods: 25 | Enabled: true 26 | -------------------------------------------------------------------------------- /lib/octodown/renderer/raw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octodown/renderer/renderable' 4 | 5 | module Octodown 6 | module Renderer 7 | class Raw 8 | include Renderable 9 | 10 | attr_reader :content 11 | 12 | def initialize(markdown, options) 13 | @content = HTML.render markdown, options 14 | end 15 | 16 | def present 17 | STDOUT.puts content 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir[File.join(Dir.pwd, 'tasks', '**', '*.rb')].sort.each { |f| require f } 4 | Dir[File.join(Dir.pwd, 'tasks', '*.rake')].each { |f| load f } 5 | 6 | require 'bundler/gem_tasks' 7 | require 'rspec/core/rake_task' 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | RSpec::Core::RakeTask.new :spec do |task| 13 | task.rspec_opts = '--format documentation' 14 | end 15 | 16 | task :default => %i[spec rubocop] 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /lib/octodown/support/services/document_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Octodown 4 | module Support 5 | module Services 6 | class DocumentPresenter 7 | def self.call(file, options) 8 | include Octodown::Renderer 9 | 10 | case options[:presenter] 11 | when :raw then Raw 12 | when :html then HTML 13 | when :server then Server 14 | end.new(GithubMarkdown.render(file, options), options).present 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/octodown/support/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Octodown 6 | module Support 7 | class Logger 8 | FORMAT = "%-5s: %s\n" 9 | 10 | def self.build(dev: STDOUT, level: ::Logger::INFO) 11 | dev.sync = true 12 | logger = ::Logger.new(dev) 13 | logger.level = level 14 | logger.formatter = method(:formatter) 15 | logger 16 | end 17 | 18 | def self.formatter(severity, _datetime, _progname, msg) 19 | format(FORMAT, severity, msg) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/octodown/support/persistent_tempfile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'tempfile' 4 | require 'fileutils' 5 | 6 | module Octodown 7 | module Support 8 | class PersistentTempfile < Tempfile 9 | def self.create(content, ext) 10 | document = new ['octodown', ".#{ext}"] 11 | document.persistent_write content 12 | end 13 | 14 | def persist 15 | ObjectSpace.undefine_finalizer self 16 | self 17 | end 18 | 19 | def persistent_write(content) 20 | write content 21 | close 22 | persist 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/octodown/support/services/riposter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Octodown 4 | module Support 5 | module Services 6 | class Riposter 7 | def self.call(file, &listener_callback) 8 | require 'listen' 9 | 10 | return if @listener&.processing? 11 | 12 | path = File.dirname(File.expand_path(file.path)) 13 | escaped_path = Regexp.escape(file.path) 14 | regex = Regexp.new("^#{escaped_path}$") 15 | 16 | @listener ||= Listen.to(path, only: regex) do |modified, added, _rm| 17 | listener_callback.call if modified.any? || added.any? 18 | end 19 | 20 | @listener.start 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ci 3 | on: 4 | push: 5 | paths: 6 | - ".github/workflows/ci.yml" 7 | - "lib/**" 8 | - "*.gemspec" 9 | - "spec/**" 10 | - "Rakefile" 11 | - "Gemfile" 12 | - ".rubocop.yml" 13 | pull_request: 14 | branches: 15 | - master 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | ruby: ["2.5", "2.6", "2.7"] 22 | name: Ruby ${{ matrix.ruby }} 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: ${{ matrix.ruby }} 28 | bundler-cache: true # 'bundle install' and cache 29 | - name: Run all tests 30 | run: bundle exec rake 31 | -------------------------------------------------------------------------------- /lib/octodown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octodown/renderer/renderable' 4 | require 'octodown/support/persistent_tempfile' 5 | require 'octodown/renderer/github_markdown' 6 | require 'octodown/renderer/html' 7 | require 'octodown/renderer/raw' 8 | require 'octodown/renderer/server' 9 | require 'octodown/support/relative_root_filter' 10 | require 'octodown/support/services/document_presenter' 11 | require 'octodown/support/services/riposter' 12 | require 'octodown/version' 13 | 14 | module Octodown 15 | def self.call(options) 16 | include Octodown::Support::Services 17 | 18 | DocumentPresenter.call options[:file], options 19 | end 20 | 21 | def self.root 22 | Gem::Specification.find_by_name('octodown').gem_dir 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.8.0] - 2018-5-3 4 | 5 | ### Added 6 | 7 | - Just running `octodown` will now prompt you for which file to edit 8 | - Added `--quiet` option 9 | - Added `--no-open` option to skip browser opening 10 | 11 | ### Changed 12 | 13 | - Updated styles to newest GitHub styles 14 | - No more implicit STDIN, must pass `--stdin` flag 15 | 16 | ## [1.6.0] - 2017-10-11 17 | 18 | ### Added 19 | 20 | - Checkboxes now properly work thanks to @rafasc 21 | - Breaking: Upgrade of deps now requires Ruby >= 2.2.5 22 | 23 | ## [1.3.0] - 2016-2-12 24 | 25 | This release focuses on removing some dependencies to make Octodown's footprint 26 | a bit smaller. 27 | 28 | ### Added 29 | - New styles for Github and Atom 30 | - CHANGELOG.md (meta!) 31 | 32 | ### Removed 33 | - PDF Support 34 | - HTML Sanitization 35 | -------------------------------------------------------------------------------- /spec/lib/support/relative_root_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Octodown::Support::RelativeRootFilter do 4 | let(:_http_uri?) do 5 | lambda { |uri| subject.send :http_uri?, uri } 6 | end 7 | 8 | subject { Octodown::Support::RelativeRootFilter.new nil } 9 | 10 | # Testing private methods because Nokogirl is a black box 11 | it 'detects an non-HTTP/HTTPS URI correctly' do 12 | expect(_http_uri?['assets/test.png']).to eq false 13 | expect(_http_uri?['#array#bsearch-vs-array']).to eq false 14 | end 15 | 16 | it 'detects HTTP/HTTPS URI correctly' do 17 | expect(_http_uri?['http://foo.com/asset/test.png']).to eq true 18 | expect(_http_uri?['https://foo.com/aset/test.png']).to eq true 19 | end 20 | 21 | it 'renders the relative root correctly' do 22 | root = '/home/test' 23 | src = 'dummy/test.md' 24 | 25 | expect(subject.send(:relative_path_from_document_root, root, src)).to eq( 26 | '/home/test/dummy/test.md' 27 | ) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | 5 | require 'octodown' 6 | require 'rack/test' 7 | require 'logger' 8 | require 'rspec/retry' 9 | 10 | RSpec.configure do |config| 11 | config.expect_with :rspec do |expectations| 12 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 13 | end 14 | 15 | config.verbose_retry = true 16 | config.default_formatter = 'doc' if config.files_to_run.one? 17 | config.order = :random 18 | config.include Rack::Test::Methods 19 | 20 | Kernel.srand config.seed 21 | 22 | def dummy_path 23 | File.join(__dir__, 'support', 'test.md') 24 | end 25 | 26 | def dummy_file 27 | File.new dummy_path 28 | end 29 | 30 | def assets_dir(*args) 31 | File.join Octodown.root, 'assets', args 32 | end 33 | 34 | def opts 35 | { 36 | logger: Logger.new(File::NULL), 37 | port: 8887, 38 | presenter: :html, 39 | style: :github 40 | } 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/lib/renderer/html_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | include Octodown 4 | 5 | describe Renderer::HTML do 6 | let(:html) { Renderer::GithubMarkdown.render File.new(dummy_path) } 7 | 8 | subject { Renderer::HTML.new(html, opts).content } 9 | 10 | before { allow(Octodown).to receive(:root) { '.' } } 11 | 12 | it 'includes HTML from markdown rendering phase' do 13 | expect(subject).to include '

Hello world!

' 14 | end 15 | 16 | it 'sets the title' do 17 | expect(subject).to include 'Octodown Preview' 18 | end 19 | 20 | it 'injects Github CSS' do 21 | css = File.read assets_dir('github.css') 22 | expect(subject).to include css 23 | end 24 | 25 | it 'injects higlighting CSS' do 26 | css = File.read assets_dir('highlight.css') 27 | expect(subject).to include css 28 | end 29 | 30 | it 'does not include jQuery lol' do 31 | expect(subject).not_to include 'jquery' 32 | end 33 | 34 | it 'includes correct websocket address in js' do 35 | expect(subject).to include 'new ReconnectingWebSocket("ws://localhost:8887"' 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Ian Ker-Seymer 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tasks/styles.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open-uri' 4 | require 'fileutils' 5 | require 'tempfile' 6 | 7 | task :styles do 8 | begin 9 | FileUtils.mkdir 'tmp' 10 | download_deps 11 | compile_less 12 | ensure 13 | FileUtils.remove_dir 'tmp' 14 | end 15 | end 16 | 17 | def deps 18 | { 19 | 'markdown-preview-default' => 20 | 'markdown-preview/master/styles/markdown-preview-default.less', 21 | 'syntax-variables' => 22 | 'atom/master/static/variables/syntax-variables.less' 23 | } 24 | end 25 | 26 | def download_deps 27 | host = 'https://raw.githubusercontent.com/atom/' 28 | 29 | deps.each do |k, v| 30 | File.open("tmp/#{k}.less", 'w') do |out_file| 31 | open(host + v, 'r') do |in_file| 32 | out_file << in_file.read 33 | end 34 | end 35 | end 36 | end 37 | 38 | def compile_less 39 | tmp = 'tmp/github.css' 40 | out_file = 'assets/atom.css' 41 | `lessc tmp/markdown-preview-default.less > #{tmp}` 42 | 43 | File.open out_file, 'w' do |file| 44 | css = File.read(tmp).gsub(/markdown-preview/, 'markdown-body') 45 | file << css 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/lib/renderer/github_markdown_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | include Octodown::Renderer 4 | describe GithubMarkdown do 5 | subject { GithubMarkdown.render dummy_file } 6 | 7 | it 'create HTML from markdown file' do 8 | expect(subject).to include '

Hello world!

' 9 | end 10 | 11 | it 'highlights the code' do 12 | expect(subject).to include 'class="highlight' 13 | end 14 | 15 | it 'properly recognizes stdin' do 16 | allow(STDIN).to receive(:read).and_return 'Mic check... 1, 2, 3.' 17 | 18 | expect(GithubMarkdown.render(STDIN)).to include 'Mic check... 1, 2, 3.' 19 | end 20 | 21 | let :md_factory do 22 | lambda do |params| 23 | GithubMarkdown.render( 24 | dummy_file, 25 | opts.merge(gfm: params[:gfm]) 26 | ) 27 | end 28 | end 29 | 30 | it 'renders hard-wraps' do 31 | expect(md_factory[gfm: true]).to include '
' 32 | end 33 | 34 | describe 'local file linking' do 35 | it 'includes the local file from correct location' do 36 | dirname = "#{File.dirname dummy_path}/test.txt" 37 | expect(subject).to include 'some-file' 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /assets/highlight.css: -------------------------------------------------------------------------------- 1 | .highlight table td{padding:5px}.highlight table pre{margin:0}.highlight .cm{color:#998;font-style:italic}.highlight .cp{color:#999;font-weight:700}.highlight .c1{color:#998;font-style:italic}.highlight .cs{color:#999;font-weight:700;font-style:italic}.highlight .c,.highlight .cd{color:#998;font-style:italic}.highlight .err{color:#a61717;background-color:#e3d2d2}.highlight .gd{color:#000;background-color:#fdd}.highlight .ge{color:#000;font-style:italic}.highlight .gr{color:#a00}.highlight .gh{color:#999}.highlight .gi{color:#000;background-color:#dfd}.highlight .go{color:#888}.highlight .gp{color:#555}.highlight .gs{font-weight:700}.highlight .gu{color:#aaa}.highlight .gt{color:#a00}.highlight .kc,.highlight .kd,.highlight .kn,.highlight .kp,.highlight .kr{color:#000;font-weight:700}.highlight .kt{color:#458;font-weight:700}.highlight .k,.highlight .kv{color:#000;font-weight:700}.highlight .il,.highlight .m,.highlight .mb,.highlight .mf,.highlight .mh,.highlight .mi,.highlight .mo,.highlight .mx{color:#099}.highlight .s2,.highlight .sb,.highlight .sc,.highlight .sd,.highlight .se,.highlight .sh,.highlight .si,.highlight .sx{color:#d14}.highlight .sr{color:#009926}.highlight .s1{color:#d14}.highlight .ss{color:#990073}.highlight .s{color:#d14}.highlight .na{color:teal}.highlight .bp{color:#999}.highlight .nb{color:#0086B3}.highlight .nc{color:#458;font-weight:700}.highlight .no{color:teal}.highlight .nd{color:#3c5d5d;font-weight:700}.highlight .ni{color:purple}.highlight .ne,.highlight .nf,.highlight .nl{color:#900;font-weight:700}.highlight .nn{color:#555}.highlight .nt{color:navy}.highlight .nv,.highlight .vc,.highlight .vg,.highlight .vi{color:teal}.highlight .o,.highlight .ow{color:#000;font-weight:700}.highlight .w{color:#bbb}.highlight{background-color:#f8f8f8} 2 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2020-02-18 19:49:30 -0500 using RuboCop version 0.80.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: Include. 11 | # Include: **/*.gemspec 12 | Gemspec/RequiredRubyVersion: 13 | Exclude: 14 | - 'octodown.gemspec' 15 | 16 | # Offense count: 1 17 | # Configuration parameters: CountComments, ExcludedMethods. 18 | # ExcludedMethods: refine 19 | Metrics/BlockLength: 20 | Max: 29 21 | 22 | # Offense count: 1 23 | # Configuration parameters: CountComments, ExcludedMethods. 24 | Metrics/MethodLength: 25 | Max: 12 26 | 27 | # Offense count: 1 28 | # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. 29 | # AllowedNames: io, id, to, by, on, in, at, ip, db, os, pp 30 | Naming/MethodParameterName: 31 | Exclude: 32 | - 'lib/octodown/renderer/server.rb' 33 | 34 | # Offense count: 1 35 | Security/Open: 36 | Exclude: 37 | - 'tasks/styles.rake' 38 | 39 | # Offense count: 2 40 | Style/CommentedKeyword: 41 | Exclude: 42 | - 'lib/octodown/renderer/server.rb' 43 | 44 | # Offense count: 3 45 | Style/MixinUsage: 46 | Exclude: 47 | - 'spec/lib/renderer/github_markdown_spec.rb' 48 | - 'spec/lib/renderer/html_spec.rb' 49 | - 'spec/lib/renderer/server_spec.rb' 50 | 51 | # Offense count: 1 52 | # Cop supports --auto-correct. 53 | # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 54 | # URISchemes: http, https 55 | Layout/LineLength: 56 | Max: 81 57 | -------------------------------------------------------------------------------- /lib/octodown/renderer/github_markdown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rouge' 4 | require 'html/pipeline' 5 | require 'task_list/filter' 6 | require 'octodown/renderer/renderable' 7 | 8 | module Octodown 9 | module Renderer 10 | class GithubMarkdown 11 | include HTML 12 | include Renderable 13 | 14 | attr_reader :options, :file, :logger 15 | 16 | def initialize(file, options = {}) 17 | @file = file 18 | @options = options 19 | @logger = options[:logger] 20 | end 21 | 22 | def content 23 | if file == STDIN 24 | buffer = file.read 25 | else 26 | begin 27 | File.open(file.path, 'r') { |f| buffer = f.read } 28 | rescue Errno::ENOENT 29 | logger.warn 'Something went wrong when trying to open the file' 30 | end 31 | end 32 | pipeline.call(buffer ||= 'could not read changes')[:output].to_s 33 | end 34 | 35 | private 36 | 37 | def context 38 | { 39 | asset_root: 'https://github.githubassets.com/images/icons/', 40 | server: options[:presenter] == :server, 41 | original_document_root: document_root, 42 | scope: 'highlight', 43 | gfm: options[:gfm] || false 44 | } 45 | end 46 | 47 | def pipeline 48 | Pipeline.new [ 49 | Pipeline::MarkdownFilter, 50 | Pipeline::SyntaxHighlightFilter, 51 | Support::RelativeRootFilter, 52 | Pipeline::ImageMaxWidthFilter, 53 | Pipeline::MentionFilter, 54 | Pipeline::EmojiFilter, 55 | TaskList::Filter 56 | ], context 57 | end 58 | 59 | def document_root 60 | case file 61 | when STDIN then Dir.pwd 62 | else File.dirname File.expand_path(file.path) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/octodown/support/relative_root_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'uri' 4 | 5 | module Octodown 6 | module Support 7 | class RelativeRootFilter < HTML::Pipeline::Filter 8 | attr_accessor :root, :server 9 | 10 | def call 11 | @root = context[:original_document_root] 12 | @server = context[:server] 13 | 14 | filter_images doc.search('img') 15 | filter_links doc.search('a[href]') 16 | end 17 | 18 | private 19 | 20 | def relative_path_from_document_root(root, src) 21 | server ? src : File.join(root, src).to_s 22 | end 23 | 24 | def http_uri?(src) 25 | parsed_uri = begin 26 | URI.parse src 27 | rescue URI::InvalidURIError 28 | src 29 | end 30 | 31 | parsed_uri.is_a? URI::HTTP 32 | end 33 | 34 | # TODO: These two methods are highly similar and can be refactored, but 35 | # I'm can't find the right abstraction at the moment that isn't a total 36 | # hack involving bizarre object references and mutation 37 | 38 | def filter_images(images) 39 | images.each do |img| 40 | src = img['src'] 41 | 42 | next if src.nil? 43 | 44 | src.strip! 45 | 46 | unless http_uri? src 47 | path = relative_path_from_document_root root, src 48 | img['src'] = path 49 | end 50 | end 51 | 52 | doc 53 | end 54 | 55 | def filter_links(links) 56 | links.each do |a| 57 | src = a.attributes['href'].value 58 | 59 | next if src.nil? 60 | 61 | src.strip! 62 | 63 | unless http_uri? src 64 | path = relative_path_from_document_root root, src 65 | a.attributes['href'].value = path 66 | end 67 | end 68 | 69 | doc 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/octodown/renderer/html.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'erb' 4 | require 'octodown/renderer/renderable' 5 | 6 | module Octodown 7 | module Renderer 8 | class HTML 9 | include Octodown::Support 10 | include Renderable 11 | 12 | attr_reader :rendered_markdown, :filepath, :options, :logger 13 | 14 | def initialize(rendered_markdown, options = {}) 15 | @logger = options[:logger] 16 | @rendered_markdown = rendered_markdown 17 | @options = options 18 | @filepath = File.join parent_dir, 'template', 'octodown.html.erb' 19 | end 20 | 21 | def content 22 | template_text = File.read filepath 23 | erb_template = ERB.new template_text 24 | erb_template.result binding 25 | end 26 | 27 | def title 28 | 'Octodown Preview' 29 | end 30 | 31 | def stylesheet 32 | stylesheet = "#{options[:style]}.css" 33 | inject_html_node_with_file_content assets_dir(stylesheet), :style 34 | end 35 | 36 | def highlight_stylesheet 37 | inject_html_node_with_file_content assets_dir('highlight.css'), :style 38 | end 39 | 40 | def host 41 | "ws://localhost:#{options[:port]}".dump 42 | end 43 | 44 | def present 45 | if options[:no_open] 46 | logger.warn('--no-open argument was used so no browser will be opened') 47 | else 48 | Launchy.open PersistentTempfile.create(content, :html).path 49 | end 50 | end 51 | 52 | private 53 | 54 | def inject_html_node_with_file_content(name, tag) 55 | "<#{tag}>#{File.read name}" 56 | end 57 | 58 | def assets_dir(*args) 59 | File.join Octodown.root, 'assets', args 60 | end 61 | 62 | def parent_dir 63 | current_file = File.dirname __FILE__ 64 | File.expand_path '..', current_file 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /octodown.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require 'octodown/version' 7 | Gem::Specification.new do |spec| 8 | spec.name = 'octodown' 9 | spec.version = Octodown::VERSION 10 | spec.authors = ['Ian Ker-Seymer'] 11 | spec.email = ['i.kerseymer@gmail.com'] 12 | spec.summary = 'GitHub Markdown straight from your shell.' 13 | spec.homepage = 'https://github.com/ianks/octodown' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = '>= 2.2.5' 16 | 17 | spec.files = Dir['{lib,assets,bin}/**/**'].reject { |f| f.end_with?('.gif') } 18 | spec.executables << 'octodown' 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'commonmarker', '~> 0.17' 22 | spec.add_dependency 'deckar01-task_list', '~> 2.0' 23 | spec.add_dependency 'faye-websocket', '~> 0.10' 24 | spec.add_dependency 'gemoji', '>= 2', '< 4' 25 | spec.add_dependency 'html-pipeline', '>= 2.8', '< 2.13' 26 | spec.add_dependency 'launchy', '~> 2.4', '>= 2.4.3' 27 | spec.add_dependency 'listen', '~> 3.7' 28 | spec.add_dependency 'puma', '>= 3.7', '< 5.0' 29 | spec.add_dependency 'rack', '~> 2.0' 30 | spec.add_dependency 'rouge', '~> 3.1' 31 | spec.add_dependency 'tty-prompt', '~> 0.16' 32 | 33 | spec.add_development_dependency 'bundler', '~> 2.0' 34 | spec.add_development_dependency 'octokit' 35 | spec.add_development_dependency 'rack-test', '~> 1.0' 36 | spec.add_development_dependency 'rake', '~> 13.0' 37 | spec.add_development_dependency 'rspec', '~> 3.3' 38 | spec.add_development_dependency 'rspec-retry' 39 | spec.add_development_dependency 'rubocop', '~> 0.55' 40 | end 41 | -------------------------------------------------------------------------------- /bin/octodown: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'octodown' 5 | require 'octodown/support/file_chooser' 6 | require 'octodown/support/logger' 7 | require 'optparse' 8 | require 'launchy' 9 | # Default options 10 | options = { 11 | port: 8887, 12 | presenter: :server, 13 | style: :github, 14 | stdin: false, 15 | logger: Octodown::Support::Logger.build, 16 | no_open: false 17 | } 18 | 19 | OptionParser.new do |opts| 20 | opts.banner = 'Usage: octodown [options]' 21 | 22 | opts.on_tail '--version', 'Show version' do 23 | puts "octodown v#{Octodown::VERSION}" 24 | exit 25 | end 26 | 27 | opts.on( 28 | '-s', '--style [STYLE]', %i[github atom], 'Choose style (atom, github)' 29 | ) do |s| 30 | options[:style] = s 31 | end 32 | 33 | opts.on '--stdin', 'Read from STDIN' do 34 | options[:stdin] = true 35 | end 36 | 37 | opts.on '--quiet', 'Shhhh.. keep quiet' do 38 | options[:logger].level = Logger::FATAL 39 | end 40 | 41 | opts.on '--debug', 'Debug the gem' do 42 | ENV['LISTEN_GEM_DEBUGGING'] = '2' 43 | options[:logger].level = Logger::DEBUG 44 | end 45 | 46 | opts.on '-r', '--raw', 'Print raw HTML to STDOUT' do 47 | options[:presenter] = :raw 48 | end 49 | 50 | opts.on '-l', '--live-reload', 'Start a LiveReload server' do 51 | options[:presenter] = :server 52 | end 53 | 54 | opts.on '-h', '--html', 'Render to HTML' do 55 | options[:presenter] = :html 56 | end 57 | 58 | opts.on( 59 | '-P', '--port [PORT]', "LiveReload port (default: #{options[:port]})" 60 | ) do |port| 61 | options[:presenter] = :server 62 | options[:port] = port.to_i 63 | end 64 | 65 | opts.on_tail '-h', '--help', 'Show this message' do 66 | puts opts 67 | exit 68 | end 69 | 70 | opts.on '--no-open', 'Do not open the browser' do 71 | options[:no_open] = true 72 | end 73 | end.parse! 74 | 75 | options[:file] = if ARGF.file == STDIN && options[:stdin] 76 | STDIN 77 | elsif ARGF.file != STDIN 78 | ARGF.file 79 | else 80 | Octodown::FileChooser.new(logger: options[:logger]).call 81 | end 82 | 83 | Octodown.call options 84 | -------------------------------------------------------------------------------- /spec/lib/renderer/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faye/websocket' 4 | 5 | include Octodown::Renderer 6 | class Dud < StandardError; end 7 | 8 | describe Server do 9 | let(:content) { File.read dummy_path } 10 | let(:app) { subject.app } 11 | let(:options) { opts.merge(file: File.new(dummy_path)) } 12 | 13 | subject do 14 | Server.new content, options 15 | end 16 | 17 | after(:each) do 18 | options[:file].close 19 | end 20 | 21 | before do 22 | allow_any_instance_of(Server).to receive(:maybe_launch_browser) 23 | .and_return true 24 | end 25 | 26 | it 'serves a Rack app' do 27 | expect(Rack::Handler::Puma).to receive(:run) 28 | 29 | subject.present 30 | end 31 | 32 | it 'register the listener' do 33 | allow(Rack::Handler::Puma).to receive(:run).and_return true 34 | expect(Octodown::Support::Services::Riposter).to receive :call 35 | 36 | subject.present 37 | end 38 | 39 | it 'generates HTML for each request' do 40 | get '/' 41 | 42 | expect(last_response).to be_ok 43 | expect(last_response.body).to include '

Hello world!

' 44 | end 45 | 46 | it 'regenerates HTML for each request' do 47 | get '/' 48 | 49 | expect(last_response.body).to include '

Hello world!

' 50 | 51 | options[:file].reopen('/dev/null') 52 | options[:file].sync = true 53 | 54 | get '/' 55 | 56 | expect(last_response.body).to_not include '

Hello world!

' 57 | end 58 | 59 | context 'with option :port' do 60 | subject do 61 | Server.new content, 62 | logger: options[:logger], 63 | file: options[:file], 64 | port: 4567 65 | end 66 | 67 | it 'serves in the specified port' do 68 | expect(Rack::Handler::Puma).to receive(:run) 69 | .with app, Port: 4567, Host: 'localhost', Silent: true, Threads: '2:8' 70 | subject.present 71 | end 72 | end 73 | 74 | context 'with WebSocket request' do 75 | it 'calls the WebSocket handler' do 76 | allow(Faye::WebSocket).to receive(:websocket?).and_return true 77 | 78 | expect(Faye::WebSocket).to receive(:new) 79 | expect { get '/' }.to raise_error StandardError 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/octodown/support/file_chooser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'English' 4 | require 'tty-prompt' 5 | 6 | module Octodown 7 | class FileChooser 8 | TUI = ::TTY::Prompt.new(enable_color: true) 9 | 10 | EXTENSIONS = %w[markdown 11 | mdown 12 | mkdn 13 | md 14 | mkd 15 | mdwn 16 | mdtxt 17 | mdtext 18 | text 19 | Rmd].freeze 20 | 21 | class MarkdownFileList 22 | class Git 23 | def call 24 | ext_args = EXTENSIONS.map { |ext| "**/*.#{ext} *.#{ext}" }.join(' ') 25 | `git ls-files --cached --others -z #{ext_args}`.split("\x0").uniq 26 | end 27 | 28 | def runnable? 29 | `git rev-parse --is-inside-work-tree` 30 | $CHILD_STATUS.success? 31 | rescue StandardError 32 | false 33 | end 34 | end 35 | 36 | class Glob 37 | def call 38 | Dir.glob "*.{#{EXTENSIONS.join(',')}}" 39 | end 40 | 41 | def runnable? 42 | true 43 | end 44 | end 45 | 46 | attr_reader :logger 47 | 48 | def initialize(logger) 49 | @logger = logger 50 | end 51 | 52 | def call 53 | logger.debug("File choose strategy: #{winning_strategy.class.name}") 54 | winning_strategy.call 55 | end 56 | 57 | def winning_strategy 58 | strats = [Git.new, Glob.new] 59 | @winning_strategy ||= strats.find(&:runnable?) 60 | end 61 | end 62 | 63 | attr_reader :prompt, :logger 64 | 65 | def initialize(logger:) 66 | @logger = logger 67 | @prompt = TUI 68 | end 69 | 70 | def call 71 | choices = all_markdown_files 72 | choice = prompt.select('Which file would you like to edit?', choices) 73 | File.open(choice, 'r') 74 | end 75 | 76 | def abort_no_files_found! 77 | prompt.error 'We could not find any markdown files in this folder.' 78 | puts 79 | prompt.error 'Try passing the file explicitly such as, i.e:' 80 | prompt.error ' $ octodown README.md' 81 | exit 1 82 | end 83 | 84 | def all_markdown_files 85 | choices = MarkdownFileList.new(logger).call 86 | 87 | abort_no_files_found! if choices.empty? 88 | 89 | choices.sort_by! { |c| c.split(File::SEPARATOR).length } 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'faye/websocket' 5 | require 'eventmachine' 6 | require 'pty' 7 | require 'expect' 8 | 9 | describe 'Integration' do 10 | def octodown 11 | File.join(__dir__, '..', 'bin', 'octodown') 12 | end 13 | 14 | context 'when running with an explicit file' do 15 | before :all do 16 | @pid = fork do 17 | exec "bundle exec #{octodown} #{dummy_path} --quiet --no-open" 18 | end 19 | sleep 5 20 | end 21 | 22 | after :all do 23 | Process.kill('TERM', @pid) 24 | Process.wait @pid 25 | end 26 | 27 | it 'runs and serves the files over http' do 28 | res = Net::HTTP.start 'localhost', 8887 do |http| 29 | http.request(Net::HTTP::Get.new('/')) 30 | end 31 | expect(res.body).to include "You are now reading markdown.\n"\ 32 | 'How lucky you are!' 33 | end 34 | it 'runs and receives data from the websocket' do 35 | message = nil 36 | EM.run do 37 | ws = Faye::WebSocket::Client.new('ws://localhost:8887/') 38 | ws.add_event_listener('message', lambda { |e| message = e.data }) 39 | 40 | start = Time.now 41 | timer = EM.add_periodic_timer 0.1 do 42 | if message || Time.now.to_i - start.to_i > 5 43 | timer.cancel 44 | EM.stop_event_loop 45 | end 46 | end 47 | end 48 | expect(message).to include("You are now reading markdown.\n"\ 49 | 'How lucky you are!') 50 | end 51 | end 52 | 53 | context 'when no file is passed' do 54 | it 'prompts the user to pick the file', retry: 5 do 55 | aggregator = double(prompt_was_read: true, html_was_rendered: true) 56 | 57 | PTY.spawn(octodown, '--raw', '--no-open') do |stdin, stdout, _pid| 58 | stdin.expect(/README\.md/, 5) do |_m| 59 | aggregator.prompt_was_read 60 | stdout.printf("\n") 61 | end 62 | 63 | stdin.expect(/DOCTYPE/, 5) do |result| 64 | aggregator.html_was_rendered(result.first) 65 | end 66 | end 67 | 68 | aggregate_failures do 69 | expect(aggregator).to have_received(:prompt_was_read) 70 | expect(aggregator) 71 | .to have_received(:html_was_rendered) 72 | .with(a_string_matching(/DOCTYPE/)) 73 | end 74 | end 75 | end 76 | 77 | context 'when data is passed via stdin' do 78 | it 'prompts the user to pick the file' do 79 | result = `echo "# Hello world" | #{octodown} --stdin --raw` 80 | 81 | expect(result).to match(/DOCTYPE/) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /assets/atom.css: -------------------------------------------------------------------------------- 1 | .markdown-body:not([data-use-github-style]) { 2 | padding: 2em; 3 | font-size: 1.2em; 4 | color: #333; 5 | background-color: #fff; 6 | overflow: auto; 7 | } 8 | .markdown-body:not([data-use-github-style]) > :first-child { 9 | margin-top: 0; 10 | } 11 | .markdown-body:not([data-use-github-style]) h1, 12 | .markdown-body:not([data-use-github-style]) h2, 13 | .markdown-body:not([data-use-github-style]) h3, 14 | .markdown-body:not([data-use-github-style]) h4, 15 | .markdown-body:not([data-use-github-style]) h5, 16 | .markdown-body:not([data-use-github-style]) h6 { 17 | line-height: 1.2; 18 | margin-top: 1.5em; 19 | margin-bottom: 0.5em; 20 | color: #000000; 21 | } 22 | .markdown-body:not([data-use-github-style]) h1 { 23 | font-size: 2.4em; 24 | font-weight: 300; 25 | } 26 | .markdown-body:not([data-use-github-style]) h2 { 27 | font-size: 1.8em; 28 | font-weight: 400; 29 | } 30 | .markdown-body:not([data-use-github-style]) h3 { 31 | font-size: 1.5em; 32 | font-weight: 500; 33 | } 34 | .markdown-body:not([data-use-github-style]) h4 { 35 | font-size: 1.2em; 36 | font-weight: 600; 37 | } 38 | .markdown-body:not([data-use-github-style]) h5 { 39 | font-size: 1.1em; 40 | font-weight: 600; 41 | } 42 | .markdown-body:not([data-use-github-style]) h6 { 43 | font-size: 1em; 44 | font-weight: 600; 45 | } 46 | .markdown-body:not([data-use-github-style]) strong { 47 | color: #000000; 48 | } 49 | .markdown-body:not([data-use-github-style]) del { 50 | color: #5c5c5c; 51 | } 52 | .markdown-body:not([data-use-github-style]) a, 53 | .markdown-body:not([data-use-github-style]) a code { 54 | color: #333; 55 | } 56 | .markdown-body:not([data-use-github-style]) img { 57 | max-width: 100%; 58 | } 59 | .markdown-body:not([data-use-github-style]) > p { 60 | margin-top: 0; 61 | margin-bottom: 1.5em; 62 | } 63 | .markdown-body:not([data-use-github-style]) > ul, 64 | .markdown-body:not([data-use-github-style]) > ol { 65 | margin-bottom: 1.5em; 66 | } 67 | .markdown-body:not([data-use-github-style]) blockquote { 68 | margin: 1.5em 0; 69 | font-size: inherit; 70 | color: #5c5c5c; 71 | border-color: #d6d6d6; 72 | border-width: 4px; 73 | } 74 | .markdown-body:not([data-use-github-style]) hr { 75 | margin: 3em 0; 76 | border-top: 2px dashed #d6d6d6; 77 | background: none; 78 | } 79 | .markdown-body:not([data-use-github-style]) table { 80 | margin: 1.5em 0; 81 | } 82 | .markdown-body:not([data-use-github-style]) th { 83 | color: #000000; 84 | } 85 | .markdown-body:not([data-use-github-style]) th, 86 | .markdown-body:not([data-use-github-style]) td { 87 | padding: 0.66em 1em; 88 | border: 1px solid #d6d6d6; 89 | } 90 | .markdown-body:not([data-use-github-style]) code { 91 | color: #000000; 92 | background-color: #f0f0f0; 93 | } 94 | .markdown-body:not([data-use-github-style]) atom-text-editor { 95 | margin: 1.5em 0; 96 | padding: 1em; 97 | font-size: 0.92em; 98 | border-radius: 3px; 99 | background-color: #f5f5f5; 100 | } 101 | .markdown-body:not([data-use-github-style]) kbd { 102 | color: #000000; 103 | border: 1px solid #d6d6d6; 104 | border-bottom: 2px solid #c7c7c7; 105 | background-color: #f0f0f0; 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :octocat: octodown 2 | 3 | [![GemVersion](https://badge.fury.io/rb/octodown.svg)](http://badge.fury.io/rb/octodown) 4 | ![Build Status](https://github.com/ianks/octodown/workflows/ci/badge.svg) 5 | 6 | Ever wanted to easily preview what your markdown would look like _exactly_ on 7 | Github? Ever wanted to do that from inside of a Terminal? 8 | 9 | Octodown uses the same parsers and CSS that Github uses for their markdown 10 | rendering. Github markdown styling looks beautiful, so it is Octodown's 11 | primary goal to reproduce it as faithfully as possible. 12 | 13 | ![Octodown GIF](assets/octodown.gif?raw=true) 14 | 15 | --- 16 | 17 | ## Features 18 | 19 | - :new: Edit your markdown like a boss with LiveReload. 20 | 21 | - `octodown README.md` 22 | 23 | - Uses the same markdown parsers and CSS as Github for true duplication. 24 | 25 | - Yes emojis _are_ included. :smiling_imp: 26 | 27 | - Fast. `octodown` uses native parsers to ensure performance. 28 | - Multiple CSS styles. 29 | 30 | - `octodown --style atom README.md` 31 | - The `github` markdown (default) 32 | - The `atom` text editor markdown 33 | 34 | - Properly parses `STDIN`. 35 | - `cat README.md | octodown --stdin` 36 | 37 | ## Installation 38 | 39 | _Requirements_: Ruby >= 2.0 40 | 41 | 1. Install `icu4c` and `cmake`: 42 | 43 | - Mac: `brew install icu4c cmake pkg-config` 44 | - Apt: `sudo apt-get install -y libicu-dev cmake pkg-config ruby-dev` 45 | 46 | 1. Install octodown: 47 | 48 | - If you have a non-system Ruby (_highly recommended_): `gem install octodown` 49 | - Else: `sudo gem install octodown` 50 | 51 | ## Usage in VIM (_optional_): 52 | 53 | - Use [asyncrun.vim](https://github.com/skywind3000/asyncrun.vim): 54 | 55 | ```viml 56 | " Plug 'skywind3000/asyncrun.vim' in your vimrc or init.nvim 57 | 58 | :AsyncRun octodown % 59 | 60 | " or, run whenever a mardown document is opened 61 | 62 | autocmd FileType markdown :AsyncRun octodown % 63 | ``` 64 | 65 | - Use [Dispatch](https://github.com/tpope/vim-dispatch) and add this to 66 | your ~/.vimrc: 67 | 68 | ```viml 69 | " Use octodown as default build command for Markdown files 70 | autocmd FileType markdown let b:dispatch = 'octodown %' 71 | ``` 72 | 73 | - Caveat: make sure you follow the directions on the Dispatch README.md and 74 | make sure that the correct version of Ruby (the one which as Octodown 75 | install as a Gem), is used. 76 | 77 | ## Usage 78 | 79 | 1. Keeping it simple (choose your files from a menu): 80 | 81 | - `octodown` 82 | 83 | 1. Markdown preview styling: 84 | 85 | - `octodown --style atom README.md` 86 | 87 | 1. Unix lovers: 88 | 89 | - `echo '# Hello world!' | octodown --raw --stdin > index.html` 90 | 91 | ## Notes 92 | 93 | 1. With `--stdin`, octodown will read `STDIN` until `EOF` is reached. 94 | 95 | - In order to work with this mode, type what you want into the input, then press 96 | `Ctrl-D` when finished. 97 | 98 | ## Contributing 99 | 100 | 1. Fork it ( https://github.com/ianks/octodown/fork ) 101 | 1. Create your feature branch (`git checkout -b my-new-feature`) 102 | 1. Commit your changes (`git commit -am 'Add some feature'`) 103 | 1. Run the test suite (`bundle exec rake`) 104 | 1. Push to the branch (`git push origin my-new-feature`) 105 | 1. Create a new Pull Request 106 | -------------------------------------------------------------------------------- /lib/octodown/renderer/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faye/websocket' 4 | require 'puma' 5 | require 'rack' 6 | require 'rack/handler/puma' 7 | require 'launchy' 8 | 9 | module Octodown 10 | module Renderer 11 | class Server 12 | Thread.abort_on_exception = true 13 | 14 | attr_reader :file, :path, :options, :port, :logger 15 | 16 | def initialize(_content, options = {}) 17 | @logger = options[:logger] 18 | @file = options[:file] 19 | @options = options 20 | @path = File.dirname(File.expand_path(file.path)) 21 | @port = options[:port] 22 | @websockets = [] 23 | @mutex = Mutex.new 24 | end 25 | 26 | def present 27 | register_listener 28 | 29 | Thread.new do 30 | maybe_launch_browser 31 | end 32 | 33 | boot_server 34 | end 35 | 36 | def boot_server 37 | logger.info "#{file.path} is getting octodown'd" 38 | logger.info "Server running on http://localhost:#{port}" 39 | Rack::Handler::Puma.run app, 40 | Host: 'localhost', 41 | Port: port, 42 | Silent: true, 43 | Threads: '2:8' 44 | end 45 | 46 | def maybe_launch_browser 47 | return if options[:no_open] 48 | 49 | sleep 2.5 50 | 51 | @mutex.synchronize do 52 | if @websockets.empty? 53 | logger.info 'Loading preview in a new browser tab' 54 | Launchy.open "http://localhost:#{port}" 55 | else 56 | logger.info 'Re-using existing browser tab' 57 | end 58 | end 59 | end 60 | 61 | def call(env) 62 | ::Faye::WebSocket.websocket?(env) ? render_ws(env) : render_http(env) 63 | end 64 | 65 | # Cascade through this app and Rack::File app. 66 | # If Server returns 404, Rack::File will try to serve a static file. 67 | def app 68 | @app ||= Rack::Cascade.new([self, Rack::File.new(path)]) 69 | end 70 | 71 | private 72 | 73 | def render_ws(env) 74 | md = render_md(file) 75 | 76 | socket = ::Faye::WebSocket.new(env) 77 | 78 | socket.on(:open) do 79 | @websockets << socket 80 | log_clients('Client joined') 81 | socket.send md 82 | end 83 | 84 | socket.on(:close) do 85 | @websockets = @websockets.reject { |s| s == socket } 86 | log_clients('Client left') 87 | end 88 | 89 | socket.rack_response 90 | end 91 | 92 | def render_http(env) 93 | Rack::Response.new.tap do |res| 94 | res.headers.merge 'Content-Type' => 'text/html' 95 | res.status = valid_req?(env) ? 200 : 404 96 | res.write(body) if valid_req? env 97 | end.finish 98 | end 99 | 100 | def valid_req?(env) 101 | env['PATH_INFO'] == '/' && env['REQUEST_METHOD'] == 'GET' 102 | end 103 | 104 | # Render HTML body from Markdown 105 | def body 106 | HTML.render render_md(file), options 107 | end 108 | 109 | def register_listener 110 | Octodown::Support::Services::Riposter.call file do 111 | logger.info "Changes to #{file.path} detected, updating" 112 | md = render_md(file) 113 | @websockets.each do |socket| 114 | Thread.new do 115 | socket.send md 116 | end 117 | end 118 | end 119 | end 120 | 121 | def render_md(f) 122 | Renderer::GithubMarkdown.render f, options 123 | end 124 | 125 | def log_clients(msg) 126 | logger.debug "#{msg}. Number of websocket clients: #{@websockets.size}" 127 | end 128 | end 129 | end # Support 130 | end # Octodown 131 | -------------------------------------------------------------------------------- /lib/octodown/template/octodown.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= title %> 7 | 8 | 9 | <%= highlight_stylesheet %> 10 | <%= stylesheet %> 11 | 12 | 13 | 14 |
15 |
16 |
17 |

18 | 19 | README.md 20 |

21 |
22 | <%= rendered_markdown %> 23 |
24 |
25 |
26 |
27 | 28 | 29 | <%# reconnecting-websocket.min.js %> 30 | 31 | 34 | 35 | 51 | 52 | --------------------------------------------------------------------------------