├── .gitignore ├── .rspec ├── .ruby-version ├── Gemfile ├── LICENSE.txt ├── README.md ├── jekyll-include_snippet.gemspec ├── lib └── jekyll │ ├── include_snippet.rb │ └── include_snippet │ ├── extractor.rb │ ├── liquid_tag.rb │ └── version.rb └── spec ├── extractor_spec.rb ├── jekyll-include_snippet_spec.rb ├── liquid_tag_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Tom Dalling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jekyll::IncludeSnippet 2 | 3 | Include snippets of text from external files into your markdown 4 | 5 | ## Installation 6 | 7 | Add it to your `Gemfile` (in a way that Jekyll will load): 8 | 9 | source 'https://rubygems.org' 10 | 11 | group :jekyll_plugins do 12 | gem 'jekyll-include_snippet' 13 | end 14 | 15 | ## Usage 16 | 17 | Put the special "begin-snippet" and "end-snippet" comments into your source file: 18 | 19 | # blah.rb 20 | 21 | class Blah 22 | # begin-snippet: my_method_snippet 23 | def blah 24 | puts 'blah blah blah' 25 | end 26 | # end-snippet 27 | end 28 | 29 | Use it from your markdown: 30 | 31 | --- 32 | title: "My Blerg Post" 33 | date: "2018-01-01" 34 | --- 35 | 36 | Blah blah here is some code: 37 | 38 | ```ruby 39 | {% include_snippet my_method_snippet from path/to/blah.rb %} 40 | ``` 41 | 42 | Optionally, you can set a default source path in the YAML frontmatter: 43 | 44 | --- 45 | title: "My Blerg Post" 46 | date: "2018-01-01" 47 | snippet_source: "path/to/blah.rb" 48 | --- 49 | 50 | ```ruby 51 | {% include_snippet my_method_snippet %} 52 | ``` 53 | 54 | ## Languages Other Than Ruby 55 | 56 | If you're using another language, you will probably need to change the "comment prefix". 57 | 58 | --- 59 | title: "My Blerg Post" 60 | date: "2018-01-01" 61 | snippet_comment_prefix: "//" 62 | --- 63 | 64 | ```js 65 | {% include_snippet whatever from whatever.js %} 66 | ``` 67 | 68 | ```js 69 | // whatever.js 70 | 71 | // begin-snippet: whatever 72 | function whatever() { 73 | console.log("Hello there"); 74 | } 75 | // end-snippet 76 | ``` 77 | 78 | ## License 79 | 80 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 81 | 82 | ## TODO 83 | 84 | - Could easily be optimised for better performance 85 | - Maybe a feature for evaluating code and including the result 86 | -------------------------------------------------------------------------------- /jekyll-include_snippet.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "jekyll/include_snippet/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "jekyll-include_snippet" 8 | spec.version = Jekyll::IncludeSnippet::VERSION 9 | spec.authors = ["Tom Dalling"] 10 | spec.email = ["tom@tomdalling.com"] 11 | 12 | spec.summary = %q{Include snippets of text from external files into your markdown} 13 | spec.homepage = "https://github.com/tomdalling/jekyll-include_snippet" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = "exe" 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_runtime_dependency 'liquid', '>= 3.0' 24 | 25 | spec.add_development_dependency "bundler", ">= 1.15" 26 | spec.add_development_dependency "rspec", "~> 3.7" 27 | spec.add_development_dependency "rspec-its", "~> 1.2" 28 | spec.add_development_dependency "byebug" 29 | spec.add_development_dependency "gem-release" 30 | end 31 | -------------------------------------------------------------------------------- /lib/jekyll/include_snippet.rb: -------------------------------------------------------------------------------- 1 | require 'liquid' 2 | 3 | module Jekyll 4 | module IncludeSnippet 5 | end 6 | end 7 | 8 | %w(version extractor liquid_tag).each do |file| 9 | require "jekyll/include_snippet/#{file}" 10 | end 11 | 12 | -------------------------------------------------------------------------------- /lib/jekyll/include_snippet/extractor.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module IncludeSnippet 3 | class Extractor 4 | attr_reader :comment_prefix 5 | 6 | def initialize(comment_prefix:) 7 | @comment_prefix = comment_prefix 8 | end 9 | 10 | def call(source) 11 | everything = Snippet.new(name: 'everything', indent: 0) 12 | all_snippets = [] 13 | active_snippets = [] 14 | 15 | source.each_line.each_with_index do |line, lineno| 16 | case line 17 | when begin_regex 18 | active_snippets << Snippet.new(name: $2.strip, indent: $1.length) 19 | when end_regex 20 | raise missing_begin_snippet(lineno) if active_snippets.empty? 21 | all_snippets << active_snippets.pop 22 | else 23 | (active_snippets + [everything]).each do |snippet| 24 | snippet.lines << line 25 | end 26 | end 27 | end 28 | 29 | (all_snippets + [everything]) 30 | .map { |s| [s.name, s.dedented_text] } 31 | .to_h 32 | end 33 | 34 | private 35 | 36 | def begin_regex 37 | %r{ 38 | (\s*) # optional whitespace (indenting) 39 | #{Regexp.quote(comment_prefix)} # the comment prefix 40 | \s* # optional whitespace 41 | begin-snippet: # magic string for beginning a snippet 42 | (.+) # the remainder of the line is the snippet name 43 | }x 44 | end 45 | 46 | def end_regex 47 | %r{ 48 | \s* # optional whitespace (indenting) 49 | #{Regexp.quote(comment_prefix)} # the comment prefix 50 | \s* # optional whitespace 51 | end-snippet # Magic string for ending a snippet 52 | }x 53 | end 54 | 55 | def missing_begin_snippet(lineno) 56 | <<~END_ERROR 57 | There was an `end-snippet` on line #{lineno}, but there doesn't 58 | appear to be any matching `begin-snippet` line. 59 | 60 | Make sure you have the correct `begin-snippet` comment -- 61 | something like this: 62 | 63 | # begin-snippet: MyRadCode 64 | 65 | END_ERROR 66 | end 67 | 68 | class Snippet 69 | attr_reader :name, :indent, :lines 70 | 71 | def initialize(name:, indent:) 72 | @name = name 73 | @indent = indent 74 | @lines = [] 75 | end 76 | 77 | def dedented_text 78 | lines 79 | .map { |line| dedent(line) } 80 | .join 81 | .rstrip 82 | end 83 | 84 | def dedent(line) 85 | if line.length >= indent 86 | line[indent..-1] 87 | else 88 | line 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/jekyll/include_snippet/liquid_tag.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module IncludeSnippet 3 | class LiquidTag < Liquid::Tag 4 | DEFAULT_COMMENT_PREFIX = '#' 5 | 6 | def initialize(tag_name, arg_str, tokens) 7 | super 8 | @snippet_name, @source_path = arg_str.split(/\sfrom\s/).map(&:strip) 9 | end 10 | 11 | def render(context) 12 | source_path = source_path_for(context) 13 | source = File.read(source_path) 14 | extractor = Extractor.new(comment_prefix: comment_prefix_for(context)) 15 | snippets = extractor.(source) 16 | snippets.fetch(@snippet_name) do 17 | fail "Snippet not found: #{@snippet_name.inspect}\n in file: #{@source_path}" 18 | end 19 | end 20 | 21 | private 22 | 23 | def source_path_for(context) 24 | result = get_option(:snippet_source, context) || @source_path 25 | 26 | if result.nil? 27 | fail "No source path provided for snippet: #{@snippet_name}" 28 | end 29 | 30 | result 31 | end 32 | 33 | def comment_prefix_for(context) 34 | get_option(:snippet_comment_prefix, context) || DEFAULT_COMMENT_PREFIX 35 | end 36 | 37 | def get_option(option_name, context) 38 | page = context['page'] 39 | page && page[option_name.to_s] 40 | end 41 | end 42 | end 43 | end 44 | 45 | Liquid::Template.register_tag('include_snippet', Jekyll::IncludeSnippet::LiquidTag) 46 | -------------------------------------------------------------------------------- /lib/jekyll/include_snippet/version.rb: -------------------------------------------------------------------------------- 1 | module Jekyll 2 | module IncludeSnippet 3 | VERSION = "0.2.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/extractor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Jekyll::IncludeSnippet::Extractor do 2 | subject { extractor.(input) } 3 | let(:extractor) { described_class.new(comment_prefix: '#') } 4 | 5 | context 'a simple snippet' do 6 | let(:input) { <<~END_INPUT } 7 | This is the first line 8 | 9 | # begin-snippet: wakka 10 | This is the wakka snippet. 11 | # end-snippet 12 | 13 | Last line. 14 | END_INPUT 15 | 16 | it "works" do 17 | expect(subject['wakka']).to eq('This is the wakka snippet.') 18 | expect(subject['everything']).to eq(<<~END_TEXT.strip) 19 | This is the first line 20 | 21 | This is the wakka snippet. 22 | 23 | Last line. 24 | END_TEXT 25 | end 26 | end 27 | 28 | context 'nested snippets' do 29 | let(:input) { <<~END_INPUT } 30 | #begin-snippet: outer 31 | This is outer 32 | #begin-snippet: inner 33 | This is inner 34 | #end-snippet 35 | #end-snippet 36 | END_INPUT 37 | 38 | it 'works' do 39 | expect(subject['inner']).to eq('This is inner') 40 | expect(subject['outer']).to eq("This is outer\nThis is inner") 41 | end 42 | end 43 | 44 | context 'indented snippets' do 45 | let(:input) { <<~END_INPUT } 46 | First 47 | #begin-snippet: indented 48 | I 49 | am 50 | 51 | indented 52 | #end-snippet 53 | Last 54 | END_INPUT 55 | 56 | it 'works' do 57 | expect(subject['indented']).to eq([ 58 | " I", 59 | " am", 60 | "", 61 | " indented" 62 | ].join("\n")) 63 | 64 | expect(subject['everything']).to eq([ 65 | "First", 66 | " I", 67 | " am", 68 | "", 69 | " indented", 70 | "Last" 71 | ].join("\n")) 72 | end 73 | end 74 | 75 | context 'ending a snippet that never began' do 76 | let(:input) { "# end-snippet\n" } 77 | 78 | it "raises a nice error" do 79 | expect { subject }.to raise_error(<<~END_ERROR) 80 | There was an `end-snippet` on line 0, but there doesn't 81 | appear to be any matching `begin-snippet` line. 82 | 83 | Make sure you have the correct `begin-snippet` comment -- 84 | something like this: 85 | 86 | # begin-snippet: MyRadCode 87 | 88 | END_ERROR 89 | end 90 | end 91 | 92 | context 'with a different comment prefix' do 93 | let(:extractor) { described_class.new(comment_prefix: "//") } 94 | 95 | let(:input) { <<~END_INPUT } 96 | // begin-snippet: wakka 97 | This is the wakka snippet. 98 | // end-snippet 99 | END_INPUT 100 | 101 | it "works" do 102 | expect(subject['wakka']).to eq('This is the wakka snippet.') 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/jekyll-include_snippet_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Jekyll::IncludeSnippet do 4 | it "has a version number" do 5 | expect(Jekyll::IncludeSnippet::VERSION.split('.').size).to eq(3) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/liquid_tag_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | RSpec.describe Jekyll::IncludeSnippet::LiquidTag do 4 | subject do 5 | tag = described_class.parse('include_snippet', args, nil, options) 6 | tag.render(context) 7 | end 8 | let(:options) { OpenStruct.new(line_number: 123) } 9 | before(:each) do 10 | allow(File).to receive(:read).with('path/to/buzz.rb').and_return(source_code) 11 | end 12 | let(:source_code) { <<~END_SOURCE } 13 | #begin-snippet: geralt_from_rivia 14 | I hate portals 15 | #end-snippet 16 | END_SOURCE 17 | 18 | context 'with explicit source path argument to tag' do 19 | let(:context) { {} } 20 | let(:args) { "geralt_from_rivia from path/to/buzz.rb" } 21 | 22 | it { is_expected.to eq('I hate portals') } 23 | end 24 | 25 | context 'with implicit source path taken from context' do 26 | # this context mimics the open that Jekyll provides 27 | let(:context) {{ 'page' => { 'snippet_source' => 'path/to/buzz.rb' } }} 28 | let(:args) { "geralt_from_rivia" } 29 | 30 | it { is_expected.to eq('I hate portals') } 31 | end 32 | 33 | context 'without any source path defined' do 34 | let(:context) { {} } 35 | let(:args) { "doesnt_matter" } 36 | 37 | it 'causes an error' do 38 | expect { subject }.to raise_error(RuntimeError) 39 | end 40 | end 41 | 42 | context 'with a custom comment prefix' do 43 | let(:context) {{ 'page' => { 'snippet_comment_prefix' => '//' } }} 44 | let(:args) { 'geralt_from_rivia from path/to/buzz.rb' } 45 | let(:source_code) { <<~END_SOURCE } 46 | //begin-snippet: geralt_from_rivia 47 | I hate portals 48 | //end-snippet 49 | END_SOURCE 50 | 51 | it { is_expected.to eq('I hate portals') } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require 'rspec/its' 3 | require 'byebug' 4 | require "jekyll/include_snippet" 5 | 6 | RSpec.configure do |config| 7 | # Enable flags like --only-failures and --next-failure 8 | config.example_status_persistence_file_path = ".rspec_status" 9 | 10 | # Disable RSpec exposing methods globally on `Module` and `main` 11 | config.disable_monkey_patching! 12 | 13 | config.expect_with :rspec do |c| 14 | c.syntax = :expect 15 | end 16 | end 17 | --------------------------------------------------------------------------------