├── .gitignore ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── TUTORIAL.md ├── bin ├── console └── setup ├── exe └── leg ├── leg.gemspec ├── lib ├── leg.rb └── leg │ ├── cli.rb │ ├── commands.rb │ ├── commands │ ├── amend.rb │ ├── base_command.rb │ ├── build.rb │ ├── commit.rb │ ├── diff.rb │ ├── help.rb │ ├── init.rb │ ├── reset.rb │ ├── resolve.rb │ ├── save.rb │ ├── status.rb │ └── step.rb │ ├── config.rb │ ├── default_templates.rb │ ├── diff.rb │ ├── diff_transformers.rb │ ├── diff_transformers │ ├── base_transformer.rb │ ├── fold_sections.rb │ ├── omit_adjacent_removals.rb │ ├── syntax_highlight.rb │ └── trim_blank_lines.rb │ ├── line.rb │ ├── markdown.rb │ ├── page.rb │ ├── representations.rb │ ├── representations │ ├── base_representation.rb │ ├── git.rb │ └── litdiff.rb │ ├── step.rb │ ├── template.rb │ ├── tutorial.rb │ └── version.rb └── test ├── integration └── workflow_test.rb ├── leg_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /.tags 10 | *.gem 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at jeremy.ruten@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in leg.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | leg (0.0.1) 5 | redcarpet (= 3.4.0) 6 | rouge (= 2.0.7) 7 | rugged (= 0.27.2) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | coderay (1.1.2) 13 | method_source (0.9.0) 14 | minitest (5.11.3) 15 | pry (0.11.3) 16 | coderay (~> 1.1.0) 17 | method_source (~> 0.9.0) 18 | rake (10.5.0) 19 | redcarpet (3.4.0) 20 | rouge (2.0.7) 21 | rugged (0.27.2) 22 | 23 | PLATFORMS 24 | ruby 25 | 26 | DEPENDENCIES 27 | bundler (~> 1.16) 28 | leg! 29 | minitest (~> 5.0) 30 | pry 31 | rake (~> 10.0) 32 | 33 | BUNDLED WITH 34 | 1.16.1 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Jeremy Ruten 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 | # leg 2 | 3 | Command line tool that helps you make step-by-step programming walkthroughs. 4 | 5 | **NOTE:** This project is in a *very* unstable state at the moment. If you really want to use the last "stable" version of this project, head over to the [`snaptoken`](https://github.com/yjerem/leg/tree/snaptoken) branch. (Just know that it will be replaced by a completely new tool and file format in the near future.) 6 | 7 | ## Install 8 | 9 | $ gem install leg 10 | 11 | ## Usage 12 | 13 | See the [tutorial](TUTORIAL.md)! 14 | 15 | --- 16 | 17 | # Leg 18 | 19 | Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/leg`. To experiment with that code, run `bin/console` for an interactive prompt. 20 | 21 | TODO: Delete this and the text above, and describe your gem 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'leg' 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | 35 | Or install it yourself as: 36 | 37 | $ gem install leg 38 | 39 | ## Usage 40 | 41 | TODO: Write usage instructions here 42 | 43 | ## Development 44 | 45 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 46 | 47 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 48 | 49 | ## Contributing 50 | 51 | Bug reports and pull requests are welcome on GitHub at https://github.com/yjerem/leg. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 52 | 53 | ## License 54 | 55 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 56 | 57 | ## Code of Conduct 58 | 59 | Everyone interacting in the Leg project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/yjerem/leg/blob/master/CODE_OF_CONDUCT.md). 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | t.warning = false 9 | end 10 | 11 | task :default => :test 12 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | # Creating a step-by-step programming tutorial with `leg` 2 | 3 | Install `leg` (you'll need Ruby for this): 4 | 5 | ```sh 6 | $ gem install leg 7 | ``` 8 | 9 | Create a project folder for your tutorial, `cd` into it, and run `leg init`. 10 | 11 | ```sh 12 | $ cd ~/projects 13 | $ mkdir fizzbuzz-tutorial 14 | $ cd fizzbuzz-tutorial 15 | $ leg init 16 | ``` 17 | 18 | This creates two folders: `doc/`, where you will write the text of your 19 | tutorial, and `step/`, which is a "working directory" that you will use to 20 | write and modify the actual code steps of your tutorial. 21 | 22 | (Also, a hidden `.leg/` directory is created which marks your project folder as 23 | the root of a `leg` tutorial. This is used by `leg` internally, so you don't 24 | have to worry about it.) 25 | 26 | Create a file in the `step/` folder called `fizzbuzz.js`, with the following 27 | contents: 28 | 29 | ```js 30 | var n = 1; 31 | while (n <= 100) { 32 | console.log(n); 33 | n++; 34 | } 35 | ``` 36 | 37 | Save it, and then run `leg commit`. Congratulations, you have created your 38 | first step! Now open `step/fizzbuzz.js` again, and modify it like so: 39 | 40 | ```js 41 | var n = 1; 42 | while (n <= 100) { 43 | if (n % 3 == 0) { 44 | console.log("Fizz"); 45 | } else { 46 | console.log(n); 47 | } 48 | n++; 49 | } 50 | ``` 51 | 52 | Save it, and then run `leg commit`. Your tutorial now has 2 steps! 53 | 54 | ## Rendering the tutorial 55 | 56 | Run the `leg build` command to build the tutorial into a nice HTML page. Open 57 | `build/html/tutorial.html` in your web browser to see the result. 58 | 59 | You can add text in between the steps of your tutorial by simply adding 60 | (Markdown-formatted) text to `doc/tutorial.litdiff`. For example: 61 | 62 | ```diff 63 | # My FizzBuzz Tutorial 64 | 65 | Hello! This is a step-by-step tutorial that teaches you how to write your own 66 | [FizzBuzz](http://wiki.c2.com/?FizzBuzzTest). 67 | 68 | The first thing to do is to write a `while` loop. 69 | 70 | ~~~ Loop through numbers 1 to 100 71 | --- /dev/null 72 | +++ b/fizzbuzz.js 73 | @@ -0,0 +1,5 @@ 74 | +var n = 1; 75 | +while (n <= 100) { 76 | + console.log(n); 77 | + n++; 78 | +} 79 | 80 | Now that we have our `while` loop, with the base case of printing out each 81 | number, we can start handling special cases. We'll start with handling "Fizz". 82 | 83 | ~~~ Print "Fizz" for multiples of 3 84 | --- a/fizzbuzz.js 85 | +++ b/fizzbuzz.js 86 | @@ -1,5 +1,9 @@ 87 | |var n = 1; 88 | |while (n <= 100) { 89 | - console.log(n); 90 | + if (n % 3 == 0) { 91 | + console.log("Fizz"); 92 | + } else { 93 | + console.log(n); 94 | + } 95 | | n++; 96 | |} 97 | ``` 98 | 99 | ## Adding the rest of the steps 100 | 101 | ```js 102 | var n = 1; 103 | while (n <= 100) { 104 | if (n % 3 == 0) { 105 | console.log("Fizz"); 106 | } else if (n % 5 == 0) { 107 | console.log("Buzz"); 108 | } else { 109 | console.log(n); 110 | } 111 | n++; 112 | } 113 | ``` 114 | 115 | ```js 116 | var n = 1; 117 | while (n <= 100) { 118 | if (n % 3 == 0) { 119 | console.log("Fizz"); 120 | } else if (n % 5 == 0) { 121 | console.log("Buzz"); 122 | } else { 123 | console.log(n); 124 | } 125 | n++; 126 | } 127 | ``` 128 | 129 | ```js 130 | var n = 1; 131 | while (n <= 100) { 132 | if (n % 3 == 0) { 133 | console.log("Fizz"); 134 | } else if (n % 5 == 0) { 135 | console.log("Buzz"); 136 | } else if (n % 15 == 0) { 137 | console.log("FizzBuzz"); 138 | } else { 139 | console.log(n); 140 | } 141 | n++; 142 | } 143 | ``` 144 | 145 | ## Amending a step 146 | 147 | Oops, that last step we committed has a bug! Let's fix that. In 148 | `step/fizzbuzz.js`, move the `n % 15` case above the other two cases: 149 | 150 | ```js 151 | var n = 1; 152 | while (n <= 100) { 153 | if (n % 15 == 0) { 154 | console.log("FizzBuzz"); 155 | } else if (n % 3 == 0) { 156 | console.log("Fizz"); 157 | } else if (n % 5 == 0) { 158 | console.log("Buzz"); 159 | } else { 160 | console.log(n); 161 | } 162 | n++; 163 | } 164 | ``` 165 | 166 | Now run the `leg amend` command, and it will overwrite the step you committed 167 | with this new code. 168 | 169 | ## Resolving conflicts 170 | 171 | Let's try something a little more challenging. Let's say we want to rewrite our 172 | steps to use a `for` loop instead of a `while` loop. That means going back and 173 | amending the first step of our tutorial. 174 | 175 | We can do that by running `leg 1` to checkout the first step of the tutorial 176 | into the `step/` folder. Then modify `step/fizzbuzz.js` to look like this: 177 | 178 | ```js 179 | for (var n = 1; n <= 100; n++) { 180 | console.log(n); 181 | } 182 | ``` 183 | 184 | Now run `leg amend`. `leg` will try to apply the change you just made to all 185 | the steps that come after it. Often, with large files, it can do this 186 | automatically. But sometimes you will have to resolve merge conflicts manually. 187 | 188 | In this case, after running `leg amend`, you will get a message saying that you 189 | need to resolve a merge conflict with step 2. To resolve the conflict, open 190 | `step/fizzbuzz.js` and resolve it by hand, or use a merge tool. The final 191 | result should look like this: 192 | 193 | ```js 194 | for (var n = 1; n <= 100; n++) { 195 | if (n % 3 == 0) { 196 | console.log("Fizz"); 197 | } else { 198 | console.log(n); 199 | } 200 | } 201 | ``` 202 | 203 | When the conflict is resolved and you've saved `step/fizzbuzz.js`, run 204 | `leg resolve` to continue. 205 | 206 | ## Inserting steps 207 | 208 | So far, whenever we've run `leg commit` we've been adding steps to the end of 209 | our tutorial. We can also insert commits in the middle of our tutorial by 210 | running `leg `, making changes, and running `leg commit`. This 211 | will add step(s) *after* the given ``. 212 | 213 | For example, let's add a step after step 1 that prints out a welcome message at 214 | the beginning of the program. First, run `leg 1`. This checks out step 1 into 215 | the `step/` folder. Now modify `step/fizzbuzz.js` thusly: 216 | 217 | ```js 218 | console.log("Welcome to fizzbuzz!"); 219 | 220 | for (var n = 1; n <= 100; n++) { 221 | console.log(n); 222 | } 223 | ``` 224 | 225 | Save the file and run `leg commit`. A new step 2 will be inserted and all the 226 | steps afterward will be bumped up one. Note that there may be conflicts that 227 | need to be resolved, as is always possible when making changes to past steps. 228 | 229 | ## Splitting a step into multiple steps 230 | 231 | (The example here will be splitting the `n % 15` step into a step that does 232 | `n % 3 == 0 && n % 5 == 0` and then a step that simplifies that to `n % 15`. 233 | And maybe I'll think of a third step it can be split into...) 234 | 235 | ## Reording steps 236 | 237 | (Here we'll try moving the welcome message step to the end of the tutorial.) 238 | 239 | ## Squashing multiple steps into a single step 240 | 241 | (I guess here we'll undo the splitting into steps of `n % 15` that we did 242 | earlier, for lack of any better ideas.) 243 | 244 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "leg" 5 | 6 | include Leg 7 | 8 | require "pry" 9 | Pry.start 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /exe/leg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'leg' 4 | 5 | status = Leg::CLI.new.run(ARGV) 6 | exit! if status == false 7 | -------------------------------------------------------------------------------- /leg.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "leg/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "leg" 7 | spec.version = Leg::VERSION 8 | spec.authors = ["Jeremy Ruten"] 9 | spec.email = ["jeremy.ruten@gmail.com"] 10 | 11 | spec.summary = %q{Tools for creating step-by-step programming tutorials} 12 | #spec.description = %q{TODO: Write a longer description or delete this line.} 13 | spec.homepage = "https://github.com/yjerem/leg" 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 'rugged', '0.27.2' 24 | spec.add_runtime_dependency 'redcarpet', '3.4.0' 25 | spec.add_runtime_dependency 'rouge', '2.0.7' 26 | 27 | spec.add_development_dependency "bundler", "~> 1.16" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "minitest", "~> 5.0" 30 | spec.add_development_dependency "pry" 31 | end 32 | -------------------------------------------------------------------------------- /lib/leg.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | require 'fileutils' 3 | require 'open3' 4 | require 'optparse' 5 | require 'redcarpet' 6 | require 'rouge' 7 | require 'rouge/plugins/redcarpet' 8 | require 'rugged' 9 | require 'yaml' 10 | 11 | require 'leg/cli' 12 | require 'leg/commands' 13 | require 'leg/config' 14 | require 'leg/default_templates' 15 | require 'leg/diff' 16 | require 'leg/diff_transformers' 17 | require 'leg/line' 18 | require 'leg/markdown' 19 | require 'leg/page' 20 | require 'leg/representations' 21 | require 'leg/step' 22 | require 'leg/template' 23 | require 'leg/tutorial' 24 | require 'leg/version' 25 | 26 | module Leg 27 | end 28 | -------------------------------------------------------------------------------- /lib/leg/cli.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class CLI 3 | def initialize(options = {}) 4 | @options = options 5 | 6 | initial_dir = FileUtils.pwd 7 | 8 | @config = nil 9 | last_dir = nil 10 | while FileUtils.pwd != last_dir 11 | if File.exist?("leg.yml") 12 | @config = Leg::Config.new(FileUtils.pwd) 13 | @config.load! 14 | break 15 | end 16 | 17 | last_dir = FileUtils.pwd 18 | FileUtils.cd("..") 19 | end 20 | 21 | FileUtils.cd(initial_dir) 22 | end 23 | 24 | def run(args) 25 | args = ["help"] if args.empty? 26 | cmd_name = args.shift.downcase 27 | 28 | if cmd_name =~ /\A\d+\z/ 29 | args.unshift(cmd_name) 30 | cmd_name = "step" 31 | end 32 | 33 | if cmd = Leg::Commands::LIST.find { |cmd| cmd.name == cmd_name } 34 | command = cmd.new(args, @config) 35 | command.opts[:quiet] = true if @options[:force_quiet] 36 | command.run 37 | else 38 | puts "There is no '#{cmd_name}' command. Run `leg help` for help." 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/leg/commands.rb: -------------------------------------------------------------------------------- 1 | require 'leg/commands/base_command' 2 | 3 | module Leg 4 | module Commands 5 | LIST = [] 6 | end 7 | end 8 | 9 | require 'leg/commands/init' 10 | require 'leg/commands/build' 11 | require 'leg/commands/status' 12 | require 'leg/commands/step' 13 | require 'leg/commands/diff' 14 | require 'leg/commands/commit' 15 | require 'leg/commands/amend' 16 | require 'leg/commands/save' 17 | require 'leg/commands/resolve' 18 | require 'leg/commands/reset' 19 | require 'leg/commands/help' 20 | -------------------------------------------------------------------------------- /lib/leg/commands/amend.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Amend < BaseCommand 4 | def self.name 5 | "amend" 6 | end 7 | 8 | def self.summary 9 | "Modify a step." 10 | end 11 | 12 | def self.usage 13 | "[-s]" 14 | end 15 | 16 | def setopts!(o) 17 | o.on("-m", "--message MESSAGE", "Set the step summary to MESSAGE") do |m| 18 | @opts[:message] = m 19 | end 20 | o.on("-d", "--default-message", "Leave the step summary unchanged, or set it to a default if empty") do |d| 21 | @opts[:default_message] = d 22 | end 23 | o.on("-s", "--stay", "Don't resolve rest of steps yet") do |s| 24 | @opts[:stay] = s 25 | end 26 | end 27 | 28 | def run 29 | needs! :config, :repo 30 | 31 | commit_options = { 32 | amend: true, 33 | no_rebase: @opts[:stay], 34 | message: @opts[:message], 35 | use_default_message: @opts[:default_message] 36 | } 37 | 38 | if @git.commit!(commit_options) 39 | unless @opts[:stay] 40 | git_to_litdiff! 41 | output "Success!\n" 42 | end 43 | else 44 | output "Looks like you've got a conflict to resolve!\n" 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/leg/commands/base_command.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class BaseCommand 4 | attr_reader :config, :opts 5 | 6 | def initialize(args, config) 7 | @args = args 8 | @config = config 9 | @git = Leg::Representations::Git.new(@config) 10 | @litdiff = Leg::Representations::Litdiff.new(@config) 11 | parseopts! 12 | end 13 | 14 | def self.name; raise NotImplementedError; end 15 | def self.summary; raise NotImplementedError; end 16 | def setopts!(o); raise NotImplementedError; end 17 | def run; raise NotImplementedError; end 18 | 19 | def self.inherited(subclass) 20 | Leg::Commands::LIST << subclass 21 | end 22 | 23 | def parseopts! 24 | parser = OptionParser.new do |o| 25 | o.banner = "Usage: leg #{self.class.name} #{self.class.usage}" 26 | self.class.summary.split("\n").each do |line| 27 | o.separator " #{line}" 28 | end 29 | o.separator "" 30 | o.separator "Options:" 31 | setopts!(o) 32 | o.on_tail("-h", "--help", "Show this message") do 33 | puts o 34 | exit 35 | end 36 | end 37 | @opts = {} 38 | parser.parse!(@args) 39 | rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e 40 | puts "#{e.message}" 41 | puts 42 | parser.parse("--help") 43 | end 44 | 45 | def needs!(*whats) 46 | whats.each do |what| 47 | case what 48 | when :config 49 | if @config.nil? 50 | puts "Error: You are not in a leg working directory." 51 | exit 1 52 | end 53 | when :repo 54 | if @litdiff.modified? and @git.modified? 55 | puts "Error: doc/ and .leg/repo have diverged!" 56 | exit 1 57 | elsif @litdiff.modified? or !@git.exists? 58 | litdiff_to_git! 59 | end 60 | end 61 | end 62 | end 63 | 64 | def output(text) 65 | print text unless @opts[:quiet] 66 | end 67 | 68 | def git_to_litdiff! 69 | tutorial = @git.load! do |step_num| 70 | output "\r\e[K[repo/ -> Tutorial] Step #{step_num}" 71 | end 72 | output "\n" 73 | 74 | num_steps = tutorial.num_steps 75 | @litdiff.save!(tutorial) do |step_num| 76 | output "\r\e[K[Tutorial -> doc/] Step #{step_num}/#{num_steps}" 77 | end 78 | output "\n" 79 | 80 | @config.synced! 81 | end 82 | 83 | def litdiff_to_git! 84 | tutorial = @litdiff.load! do |step_num| 85 | output "\r\e[K[doc/ -> Tutorial] Step #{step_num}" 86 | end 87 | output "\n" 88 | 89 | num_steps = tutorial.num_steps 90 | @git.save!(tutorial) do |step_num| 91 | output "\r\e[K[Tutorial -> repo/] Step #{step_num}/#{num_steps}" 92 | end 93 | output "\n" 94 | 95 | @config.synced! 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/leg/commands/build.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Build < BaseCommand 4 | def self.name 5 | "build" 6 | end 7 | 8 | def self.summary 9 | "Render repo/ into an HTML or Markdown book." 10 | end 11 | 12 | def self.usage 13 | "[-q]" 14 | end 15 | 16 | def setopts!(o) 17 | o.on("-q", "--quiet", "Don't output progress") do |q| 18 | @opts[:quiet] = q 19 | end 20 | end 21 | 22 | def run 23 | needs! :config, :repo 24 | 25 | tutorial = @git.load!(full_diffs: true, diffs_ignore_whitespace: true) do |step_num| 26 | output "\r\e[K[repo/ -> Tutorial] Step #{step_num}" 27 | end 28 | output "\n" 29 | 30 | num_steps = tutorial.num_steps 31 | 32 | if @config.options[:diff_transformers].nil? 33 | @config.options[:diff_transformers] = [ 34 | { 'FoldSections' => { 35 | unfold_before_new_section: true, 36 | section_types: [ 37 | { name: 'comments', start: "^/\\*\\*\\*.+\\*\\*\\*/$", end: nil }, 38 | { name: 'braces', start: "^\\S.*{$", end: "^}( \\w+)?;?$" } 39 | ] 40 | }}, 41 | 'TrimBlankLines', 42 | 'OmitAdjacentRemovals' 43 | ] 44 | end 45 | 46 | if @config.options[:diff_transformers] 47 | transformers = @config.options[:diff_transformers].map do |transformer_config| 48 | if transformer_config.is_a? String 49 | transformer = transformer_config 50 | options = {} 51 | else 52 | transformer = transformer_config.keys.first 53 | options = transformer_config.values.first 54 | end 55 | Leg::DiffTransformers.const_get(transformer).new(options) 56 | end 57 | 58 | tutorial.transform_diffs(transformers) do |step_num| 59 | output "\r\e[K[Transform diffs] Step #{step_num}/#{num_steps}" 60 | end 61 | output "\n" 62 | end 63 | 64 | templates = Dir[File.join(@config.path, "template{,-?*}")].map do |template_dir| 65 | [template_dir, File.basename(template_dir).split("-")[1] || "html"] 66 | end 67 | if templates.empty? 68 | templates = [[nil, "html"], [nil, "md"]] 69 | end 70 | 71 | FileUtils.rm_rf(File.join(@config.path, "build")) 72 | templates.each do |template_dir, format| 73 | FileUtils.cd(@config.path) do 74 | FileUtils.mkdir_p("build/#{format}") 75 | 76 | include_default_css = (format == "html") 77 | page_template = Leg::DefaultTemplates::PAGE[format] 78 | if template_dir && File.exist?(File.join(template_dir, "page.#{format}.erb")) 79 | page_template = File.read(File.join(template_dir, "page.#{format}.erb")) 80 | include_default_css = false 81 | end 82 | page_template.gsub!(/\\\s*/, "") 83 | 84 | step_template = Leg::DefaultTemplates::STEP[format] 85 | if template_dir && File.exist?(File.join(template_dir, "step.#{format}.erb")) 86 | step_template = File.read(File.join(template_dir, "step.#{format}.erb")) 87 | end 88 | step_template.gsub!(/\\\s*/, "") 89 | 90 | tutorial.pages.each do |page| 91 | output "\r\e[K[Tutorial -> build/] Page #{page.filename}" 92 | 93 | content = Leg::Template.render_page(page_template, step_template, format, page, tutorial, @config) 94 | File.write("build/#{format}/#{page.filename}.#{format}", content) 95 | end 96 | output "\n" 97 | 98 | if template_dir 99 | FileUtils.cd(template_dir) do 100 | Dir["*"].each do |f| 101 | name = File.basename(f) 102 | 103 | next if ["page.#{format}.erb", "step.#{format}.erb"].include? name 104 | next if name.start_with? "_" 105 | 106 | # XXX: currently only processes top-level ERB template files. 107 | if name.end_with? ".erb" 108 | content = Leg::Template.render(File.read(f), tutorial, @config) 109 | File.write("../build/#{format}/#{name[0..-5]}", content) 110 | else 111 | FileUtils.cp_r(f, "../build/#{format}/#{name}") 112 | end 113 | end 114 | end 115 | end 116 | 117 | if include_default_css && !File.exist?("build/#{format}/style.css") 118 | content = Leg::Template.render(Leg::DefaultTemplates::CSS, tutorial, @config) 119 | File.write("build/#{format}/style.css", content) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/leg/commands/commit.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Commit < BaseCommand 4 | def self.name 5 | "commit" 6 | end 7 | 8 | def self.summary 9 | "Append or insert a new step." 10 | end 11 | 12 | def self.usage 13 | "[-s]" 14 | end 15 | 16 | def setopts!(o) 17 | o.on("-m", "--message MESSAGE", "Set the step summary to MESSAGE") do |m| 18 | @opts[:message] = m 19 | end 20 | o.on("-d", "--default-message", "Leave the step summary unchanged, or set it to a default if empty") do |d| 21 | @opts[:default_message] = d 22 | end 23 | o.on("-s", "--stay", "Don't resolve rest of steps yet") do |s| 24 | @opts[:stay] = s 25 | end 26 | end 27 | 28 | def run 29 | needs! :config, :repo 30 | 31 | commit_options = { 32 | no_rebase: @opts[:stay], 33 | message: @opts[:message], 34 | use_default_message: @opts[:default_message] 35 | } 36 | 37 | if @git.commit!(commit_options) 38 | unless @opts[:stay] 39 | git_to_litdiff! 40 | output "Success!\n" 41 | end 42 | else 43 | output "Looks like you've got a conflict to resolve!\n" 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/leg/commands/diff.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Diff < BaseCommand 4 | def self.name 5 | "diff" 6 | end 7 | 8 | def self.summary 9 | "Compare last step with changes made in step/." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | @git.copy_step_to_repo! 23 | FileUtils.cd(@git.repo_path) do 24 | system("git diff") 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/leg/commands/help.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Help < BaseCommand 4 | def self.name 5 | "help" 6 | end 7 | 8 | def self.summary 9 | "Print out list of commands, or get help\n" + 10 | "on a specific command." 11 | end 12 | 13 | def self.usage 14 | "[]" 15 | end 16 | 17 | def setopts!(o) 18 | end 19 | 20 | def run 21 | if @args.empty? 22 | puts "<< Hello! I am leg, version #{Leg::VERSION} >>" 23 | puts 24 | puts "Usage: leg [args...]" 25 | puts 26 | puts "Commands:" 27 | Leg::Commands::LIST.each do |cmd| 28 | puts " #{cmd.name} #{cmd.usage}" 29 | cmd.summary.split("\n").each do |line| 30 | puts " #{line}" 31 | end 32 | end 33 | puts 34 | puts "For more help on a specific command, run `leg help `." 35 | elsif cmd = Leg::Commands::LIST.find { |cmd| cmd.name == @args.first } 36 | cmd.new(["--help"], @config) 37 | else 38 | puts "There is no '#{@args.first}' command." 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/leg/commands/init.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Init < BaseCommand 4 | def self.name 5 | "init" 6 | end 7 | 8 | def self.summary 9 | "Initialize a new leg project." 10 | end 11 | 12 | def self.usage 13 | "[new-dir]" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | if @config 21 | puts "You are already in a leg working directory." 22 | return false 23 | end 24 | 25 | if new_dir = @args.first 26 | if File.exist?(new_dir) 27 | puts "Error: directory already exists." 28 | return false 29 | end 30 | FileUtils.mkdir(new_dir) 31 | FileUtils.cd(new_dir) 32 | end 33 | 34 | FileUtils.mkdir_p(".leg/repo") 35 | FileUtils.mkdir_p("step") 36 | FileUtils.mkdir_p("doc") 37 | File.write("doc/tutorial.litdiff", "") 38 | File.write("leg.yml", "---\n") 39 | 40 | config = Leg::Config.new(FileUtils.pwd) 41 | git = Leg::Representations::Git.new(config) 42 | git.init! 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/leg/commands/reset.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Reset < BaseCommand 4 | def self.name 5 | "reset" 6 | end 7 | 8 | def self.summary 9 | "Abort any saves in progress and checkout the top-most step." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | @git.reset! 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/leg/commands/resolve.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Resolve < BaseCommand 4 | def self.name 5 | "resolve" 6 | end 7 | 8 | def self.summary 9 | "Continue rewriting steps after resolving a merge conflict." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | if @git.resolve! 23 | git_to_litdiff! 24 | output "Success!\n" 25 | else 26 | output "Looks like you've got a conflict to resolve!\n" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leg/commands/save.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Save < BaseCommand 4 | def self.name 5 | "save" 6 | end 7 | 8 | def self.summary 9 | "Save changes to doc/." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | if @git.rebase_remaining! 23 | git_to_litdiff! 24 | output "Success!\n" 25 | else 26 | output "Looks like you've got a conflict to resolve!\n" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leg/commands/status.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Status < BaseCommand 4 | def self.name 5 | "status" 6 | end 7 | 8 | def self.summary 9 | "Show unsaved changes and the state of the step/ folder." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | state = @git.state 23 | case state.operation 24 | when nil 25 | if state.step_number.nil? 26 | output "Nothing to report.\n" 27 | else 28 | output "Step #{state.step_number} checked out into step/.\n" 29 | end 30 | when :commit 31 | if state.args[1] 32 | output "Amended step #{state.step_number}. " 33 | end 34 | if state.args[0] > 0 35 | output "Added #{state.args[0]} step#{'s' if state.args[0] != 1} after step #{state.step_number}." 36 | end 37 | output "\n" 38 | else 39 | raise "unknown operation" 40 | end 41 | 42 | if state.conflict 43 | output "\n" 44 | output "Currently in a merge conflict. Resolve the conflict in step/ and\n" 45 | output "run `leg resolve` to continue.\n" 46 | elsif !state.operation.nil? 47 | output "\n" 48 | output "The above change(s) have not been saved yet. Run `leg save` to\n" 49 | output "save to the doc/ folder.\n" 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/leg/commands/step.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Commands 3 | class Step < BaseCommand 4 | def self.name 5 | "step" 6 | end 7 | 8 | def self.summary 9 | "Select a step for editing." 10 | end 11 | 12 | def self.usage 13 | "" 14 | end 15 | 16 | def setopts!(o) 17 | end 18 | 19 | def run 20 | needs! :config, :repo 21 | 22 | step_number = @args.first.to_i 23 | 24 | unless @git.checkout!(@args.first.to_i) 25 | output "Error: Step not found.\n" 26 | return false 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/leg/config.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Config 3 | attr_reader :path, :options 4 | 5 | def initialize(path) 6 | @path = path 7 | end 8 | 9 | def load! 10 | @options = YAML.load_file(File.join(@path, "leg.yml")) 11 | @options = {} unless @options.is_a? Hash 12 | @options = symbolize_keys(@options) 13 | end 14 | 15 | def last_synced_at 16 | File.mtime(last_synced_path) if File.exist?(last_synced_path) 17 | end 18 | 19 | def synced! 20 | FileUtils.touch(last_synced_path) 21 | end 22 | 23 | private 24 | 25 | def last_synced_path 26 | File.join(@path, ".leg/last_synced") 27 | end 28 | 29 | def symbolize_keys(value) 30 | case value 31 | when Hash 32 | value.map do |k, v| 33 | [k.to_sym, symbolize_keys(v)] 34 | end.to_h 35 | when Array 36 | value.map { |v| symbolize_keys(v) } 37 | else 38 | value 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/leg/default_templates.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DefaultTemplates 3 | PAGE = {} 4 | STEP = {} 5 | 6 | PAGE["html"] = <<~TEMPLATE 7 | 8 | 9 | 10 | 11 | 12 | <%= page_number %>. <%= page_title %> 13 | 14 | 15 | 16 |
17 | 32 |
33 |
34 | <%= content %> 35 |
36 | 37 | 38 | TEMPLATE 39 | 40 | STEP["html"] = <<~TEMPLATE 41 |
42 |
43 | <%= number %> 44 |
45 | 46 | <% for diff in diffs %> 47 | <% diff = Leg::DiffTransformers::SyntaxHighlight.new.transform(diff) %> 48 |
49 |
50 |
51 | <%= markdown(summary) %> 52 |
53 |
54 | <%= diff.filename %> 55 |
56 |
57 |
58 | 59 | <% for line in diff.lines %> 60 | 61 | 64 | 71 | 72 | <% end %> 73 |
62 | <%= line.line_number %> 63 | \\ 65 | <% if line.type == :folded %>\\ 66 | <%= line.source.gsub('', '…') %>\\ 67 | <% else %>\\ 68 | <%= line.source %>\\ 69 | <% end %>\\ 70 |
74 |
75 |
76 | <% end %> 77 |
78 | TEMPLATE 79 | 80 | PAGE["md"] = <<~TEMPLATE 81 | <%= content %> 82 | TEMPLATE 83 | 84 | STEP["md"] = <<~TEMPLATE 85 | ## <%= number %>. <%= summary %> 86 | 87 | <% for diff in diffs %>\\ 88 | ```diff 89 | // <%= diff.filename %> 90 | <% for line in diff.lines %>\\ 91 | <%= { added: '+', removed: '-', unchanged: ' ', folded: '@' }[line.type] + line.source %> 92 | <% end %>\\ 93 | ``` 94 | <% end %> 95 | TEMPLATE 96 | 97 | CSS = <<~TEMPLATE 98 | * { 99 | margin: 0; 100 | padding: 0; 101 | box-sizing: border-box; 102 | } 103 | 104 | body { 105 | font-family: Utopia, Georgia, Times, 'Apple Symbols', serif; 106 | line-height: 140%; 107 | color: #333; 108 | font-size: 18px; 109 | } 110 | 111 | #container { 112 | width: 750px; 113 | margin: 18px auto; 114 | } 115 | 116 | .bar { 117 | display: block; 118 | width: 100%; 119 | background-color: #ceb; 120 | box-shadow: 0px 0px 15px 1px #ddd; 121 | } 122 | 123 | .bar > nav { 124 | display: flex; 125 | justify-content: space-between; 126 | width: 700px; 127 | margin: 0 auto; 128 | } 129 | 130 | footer.bar > nav { 131 | justify-content: center; 132 | } 133 | 134 | .bar > nav > a { 135 | display: block; 136 | padding: 2px 0 4px 0; 137 | color: #152; 138 | } 139 | 140 | h1, h2, h3, h4, h5, h6 { 141 | font-family: Futura, Helvetica, Arial, sans-serif; 142 | color: #222; 143 | line-height: 100%; 144 | margin-top: 32px; 145 | } 146 | 147 | h2 a, h3 a, h4 a { 148 | color: inherit; 149 | text-decoration: none; 150 | } 151 | 152 | h2 a::before, h3 a::before, h4 a::before { 153 | content: '#'; 154 | color: #fff; 155 | font-weight: normal; 156 | transition: color 0.15s ease; 157 | display: block; 158 | float: left; 159 | width: 32px; 160 | margin-left: -32px; 161 | } 162 | 163 | h2 a:hover::before, h3 a:hover::before, h4 a:hover::before { 164 | color: #ccc; 165 | } 166 | 167 | h1 { 168 | margin-top: 0; 169 | font-size: 38px; 170 | border-bottom: 3px solid #e7c; 171 | display: inline-block; 172 | } 173 | 174 | h2 { 175 | font-size: 26px; 176 | } 177 | 178 | p { 179 | margin-top: 18px; 180 | } 181 | 182 | ul, ol { 183 | margin-top: 18px; 184 | margin-left: 36px; 185 | } 186 | 187 | hr { 188 | border: none; 189 | border-bottom: 1px solid #888; 190 | } 191 | 192 | a { 193 | color: #26d; 194 | } 195 | 196 | code { 197 | font-family: monospace; 198 | font-size: inherit; 199 | white-space: nowrap; 200 | background-color: #eff4ea; 201 | padding: 1px 3px; 202 | } 203 | 204 | h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { 205 | font-weight: normal; 206 | } 207 | 208 | kbd { 209 | font-family: monospace; 210 | border-radius: 3px; 211 | padding: 2px 3px; 212 | box-shadow: 1px 1px 1px #777; 213 | margin: 2px; 214 | font-size: 14px; 215 | background: #f7f7f7; 216 | font-weight: 500; 217 | color: #555; 218 | white-space: nowrap; 219 | } 220 | 221 | h1 kbd, h2 kbd, h3 kbd, h4 kbd, h5 kbd, h6 kbd { 222 | font-size: 80%; 223 | } 224 | 225 | .step { 226 | margin-top: 18px; 227 | } 228 | 229 | .step-number { 230 | position: absolute; 231 | margin-top: -6px; 232 | margin-left: -148px; 233 | font-size: 48px; 234 | font-family: Helvetica, sans-serif; 235 | line-height: 130%; 236 | width: 128px; 237 | text-align: right; 238 | } 239 | 240 | .diff { 241 | border: 1px solid #ede7e3; 242 | border-radius: 3px; 243 | } 244 | 245 | .diff .diff-header { 246 | display: flex; 247 | justify-content: space-between; 248 | align-items: center; 249 | padding: 7px 10px; 250 | background-color: #fafbfc; 251 | border-bottom: 1px solid #ede7e3; 252 | font-size: 16px; 253 | } 254 | 255 | .diff .diff-summary { 256 | color: #666; 257 | font-size: 18px; 258 | } 259 | 260 | .diff .diff-summary p { 261 | margin-top: 0; 262 | } 263 | 264 | .diff .diff-filename { 265 | color: #666; 266 | font-weight: bold; 267 | } 268 | 269 | .diff table { 270 | width: 100%; 271 | border-spacing: 0; 272 | border-collapse: collapse; 273 | } 274 | 275 | .diff tr { 276 | height: 20px; 277 | line-height: 20px; 278 | padding: 0 5px; 279 | background-color: #fff; 280 | font-family: monospace; 281 | font-size: 14px; 282 | } 283 | 284 | .diff td.line-number { 285 | width: 1%; 286 | min-width: 55px; 287 | text-align: right; 288 | padding-right: 15px; 289 | background-color: #fafbfc; 290 | color: #ccc; 291 | } 292 | 293 | .diff td.line { 294 | white-space: pre; 295 | position: relative; 296 | background-color: inherit; 297 | padding-left: 5px; 298 | } 299 | 300 | .diff td.line.folded { 301 | background-color: #eef; 302 | opacity: 0.5; 303 | } 304 | 305 | .diff td.line.added { 306 | background-color: #ffd; 307 | text-decoration: none; 308 | } 309 | 310 | .diff td.line.removed { 311 | background-color: #fdd; 312 | text-decoration: line-through; 313 | } 314 | 315 | @media screen and (max-width: 700px) { 316 | #container { 317 | width: auto; 318 | margin: 18px 0; 319 | padding: 0 5px; 320 | } 321 | 322 | .bar > nav { 323 | width: auto; 324 | margin: 0; 325 | padding: 0 5px; 326 | } 327 | 328 | .diff .diff-code { 329 | overflow-x: scroll; 330 | } 331 | 332 | .diff .table { 333 | width: 700px; 334 | } 335 | } 336 | 337 | <%= syntax_highlighting_css ".line" %> 338 | TEMPLATE 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /lib/leg/diff.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Diff 3 | attr_accessor :filename, :is_new_file, :lines 4 | 5 | def initialize(filename = nil, is_new_file = false, lines = []) 6 | @filename = filename 7 | @is_new_file = is_new_file 8 | @lines = lines 9 | end 10 | 11 | def clone 12 | Leg::Diff.new(@filename.dup, @is_new_file, @lines.map(&:clone)) 13 | end 14 | 15 | def clone_empty 16 | Leg::Diff.new(@filename.dup, @is_new_file, []) 17 | end 18 | 19 | # Append a Line to the Diff. 20 | def <<(line) 21 | unless line.is_a? Leg::Line 22 | raise ArgumentError, "expected a Line" 23 | end 24 | @lines << line 25 | self 26 | end 27 | 28 | def to_patch(options = {}) 29 | patch = "" 30 | patch += "diff --git a/#{@filename} b/#{@filename}\n" unless options[:strip_git_lines] 31 | if @is_new_file 32 | patch += "new file mode 100644\n" unless options[:strip_git_lines] 33 | patch += "--- /dev/null\n" 34 | else 35 | patch += "--- #{'a/' unless options[:strip_git_lines]}#{@filename}\n" 36 | end 37 | patch += "+++ #{'b/' unless options[:strip_git_lines]}#{@filename}\n" 38 | 39 | find_hunks.each do |hunk| 40 | patch += hunk_header(hunk) 41 | hunk.each do |line| 42 | patch += line.to_patch(options) 43 | end 44 | end 45 | 46 | patch 47 | end 48 | 49 | # Parse a git diff and return an array of Diff objects, one for each file in 50 | # the git diff. 51 | def self.parse(git_diff) 52 | in_diff = false 53 | old_line_num = nil 54 | new_line_num = nil 55 | cur_diff = nil 56 | diffs = [] 57 | 58 | git_diff.lines.each do |line| 59 | if line =~ /^--- (.+)$/ 60 | cur_diff = Leg::Diff.new 61 | if $1 == '/dev/null' 62 | cur_diff.is_new_file = true 63 | end 64 | diffs << cur_diff 65 | in_diff = false 66 | elsif line =~ /^\+\+\+ (.+)$/ 67 | cur_diff.filename = $1.strip.sub(/^b\//, '') 68 | elsif line =~ /^@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@/ 69 | # TODO: somehow preserve function name that comes to the right of the @@ header? 70 | in_diff = true 71 | old_line_num = $1.to_i 72 | new_line_num = $3.to_i 73 | elsif in_diff && line[0] == '\\' 74 | # Ignore "\ No newline at end of file". 75 | elsif in_diff && [' ', '|', '+', '-'].include?(line[0]) 76 | case line[0] 77 | when ' ', '|' 78 | line_nums = [old_line_num, new_line_num] 79 | old_line_num += 1 80 | new_line_num += 1 81 | cur_diff << Leg::Line::Unchanged.new(line[1..-1], line_nums) 82 | when '+' 83 | line_nums = [nil, new_line_num] 84 | new_line_num += 1 85 | cur_diff << Leg::Line::Added.new(line[1..-1], line_nums) 86 | when '-' 87 | line_nums = [old_line_num, nil] 88 | old_line_num += 1 89 | cur_diff << Leg::Line::Removed.new(line[1..-1], line_nums) 90 | end 91 | else 92 | in_diff = false 93 | end 94 | end 95 | 96 | diffs 97 | end 98 | 99 | private 100 | 101 | # :S 102 | def hunk_header(hunk) 103 | old_line, new_line = hunk.first.line_numbers 104 | old_line ||= 1 105 | new_line ||= 1 106 | 107 | old_count = hunk.count { |line| [Leg::Line::Removed, Leg::Line::Unchanged].include? line.class } 108 | new_count = hunk.count { |line| [Leg::Line::Added, Leg::Line::Unchanged].include? line.class } 109 | 110 | old_line = 0 if old_count == 0 111 | new_line = 0 if new_count == 0 112 | 113 | "@@ -#{old_line},#{old_count} +#{new_line},#{new_count} @@\n" 114 | end 115 | 116 | # :( 117 | def find_hunks 118 | raise "can't create patch from empty diff" if @lines.empty? 119 | hunks = [] 120 | cur_hunk = [@lines.first] 121 | cur_line_nums = @lines.first.line_numbers.dup 122 | @lines[1..-1].each do |line| 123 | case line 124 | when Leg::Line::Unchanged 125 | cur_line_nums[0] = cur_line_nums[0].nil? ? line.line_numbers[0] : (cur_line_nums[0] + 1) 126 | cur_line_nums[1] = cur_line_nums[1].nil? ? line.line_numbers[1] : (cur_line_nums[1] + 1) 127 | when Leg::Line::Added 128 | cur_line_nums[1] = cur_line_nums[1].nil? ? line.line_numbers[1] : (cur_line_nums[1] + 1) 129 | when Leg::Line::Removed 130 | cur_line_nums[0] = cur_line_nums[0].nil? ? line.line_numbers[0] : (cur_line_nums[0] + 1) 131 | when Leg::Line::Folded 132 | raise "can't create patch from diff with folded lines" 133 | end 134 | 135 | old_match = (line.line_numbers[0].nil? || line.line_numbers[0] == cur_line_nums[0]) 136 | new_match = (line.line_numbers[1].nil? || line.line_numbers[1] == cur_line_nums[1]) 137 | 138 | if !old_match || !new_match 139 | hunks << cur_hunk 140 | 141 | cur_hunk = [] 142 | cur_line_nums = line.line_numbers.dup 143 | end 144 | 145 | cur_hunk << line 146 | end 147 | hunks << cur_hunk 148 | hunks 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers.rb: -------------------------------------------------------------------------------- 1 | require 'leg/diff_transformers/base_transformer' 2 | 3 | require 'leg/diff_transformers/fold_sections' 4 | require 'leg/diff_transformers/omit_adjacent_removals' 5 | require 'leg/diff_transformers/syntax_highlight' 6 | require 'leg/diff_transformers/trim_blank_lines' 7 | 8 | module Leg 9 | module DiffTransformers 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers/base_transformer.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DiffTransformers 3 | class BaseTransformer 4 | def initialize(options = {}) 5 | @options = options 6 | end 7 | 8 | def transform(diff) 9 | raise NotImplementedError 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers/fold_sections.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DiffTransformers 3 | class FoldSections < BaseTransformer 4 | def transform(diff) 5 | sections = @options[:section_types].map { [] } 6 | 7 | cur_sections = @options[:section_types].map { nil } 8 | diff.lines.each.with_index do |line, idx| 9 | @options[:section_types].each.with_index do |section_type, level| 10 | if line.source =~ Regexp.new(section_type[:start]) 11 | if !section_type[:end] && cur_sections[level] 12 | cur_sections[level].end_line = idx - 1 13 | if @options[:unfold_before_new_section] 14 | cur_sections[level].dirty! if [:added, :removed].include? line.type 15 | end 16 | sections[level] << cur_sections[level] 17 | end 18 | 19 | cur_sections[level] = Section.new(level, idx) 20 | 21 | if [:added, :removed].include? line.type 22 | cur_sections[level].dirty! 23 | end 24 | elsif section_type[:end] && line.source =~ Regexp.new(section_type[:end]) 25 | if [:added, :removed].include? line.type 26 | cur_sections[level].dirty! 27 | end 28 | 29 | cur_sections[level].end_line = idx 30 | sections[level] << cur_sections[level] 31 | cur_sections[level] = nil 32 | elsif cur_sections[level] 33 | if [:added, :removed].include? line.type 34 | cur_sections[level].dirty! 35 | end 36 | end 37 | end 38 | end 39 | cur_sections.each.with_index do |section, level| 40 | unless section.nil? 41 | section.end_line = diff.lines.length - 1 42 | sections[level] << section 43 | end 44 | end 45 | 46 | new_diff = diff.clone 47 | sections.each.with_index do |level_sections, level| 48 | level_sections.each do |section| 49 | if !section.dirty? && !new_diff.lines[section.to_range].any?(&:nil?) 50 | start_line = new_diff.lines[section.start_line] 51 | end_line = new_diff.lines[section.end_line] 52 | 53 | summary_lines = [start_line] 54 | summary_lines << end_line if @options[:section_types][level][:end] 55 | summary = summary_lines.map(&:source).join(" … ") 56 | 57 | line_numbers = [start_line.line_number, end_line.line_number] 58 | 59 | folded_line = Leg::Line::Folded.new(summary, line_numbers) 60 | 61 | section.to_range.each do |idx| 62 | new_diff.lines[idx] = nil 63 | end 64 | 65 | new_diff.lines[section.start_line] = folded_line 66 | end 67 | end 68 | end 69 | new_diff.lines.compact! 70 | new_diff 71 | end 72 | 73 | class Section 74 | attr_accessor :level, :start_line, :end_line, :dirty 75 | 76 | def initialize(level, start_line, end_line = nil, dirty = false) 77 | @level, @start_line, @end_line, @dirty = level, start_line, end_line, dirty 78 | end 79 | 80 | def to_range 81 | start_line..end_line 82 | end 83 | 84 | def dirty?; @dirty; end 85 | def dirty!; @dirty = true; end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers/omit_adjacent_removals.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DiffTransformers 3 | class OmitAdjacentRemovals < BaseTransformer 4 | def transform(diff) 5 | new_diff = diff.clone 6 | 7 | removed_lines = [] 8 | saw_added_line = false 9 | new_diff.lines.each.with_index do |line, idx| 10 | case line.type 11 | when :unchanged, :folded 12 | if saw_added_line 13 | removed_lines.each do |removed_idx| 14 | new_diff.lines[removed_idx] = nil 15 | end 16 | end 17 | 18 | removed_lines = [] 19 | saw_added_line = false 20 | when :added 21 | saw_added_line = true 22 | when :removed 23 | removed_lines << idx 24 | end 25 | end 26 | 27 | if saw_added_line 28 | removed_lines.each do |removed_idx| 29 | new_diff.lines[removed_idx] = nil 30 | end 31 | end 32 | 33 | new_diff.lines.compact! 34 | new_diff 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers/syntax_highlight.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DiffTransformers 3 | class SyntaxHighlight < BaseTransformer 4 | class HTMLLineByLine < Rouge::Formatter 5 | def initialize(formatter) 6 | @formatter = formatter 7 | end 8 | 9 | def stream(tokens, &b) 10 | token_lines(tokens) do |line| 11 | line.each do |tok, val| 12 | yield @formatter.span(tok, val) 13 | end 14 | yield "\n" 15 | end 16 | end 17 | end 18 | 19 | SYNTAX_HIGHLIGHTER = HTMLLineByLine.new(Rouge::Formatters::HTML.new) 20 | 21 | def transform(diff) 22 | new_diff = diff.clone 23 | code = new_diff.lines.map(&:source).join("\n") + "\n" 24 | lexer = Rouge::Lexer.guess(filename: new_diff.filename, source: code) 25 | SYNTAX_HIGHLIGHTER.format(lexer.lex(code)).lines.each.with_index do |line_hl, idx| 26 | new_diff.lines[idx].source = line_hl 27 | end 28 | new_diff 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/leg/diff_transformers/trim_blank_lines.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module DiffTransformers 3 | class TrimBlankLines < BaseTransformer 4 | def transform(diff) 5 | new_diff = diff.clone_empty 6 | diff.lines.each.with_index do |line, idx| 7 | line = line.clone 8 | if line.blank? && [:added, :removed].include?(line.type) 9 | prev_line = idx > 0 ? diff.lines[idx - 1] : nil 10 | next_line = idx < diff.lines.length - 1 ? diff.lines[idx + 1] : nil 11 | 12 | prev_changed = prev_line && [:added, :removed].include?(prev_line.type) 13 | next_changed = next_line && [:added, :removed].include?(next_line.type) 14 | 15 | if !prev_changed || !next_changed 16 | line = Leg::Line::Unchanged.new(line.source, line.line_numbers) 17 | end 18 | end 19 | new_diff.lines << line 20 | end 21 | new_diff 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/leg/line.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Line 3 | attr_accessor :source, :line_numbers 4 | 5 | def initialize(source, line_numbers) 6 | @source = source.chomp 7 | @line_numbers = line_numbers 8 | end 9 | 10 | def clone 11 | self.class.new(@source.dup, @line_numbers.dup) 12 | end 13 | 14 | def blank? 15 | @source.strip.empty? 16 | end 17 | 18 | def line_number 19 | raise NotImplementedError 20 | end 21 | 22 | def to_patch(options = {}) 23 | raise NotImplementedError 24 | end 25 | 26 | class Added < Line 27 | def type 28 | :added 29 | end 30 | 31 | def line_number 32 | @line_numbers[1] 33 | end 34 | 35 | def to_patch(options = {}) 36 | "+#{@source}\n" 37 | end 38 | end 39 | 40 | class Removed < Line 41 | def type 42 | :removed 43 | end 44 | 45 | def line_number 46 | @line_numbers[0] 47 | end 48 | 49 | def to_patch(options = {}) 50 | "-#{@source}\n" 51 | end 52 | end 53 | 54 | class Unchanged < Line 55 | def type 56 | :unchanged 57 | end 58 | 59 | def line_number 60 | @line_numbers[1] 61 | end 62 | 63 | def to_patch(options = {}) 64 | char = options[:unchanged_char] || " " 65 | "#{char}#{@source}\n" 66 | end 67 | end 68 | 69 | class Folded < Line 70 | def type 71 | :folded 72 | end 73 | 74 | def line_number 75 | @line_numbers[0] 76 | end 77 | 78 | def to_patch(options = {}) 79 | raise "can't convert folded line to patch" 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/leg/markdown.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Markdown 3 | class HTMLRouge < Redcarpet::Render::HTML 4 | include Rouge::Plugins::Redcarpet 5 | end 6 | 7 | HTML_RENDERER = HTMLRouge.new(with_toc_data: true) 8 | MARKDOWN_RENDERER = Redcarpet::Markdown.new(HTML_RENDERER, fenced_code_blocks: true) 9 | 10 | def self.render(source) 11 | html = MARKDOWN_RENDERER.render(source) 12 | html = Redcarpet::Render::SmartyPants.render(html) 13 | html.gsub!(/<\/code>‘/) { "’" } 14 | html.gsub!(/^\s*(.+)<\/h\d>$/) { 15 | "#{$3}" 16 | } 17 | html 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/leg/page.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Page 3 | attr_accessor :filename, :steps, :footer_text 4 | 5 | def initialize(filename = "tutorial") 6 | @filename = filename 7 | @steps = [] 8 | @footer_text = nil 9 | end 10 | 11 | def <<(step) 12 | @steps << step 13 | self 14 | end 15 | 16 | def empty? 17 | @steps.empty? 18 | end 19 | 20 | def title 21 | first_line = @steps.first ? @steps.first.text.lines.first : (@footer_text ? @footer_text.lines.first : nil) 22 | if first_line && first_line.start_with?("# ") 23 | first_line[2..-1].strip 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/leg/representations.rb: -------------------------------------------------------------------------------- 1 | require 'leg/representations/base_representation' 2 | 3 | require 'leg/representations/git' 4 | require 'leg/representations/litdiff' 5 | 6 | module Leg 7 | module Representations 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/leg/representations/base_representation.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Representations 3 | class BaseRepresentation 4 | def initialize(config) 5 | @config = config 6 | end 7 | 8 | # Should save tutorial to disk. 9 | def save!(tutorial, options = {}) 10 | raise NotImplementedError 11 | end 12 | 13 | # Should load tutorial from disk, and return it. 14 | def load!(options = {}) 15 | raise NotImplementedError 16 | end 17 | 18 | # Returns true if this representation has been modified by the user since the 19 | # last sync. 20 | def modified? 21 | synced_at = @config.last_synced_at 22 | repr_modified_at = modified_at 23 | return false if synced_at.nil? or repr_modified_at.nil? 24 | 25 | repr_modified_at > synced_at 26 | end 27 | 28 | # Returns true if this representation currently exists on disk. 29 | def exists? 30 | !modified_at.nil? 31 | end 32 | 33 | private 34 | 35 | # Should return the Time the representation on disk was last modified, or nil 36 | # if the representation doesn't exist. 37 | def modified_at 38 | raise NotImplementedError 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/leg/representations/git.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Representations 3 | class Git < BaseRepresentation 4 | def save!(tutorial, options = {}) 5 | FileUtils.rm_rf(repo_path) 6 | FileUtils.mkdir_p(repo_path) 7 | 8 | FileUtils.cd(repo_path) do 9 | repo = Rugged::Repository.init_at(".") 10 | 11 | step_num = 1 12 | tutorial.pages.each do |page| 13 | message = "~~~ #{page.filename}" 14 | message << "\n\n#{page.footer_text}" if page.footer_text 15 | add_commit(repo, nil, message, step_num) 16 | page.steps.each do |step| 17 | message = "#{step.summary}\n\n#{step.text}".strip 18 | add_commit(repo, step.to_patch, message, step_num) 19 | 20 | yield step_num if block_given? 21 | step_num += 1 22 | end 23 | end 24 | 25 | #if options[:extra_path] 26 | # FileUtils.cp_r(File.join(options[:extra_path], "."), ".") 27 | # add_commit(repo, nil, "-", step_num, counter) 28 | #end 29 | 30 | repo.checkout_head(strategy: :force) if repo.branches["master"] 31 | end 32 | end 33 | 34 | # Options: 35 | # full_diffs: If true, diffs contain the entire file in one hunk instead of 36 | # multiple contextual hunks. 37 | # diffs_ignore_whitespace: If true, diffs don't show changes to lines when 38 | # only the amount of whitespace is changed. 39 | def load!(options = {}) 40 | git_diff_options = {} 41 | git_diff_options[:context_lines] = 100_000 if options[:full_diffs] 42 | git_diff_options[:ignore_whitespace_change] = true if options[:diffs_ignore_whitespace] 43 | 44 | page = nil 45 | tutorial = Leg::Tutorial.new(@config) 46 | each_step(git_diff_options) do |step_num, commit, summary, text, patches| 47 | if patches.empty? 48 | if summary =~ /^~~~ (.+)$/ 49 | tutorial << page unless page.nil? 50 | 51 | page = Leg::Page.new($1) 52 | page.footer_text = text unless text.empty? 53 | else 54 | puts "Warning: ignoring empty commit." 55 | end 56 | else 57 | patch = patches.map(&:to_s).join("\n") 58 | step_diffs = Leg::Diff.parse(patch) 59 | 60 | page ||= Leg::Page.new 61 | page << Leg::Step.new(step_num, summary, text, step_diffs) 62 | 63 | yield step_num if block_given? 64 | end 65 | end 66 | tutorial << page unless page.nil? 67 | tutorial 68 | end 69 | 70 | def copy_repo_to_step! 71 | FileUtils.mkdir_p(step_path) 72 | FileUtils.rm_rf(File.join(step_path, "."), secure: true) 73 | FileUtils.cd(repo_path) do 74 | files = Dir.glob("*", File::FNM_DOTMATCH) - [".", "..", ".git"] 75 | files.each do |f| 76 | FileUtils.cp_r(f, File.join(step_path, f)) 77 | end 78 | end 79 | end 80 | 81 | def copy_step_to_repo! 82 | FileUtils.mv( 83 | File.join(repo_path, ".git"), 84 | File.join(repo_path, "../.gittemp") 85 | ) 86 | FileUtils.rm_rf(File.join(repo_path, "."), secure: true) 87 | FileUtils.mv( 88 | File.join(repo_path, "../.gittemp"), 89 | File.join(repo_path, ".git") 90 | ) 91 | FileUtils.cd(step_path) do 92 | files = Dir.glob("*", File::FNM_DOTMATCH) - [".", ".."] 93 | files.each do |f| 94 | FileUtils.cp_r(f, File.join(repo_path, f)) 95 | end 96 | end 97 | end 98 | 99 | def repo_path 100 | File.join(@config.path, ".leg/repo") 101 | end 102 | 103 | def repo 104 | @repo ||= Rugged::Repository.new(repo_path) 105 | end 106 | 107 | def each_commit(options = {}) 108 | walker = Rugged::Walker.new(repo) 109 | walker.sorting(Rugged::SORT_TOPO | Rugged::SORT_REVERSE) 110 | 111 | master_commit = repo.branches["master"].target 112 | walker.push(master_commit) 113 | 114 | return [] if master_commit.oid == options[:after] 115 | walker.hide(options[:after]) if options[:after] 116 | 117 | return walker.to_a if not block_given? 118 | 119 | walker.each do |commit| 120 | yield commit 121 | end 122 | end 123 | 124 | alias_method :commits, :each_commit 125 | 126 | def each_step(git_diff_options = {}) 127 | empty_tree = Rugged::Tree.empty(repo) 128 | step_num = 1 129 | each_commit do |commit| 130 | commit_message = commit.message.strip 131 | summary = commit_message.lines.first.strip 132 | text = (commit_message.lines[2..-1] || []).join.strip 133 | next if commit_message == "-" 134 | commit_message = "" if commit_message == "~" 135 | last_commit = commit.parents.first 136 | diff = (last_commit || empty_tree).diff(commit, git_diff_options) 137 | patches = diff.each_patch.to_a 138 | 139 | if patches.empty? 140 | yield nil, commit, summary, text, patches 141 | else 142 | yield step_num, commit, summary, text, patches 143 | step_num += 1 144 | end 145 | end 146 | end 147 | 148 | def init! 149 | FileUtils.mkdir_p(repo_path) 150 | FileUtils.cd(repo_path) { `git init` } 151 | end 152 | 153 | def checkout!(step_number) 154 | each_step do |cur_step, commit| 155 | if cur_step == step_number 156 | FileUtils.cd(repo_path) { `git checkout #{commit.oid} 2>/dev/null` } 157 | save_state(load_state.step!(step_number)) 158 | copy_repo_to_step! 159 | return true 160 | end 161 | end 162 | end 163 | 164 | def commit!(options = {}) 165 | copy_step_to_repo! 166 | remaining_commits = repo.branches["master"] ? commits(after: repo.head.target.oid).map(&:oid) : [] 167 | FileUtils.cd(repo_path) do 168 | `git add -A` 169 | 170 | cmd = ["git", "commit", "-q"] 171 | cmd << "--amend" if options[:amend] 172 | cmd << "-m" << options[:message] if options[:message] 173 | cmd << "--no-edit" if options[:use_default_message] && options[:amend] 174 | cmd << "-m" << "Untitled step" if options[:use_default_message] && !options[:amend] 175 | system(*cmd) 176 | end 177 | if options[:amend] 178 | save_state(load_state.amend!) 179 | else 180 | save_state(load_state.add_commit!) 181 | end 182 | if options[:no_rebase] 183 | save_remaining_commits(remaining_commits) 184 | true 185 | else 186 | rebase!(remaining_commits) 187 | end 188 | end 189 | 190 | def resolve! 191 | copy_step_to_repo! 192 | FileUtils.cd(repo_path) do 193 | `git add -A` 194 | `git -c core.editor=true cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits --continue 2>/dev/null` 195 | end 196 | rebase!(load_remaining_commits) 197 | end 198 | 199 | def rebase_remaining! 200 | rebase!(load_remaining_commits) 201 | end 202 | 203 | def rebase!(remaining_commits) 204 | FileUtils.cd(repo_path) do 205 | remaining_commits.each.with_index do |commit, commit_idx| 206 | `git cherry-pick --allow-empty --allow-empty-message --keep-redundant-commits #{commit} 2>/dev/null` 207 | 208 | if not $?.success? 209 | copy_repo_to_step! 210 | save_remaining_commits(remaining_commits[(commit_idx+1)..-1]) 211 | save_state(load_state.conflict!) 212 | return false 213 | end 214 | end 215 | end 216 | 217 | save_remaining_commits(nil) 218 | save_state(nil) 219 | 220 | repo.references.update(repo.branches["master"], repo.head.target_id) 221 | repo.head = "refs/heads/master" 222 | 223 | copy_repo_to_step! 224 | 225 | true 226 | end 227 | 228 | def reset! 229 | save_state(nil) 230 | save_remaining_commits(nil) 231 | FileUtils.cd(repo_path) do 232 | `git cherry-pick --abort 2>/dev/null` 233 | end 234 | repo.head = "refs/heads/master" 235 | repo.checkout_head(strategy: :force) 236 | copy_repo_to_step! 237 | end 238 | 239 | def state 240 | load_state 241 | end 242 | 243 | private 244 | 245 | def step_path 246 | File.join(@config.path, "step") 247 | end 248 | 249 | def remaining_commits_path 250 | File.join(@config.path, ".leg/remaining_commits") 251 | end 252 | 253 | def modified_at 254 | if File.exist? repo_path 255 | repo = Rugged::Repository.new(repo_path) 256 | if master = repo.branches["master"] 257 | master.target.time 258 | end 259 | end 260 | end 261 | 262 | def state_path 263 | File.join(@config.path, ".leg/state.yml") 264 | end 265 | 266 | def load_state 267 | @state ||= 268 | if File.exist?(state_path) 269 | YAML.load_file(state_path) 270 | else 271 | State.new 272 | end 273 | end 274 | 275 | def save_state(state) 276 | @state = state 277 | if state.nil? 278 | FileUtils.rm_f(state_path) 279 | else 280 | File.write(state_path, state.to_yaml) 281 | end 282 | end 283 | 284 | def load_remaining_commits 285 | if File.exist?(remaining_commits_path) 286 | File.readlines(remaining_commits_path).map(&:strip).reject(&:empty?) 287 | else 288 | [] 289 | end 290 | end 291 | 292 | def save_remaining_commits(remaining_commits) 293 | if remaining_commits && !remaining_commits.empty? 294 | File.write(remaining_commits_path, remaining_commits.join("\n")) 295 | else 296 | FileUtils.rm_f(remaining_commits_path) 297 | end 298 | end 299 | 300 | def add_commit(repo, diff, message, step_num) 301 | message ||= "~" 302 | message.strip! 303 | message = "~" if message.empty? 304 | 305 | if diff 306 | stdin = IO.popen("git apply -", "w") 307 | stdin.write diff 308 | stdin.close 309 | end 310 | 311 | index = repo.index 312 | index.read_tree(repo.head.target.tree) unless repo.empty? 313 | 314 | Dir["**/*"].each do |path| 315 | unless File.directory?(path) 316 | oid = repo.write(File.read(path), :blob) 317 | index.add(path: path, oid: oid, mode: 0100644) 318 | end 319 | end 320 | 321 | options = {} 322 | options[:tree] = index.write_tree(repo) 323 | if @config.options[:repo_author_name] 324 | options[:author] = { 325 | name: @config.options[:repo_author_name], 326 | email: @config.options[:repo_author_email], 327 | time: Time.now 328 | } 329 | options[:committer] = options[:author] 330 | end 331 | options[:message] = message 332 | options[:parents] = repo.empty? ? [] : [repo.head.target] 333 | options[:update_ref] = "HEAD" 334 | 335 | commit_oid = Rugged::Commit.create(repo, options) 336 | 337 | if diff 338 | repo.references.create("refs/tags/step-#{step_num}", commit_oid) 339 | end 340 | end 341 | 342 | class State 343 | attr_accessor :step_number, :operation, :args, :conflict 344 | 345 | def initialize 346 | @step_number = nil 347 | @operation = nil 348 | @args = [] 349 | @conflict = false 350 | end 351 | 352 | def step!(step_number) 353 | @step_number = step_number 354 | self 355 | end 356 | 357 | def add_commit! 358 | if @operation.nil? 359 | @operation = :commit 360 | @args = [1, false] 361 | elsif @operation == :commit 362 | @args[0] += 1 363 | else 364 | raise "@operation must be :commit or nil" 365 | end 366 | self 367 | end 368 | 369 | def amend! 370 | if @operation.nil? 371 | @operation = :commit 372 | @args = [0, true] 373 | elsif @operation == :commit 374 | @args[1] = true 375 | else 376 | raise "@operation must be :commit or nil" 377 | end 378 | self 379 | end 380 | 381 | def conflict! 382 | @conflict = true 383 | self 384 | end 385 | end 386 | end 387 | end 388 | end 389 | -------------------------------------------------------------------------------- /lib/leg/representations/litdiff.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Representations 3 | class Litdiff < BaseRepresentation 4 | def save!(tutorial, options = {}) 5 | FileUtils.mkdir_p(path) 6 | FileUtils.rm_rf(File.join(path, "."), secure: true) 7 | 8 | step_num = 1 9 | tutorial.pages.each.with_index do |page, page_idx| 10 | output = "" 11 | page.steps.each do |step| 12 | output << step.text << "\n\n" unless step.text.empty? 13 | output << "~~~ #{step_num}. #{step.summary}\n" 14 | output << step.to_patch(unchanged_char: "|", strip_git_lines: true) << "\n" 15 | 16 | yield step_num if block_given? 17 | step_num += 1 18 | end 19 | output << page.footer_text << "\n" if page.footer_text 20 | 21 | filename = page.filename + ".litdiff" 22 | filename = "%02d.%s" % [page_idx + 1, filename] if tutorial.pages.length > 1 23 | 24 | File.write(File.join(path, filename), output) 25 | end 26 | end 27 | 28 | def load!(options = {}) 29 | step_num = 1 30 | tutorial = Leg::Tutorial.new(@config) 31 | Dir[File.join(path, "*.litdiff")].sort_by { |f| File.basename(f).to_i }.each do |diff_path| 32 | filename = File.basename(diff_path).sub(/\.litdiff$/, "").sub(/^\d+\./, "") 33 | page = Leg::Page.new(filename) 34 | File.open(diff_path, "r") do |f| 35 | cur_text = "" 36 | cur_diff = nil 37 | cur_summary = nil 38 | while line = f.gets 39 | if line =~ /^~~~\s*(\d+\.)?(.+)$/ 40 | cur_summary = $2.strip 41 | cur_diff = "" 42 | elsif cur_diff 43 | if line.chomp.empty? 44 | step_diffs = Leg::Diff.parse(cur_diff) 45 | page << Leg::Step.new(step_num, cur_summary, cur_text.strip, step_diffs) 46 | 47 | yield step_num if block_given? 48 | step_num += 1 49 | 50 | cur_text = "" 51 | cur_summary = nil 52 | cur_diff = nil 53 | else 54 | cur_diff << line.sub(/^\|/, " ") 55 | end 56 | else 57 | cur_text << line 58 | end 59 | end 60 | if cur_diff 61 | step_diffs = Leg::Diff.parse(cur_diff) 62 | page << Leg::Step.new(step_num, cur_summary, cur_text.strip, step_diffs) 63 | elsif !cur_text.strip.empty? 64 | page.footer_text = cur_text.strip 65 | end 66 | end 67 | tutorial << page 68 | end 69 | tutorial 70 | end 71 | 72 | def path 73 | File.join(@config.path, "doc") 74 | end 75 | 76 | private 77 | 78 | def modified_at 79 | if File.exist? path 80 | Dir[File.join(path, "**/*")].map { |f| File.mtime(f) }.max 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/leg/step.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Step 3 | attr_accessor :number, :summary, :text, :diffs 4 | 5 | def initialize(number, summary, text, diffs) 6 | @number = number 7 | @summary = summary.strip 8 | @text = text.strip 9 | @diffs = diffs 10 | end 11 | 12 | def to_patch(options = {}) 13 | @diffs.map { |diff| diff.to_patch(options) }.join("\n") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/leg/template.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | module Template 3 | def self.render(template_source, tutorial, config, params = {}) 4 | Leg::Template::Context.new(template_source, tutorial, config, params).render_template 5 | end 6 | 7 | def self.render_page(page_template, step_template, format, page, tutorial, config) 8 | content = "" 9 | page.steps.each do |step| 10 | if !step.text.strip.empty? 11 | output = step.text.strip + "\n\n" 12 | if format == "html" 13 | output = Leg::Markdown.render(output) 14 | end 15 | content << output 16 | end 17 | 18 | content << Leg::Template.render_step(step_template, step, tutorial, config) 19 | end 20 | if page.footer_text 21 | # TODO: DRY this up. Please. 22 | output = page.footer_text.strip + "\n\n" 23 | if format == "html" 24 | output = Leg::Markdown.render(output) 25 | end 26 | content << output 27 | end 28 | 29 | page_number = tutorial.pages.index(page) + 1 30 | 31 | Leg::Template.render(page_template, tutorial, config, 32 | page_title: page.title, 33 | content: content, 34 | page_number: page_number, 35 | prev_page: page_number > 1 ? tutorial.pages[page_number - 2] : nil, 36 | next_page: page_number < tutorial.pages.length ? tutorial.pages[page_number] : nil 37 | ) 38 | end 39 | 40 | def self.render_step(step_template, step, tutorial, config) 41 | Leg::Template.render(step_template, tutorial, config, 42 | number: step.number, 43 | summary: step.summary, 44 | diffs: step.diffs 45 | ) 46 | end 47 | 48 | class Context 49 | def initialize(template_source, tutorial, config, params) 50 | @template_source = template_source 51 | @tutorial = tutorial 52 | @config = config 53 | @params = params 54 | end 55 | 56 | def render_template 57 | b = binding 58 | @config.options.merge(@params).each do |name, value| 59 | b.local_variable_set(name, value) 60 | end 61 | ERB.new(@template_source).result(b) 62 | end 63 | 64 | def render(path) 65 | if !path.end_with? ".md" 66 | raise ArgumentError, "Only .md files are supported by render() at the moment." 67 | end 68 | 69 | contents = File.read(path) 70 | Leg::Markdown.render(contents) 71 | end 72 | 73 | def markdown(source) 74 | Leg::Markdown.render(source) 75 | end 76 | 77 | def pages 78 | @tutorial.pages 79 | end 80 | 81 | def syntax_highlighting_css(scope) 82 | syntax_theme = @config.options[:syntax_theme] || "github" 83 | if syntax_theme.is_a? String 84 | theme = Rouge::Theme.find(syntax_theme) 85 | elsif syntax_theme.is_a? Hash 86 | theme = Class.new(Rouge::Themes::Base16) 87 | theme.name "base16.custom" 88 | theme.palette syntax_theme 89 | end 90 | 91 | theme.render(scope: scope) 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/leg/tutorial.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | class Tutorial 3 | attr_accessor :config 4 | attr_reader :pages 5 | 6 | def initialize(config = nil) 7 | @config = config 8 | @pages = [] 9 | end 10 | 11 | def <<(page) 12 | @pages << page 13 | self 14 | end 15 | 16 | def clear 17 | @pages.clear 18 | end 19 | 20 | def step(number) 21 | cur = 1 22 | @pages.each do |page| 23 | page.steps.each do |step| 24 | return step if cur == number 25 | cur += 1 26 | end 27 | end 28 | end 29 | 30 | def num_steps 31 | @pages.map(&:steps).map(&:length).sum 32 | end 33 | 34 | def transform_diffs(transformers, &progress_block) 35 | step_num = 1 36 | @pages.each do |page| 37 | page.steps.each do |step| 38 | step.diffs.map! do |diff| 39 | transformers.inject(diff) do |acc, transformer| 40 | transformer.transform(acc) 41 | end 42 | end 43 | progress_block.(step_num) if progress_block 44 | step_num += 1 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/leg/version.rb: -------------------------------------------------------------------------------- 1 | module Leg 2 | VERSION = "0.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /test/integration/workflow_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | STEP_1 = <<~END 4 | int main(void) {} 5 | END 6 | 7 | STEP_2 = <<~END 8 | int main(void) { 9 | return 0; 10 | } 11 | END 12 | 13 | STEP_3 = <<~END 14 | int main(void) { 15 | printf("Hello, world!\\n"); 16 | 17 | return 0; 18 | } 19 | END 20 | 21 | STEP_4 = <<~END 22 | #include 23 | 24 | int main(void) { 25 | printf("Hello, world!\\n"); 26 | 27 | return 0; 28 | } 29 | END 30 | 31 | STEP_2B = <<~END 32 | int main(int argc, char *argv[]) { 33 | return 0; 34 | } 35 | END 36 | 37 | STEP_3B = <<~END 38 | int main(int argc, char *argv[]) { 39 | printf("Hello, world!\\n"); 40 | 41 | return 0; 42 | } 43 | END 44 | 45 | STEP_4B = <<~END 46 | #include 47 | 48 | int main(int argc, char *argv[]) { 49 | printf("Hello, world!\\n"); 50 | 51 | return 0; 52 | } 53 | END 54 | 55 | STEP_4C = <<~END 56 | int main(int argc, char *argv[]) { 57 | printf("Hello, world!\\n"); 58 | 59 | return 0; 60 | } 61 | 62 | // the end 63 | END 64 | 65 | STEP_5C = <<~END 66 | #include 67 | 68 | int main(int argc, char *argv[]) { 69 | printf("Hello, world!\\n"); 70 | 71 | return 0; 72 | } 73 | 74 | // the end 75 | END 76 | 77 | LITDIFF = <<~END 78 | ~~~ 1. Main function 79 | --- /dev/null 80 | +++ hello.c 81 | @@ -0,0 +1,1 @@ 82 | +int main(void) {} 83 | 84 | ~~~ 2. Return zero 85 | --- hello.c 86 | +++ hello.c 87 | @@ -1,1 +1,3 @@ 88 | -int main(void) {} 89 | +int main(int argc, char *argv[]) { 90 | + return 0; 91 | +} 92 | 93 | ~~~ 3. Print message 94 | --- hello.c 95 | +++ hello.c 96 | @@ -1,3 +1,5 @@ 97 | |int main(int argc, char *argv[]) { 98 | + printf("Hello, world!\\n"); 99 | + 100 | | return 0; 101 | |} 102 | 103 | ~~~ 4. Add comment 104 | --- hello.c 105 | +++ hello.c 106 | @@ -3,3 +3,5 @@ 107 | | 108 | | return 0; 109 | |} 110 | + 111 | +// the end 112 | 113 | ~~~ 5. Include 114 | --- hello.c 115 | +++ hello.c 116 | @@ -1,3 +1,5 @@ 117 | +#include 118 | + 119 | |int main(int argc, char *argv[]) { 120 | | printf("Hello, world!\\n"); 121 | | 122 | 123 | END 124 | 125 | MARKDOWN = <<~END 126 | ## 1. Main function 127 | 128 | ```diff 129 | // hello.c 130 | +int main(void) {} 131 | ``` 132 | 133 | ## 2. Return zero 134 | 135 | ```diff 136 | // hello.c 137 | +int main(int argc, char *argv[]) { 138 | + return 0; 139 | +} 140 | ``` 141 | 142 | ## 3. Print message 143 | 144 | ```diff 145 | // hello.c 146 | int main(int argc, char *argv[]) { 147 | + printf("Hello, world!\\n"); 148 | \\ 149 | return 0; 150 | } 151 | ``` 152 | 153 | ## 4. Add comment 154 | 155 | ```diff 156 | // hello.c 157 | @int main(int argc, char *argv[]) { … } 158 | \\ 159 | +// the end 160 | ``` 161 | 162 | ## 5. Include 163 | 164 | ```diff 165 | // hello.c 166 | +#include 167 | \\ 168 | @int main(int argc, char *argv[]) { … } 169 | \\ 170 | // the end 171 | ``` 172 | 173 | 174 | END 175 | 176 | MARKDOWN.gsub!(/\\$/, "") 177 | 178 | class WorkflowTest < Minitest::Test 179 | def test_workflow 180 | Dir.mktmpdir do |dir| 181 | FileUtils.cd(dir) 182 | 183 | leg_command "init" 184 | 185 | File.write("step/hello.c", STEP_1) 186 | leg_command "commit", "-m", "Main function" 187 | 188 | File.write("step/hello.c", STEP_2) 189 | leg_command "commit", "-m", "Return zero" 190 | 191 | File.write("step/hello.c", STEP_3) 192 | leg_command "commit", "-m", "Print message" 193 | 194 | File.write("step/hello.c", STEP_4) 195 | leg_command "commit", "-m", "Include " 196 | 197 | leg_command "1" 198 | assert_equal STEP_1, File.read("step/hello.c") 199 | 200 | leg_command "2" 201 | assert_equal STEP_2, File.read("step/hello.c") 202 | 203 | leg_command "3" 204 | assert_equal STEP_3, File.read("step/hello.c") 205 | 206 | leg_command "reset" 207 | assert_equal STEP_4, File.read("step/hello.c") 208 | 209 | leg_command "2" 210 | assert_equal STEP_2, File.read("step/hello.c") 211 | 212 | File.write("step/hello.c", STEP_2B) 213 | leg_command "amend", "-d" 214 | assert_includes File.read("step/hello.c"), "<<<<<<< HEAD" 215 | 216 | File.write("step/hello.c", STEP_3B) 217 | leg_command "resolve" 218 | assert_includes File.read("step/hello.c"), "<<<<<<< HEAD" 219 | 220 | File.write("step/hello.c", STEP_4B) 221 | leg_command "resolve" 222 | refute_includes File.read("step/hello.c"), "<<<<<<< HEAD" 223 | 224 | leg_command "1" 225 | assert_equal STEP_1, File.read("step/hello.c") 226 | 227 | leg_command "2" 228 | assert_equal STEP_2B, File.read("step/hello.c") 229 | 230 | leg_command "3" 231 | assert_equal STEP_3B, File.read("step/hello.c") 232 | 233 | leg_command "reset" 234 | assert_equal STEP_4B, File.read("step/hello.c") 235 | 236 | leg_command "3" 237 | assert_equal STEP_3B, File.read("step/hello.c") 238 | 239 | File.write("step/hello.c", STEP_4C) 240 | leg_command "commit", "-m", "Add comment" 241 | assert_equal STEP_5C, File.read("step/hello.c") 242 | 243 | assert_equal LITDIFF, File.read("doc/tutorial.litdiff") 244 | 245 | leg_command "build" 246 | assert File.exists?("build/html/tutorial.html") 247 | assert_equal MARKDOWN, File.read("build/md/tutorial.md") 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /test/leg_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class LegTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::Leg::VERSION 6 | end 7 | 8 | def test_it_does_something_useful 9 | assert true 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require "leg" 3 | 4 | require "minitest/autorun" 5 | require "minitest/pride" 6 | 7 | def leg_command(*args) 8 | Leg::CLI.new(force_quiet: true).run(args) 9 | end 10 | --------------------------------------------------------------------------------