diff options
Diffstat (limited to 'guides/rails_guides')
-rw-r--r-- | guides/rails_guides/generator.rb | 310 | ||||
-rw-r--r-- | guides/rails_guides/helpers.rb | 45 | ||||
-rw-r--r-- | guides/rails_guides/indexer.rb | 68 | ||||
-rw-r--r-- | guides/rails_guides/levenshtein.rb | 31 | ||||
-rw-r--r-- | guides/rails_guides/textile_extensions.rb | 63 |
5 files changed, 517 insertions, 0 deletions
diff --git a/guides/rails_guides/generator.rb b/guides/rails_guides/generator.rb new file mode 100644 index 0000000000..d6a98f9ac4 --- /dev/null +++ b/guides/rails_guides/generator.rb @@ -0,0 +1,310 @@ +# --------------------------------------------------------------------------- +# +# This script generates the guides. It can be invoked either directly or via the +# generate_guides rake task within the railties directory. +# +# Guides are taken from the source directory, and the resulting HTML goes into the +# output directory. Assets are stored under files, and copied to output/files as +# part of the generation process. +# +# Some arguments may be passed via environment variables: +# +# WARNINGS +# If you are writing a guide, please work always with WARNINGS=1. Users can +# generate the guides, and thus this flag is off by default. +# +# Internal links (anchors) are checked. If a reference is broken levenshtein +# distance is used to suggest an existing one. This is useful since IDs are +# generated by Textile from headers and thus edits alter them. +# +# Also detects duplicated IDs. They happen if there are headers with the same +# text. Please do resolve them, if any, so guides are valid XHTML. +# +# ALL +# Set to "1" to force the generation of all guides. +# +# ONLY +# Use ONLY if you want to generate only one or a set of guides. Prefixes are +# enough: +# +# # generates only association_basics.html +# ONLY=assoc ruby rails_guides.rb +# +# Separate many using commas: +# +# # generates only association_basics.html and migrations.html +# ONLY=assoc,migrations ruby rails_guides.rb +# +# Note that if you are working on a guide generation will by default process +# only that one, so ONLY is rarely used nowadays. +# +# GUIDES_LANGUAGE +# Use GUIDES_LANGUAGE when you want to generate translated guides in +# <tt>source/<GUIDES_LANGUAGE></tt> folder (such as <tt>source/es</tt>). +# Ignore it when generating English guides. +# +# EDGE +# Set to "1" to indicate generated guides should be marked as edge. This +# inserts a badge and changes the preamble of the home page. +# +# KINDLE +# Set to "1" to generate the .mobi with all the guides. The kindlegen +# executable must be in your PATH. You can get it for free from +# http://www.amazon.com/kindlepublishing +# +# --------------------------------------------------------------------------- + +require 'set' +require 'fileutils' + +require 'active_support/core_ext/string/output_safety' +require 'active_support/core_ext/object/blank' +require 'action_controller' +require 'action_view' + +require 'rails_guides/indexer' +require 'rails_guides/helpers' +require 'rails_guides/levenshtein' + +module RailsGuides + class Generator + attr_reader :guides_dir, :source_dir, :output_dir, :edge, :warnings, :all + + GUIDES_RE = /\.(?:textile|erb)$/ + + def initialize(output=nil) + set_flags_from_environment + + if kindle? + check_for_kindlegen + register_kindle_mime_types + end + + initialize_dirs(output) + create_output_dir_if_needed + end + + def set_flags_from_environment + @edge = ENV['EDGE'] == '1' + @warnings = ENV['WARNINGS'] == '1' + @all = ENV['ALL'] == '1' + @kindle = ENV['KINDLE'] == '1' + @version = ENV['RAILS_VERSION'] || `git rev-parse --short HEAD`.chomp + @lang = ENV['GUIDES_LANGUAGE'] + end + + def register_kindle_mime_types + Mime::Type.register_alias("application/xml", :opf, %w(opf)) + Mime::Type.register_alias("application/xml", :ncx, %w(ncx)) + end + + def generate + generate_guides + copy_assets + generate_mobi if kindle? + end + + private + + def kindle? + @kindle + end + + def check_for_kindlegen + if `which kindlegen`.blank? + raise "Can't create a kindle version without `kindlegen`." + end + end + + def generate_mobi + opf = "#{output_dir}/rails_guides.opf" + out = "#{output_dir}/kindlegen.out" + + system "kindlegen #{opf} -o #{mobi} > #{out} 2>&1" + puts "Guides compiled as Kindle book to #{mobi}" + puts "(kindlegen log at #{out})." + end + + def mobi + "ruby_on_rails_guides_#@version%s.mobi" % (@lang.present? ? ".#@lang" : '') + end + + def initialize_dirs(output) + @guides_dir = File.join(File.dirname(__FILE__), '..') + @source_dir = "#@guides_dir/source/#@lang" + @output_dir = if output + output + elsif kindle? + "#@guides_dir/output/kindle/#@lang" + else + "#@guides_dir/output/#@lang" + end.sub(%r</$>, '') + end + + def create_output_dir_if_needed + FileUtils.mkdir_p(output_dir) + end + + def generate_guides + guides_to_generate.each do |guide| + output_file = output_file_for(guide) + generate_guide(guide, output_file) if generate?(guide, output_file) + end + end + + def guides_to_generate + guides = Dir.entries(source_dir).grep(GUIDES_RE) + + if kindle? + Dir.entries("#{source_dir}/kindle").grep(GUIDES_RE).map do |entry| + guides << "kindle/#{entry}" + end + end + + ENV.key?('ONLY') ? select_only(guides) : guides + end + + def select_only(guides) + prefixes = ENV['ONLY'].split(",").map(&:strip) + guides.select do |guide| + prefixes.any? { |p| guide.start_with?(p) || guide.start_with?("kindle") } + end + end + + def copy_assets + FileUtils.cp_r(Dir.glob("#{guides_dir}/assets/*"), output_dir) + end + + def output_file_for(guide) + if guide =~/\.textile$/ + guide.sub(/\.textile$/, '.html') + else + guide.sub(/\.erb$/, '') + end + end + + def output_path_for(output_file) + File.join(output_dir, File.basename(output_file)) + end + + def generate?(source_file, output_file) + fin = File.join(source_dir, source_file) + fout = output_path_for(output_file) + all || !File.exists?(fout) || File.mtime(fout) < File.mtime(fin) + end + + def generate_guide(guide, output_file) + output_path = output_path_for(output_file) + puts "Generating #{guide} as #{output_file}" + layout = kindle? ? 'kindle/layout' : 'layout' + + File.open(output_path, 'w') do |f| + view = ActionView::Base.new(source_dir, :edge => @edge, :version => @version, :mobi => "kindle/#{mobi}") + view.extend(Helpers) + + if guide =~ /\.(\w+)\.erb$/ + # Generate the special pages like the home. + # Passing a template handler in the template name is deprecated. So pass the file name without the extension. + result = view.render(:layout => layout, :formats => [$1], :file => $`) + else + body = File.read(File.join(source_dir, guide)) + body = set_header_section(body, view) + body = set_index(body, view) + + result = view.render(:layout => layout, :text => textile(body)) + + warn_about_broken_links(result) if @warnings + end + + f.write(result) + end + end + + def set_header_section(body, view) + new_body = body.gsub(/(.*?)endprologue\./m, '').strip + header = $1 + + header =~ /h2\.(.*)/ + page_title = "Ruby on Rails Guides: #{$1.strip}" + + header = textile(header) + + view.content_for(:page_title) { page_title.html_safe } + view.content_for(:header_section) { header.html_safe } + new_body + end + + def set_index(body, view) + index = <<-INDEX + <div id="subCol"> + <h3 class="chapter"><img src="images/chapters_icon.gif" alt="" />Chapters</h3> + <ol class="chapters"> + INDEX + + i = Indexer.new(body, warnings) + i.index + + # Set index for 2 levels + i.level_hash.each do |key, value| + link = view.content_tag(:a, :href => key[:id]) { textile(key[:title], true).html_safe } + + children = value.keys.map do |k| + view.content_tag(:li, + view.content_tag(:a, :href => k[:id]) { textile(k[:title], true).html_safe }) + end + + children_ul = children.empty? ? "" : view.content_tag(:ul, children.join(" ").html_safe) + + index << view.content_tag(:li, link.html_safe + children_ul.html_safe) + end + + index << '</ol>' + index << '</div>' + + view.content_for(:index_section) { index.html_safe } + + i.result + end + + def textile(body, lite_mode=false) + t = RedCloth.new(body) + t.hard_breaks = false + t.lite_mode = lite_mode + t.to_html(:notestuff, :plusplus, :code) + end + + def warn_about_broken_links(html) + anchors = extract_anchors(html) + check_fragment_identifiers(html, anchors) + end + + def extract_anchors(html) + # Textile generates headers with IDs computed from titles. + anchors = Set.new + html.scan(/<h\d\s+id="([^"]+)/).flatten.each do |anchor| + if anchors.member?(anchor) + puts "*** DUPLICATE ID: #{anchor}, please use an explicit ID, e.g. h4(#explicit-id), or consider rewording" + else + anchors << anchor + end + end + + # Footnotes. + anchors += Set.new(html.scan(/<p\s+class="footnote"\s+id="([^"]+)/).flatten) + anchors += Set.new(html.scan(/<sup\s+class="footnote"\s+id="([^"]+)/).flatten) + return anchors + end + + def check_fragment_identifiers(html, anchors) + html.scan(/<a\s+href="#([^"]+)/).flatten.each do |fragment_identifier| + next if fragment_identifier == 'mainCol' # in layout, jumps to some DIV + unless anchors.member?(fragment_identifier) + guess = anchors.min { |a, b| + Levenshtein.distance(fragment_identifier, a) <=> Levenshtein.distance(fragment_identifier, b) + } + puts "*** BROKEN LINK: ##{fragment_identifier}, perhaps you meant ##{guess}." + end + end + end + end +end diff --git a/guides/rails_guides/helpers.rb b/guides/rails_guides/helpers.rb new file mode 100644 index 0000000000..e6ef656474 --- /dev/null +++ b/guides/rails_guides/helpers.rb @@ -0,0 +1,45 @@ +module RailsGuides + module Helpers + def guide(name, url, options = {}, &block) + link = content_tag(:a, :href => url) { name } + result = content_tag(:dt, link) + + if options[:work_in_progress] + result << content_tag(:dd, 'Work in progress', :class => 'work-in-progress') + end + + result << content_tag(:dd, capture(&block)) + result + end + + def documents_by_section + @documents_by_section ||= YAML.load_file(File.expand_path('../../source/documents.yaml', __FILE__)) + end + + def documents_flat + documents_by_section.map {|section| section['documents']}.flatten + end + + def finished_documents(documents) + documents.reject { |document| document['work_in_progress'] } + end + + def docs_for_menu(position) + position == 'L' ? documents_by_section.to(3) : documents_by_section.from(4) + end + + def author(name, nick, image = 'credits_pic_blank.gif', &block) + image = "images/#{image}" + + result = content_tag(:img, nil, :src => image, :class => 'left pic', :alt => name, :width => 91, :height => 91) + result << content_tag(:h3, name) + result << content_tag(:p, capture(&block)) + content_tag(:div, result, :class => 'clearfix', :id => nick) + end + + def code(&block) + c = capture(&block) + content_tag(:code, c) + end + end +end diff --git a/guides/rails_guides/indexer.rb b/guides/rails_guides/indexer.rb new file mode 100644 index 0000000000..89fbccbb1d --- /dev/null +++ b/guides/rails_guides/indexer.rb @@ -0,0 +1,68 @@ +require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/string/inflections' + +module RailsGuides + class Indexer + attr_reader :body, :result, :warnings, :level_hash + + def initialize(body, warnings) + @body = body + @result = @body.dup + @warnings = warnings + end + + def index + @level_hash = process(body) + end + + private + + def process(string, current_level=3, counters=[1]) + s = StringScanner.new(string) + + level_hash = {} + + while !s.eos? + re = %r{^h(\d)(?:\((#.*?)\))?\s*\.\s*(.*)$} + s.match?(re) + if matched = s.matched + matched =~ re + level, idx, title = $1.to_i, $2, $3.strip + + if level < current_level + # This is needed. Go figure. + return level_hash + elsif level == current_level + index = counters.join(".") + idx ||= '#' + title_to_idx(title) + + raise "Parsing Fail" unless @result.sub!(matched, "h#{level}(#{idx}). #{index} #{title}") + + key = { + :title => title, + :id => idx + } + # Recurse + counters << 1 + level_hash[key] = process(s.post_match, current_level + 1, counters) + counters.pop + + # Increment the current level + last = counters.pop + counters << last + 1 + end + end + s.getch + end + level_hash + end + + def title_to_idx(title) + idx = title.strip.parameterize.sub(/^\d+/, '') + if warnings && idx.blank? + puts "BLANK ID: please put an explicit ID for section #{title}, as in h5(#my-id)" + end + idx + end + end +end diff --git a/guides/rails_guides/levenshtein.rb b/guides/rails_guides/levenshtein.rb new file mode 100644 index 0000000000..489aa3ea7a --- /dev/null +++ b/guides/rails_guides/levenshtein.rb @@ -0,0 +1,31 @@ +module RailsGuides + module Levenshtein + # Based on the pseudocode in http://en.wikipedia.org/wiki/Levenshtein_distance + def self.distance(s1, s2) + s = s1.unpack('U*') + t = s2.unpack('U*') + m = s.length + n = t.length + + # matrix initialization + d = [] + 0.upto(m) { |i| d << [i] } + 0.upto(n) { |j| d[0][j] = j } + + # distance computation + 1.upto(m) do |i| + 1.upto(n) do |j| + cost = s[i] == t[j] ? 0 : 1 + d[i][j] = [ + d[i-1][j] + 1, # deletion + d[i][j-1] + 1, # insertion + d[i-1][j-1] + cost, # substitution + ].min + end + end + + # all done + return d[m][n] + end + end +end diff --git a/guides/rails_guides/textile_extensions.rb b/guides/rails_guides/textile_extensions.rb new file mode 100644 index 0000000000..4677fae504 --- /dev/null +++ b/guides/rails_guides/textile_extensions.rb @@ -0,0 +1,63 @@ +require 'active_support/core_ext/object/inclusion' + +module RailsGuides + module TextileExtensions + def notestuff(body) + # The following regexp detects special labels followed by a + # paragraph, perhaps at the end of the document. + # + # It is important that we do not eat more than one newline + # because formatting may be wrong otherwise. For example, + # if a bulleted list follows the first item is not rendered + # as a list item, but as a paragraph starting with a plain + # asterisk. + body.gsub!(/^(TIP|IMPORTANT|CAUTION|WARNING|NOTE|INFO)[.:](.*?)(\n(?=\n)|\Z)/m) do |m| + css_class = case $1 + when 'CAUTION', 'IMPORTANT' + 'warning' + when 'TIP' + 'info' + else + $1.downcase + end + %Q(<div class="#{css_class}"><p>#{$2.strip}</p></div>) + end + end + + def plusplus(body) + body.gsub!(/\+(.*?)\+/) do |m| + "<notextile><tt>#{$1}</tt></notextile>" + end + + # The real plus sign + body.gsub!('<plus>', '+') + end + + def brush_for(code_type) + case code_type + when 'ruby', 'sql', 'plain' + code_type + when 'erb' + 'ruby; html-script: true' + when 'html' + 'xml' # html is understood, but there are .xml rules in the CSS + else + 'plain' + end + end + + def code(body) + body.gsub!(%r{<(yaml|shell|ruby|erb|html|sql|plain)>(.*?)</\1>}m) do |m| + <<HTML +<notextile> +<div class="code_container"> +<pre class="brush: #{brush_for($1)}; gutter: false; toolbar: false"> +#{ERB::Util.h($2).strip} +</pre> +</div> +</notextile> +HTML + end + end + end +end |