├── .github └── workflows │ ├── ci.yml │ ├── gh-pages.yml │ └── publish.yml ├── .gitignore ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── Rakefile ├── doc ├── license.md ├── readme.md └── todo.md ├── double_doc.gemspec ├── lib ├── double_doc.rb ├── double_doc │ ├── client.rb │ ├── doc_extractor.rb │ ├── html_generator.rb │ ├── html_renderer.rb │ ├── import_handler.rb │ ├── task.rb │ └── version.rb └── guard │ ├── double_doc.rb │ └── doubledoc.rb ├── readme.md ├── templates ├── default.html.erb └── screen.css └── test ├── client_test.rb ├── doc_extractor_test.rb ├── html_generator_test.rb ├── import_handler_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby-version: 12 | - '3.0' 13 | - '3.1' 14 | - '3.2' 15 | - '3.3' 16 | - '3.4' 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby-version }} 22 | bundler-cache: true 23 | - name: test ${{ matrix.ruby-version }} with ${{ matrix.gemfile }} 24 | run: bundle exec rake test 25 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy static content to Pages 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | deploy: 23 | environment: 24 | name: github-pages 25 | url: ${{ steps.deployment.outputs.page_url }} 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | with: 31 | ref: gh-pages 32 | - name: Setup Pages 33 | uses: actions/configure-pages@v5 34 | - name: Upload artifact 35 | uses: actions/upload-pages-artifact@v3 36 | with: 37 | path: "." 38 | - name: Deploy to GitHub Pages 39 | id: deployment 40 | uses: actions/deploy-pages@v4 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to RubyGems.org 2 | 3 | on: 4 | push: 5 | branches: main 6 | paths: lib/double_doc/version.rb 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | environment: rubygems-publish 13 | if: github.repository_owner == 'zendesk' 14 | permissions: 15 | id-token: write 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: false 23 | 24 | - name: Install dependencies 25 | run: bundle install 26 | - uses: rubygems/release-gem@v1 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | pkg/* 4 | site 5 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.6 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [2.3.0] - 2024-01-15 11 | 12 | * Run tests on Ruby 3.0 to 3.3. 13 | * Switch to pygments 2.x (which uses Python 3). 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in double_doc.gemspec 4 | gemspec 5 | 6 | gem "bump" 7 | gem "guard", "~> 1.6" 8 | gem "maxitest" 9 | gem "mime-types" 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | double_doc (2.3.0) 5 | erubis 6 | pygments.rb (~> 2.0) 7 | rake 8 | redcarpet (< 4) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | bump (0.10.0) 14 | coderay (1.1.3) 15 | erubis (2.7.0) 16 | ffi (1.16.3) 17 | formatador (1.1.0) 18 | guard (1.8.3) 19 | formatador (>= 0.2.4) 20 | listen (~> 1.3) 21 | lumberjack (>= 1.0.2) 22 | pry (>= 0.9.10) 23 | thor (>= 0.14.6) 24 | listen (1.3.1) 25 | rb-fsevent (>= 0.9.3) 26 | rb-inotify (>= 0.9) 27 | rb-kqueue (>= 0.2) 28 | lumberjack (1.2.10) 29 | maxitest (5.8.0) 30 | minitest (>= 5.14.0, < 5.26.0) 31 | method_source (1.0.0) 32 | mime-types (3.5.2) 33 | mime-types-data (~> 3.2015) 34 | mime-types-data (3.2023.1205) 35 | minitest (5.25.4) 36 | pry (0.14.2) 37 | coderay (~> 1.1) 38 | method_source (~> 1.0) 39 | pygments.rb (2.4.1) 40 | rake (13.1.0) 41 | rb-fsevent (0.11.2) 42 | rb-inotify (0.10.1) 43 | ffi (~> 1.0) 44 | rb-kqueue (0.2.8) 45 | ffi (>= 0.5.0) 46 | redcarpet (3.6.0) 47 | thor (1.3.0) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | bump 54 | double_doc! 55 | guard (~> 1.6) 56 | maxitest 57 | mime-types 58 | 59 | BUNDLED WITH 60 | 2.5.4 61 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift 'lib' 2 | require 'double_doc' 3 | 4 | guard :double_doc, :rake_task => 'doc' do 5 | watch(/^(doc|lib|templates)\//) 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | require "double_doc/task" 4 | require "rake/testtask" 5 | require "bump/tasks" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.pattern = 'test/**/*_test.rb' 9 | t.verbose = true 10 | t.warning = false # TODO: turn on and fix 11 | end 12 | 13 | DoubleDoc::Task.new( 14 | :doc, 15 | :title => 'API Documentation', 16 | :sources => 'doc/readme.md', 17 | :md_destination => '.', 18 | :html_destination => 'site' 19 | ) 20 | 21 | task default: [:test] 22 | -------------------------------------------------------------------------------- /doc/license.md: -------------------------------------------------------------------------------- 1 | #### The MIT License 2 | 3 | Copyright © 2012 [Mick Staugaard](mailto:mick@staugaard.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /doc/readme.md: -------------------------------------------------------------------------------- 1 | @import lib/double_doc/version.rb 2 | 3 | [![CI Status](https://github.com/zendesk/double_doc/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zendesk/double_doc/actions/workflows/ci.yml) 4 | 5 | 6 | 7 | Write documentation with your code, to keep them in sync, ideal for public API docs. 8 | 9 | This document was generated using DoubleDoc from [doc/readme.md](doc/readme.md), and the source of this project is a great source for how to use DoubleDoc. 10 | 11 | ### Documentation Format 12 | Write documentation in markdown right in source code files by double commenting it: 13 | ```ruby 14 | class User < ActiveRecord::Base 15 | ## ```js 16 | ## { 17 | ## "id": 1, 18 | ## "name": "Mick Staugaard" 19 | ## } 20 | ## ``` 21 | def as_json 22 | # this comment will not be included in the documentation 23 | # as it only has a single # character 24 | super(only: [:id, :name]) 25 | end 26 | end 27 | 28 | class UsersController < ApplicationController 29 | ## ### Getting a User 30 | ## `GET /users/{id}.json` 31 | ## 32 | ## #### Format 33 | ## @@import app/models/user.rb 34 | def show 35 | render json: User.find(params[:id]) 36 | end 37 | end 38 | ``` 39 | 40 | Then write a markdown document about User API: 41 | 42 | ## Users 43 | Access users by using our REST API, blah blah blah... 44 | 45 | @@import app/controllers/users_controller.rb 46 | 47 | And DoubleDoc will generate this markdown document: 48 | 49 | ## Users 50 | Access users in by using our REST API, blah blah blah... 51 | 52 | ### Getting a User 53 | `GET /users/{id}.json` 54 | 55 | #### Format 56 | ```js 57 | { 58 | "id": 1, 59 | "name": "Mick Staugaard" 60 | } 61 | ``` 62 | 63 | @import lib/double_doc/task.rb 64 | 65 | ### Notes 66 | - Tested on ruby 3.0+ 67 | - Does not work on jruby because of its dependency on redcarpet. 68 | 69 | ### Release 70 | 71 | After merging your changes: 72 | 1. Create a PR with a version bump and updated changelog. 73 | 2. After that PR gets merged, create a new tag (by running `gem_push=no rake release` or via Github releases). 74 | 3. This will trigger the publishing workflow—[approve it in Github Actions](https://github.com/zendesk/double_doc/actions/workflows/publish.yml)). 75 | 76 | ### TODO 77 | @import doc/todo.md 78 | 79 | ### License 80 | @import doc/license.md 81 | -------------------------------------------------------------------------------- /doc/todo.md: -------------------------------------------------------------------------------- 1 | * Support for directory structures 2 | * Documentation for the Guard 3 | * Add support for extracting documentation from JavaScript files 4 | -------------------------------------------------------------------------------- /double_doc.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/double_doc/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "double_doc" 5 | s.version = DoubleDoc::VERSION 6 | s.authors = ["Mick Staugaard"] 7 | s.email = ["mick@staugaard.com"] 8 | s.homepage = "https://github.com/zendesk/double_doc" 9 | s.summary = "Documentation right where you want it" 10 | s.description = "Write documentation with your code, to keep them in sync, ideal for public API docs." 11 | 12 | s.files = Dir.glob("{lib,templates}/**/*") + ["readme.md"] 13 | 14 | s.add_runtime_dependency "rake" 15 | s.add_runtime_dependency "erubis" 16 | s.add_runtime_dependency "redcarpet", "< 4" 17 | s.add_runtime_dependency "pygments.rb", "~> 2.0" 18 | end 19 | -------------------------------------------------------------------------------- /lib/double_doc.rb: -------------------------------------------------------------------------------- 1 | require "double_doc/version" 2 | require "double_doc/task" 3 | require "double_doc/client" 4 | -------------------------------------------------------------------------------- /lib/double_doc/client.rb: -------------------------------------------------------------------------------- 1 | require 'double_doc/import_handler' 2 | require 'double_doc/html_generator' 3 | 4 | module DoubleDoc 5 | class Client 6 | attr_reader :md_sources, :options 7 | 8 | def initialize(md_sources, options = {}) 9 | @md_sources = [md_sources].flatten 10 | @options = options 11 | end 12 | 13 | def process 14 | sources = md_sources.map do |source| 15 | if source.to_s =~ /\*/ 16 | import_handler.load_paths.map do |path| 17 | Dir.glob(File.join(path, source)) 18 | end 19 | else 20 | import_handler.find_file(source).path 21 | end 22 | end.flatten.uniq 23 | 24 | generated_md_files = [] 25 | 26 | md_dst = Pathname.new(options[:md_destination]) 27 | system('mkdir', '-p', md_dst.to_s) 28 | sources.each do |src| 29 | next if File.directory?(src) 30 | dst = md_dst + File.basename(src) 31 | puts "#{src} -> #{dst}" unless options[:quiet] 32 | 33 | if src.to_s =~ /\.md$/ 34 | body = import_handler.resolve_imports(File.new(src)) 35 | else 36 | body = File.read(src) 37 | end 38 | 39 | File.open(dst, 'w') do |out| 40 | out.write(body) 41 | end 42 | 43 | generated_md_files << dst 44 | end 45 | 46 | args = options[:args] || {} 47 | html_dst = Pathname.new(options[:html_destination]) if options[:html_destination] 48 | if html_dst || args[:html_destination] 49 | html_generator = DoubleDoc::HtmlGenerator.new(generated_md_files, options.merge(args)) 50 | html_generator.generate 51 | end 52 | 53 | sources 54 | end 55 | 56 | private 57 | 58 | def import_handler 59 | return @import_handler if defined?(@import_handler) 60 | 61 | roots = options[:roots] || [File.dirname(__FILE__)] 62 | import_options = options.fetch(:import, {}) 63 | roots << { :quiet => options[:quiet] }.merge(import_options) 64 | @import_handler = DoubleDoc::ImportHandler.new(*roots) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/double_doc/doc_extractor.rb: -------------------------------------------------------------------------------- 1 | module DoubleDoc 2 | class DocExtractor 3 | TYPES = { 4 | 'rb' => /(^\s*|\s+)##\s?(?.*)$/, 5 | 'js' => %r{(^\s*|\s+)///\s?(?.*)$} 6 | }.freeze 7 | 8 | def self.extract(source, options = {}) 9 | case source 10 | when String 11 | extract_from_lines(source.split("\n"), options) 12 | when File 13 | if type = File.extname(source.path) 14 | type = type[1..-1] 15 | end 16 | type ||= 'rb' 17 | 18 | extract_from_lines(source.readlines, options.merge(:type => type)) 19 | when Array 20 | extract_from_lines(source, options) 21 | else 22 | raise "can't extract docs from #{source}" 23 | end 24 | end 25 | 26 | def self.extract_from_lines(lines, options) 27 | doc = [] 28 | extractor = TYPES[options[:type]] 29 | 30 | add_empty_line = false 31 | lines.each do |line| 32 | if match = line.match(extractor) 33 | if add_empty_line 34 | doc << '' 35 | add_empty_line = false 36 | end 37 | doc << match[:documentation_line].rstrip 38 | else 39 | add_empty_line = !doc.empty? 40 | end 41 | end 42 | 43 | return '' if doc.empty? || doc.all?(&:empty?) 44 | 45 | doc << '' 46 | 47 | return doc.join("\n") 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/double_doc/html_generator.rb: -------------------------------------------------------------------------------- 1 | require 'erubis' 2 | require 'pathname' 3 | require 'double_doc/html_renderer' 4 | require 'fileutils' 5 | 6 | module DoubleDoc 7 | class HtmlGenerator 8 | DEFAULT_CSS = File.expand_path("../../templates/screen.css", File.dirname(__FILE__)).freeze 9 | 10 | def initialize(sources, options) 11 | @sources = sources 12 | @template_file = options[:html_template] || File.expand_path("../../templates/default.html.erb", File.dirname(__FILE__)) 13 | @output_directory = Pathname.new(options[:html_destination]) 14 | @html_renderer = options[:html_renderer] || HtmlRenderer 15 | @stylesheet = options[:html_css] || DEFAULT_CSS 16 | @title = options[:title] || 'Documentation' 17 | @quiet = options[:quiet] == true 18 | @exclude_from_navigation = options[:exclude_from_navigation] || [] 19 | end 20 | 21 | def generate 22 | FileUtils.mkdir_p(@output_directory) 23 | 24 | copy_assets 25 | generated_files = [@output_directory + File.basename(@stylesheet)] 26 | 27 | @sources.each do |src| 28 | from_markdown = src.to_s =~ /\.md$/ 29 | if from_markdown 30 | path = File.basename(src).sub(/\.md$/, '.html') 31 | else 32 | path = File.basename(src) 33 | end 34 | 35 | dst = @output_directory + path 36 | puts "#{src} -> #{dst}" unless @quiet 37 | FileUtils.mkdir_p(File.dirname(dst)) 38 | 39 | if from_markdown 40 | markdown = self.class.convert_links_to_html!(File.new(src).read) 41 | body = @html_renderer.render(markdown) 42 | else 43 | body = File.new(src).read 44 | end 45 | 46 | File.open(dst, 'w') do |out| 47 | 48 | html = template.result( 49 | :title => @title, 50 | :body => body, 51 | :css => File.basename(@stylesheet), 52 | :sitemap => sitemap, 53 | :path => path 54 | ) 55 | out.write(html) 56 | end 57 | generated_files << dst 58 | end 59 | 60 | end 61 | 62 | def sitemap 63 | return @sitemap unless @sitemap.nil? 64 | 65 | @sitemap = [] 66 | 67 | @sources.each do |src| 68 | path = File.basename(src).sub(/\.md$/, '.html') 69 | 70 | next if @exclude_from_navigation.include?(path) 71 | 72 | lines = File.readlines(src) 73 | 74 | item = nil 75 | lines.each do |line| 76 | if line =~ /^\#\#\s/ 77 | title = line.sub(/^\#+\s*/, '').strip 78 | @sitemap << item if item 79 | item = SitemapItem.new(title, path) 80 | elsif line =~ /^\#\#\#\s/ 81 | item ||= SitemapItem.new(path, path) 82 | title = line.sub(/^\#+\s*/, '').strip 83 | item.add_child(SitemapItem.new(title, path, DoubleDoc::HtmlRenderer.generate_id(title))) 84 | end 85 | end 86 | 87 | @sitemap << item if item 88 | end 89 | 90 | @sitemap 91 | end 92 | 93 | def copy_assets 94 | if @stylesheet 95 | FileUtils.cp(@stylesheet, @output_directory) 96 | end 97 | end 98 | 99 | def template 100 | @template ||= Erubis::Eruby.new(File.read(@template_file)) 101 | end 102 | 103 | def self.convert_links_to_html!(markdown) 104 | markdown.gsub!(/(\[[^\]]+\]\([^\)]+)\.md([^\)]*)\)/) do |match| 105 | $1 + '.html' + $2 + ')' 106 | end 107 | markdown 108 | end 109 | 110 | class SitemapItem 111 | attr_reader :title, :path, :id, :children 112 | attr_accessor :parent 113 | 114 | def initialize(title, path, id = nil) 115 | @title = title 116 | @path = path 117 | @id = id 118 | @children = [] 119 | end 120 | 121 | def add_child(child) 122 | child.parent = self 123 | children << child 124 | child 125 | end 126 | 127 | def href 128 | if id 129 | "#{path}##{id}" 130 | else 131 | path 132 | end 133 | end 134 | end 135 | end 136 | 137 | end 138 | -------------------------------------------------------------------------------- /lib/double_doc/html_renderer.rb: -------------------------------------------------------------------------------- 1 | require 'redcarpet' 2 | require 'pygments' 3 | 4 | module DoubleDoc 5 | class HtmlRenderer 6 | 7 | def self.render(text) 8 | markdown = Redcarpet::Markdown.new(RedcarpetRenderer, :fenced_code_blocks => true, :no_intra_emphasis => true, :tables => true) 9 | markdown.render(text) 10 | end 11 | 12 | def self.generate_id(text) 13 | text.strip.downcase.gsub(/\s+/, '-') 14 | end 15 | 16 | class RedcarpetRenderer < Redcarpet::Render::HTML 17 | def header(text, level) 18 | "#{text}" 19 | end 20 | 21 | def block_code(code, language) 22 | if language 23 | Pygments.highlight(code, :lexer => language) 24 | else 25 | "
#{code}
" 26 | end 27 | end 28 | 29 | end 30 | 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /lib/double_doc/import_handler.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | require 'double_doc/doc_extractor' 3 | require 'bundler' 4 | 5 | module DoubleDoc 6 | class ImportHandler 7 | attr_reader :load_paths 8 | 9 | def initialize(*roots) 10 | options = roots.pop if roots.last.is_a?(Hash) 11 | options ||= {} 12 | 13 | @load_paths = roots.map {|root| Pathname.new(root)} 14 | @quiet = options[:quiet] == true 15 | 16 | if options[:gemfile] 17 | begin 18 | @load_paths.concat(load_paths_from_gemfile(Bundler.root)) 19 | rescue => e 20 | puts "Could not load paths from Gemfile; please make sure you've run bundle install with the correct gemset." 21 | raise e 22 | end 23 | end 24 | 25 | @docs = {} 26 | end 27 | 28 | def resolve_imports(source) 29 | case source 30 | when String 31 | resolve_imports_from_lines(source.split("\n")) 32 | when File 33 | resolve_imports_from_lines(source.readlines) 34 | when Array 35 | resolve_imports_from_lines(source) 36 | else 37 | raise "can't extract docs from #{source}" 38 | end 39 | end 40 | 41 | def find_file(path) 42 | load_path = @load_paths.detect do |load_path| 43 | (load_path + path).exist? 44 | end 45 | 46 | unless load_path 47 | raise LoadError, "No such file or directory: #{path}" 48 | end 49 | 50 | File.new(load_path + path) 51 | end 52 | 53 | protected 54 | 55 | def load_paths_from_gemfile(root) 56 | gemfile = root + "Gemfile" 57 | 58 | unless gemfile.exist? 59 | raise LoadError, "missing Gemfile inside #{root}" 60 | end 61 | 62 | with_gemfile(gemfile) do 63 | puts "Loading paths from #{gemfile}" unless @quiet 64 | 65 | defn = Bundler::Definition.build(gemfile, root + "Gemfile.lock", nil) 66 | defn.validate_ruby! 67 | defn.resolve_with_cache! 68 | 69 | defn.specs.inject([]) do |paths, spec| 70 | paths.concat(spec.load_paths.map {|p| Pathname.new(p)}) 71 | end 72 | end 73 | end 74 | 75 | def resolve_imports_from_lines(lines) 76 | doc = [] 77 | 78 | lines.each do |line| 79 | if match = line.match(/(^|\s+)@import\s+([^\s]+)\s*$/) 80 | doc << get_doc(match[2]) 81 | else 82 | doc << line.gsub('@@import', '@import').rstrip 83 | end 84 | end 85 | 86 | doc.join("\n") 87 | end 88 | 89 | def get_doc(path) 90 | return @docs[path] if @docs[path] 91 | 92 | file = find_file(path) 93 | 94 | if path =~ /\.md$/ 95 | @docs[path] = resolve_imports(file) 96 | else 97 | @docs[path] = resolve_imports(DocExtractor.extract(file)) 98 | end 99 | end 100 | 101 | def with_gemfile(gemfile) 102 | ENV["BUNDLE_GEMFILE"], orig_gemfile = gemfile.to_s, ENV["BUNDLE_GEMFILE"] 103 | yield 104 | ensure 105 | ENV["BUNDLE_GEMFILE"] = orig_gemfile 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/double_doc/task.rb: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'pathname' 3 | require 'tmpdir' 4 | require 'double_doc/client' 5 | 6 | module DoubleDoc 7 | 8 | ## ### Rake Task 9 | ## Generate documentation by telling DoubleDoc what the input files are, and where the output should go. 10 | ## In the example, `double_doc` is picked to avoid conflicts with the `doc` rake task in rails. 11 | ## 12 | ## ```ruby 13 | ## require 'double_doc' 14 | ## 15 | ## DoubleDoc::Task.new( 16 | ## :double_doc, 17 | ## sources: 'doc/source/*.md', 18 | ## md_destination: 'doc/generated', 19 | ## html_destination: 'site' 20 | ## ) 21 | ## ``` 22 | ## 23 | ## The available options are: 24 | ## 25 | ## | name | Description 26 | ## | -------------------- | ----------- 27 | ## | __sources__ | __Required__. Documentation source directory (string or array of strings). 28 | ## | __md_destination__ | __Required__. Directory where the generated markdown files should go. 29 | ## | __html_destination__ | Where a pretty HTML version of the documentation should go. 30 | ## | __html_template__ | Custom ERB template for HTML rendering, see default template for inspiration (templates/default.html.erb). 31 | ## | __html_renderer__ | Custom html rendered, defaults to `DoubleDoc::HtmlRenderer`. 32 | ## | __html_css__ | Custom CSS document path. 33 | ## | __title__ | Title for generated HTML, defaults to "Documentation". 34 | ## To generate a README.md for github, write documentation in doc/README.md and put this in the Rakefile: 35 | ## 36 | ## ```ruby 37 | ## require 'double_doc' 38 | ## 39 | ## DoubleDoc::Task.new(:double_doc, sources: 'doc/README.md', md_destination: '.') 40 | ## ``` 41 | ## 42 | ## Then run `rake double_doc`, which will generate a `readme.md` in the root of the project. 43 | ## 44 | ## If a gh-pages branch exists, run `rake doc:publish` to generate html documentation and push it to your github pages. 45 | class Task 46 | include Rake::DSL if defined?(Rake::DSL) 47 | 48 | def initialize(task_name, options) 49 | md_dst = Pathname.new(options[:md_destination]) 50 | html_dst = Pathname.new(options[:html_destination]) if options[:html_destination] 51 | 52 | destinations = [md_dst, html_dst].compact 53 | destinations.each do |dst| 54 | directory(dst.to_s) 55 | end 56 | 57 | roots = Array(options[:root]) 58 | roots << Rake.original_dir if roots.empty? 59 | 60 | desc "Generate markdown #{html_dst ? 'and HTML ' : ''}DoubleDoc documentation" 61 | generated_task = task(task_name => destinations) do |t, args| 62 | opts = args.to_h.merge(options.merge(:roots => roots)) 63 | client = DoubleDoc::Client.new(options[:sources], opts) 64 | client.process 65 | end 66 | 67 | has_github_pages = !`git branch | grep 'gh-pages'`.empty? rescue false 68 | 69 | if has_github_pages 70 | namespace(task_name) do 71 | desc "Publish DoubleDoc documentation to Github Pages" 72 | task :publish do 73 | git_clean = `git status -s`.empty? rescue false 74 | raise "Your local git repository needs to be clean for this task to run" unless git_clean 75 | 76 | git_branch = `git branch | grep "*"`.match(/\* (.*)/)[1] rescue 'master' 77 | 78 | Dir.mktmpdir do |dir| 79 | generated_task.execute(:html_destination => dir) 80 | html_files = Dir.glob(Pathname.new(dir) + '*.html') 81 | 82 | # FIXME: fail when something fails and don't just continue 83 | `git add .` 84 | `git commit -n -m 'Updated documentation'` 85 | `git checkout gh-pages` 86 | `git pull origin gh-pages` 87 | `cp #{dir}/* .` 88 | if html_files.size == 1 89 | `cp #{html_files[0]} index.html` 90 | else 91 | warn("You should probably generate an index.html") 92 | end 93 | `git add .` 94 | `git commit -n -m 'Updated Github Pages'` 95 | `git push origin gh-pages` 96 | `git checkout #{git_branch}` 97 | end 98 | end 99 | end 100 | end 101 | end 102 | 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/double_doc/version.rb: -------------------------------------------------------------------------------- 1 | ## ## DoubleDoc 2.0 2 | module DoubleDoc 3 | VERSION = "2.3.0" 4 | end 5 | -------------------------------------------------------------------------------- /lib/guard/double_doc.rb: -------------------------------------------------------------------------------- 1 | require 'guard' 2 | require 'guard/guard' 3 | require 'rake' 4 | 5 | module Guard 6 | class DoubleDoc < Guard 7 | include ::Rake::DSL 8 | 9 | def start 10 | load 'Rakefile' 11 | true 12 | end 13 | 14 | def reload 15 | stop 16 | start 17 | end 18 | 19 | def run_all 20 | run_rake_task 21 | end 22 | 23 | def run_on_change(paths) 24 | run_rake_task 25 | end 26 | 27 | def run_rake_task 28 | UI.info "generating double docs" 29 | ::Rake::Task.tasks.each { |t| t.reenable } 30 | ::Rake::Task[@options[:rake_task]].invoke 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/guard/doubledoc.rb: -------------------------------------------------------------------------------- 1 | require 'guard/double_doc' 2 | 3 | class Guard::Doubledoc < Guard::DoubleDoc 4 | end 5 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## DoubleDoc 2.0 2 | 3 | [![CI Status](https://github.com/zendesk/double_doc/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/zendesk/double_doc/actions/workflows/ci.yml) 4 | 5 | 6 | 7 | Write documentation with your code, to keep them in sync, ideal for public API docs. 8 | 9 | This document was generated using DoubleDoc from [doc/readme.md](doc/readme.md), and the source of this project is a great source for how to use DoubleDoc. 10 | 11 | ### Documentation Format 12 | Write documentation in markdown right in source code files by double commenting it: 13 | ```ruby 14 | class User < ActiveRecord::Base 15 | ## ```js 16 | ## { 17 | ## "id": 1, 18 | ## "name": "Mick Staugaard" 19 | ## } 20 | ## ``` 21 | def as_json 22 | # this comment will not be included in the documentation 23 | # as it only has a single # character 24 | super(only: [:id, :name]) 25 | end 26 | end 27 | 28 | class UsersController < ApplicationController 29 | ## ### Getting a User 30 | ## `GET /users/{id}.json` 31 | ## 32 | ## #### Format 33 | ## @import app/models/user.rb 34 | def show 35 | render json: User.find(params[:id]) 36 | end 37 | end 38 | ``` 39 | 40 | Then write a markdown document about User API: 41 | 42 | ## Users 43 | Access users by using our REST API, blah blah blah... 44 | 45 | @import app/controllers/users_controller.rb 46 | 47 | And DoubleDoc will generate this markdown document: 48 | 49 | ## Users 50 | Access users in by using our REST API, blah blah blah... 51 | 52 | ### Getting a User 53 | `GET /users/{id}.json` 54 | 55 | #### Format 56 | ```js 57 | { 58 | "id": 1, 59 | "name": "Mick Staugaard" 60 | } 61 | ``` 62 | 63 | ### Rake Task 64 | Generate documentation by telling DoubleDoc what the input files are, and where the output should go. 65 | In the example, `double_doc` is picked to avoid conflicts with the `doc` rake task in rails. 66 | 67 | ```ruby 68 | require 'double_doc' 69 | 70 | DoubleDoc::Task.new( 71 | :double_doc, 72 | sources: 'doc/source/*.md', 73 | md_destination: 'doc/generated', 74 | html_destination: 'site' 75 | ) 76 | ``` 77 | 78 | The available options are: 79 | 80 | | name | Description 81 | | -------------------- | ----------- 82 | | __sources__ | __Required__. Documentation source directory (string or array of strings). 83 | | __md_destination__ | __Required__. Directory where the generated markdown files should go. 84 | | __html_destination__ | Where a pretty HTML version of the documentation should go. 85 | | __html_template__ | Custom ERB template for HTML rendering, see default template for inspiration (templates/default.html.erb). 86 | | __html_renderer__ | Custom html rendered, defaults to `DoubleDoc::HtmlRenderer`. 87 | | __html_css__ | Custom CSS document path. 88 | | __title__ | Title for generated HTML, defaults to "Documentation". 89 | To generate a README.md for github, write documentation in doc/README.md and put this in the Rakefile: 90 | 91 | ```ruby 92 | require 'double_doc' 93 | 94 | DoubleDoc::Task.new(:double_doc, sources: 'doc/README.md', md_destination: '.') 95 | ``` 96 | 97 | Then run `rake double_doc`, which will generate a `readme.md` in the root of the project. 98 | 99 | If a gh-pages branch exists, run `rake doc:publish` to generate html documentation and push it to your github pages. 100 | 101 | ### Notes 102 | - Tested on ruby 3.0+ 103 | - Does not work on jruby because of its dependency on redcarpet. 104 | 105 | ### Releasing a new version 106 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch. 107 | In short, follow these steps: 108 | 1. Update `version.rb`, 109 | 2. run `bundle lock` to update `Gemfile.lock`, 110 | 3. merge this change into `main`, and 111 | 4. look at [the action](https://github.com/zendesk/double_doc/actions/workflows/publish.yml) for output. 112 | 113 | To create a pre-release from a non-main branch: 114 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`, 115 | 2. push this change to your branch, 116 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/double_doc/actions/workflows/publish.yml), 117 | 4. click the “Run workflow” button, 118 | 5. pick your branch from a dropdown. 119 | 120 | ### TODO 121 | * Support for directory structures 122 | * Documentation for the Guard 123 | * Add support for extracting documentation from JavaScript files 124 | 125 | ### License 126 | #### The MIT License 127 | 128 | Copyright © 2012 [Mick Staugaard](mailto:mick@staugaard.com) 129 | 130 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 131 | 132 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 133 | 134 | THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 135 | -------------------------------------------------------------------------------- /templates/default.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= title %> 8 | 9 | 10 | 11 | 31 |
32 | <%= body %> 33 |
34 | 35 | 36 | -------------------------------------------------------------------------------- /templates/screen.css: -------------------------------------------------------------------------------- 1 | html,body,div,span,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,code,del,dfn,em,img,q,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td { 2 | margin: 0; 3 | padding: 0; 4 | border: 0; 5 | font-weight: inherit; 6 | font-style: inherit; 7 | font-size: 100%; 8 | font-family: inherit; 9 | vertical-align: baseline 10 | 11 | } 12 | 13 | table { 14 | border-collapse: separate; 15 | border-spacing:0 16 | } 17 | 18 | caption, th, td { 19 | text-align: left; 20 | font-weight: normal 21 | } 22 | 23 | table, td, th { 24 | vertical-align: middle; 25 | } 26 | 27 | blockquote:before, blockquote:after, q:before, q:after { 28 | content: ""; 29 | } 30 | 31 | blockquote, q { 32 | quotes: "" ""; 33 | } 34 | 35 | a img { 36 | border: none; 37 | } 38 | 39 | body { 40 | margin: 10px; 41 | } 42 | 43 | html { 44 | height: 100%; 45 | } 46 | 47 | body { 48 | padding: 0; 49 | margin: 0; 50 | font: 18px/1.4em "Minion Pro",Times,"Times New Roman",serif; 51 | font-size-adjust: none; 52 | font-style: normal; 53 | font-variant: normal; 54 | font-weight: normal; 55 | } 56 | 57 | h1, h2, h3, h4,#header,#nav,#loader { 58 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; 59 | } 60 | 61 | a { 62 | color: #369; 63 | } 64 | 65 | #header { 66 | border-bottom: 1px solid #ccc; 67 | } 68 | 69 | #header #logo { 70 | color: #333; 71 | font-size: 18px; 72 | font-weight: bold; 73 | padding: 10px 15px; 74 | line-height: 1.2em; 75 | text-decoration: none; 76 | } 77 | 78 | #nav { 79 | position: fixed; 80 | top: 0; 81 | left: 0; 82 | width: 250px; 83 | height: 100%; 84 | background: #f9f9f9; 85 | background: rgba(0,0,0,0.10); 86 | border-right: 1px solid rgba(0,0,0,0.20); 87 | -webkit-box-shadow: rgba(0,0,0,0.10) -1px 0 3px 0 inset; 88 | -moz-box-shadow: rgba(0,0,0,0.10) -1px 0 3px 0 inset; 89 | box-shadow: rgba(0,0,0,0.10) -1px 0 3px 0 inset; 90 | text-shadow: rgba(255,255,255,0.70) 0 1px 0; 91 | overflow-x: hidden; 92 | overflow-y: auto; 93 | } 94 | 95 | #nav a { 96 | display: block; 97 | font-weight: bold; 98 | text-decoration: none 99 | } 100 | 101 | #nav #sections { 102 | margin-bottom: 5px; 103 | border-bottom: 1px solid #ccc; 104 | background: #f1f1f1; 105 | -webkit-box-shadow: rgba(0,0,0,0.15) 0 0 5px; 106 | -moz-box-shadow: rgba(0,0,0,0.15) 0 0 5px; 107 | box-shadow: rgba(0,0,0,0.15) 0 0 5px; 108 | } 109 | 110 | #nav #sections > li { 111 | border-bottom: 1px solid rgba(0,0,0,0.05); 112 | border-top: 1px solid rgba(255,255,255,0.50); 113 | } 114 | 115 | #nav #sections > li > a { 116 | padding: 5px 15px; 117 | color: #555; 118 | font-size: 14px; 119 | } 120 | 121 | #nav #sections > li > a:hover { 122 | background: #ddd; 123 | background: rgba(0,0,0,0.05); 124 | } 125 | 126 | #nav #sections > li:last-child { 127 | border-bottom: 1px solid rgba(255,255,255,0.50); 128 | } 129 | 130 | #nav #sections ul { 131 | margin-bottom: 6px; 132 | } 133 | 134 | #nav #sections ul li a { 135 | padding: 1px 25px; 136 | font-size: 13px; 137 | } 138 | 139 | #nav #sections ul li a:hover { 140 | background: #ddd; 141 | background: rgba(0,0,0,0.05); 142 | } 143 | 144 | #nav .extra{ 145 | padding: 5px 15px; 146 | min-height: 1.4em; 147 | } 148 | 149 | #nav .extra a { 150 | color: #555; 151 | font-size: 14px; 152 | } 153 | 154 | #content { 155 | margin: 0 40px 0 290px; 156 | padding: 20px 0; 157 | min-height: 100px; 158 | max-width: 800px; 159 | } 160 | 161 | #content p { 162 | padding: 0 0 .8125em 0; 163 | color: #111; 164 | font-weight: 300; 165 | zoom: 1; 166 | } 167 | 168 | #content p:before, #content p:after { 169 | content: ""; 170 | display: table; 171 | } 172 | 173 | #content p:after { 174 | clear: both; 175 | } 176 | 177 | #content p img { 178 | float: left; 179 | margin: .5em .8125em .8125em 0; 180 | padding: 0; 181 | } 182 | 183 | #content img { 184 | max-width: 100%; 185 | } 186 | 187 | #content h1, #content h2, #content h3, #content h4, #content h5, #content h6 { 188 | font-weight: normal; 189 | color: #333; 190 | line-height: 1.2em; 191 | } 192 | 193 | #content h1 { 194 | font-size: 2.125em; 195 | margin-bottom: .765em; 196 | } 197 | 198 | #content h2 { 199 | font-size: 1.7em; 200 | margin: .855em 0; 201 | } 202 | 203 | #content h3 { 204 | font-size: 1.3em; 205 | margin: .956em 0; 206 | } 207 | 208 | #content h4 { 209 | font-size: 1.1em; 210 | margin: 1.161em 0; 211 | } 212 | 213 | #content h5, #content h6 { 214 | font-size: 1em; 215 | font-weight: bold; 216 | margin: 1.238em 0; 217 | } 218 | 219 | #content ul { 220 | list-style-position: outside; 221 | } 222 | 223 | #content li ul, #content li ol { 224 | margin: 0 1.625em; 225 | } 226 | 227 | #content ul, #content ol { 228 | margin: 0 0 1.625em 1em; 229 | } 230 | 231 | #content dl { 232 | margin: 0 0 1.625em 0; 233 | } 234 | 235 | #content dl dt { 236 | font-weight: bold; 237 | } 238 | 239 | #content dl dd { 240 | margin-left: 1.625em; 241 | } 242 | 243 | #content a { 244 | text-decoration: none; 245 | } 246 | 247 | #content a:hover { 248 | text-decoration: underline; 249 | } 250 | 251 | #content table { 252 | margin-bottom: 1.625em; 253 | padding-bottom: 0.5em; 254 | border-collapse: collapse; 255 | border-spacing: 0; 256 | font-size: 80%; 257 | display: block; 258 | border: 1px solid #DDD; 259 | border-radius: 6px; 260 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 261 | } 262 | 263 | #content th { 264 | font-weight: bold; 265 | white-space: nowrap; 266 | } 267 | 268 | #content tr, #content th, #content td { 269 | margin: 0; 270 | padding: 0 0.7em; 271 | height: 26px; 272 | } 273 | 274 | #content table tbody tr:nth-child(odd) { 275 | background-color: #F8F8F8; 276 | } 277 | 278 | #content table thead tr { 279 | border-bottom: 1px solid #DADADA; 280 | line-height: 240%; 281 | } 282 | 283 | #content tfoot { 284 | font-style: italic; 285 | } 286 | 287 | #content caption { 288 | text-align: center; 289 | font-family: Georgia, serif; 290 | } 291 | 292 | #content abbr, #content acronym { 293 | border-bottom: 1px dotted #000; 294 | } 295 | 296 | #content address { 297 | margin-top: 1.625em; 298 | font-style: italic; 299 | } 300 | 301 | #content del { 302 | color: #000; 303 | } 304 | 305 | #content blockquote { 306 | padding: 1em 1em 1.625em 1em; 307 | font-family: georgia, serif; 308 | font-style: italic; 309 | } 310 | 311 | #content blockquote:before { 312 | content: "\201C"; 313 | font-size: 3em; 314 | margin-left: -.625em; 315 | font-family: georgia, serif; 316 | color: #aaa; 317 | line-height: 0; 318 | } 319 | 320 | #content blockquote > p { 321 | padding: 0; 322 | margin: 0; 323 | } 324 | 325 | #content strong { 326 | font-weight: bold; 327 | } 328 | 329 | #content em, #content dfn { 330 | font-style: italic; 331 | } 332 | 333 | #content dfn { 334 | font-weight: bold; 335 | } 336 | 337 | #content pre, #content code { 338 | margin: 0 0 1.625em; 339 | white-space: pre; 340 | } 341 | 342 | #content pre, #content code, #content tt { 343 | font: 12px "Bitstream Vera Sans Mono", "Courier", monospace; 344 | line-height: 1.5; 345 | } 346 | 347 | #content pre { 348 | background: #F8F8F8; 349 | padding: 10px; 350 | border: 1px solid #ddd; 351 | border-radius: 6px; 352 | word-wrap: break-word; 353 | } 354 | 355 | #content tt { 356 | display: block; 357 | margin: 1.625em 0; 358 | } 359 | 360 | #content hr { 361 | margin-bottom: 1.625em; 362 | } 363 | 364 | @media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min--moz-device-pixel-ratio: 1.5), only screen and (min-device-pixel-ratio: 1.5), only screen and (max-width : 480px) { 365 | #nav { 366 | position: static; 367 | width: 100%; 368 | height: auto; 369 | -webkit-box-shadow: none; 370 | -moz-box-shadow: none; 371 | box-shadow: none; 372 | border-bottom: 1px solid #aaa; 373 | } 374 | 375 | #content { 376 | margin: 0; 377 | padding: 30px; 378 | position: relative; 379 | } 380 | } 381 | 382 | /* This was generated like this: `pygmentize -S github -f html -a .highlight` */ 383 | .highlight .hll { background-color: #ffffcc } 384 | .highlight { background: #ffffff; } 385 | .highlight .c { color: #999988; font-style: italic } /* Comment */ 386 | .highlight .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 387 | .highlight .k { color: #000000; font-weight: bold } /* Keyword */ 388 | .highlight .o { color: #000000; font-weight: bold } /* Operator */ 389 | .highlight .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 390 | .highlight .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */ 391 | .highlight .c1 { color: #999988; font-style: italic } /* Comment.Single */ 392 | .highlight .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ 393 | .highlight .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 394 | .highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ 395 | .highlight .gr { color: #aa0000 } /* Generic.Error */ 396 | .highlight .gh { color: #999999 } /* Generic.Heading */ 397 | .highlight .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 398 | .highlight .go { color: #888888 } /* Generic.Output */ 399 | .highlight .gp { color: #555555 } /* Generic.Prompt */ 400 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 401 | .highlight .gu { color: #aaaaaa } /* Generic.Subheading */ 402 | .highlight .gt { color: #aa0000 } /* Generic.Traceback */ 403 | .highlight .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ 404 | .highlight .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ 405 | .highlight .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ 406 | .highlight .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ 407 | .highlight .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ 408 | .highlight .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 409 | .highlight .m { color: #009999 } /* Literal.Number */ 410 | .highlight .s { color: #d01040 } /* Literal.String */ 411 | .highlight .na { color: #008080 } /* Name.Attribute */ 412 | .highlight .nb { color: #0086B3 } /* Name.Builtin */ 413 | .highlight .nc { color: #445588; font-weight: bold } /* Name.Class */ 414 | .highlight .no { color: #008080 } /* Name.Constant */ 415 | .highlight .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ 416 | .highlight .ni { color: #800080 } /* Name.Entity */ 417 | .highlight .ne { color: #990000; font-weight: bold } /* Name.Exception */ 418 | .highlight .nf { color: #990000; font-weight: bold } /* Name.Function */ 419 | .highlight .nl { color: #990000; font-weight: bold } /* Name.Label */ 420 | .highlight .nn { color: #555555 } /* Name.Namespace */ 421 | .highlight .nt { color: #000080 } /* Name.Tag */ 422 | .highlight .nv { color: #008080 } /* Name.Variable */ 423 | .highlight .ow { color: #000000; font-weight: bold } /* Operator.Word */ 424 | .highlight .w { color: #bbbbbb } /* Text.Whitespace */ 425 | .highlight .mf { color: #009999 } /* Literal.Number.Float */ 426 | .highlight .mh { color: #009999 } /* Literal.Number.Hex */ 427 | .highlight .mi { color: #009999 } /* Literal.Number.Integer */ 428 | .highlight .mo { color: #009999 } /* Literal.Number.Oct */ 429 | .highlight .sb { color: #d01040 } /* Literal.String.Backtick */ 430 | .highlight .sc { color: #d01040 } /* Literal.String.Char */ 431 | .highlight .sd { color: #d01040 } /* Literal.String.Doc */ 432 | .highlight .s2 { color: #d01040 } /* Literal.String.Double */ 433 | .highlight .se { color: #d01040 } /* Literal.String.Escape */ 434 | .highlight .sh { color: #d01040 } /* Literal.String.Heredoc */ 435 | .highlight .si { color: #d01040 } /* Literal.String.Interpol */ 436 | .highlight .sx { color: #d01040 } /* Literal.String.Other */ 437 | .highlight .sr { color: #009926 } /* Literal.String.Regex */ 438 | .highlight .s1 { color: #d01040 } /* Literal.String.Single */ 439 | .highlight .ss { color: #990073 } /* Literal.String.Symbol */ 440 | .highlight .bp { color: #999999 } /* Name.Builtin.Pseudo */ 441 | .highlight .vc { color: #008080 } /* Name.Variable.Class */ 442 | .highlight .vg { color: #008080 } /* Name.Variable.Global */ 443 | .highlight .vi { color: #008080 } /* Name.Variable.Instance */ 444 | .highlight .il { color: #009999 } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /test/client_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | describe "import handler" do 4 | subject do 5 | DoubleDoc::Client.new(sources, options) 6 | end 7 | 8 | describe '#process' do 9 | let(:destination) { Dir.mktmpdir } 10 | let(:sources) { [Bundler.root + 'doc/readme.md'] } 11 | let(:options) { { :md_destination => destination, :roots => [Bundler.root], :quiet => true } } 12 | 13 | before do 14 | subject.process 15 | end 16 | 17 | it 'produces output at the md_destination' do 18 | _(File.exist?(destination + '/readme.md')).must_equal true 19 | end 20 | 21 | describe 'with a missing directory' do 22 | let(:destination) { Dir.mktmpdir + '/tmp' } 23 | 24 | it 'creates the directory' do 25 | _(File.exist?(destination + '/readme.md')).must_equal true 26 | end 27 | end 28 | 29 | describe 'with multiple sources' do 30 | let(:sources) { %w(readme todo).map{|f| Bundler.root + "doc/#{f}.md" } } 31 | 32 | it 'processes all sources' do 33 | _(File.exist?(destination + '/readme.md')).must_equal true 34 | _(File.exist?(destination + '/todo.md')).must_equal true 35 | end 36 | end 37 | 38 | describe 'producing html' do 39 | let(:options) { { 40 | :md_destination => destination, 41 | :html_destination => destination + '/html', 42 | :roots => [Bundler.root], 43 | :quiet => true 44 | } } 45 | 46 | it 'creates html files' do 47 | _(File.exist?(destination + '/html/readme.html')).must_equal true 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/doc_extractor_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | describe "the doc extractor" do 4 | def self.it_acts_like_an_extractor 5 | it "extracts documentation" do 6 | _(subject).must_match(/this line should be extracted/) 7 | _(subject).must_match(/this line should also be extracted/) 8 | _(subject).must_match(/but this line should/) 9 | end 10 | 11 | it "doesn't extract regular comments" do 12 | _(subject).wont_match(/this line should not be extracted/) 13 | end 14 | 15 | it "doesn't add any extra new-lines" do 16 | _(subject).must_match(/^this/m) 17 | _(subject).must_match(/should\n$/m) 18 | end 19 | 20 | it "adds an empty line between documentation sections" do 21 | _(subject).must_match(/extracted\n\nthis/m) 22 | end 23 | end 24 | 25 | describe "on .rb files" do 26 | ## this line should be extracted 27 | # this line should not be extracted 28 | ## this line should also be extracted 29 | # puts "Bug##1 this line should not be extracted" 30 | ## but this line should 31 | 32 | subject do 33 | DoubleDoc::DocExtractor.extract(File.new(__FILE__)) 34 | end 35 | 36 | it_acts_like_an_extractor 37 | end 38 | 39 | describe 'on .js files' do 40 | subject do 41 | source = <<-EOS 42 | /// this line should be extracted 43 | // this line should not be extracted 44 | /// this line should also be extracted 45 | // console.log('/// this line should not be extracted') 46 | /// but this line should 47 | EOS 48 | DoubleDoc::DocExtractor.extract(source, :type => 'js') 49 | end 50 | 51 | it_acts_like_an_extractor 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/html_generator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | require 'pathname' 3 | require 'tmpdir' 4 | 5 | describe "the html generator" do 6 | before do 7 | @root = Pathname.new(Dir.mktmpdir) 8 | @input_file_name = @root + 'source/input.md' 9 | @destination = @root + 'destination' 10 | @output_file_name = @destination + 'input.html' 11 | Dir.mkdir(@root + 'source') 12 | Dir.mkdir(@destination) 13 | @generator = DoubleDoc::HtmlGenerator.new([@input_file_name], { 14 | :html_destination => @destination, 15 | :quiet => true 16 | }) 17 | end 18 | 19 | after do 20 | FileUtils.rm_rf(@root) 21 | end 22 | 23 | describe "#generate" do 24 | before do 25 | File.open(@input_file_name, 'w') do |f| 26 | f.puts "## Hello" 27 | f.puts "and some text and a link to [the other file](other.md)" 28 | f.puts "and a link with params [params](params.md?foo=bar)" 29 | f.puts "and a link with a fragment [params](params.md#foo-bar)" 30 | end 31 | 32 | File.open(@destination + 'some_trash.html', 'w') do |f| 33 | f.puts 'what ever' 34 | end 35 | 36 | @generator.generate 37 | end 38 | 39 | it "should put an html document in the destination directory" do 40 | assert File.exist?(@output_file_name), 'did not create the html file' 41 | end 42 | 43 | it "should convert .md links to .html links" do 44 | output = File.read(@output_file_name) 45 | _(output).must_match(/the other file<\/a>/) 46 | _(output).must_match(/params<\/a>/) 47 | _(output).must_match(/params<\/a>/) 48 | end 49 | 50 | end 51 | 52 | describe "navigation" do 53 | before do 54 | @input_file_one = @root + 'source/file_one.md' 55 | File.open(@input_file_one, 'w') do |f| 56 | f.puts "## Title One" 57 | end 58 | 59 | @input_file_two = @root + 'source/file_two.md' 60 | File.open(@input_file_two, 'w') do |f| 61 | f.puts "## Title Two" 62 | end 63 | 64 | @input_files = [@input_file_one, @input_file_two] 65 | end 66 | 67 | it "should generate links for each page in the navigation area" do 68 | generator = DoubleDoc::HtmlGenerator.new(@input_files, { 69 | :html_destination => @destination, 70 | :quiet => true 71 | }) 72 | generator.generate 73 | 74 | output = File.read(@destination + 'file_one.html') 75 | 76 | _(output).must_match(/
  • \s*Title One<\/a>\s*<\/li>/) 77 | _(output).must_match(/
  • \s*Title Two<\/a>\s*<\/li>/) 78 | end 79 | 80 | it "should skip specified filed" do 81 | generator = DoubleDoc::HtmlGenerator.new(@input_files, { 82 | :html_destination => @destination, 83 | :exclude_from_navigation => ['file_two.html'], 84 | :quiet => true 85 | }) 86 | generator.generate 87 | 88 | output = File.read(@destination + 'file_one.html') 89 | 90 | _(output).must_match(/
  • \s*Title One<\/a>\s*<\/li>/) 91 | _(output).wont_match(/
  • \s*Title Two<\/a>\s*<\/li>/) 92 | end 93 | end 94 | 95 | end 96 | -------------------------------------------------------------------------------- /test/import_handler_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | describe "import handler" do 4 | subject do 5 | roots = Array(root).push(options.merge( :quiet => true )) 6 | DoubleDoc::ImportHandler.new(*roots) 7 | end 8 | 9 | after do 10 | ENV["BUNDLE_GEMFILE"] = Bundler.root.join("Gemfile").to_s 11 | end 12 | 13 | describe "multiple roots" do 14 | let(:root) { [Bundler.root + 'lib', Bundler.root + 'doc'] } 15 | let(:options) {{}} 16 | 17 | it "finds files from either root" do 18 | _(subject.find_file("double_doc.rb")).must_be_instance_of File 19 | _(subject.find_file("readme.md")).must_be_instance_of File 20 | end 21 | end 22 | 23 | describe "with gemfile" do 24 | let(:root) { Bundler.root } 25 | let(:options) {{ :gemfile => true }} 26 | 27 | describe "rubygems" do 28 | describe "load_paths" do 29 | it "should add Gemfile load paths" do 30 | _(subject.load_paths).must_include root 31 | _(subject.load_paths.size).must_be :>, 1 32 | end 33 | end 34 | 35 | describe "find_file" do 36 | it "should resolve files" do 37 | _(subject.find_file("redcarpet.rb")).must_be_instance_of File 38 | end 39 | 40 | it "should raise if unable to find file" do 41 | _{ subject.find_file("nope.rb") }.must_raise LoadError 42 | end 43 | end 44 | end 45 | 46 | describe "find_file" do 47 | it "should resolve files from path" do 48 | _(subject.find_file("double_doc.rb")).must_be_instance_of File 49 | end 50 | 51 | it "should resolve file from git" do 52 | _(subject.find_file("mime-types.rb")).must_be_instance_of File 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "maxitest/autorun" 3 | 4 | require "double_doc" 5 | --------------------------------------------------------------------------------