diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..c99d2e739 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.travis.yml b/.travis.yml index 9458486a7..390fda041 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,26 @@ language: ruby - +sudo: false +bundler_args: --jobs 4 --retry 2 --without packaging documentation before_install: - git config --global user.name "TravisCI" - git config --global user.email "noreply@example.com" - sudo apt-get install wkhtmltopdf +script: + - "bundle exec rake $CHECK" +notifications: + email: false + +matrix: + include: + - rvm: 2.0 + env: "CHECK=spec" + gemfile: Gemfile.ruby-2.0.x + + - rvm: 2.3 + env: "CHECK=spec" + + - rvm: 2.5 + env: "CHECK=spec" + + - rvm: 2.6 + env: "CHECK=spec" diff --git a/Gemfile b/Gemfile index 61ed0d2fb..e1b956ba1 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,8 @@ end group :test do gem 'rake' + gem 'rspec' + gem 'pry' end gem 'rack-contrib' diff --git a/Gemfile.ruby-2.0.x b/Gemfile.ruby-2.0.x new file mode 100644 index 000000000..19b2d3e6b --- /dev/null +++ b/Gemfile.ruby-2.0.x @@ -0,0 +1,25 @@ +source 'https://rubygems.org' + +gemspec + +gem 'public_suffix', '2.0.5' +gem 'i18n', '1.2.0' +gem 'nokogiri', '1.6.8.1' + +group :development do + gem "turn" + gem "rack-test" + gem "pdf-inspector" +end + +group :optional do + gem "pdfkit" +end + +group :test do + gem 'rake' + gem 'rspec' + gem 'pry' +end + +gem 'rack-contrib' diff --git a/Rakefile b/Rakefile index 87f901404..871a54dae 100644 --- a/Rakefile +++ b/Rakefile @@ -67,21 +67,33 @@ task 'doc:website' => [:doc] do end end -desc "Run tests" -task :test do - require 'rake/testtask' - - Rake::TestTask.new do |t| - t.libs << 'lib' - t.pattern = 'test/**/*_test.rb' - t.verbose = false +# These tests are currently unmaintained. +# @todo: port and delete +# +# desc "Run tests" +# task :test do +# require 'rake/testtask' +# +# Rake::TestTask.new do |t| +# t.libs << 'lib' +# t.pattern = 'test/**/*_test.rb' +# t.verbose = false +# end +# +# suffix = "-n #{ENV['TEST']}" if ENV['TEST'] +# sh "turn test/*_test.rb #{suffix}" +# end + +desc "Run RSpec unit tests" +task :spec do + ENV["LOG_SPEC_ORDER"] = "true" + if ENV['verbose'] == 'true' + sh %{rspec #{ENV['TEST'] || ENV['TESTS'] || 'spec'} -fd} + else + sh %{rspec #{ENV['TEST'] || ENV['TESTS'] || 'spec'}} end - - suffix = "-n #{ENV['TEST']}" if ENV['TEST'] - sh "turn test/*_test.rb #{suffix}" end - desc 'Validate translation files' task 'lang:check' do require 'yaml' diff --git a/bin/showoff b/bin/showoff index 2056044c2..7829a7e31 100755 --- a/bin/showoff +++ b/bin/showoff @@ -1,7 +1,7 @@ #! /usr/bin/env ruby $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') -require 'showoff' +#require 'showoff' require 'showoff/version' require 'rubygems' require 'fidget' @@ -28,7 +28,8 @@ module Wrapper The simplest use case is to run `showoff serve` from the directory containing the showoff.json file. desc - + switch :dev, :desc => "Use the next-gen development version of Showoff" + switch :debug, :desc => "Show application backtraces on crash" desc 'Create new showoff presentation' long_desc 'This command helps start a new showoff presentation by setting up the proper directory structure for you. It takes the directory name you would like showoff to create for you.' @@ -331,15 +332,37 @@ module Wrapper arg_name 'name' long_desc 'Creates a PDF version of the presentation as {name}.pdf' command [:pdf] do |c| + c.desc 'JSON file used to describe presentation' + c.default_value "showoff.json" + c.flag [:f, :file, :pres_file] + + c.desc 'Language code to generate.' + c.flag [:l, :lang, :language, :locale] + c.action do |global_options,options,args| - Showoff.do_static(['pdf'].concat args) + Showoff.do_static(['pdf'].concat(args), options) end end pre do |global,command,options,args| # Pre logic here - # Return true to proceed; false to abourt and not call the + # Return true to proceed; false to abort and not call the # chosen command + + if global[:debug] + ENV['GLI_DEBUG'] = 'true' + end + + if global[:dev] + require 'showoff_ng' + + if options[:file] + Showoff::Config.load(options[:file]) + end + else + require 'showoff' + end + true end diff --git a/lib/showoff.rb b/lib/showoff.rb index fe5b3dd03..4d4863c2b 100644 --- a/lib/showoff.rb +++ b/lib/showoff.rb @@ -1518,6 +1518,8 @@ def self.do_static(args, opts = {}) ["js", "css"].each { |dir| FileUtils.copy_entry("#{my_path}/#{dir}", "#{out}/#{dir}", false, false, true) } + + # @todo: uh. I don't know how this ever worked. my_path is showoff and name is presentation. # And copy the directory Dir.glob("#{my_path}/#{name}/*").each { |subpath| base = File.basename(subpath) @@ -1533,7 +1535,12 @@ def self.do_static(args, opts = {}) # ..., copy all user-defined styles and javascript files showoff.css_files.each { |path| - dest = File.join(file_dir, path) + dest = File.join(out, path) + FileUtils.mkdir_p(File.dirname(dest)) + FileUtils.copy(path, dest) + } + showoff.js_files.each { |path| + dest = File.join(out, path) FileUtils.mkdir_p(File.dirname(dest)) FileUtils.copy(path, dest) } diff --git a/lib/showoff/compiler.rb b/lib/showoff/compiler.rb new file mode 100644 index 000000000..e0abe9dd9 --- /dev/null +++ b/lib/showoff/compiler.rb @@ -0,0 +1,106 @@ +require 'tilt' +require 'tilt/erb' +require 'nokogiri' + +class Showoff::Compiler + require 'showoff/compiler/form' + require 'showoff/compiler/variables' + require 'showoff/compiler/fixups' + require 'showoff/compiler/i18n' + require 'showoff/compiler/notes' + require 'showoff/compiler/glossary' + require 'showoff/compiler/downloads' + require 'showoff/compiler/table_of_contents' + + def initialize(options) + @options = options + @profile = profile + end + + # Configures Tilt with the selected engine and options. + # + # Returns render options profile hash + # + # Source: + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff_utils.rb#L671-L720 + # TODO: per slide profiles of render options + def profile + renderer = Showoff::Config.get('markdown') + profile = Showoff::Config.get(renderer) + + begin + # Load markdown configuration + case renderer + when 'rdiscount' + Tilt.prefer Tilt::RDiscountTemplate, "markdown" + + when 'maruku' + Tilt.prefer Tilt::MarukuTemplate, "markdown" + # Now check if we can go for latex mode + require 'maruku' + require 'maruku/ext/math' + + if profile[:use_tex] + MaRuKu::Globals[:html_math_output_mathml] = false + MaRuKu::Globals[:html_math_output_png] = true + MaRuKu::Globals[:html_math_engine] = 'none' + MaRuKu::Globals[:html_png_engine] = 'blahtex' + MaRuKu::Globals[:html_png_dir] = profile[:png_dir] + MaRuKu::Globals[:html_png_url] = profile[:html_png_url] + end + + when 'bluecloth' + Tilt.prefer Tilt::BlueClothTemplate, "markdown" + + when 'kramdown' + Tilt.prefer Tilt::KramdownTemplate, "markdown" + + when 'commonmarker', 'commonmark' + Tilt.prefer Tilt::CommonMarkerTemplate, "markdown" + + when 'redcarpet', :default + Tilt.prefer Tilt::RedcarpetTemplate, "markdown" + + else + raise 'Unsupported markdown renderer' + + end + rescue LoadError + puts "ERROR: The #{renderer} markdown rendering engine does not appear to be installed correctly." + exit! 1 + end + + profile + end + + # Compiles markdown and all Showoff extensions into the final HTML output and notes. + # + # @param content [String] markdown content. + # @return [[String, Array]] A tuple of (html content, array of notes contents) + # + # @todo I think the update_image_paths() malarky is redundant. Verify that. + def render(content) + Variables::interpolate!(content) + I18n.selectLanguage!(content) + + html = Tilt[:markdown].new(nil, nil, @profile) { content }.render + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + Form.render!(doc, @options) + Fixups.updateClasses!(doc) + Fixups.updateLinks!(doc) + Fixups.updateSyntaxHighlighting!(doc) + Fixups.updateCommandlineBlocks!(doc) + Fixups.updateImagePaths!(doc, @options) + Glossary.render!(doc) + Downloads.scanForFiles!(doc, @options) + + # This call must be last in the chain because it separates notes from the + # content and returns them separately. If it's not last, then the notes + # won't have all the compilation steps applied to them. + # + # must pass in extra context because this will render markdown itself + Notes.render!(doc, @profile, @options) + end + +end diff --git a/lib/showoff/compiler/downloads.rb b/lib/showoff/compiler/downloads.rb new file mode 100644 index 000000000..0983e148b --- /dev/null +++ b/lib/showoff/compiler/downloads.rb @@ -0,0 +1,91 @@ +# adds file download link processing +class Showoff::Compiler::Downloads + + # Scan for file download links and move them to the state storage. + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The slide document + # + # @return [Nokogiri::HTML::DocumentFragment] + # The slide DOM with download links removed. + # + # @todo Should .download change meaning to 'make available on this slide'? + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L1056-L1073 + def self.scanForFiles!(doc, options) + current = Showoff::State.get(:slide_count) + doc.search('p.download').each do |container| + links = container.text.gsub(/^\.download ?/, '') + links.split("\n").each do |line| + file, modifier = line.split + modifier ||= 'next' # @todo Is this still the proper default? + + case modifier + when 'a', 'all', 'always', 'now' + self.pushFile(0, current, options[:name], file) + when 'p', 'prev', 'previous' + self.pushFile(current-1, current, options[:name], file) + when 'c', 'curr', 'current' + self.pushFile(current, current, options[:name], file) + when 'n', 'next' + self.pushFile(current+1, current, options[:name], file) + end + end + + container.remove + end + + doc + end + + +# Convention that index 0 represents files that are always available and every +# other index represents files whose visibility will be triggered on that slide. +# +# [ +# { +# :enabled => false, +# :slides => [ +# {:slidenum => num, :source => name, :file => file}, +# {:slidenum => num, :source => name, :file => file}, +# ], +# }, +# { +# :enabled => false, +# :slides => [ +# {:slidenum => num, :source => name, :file => file}, +# {:slidenum => num, :source => name, :file => file}, +# ], +# }, +# ] + + + def self.pushFile(index, current, source, file) + record = Showoff::State.getAtIndex(:downloads, index) || {} + record[:enabled] ||= false + record[:slides] ||= [] + record[:slides] << {:slidenum => current, :source => source, :file => file} + + Showoff::State.setAtIndex(:downloads, index, record) + end + + def self.enableFiles(index) + record = Showoff::State.getAtIndex(:downloads, index) + return unless record + + record[:enabled] = true + Showoff::State.setAtIndex(:downloads, index, record) + end + + def self.getFiles(index) + record = Showoff::State.getAtIndex(:downloads, index) + + if (record and record[:enabled]) + record[:slides] + else + [] + end + end + +end diff --git a/lib/showoff/compiler/fixups.rb b/lib/showoff/compiler/fixups.rb new file mode 100644 index 000000000..c5e6c8f2a --- /dev/null +++ b/lib/showoff/compiler/fixups.rb @@ -0,0 +1,142 @@ +require 'commandline_parser' + +# adds misc fixup methods to the compiler +class Showoff::Compiler::Fixups + + # Find any

or tags with classes defined via the prefixed dot syntax. + # Remove .break and .comment paragraphs and apply classes/alt to the rest. + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The slide document + # @return [Nokogiri::HTML::DocumentFragment] + # The document with classes applied. + def self.updateClasses!(doc) + doc.search('p').select {|p| p.text.start_with? '.'}.each do |p| + # The first string of plain text in the paragraph + node = p.children.first + classes, sep, text = node.content.partition(' ') + classes = classes.split('.') + classes.shift + + if ['break', 'comment'].include? classes.first + p.remove + else + p.add_class(classes.join(' ')) + node.content = text + end + end + + doc.search('img').select {|img| img.attr('alt').start_with? '.'}.each do |img| + classes, sep, text = img.attr('alt').partition(' ') + classes = classes.split('.') + classes.shift + + img.add_class(classes.join(' ')) + img.set_attribute('alt', text) + end + + doc + end + + # Ensure that all links open in a new window. Perhaps move some of this to glossary.rb + def self.updateLinks!(doc) + doc.search('a').each do |link| + next unless link['href'] + next if link['href'].start_with? '#' + next if link['href'].start_with? 'glossary://' + # Add a target so we open all external links from notes in a new window + link.set_attribute('target', '_blank') + end + + doc + end + + # This munges code blocks to ensure the proper syntax highlighting + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L1105-L1133 + def self.updateSyntaxHighlighting!(doc) + doc.search('pre').each do |pre| + pre.search('code').each do |code| + out = code.text + lang = code.get_attribute('class') + + # Skip this if we've got an empty code block + next if out.empty? + + # catch fenced code blocks from commonmarker + if (lang and lang.start_with? 'language-' ) + pre.set_attribute('class', 'highlight') + # turn the colon separated name back into classes + code.set_attribute('class', lang.gsub(':', ' ')) + + # or we've started a code block with a Showoff language tag + elsif out.strip[0, 3] == '@@@' + lines = out.split("\n") + lang = lines.shift.gsub('@@@', '').strip + pre.set_attribute('class', 'highlight') + code.set_attribute('class', 'language-' + lang.downcase) if !lang.empty? + code.content = lines.join("\n") + end + + end + end + + doc + end + + # This munges commandline code blocks for the proper classing + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L1107 + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L1135-L1163 + def self.updateCommandlineBlocks!(doc) + parser = CommandlineParser.new + doc.search('.commandline > pre > code').each do |code| + out = code.text + code.content = '' + tree = parser.parse(out) + transform = Parslet::Transform.new do + rule(:prompt => simple(:prompt), :input => simple(:input), :output => simple(:output)) do + command = Nokogiri::XML::Node.new('code', doc) + command.set_attribute('class', 'command') + command.content = "#{prompt} #{input}" + code << command + + # Add newline after the input so that users can + # advance faster than the typewriter effect + # and still keep inputs on separate lines. + code << "\n" + + unless output.to_s.empty? + + result = Nokogiri::XML::Node.new('code', doc) + result.set_attribute('class', 'result') + result.content = output + code << result + end + end + end + transform.apply(tree) + end + + doc + end + + # Because source slide files can be nested in arbitrarily deep directories we + # need to simplify paths to images when we flatten it out to a single HTML file. + # @see + # https://github.com/puppetlabs/showoff/blob/220d6eef4c5942eda625dd6edc5370c7490eced7/lib/showoff.rb#L1076-L1103 + def self.updateImagePaths!(doc, options={}) + doc.search('img').each do |img| + slide_dir = File.dirname(options[:name]) + + # We need to turn all URLs into relative from the root. If it starts with '/' + # then we can assume the author meant to start the path at the presentation root. + if img[:src].start_with? '/' + img[:src] = img[:src][1..-1] + else + # clean up the path and remove some of the relative nonsense + img[:src] = Pathname.new(File.join(slide_dir, img[:src])).cleanpath.to_path + end + end + end +end diff --git a/lib/showoff/compiler/form.rb b/lib/showoff/compiler/form.rb new file mode 100644 index 000000000..cf283608d --- /dev/null +++ b/lib/showoff/compiler/form.rb @@ -0,0 +1,236 @@ +# Adds form processing to the compiler +# +# @see https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L848-L1022 +class Showoff::Compiler::Form + + # Add the form markup to the slide and then render all elements + # + # @todo UI elements to translate once i18n is baked in. + # @todo Someday this should be rearchitected into the markdown renderer. + # + # @return [Nokogiri::HTML::DocumentFragment] + # The slide DOM with all form elements rendered. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L849-L878 + def self.render!(doc, options={}) + title = options[:form] + return unless title + + begin + tools = Nokogiri::XML::Node.new('div', doc) + tools.add_class('tools') + doc.add_child(tools) + + button = Nokogiri::XML::Node.new('input', doc) + button.add_class('display') + button.set_attribute('type', 'button') + button.set_attribute('value', I18n.t('forms.display')) + tools.add_child(button) + + submit = Nokogiri::XML::Node.new('input', doc) + submit.add_class('save') + submit.set_attribute('type', 'submit') + submit.set_attribute('value', I18n.t('forms.save')) + submit.set_attribute('disabled', 'disabled') + tools.add_child(submit) + + form = Nokogiri::XML::Node.new('form', doc) + form.set_attribute('id', title) + form.set_attribute('action', "form/#{title}") + form.set_attribute('method', 'POST') + doc.add_child(form) + + doc.children.each do |elem| + next if elem == form + elem.parent = form + end + + doc.css('p').each do |p| + if p.text =~ /^(\w*) ?(?:->)? ?(.*)? (\*?)= ?(.*)?$/ + code = $1 + id = "#{title}_#{code}" + name = $2.empty? ? code : $2 + required = ! $3.empty? + rhs = $4 + + p.replace self.form_element(id, code, name, required, rhs, p.text) + end + end + + rescue Exception => e + Showoff::Logger.warn "Form parsing failed: #{e.message}" + Showoff::Logger.debug "Backtrace:\n\t#{e.backtrace.join("\n\t")}" + end + + doc + end + + # Generates markup for any supported form element type + # + # @param id [String] + # The HTML ID for the generated markup + # @param code [String] + # The question code; used for indexing + # @param name [String] + # The full text of the question + # @param required [Boolean] + # Whether the rendered element should be marked as required + # @param rhs [String] + # The right hand side of the question specification, if on one line. + # @param text [String] + # The full content of the content, used for recursive multiline calls + # + # @return [String] + # The HTML markup for all the HTML nodes that the full element renders to. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L880-L903 + def self.form_element(id, code, name, required, rhs, text) + required = required ? 'required' : '' + str = "

" + str << "" + case rhs + when /^\[\s+(\d*)\]$$/ # value = [ 5] (textarea) + str << self.form_element_textarea(id, code, $1) + when /^___+(?:\[(\d+)\])?$/ # value = ___[50] (text) + str << self.form_element_text(id, code, $1) + when /^\(.?\)/ # value = (x) option one (=) opt2 () opt3 -> option 3 (radio) + str << self.form_element_radio(id, code, rhs.scan(/\((.?)\)\s*([^()]+)\s*/)) + when /^\[.?\]/ # value = [x] option one [=] opt2 [] opt3 -> option 3 (checkboxes) + str << self.form_element_checkboxes(id, code, rhs.scan(/\[(.?)\] ?([^\[\]]+)/)) + when /^\{(.*)\}$/ # value = {BOS, [SFO], (NYC)} (select shorthand) + str << self.form_element_select(id, code, rhs.scan(/[(\[]?\w+[)\]]?/)) + when /^\{$/ # value = { (select) + str << self.form_element_select_multiline(id, code, text) + when '' # value = (radio/checkbox list) + str << self.form_element_multiline(id, code, text) + else + Showoff::Logger.warn "Unmatched form element: #{rhs}" + end + str << '
' + end + + def self.form_element_text(id, code, length) + "" + end + + def self.form_element_textarea(id, code, rows) + rows = 3 if rows.empty? + "" + end + + def self.form_element_radio(id, code, items) + self.form_element_check_or_radio_set('radio', id, code, items) + end + + def self.form_element_checkboxes(id, code, items) + self.form_element_check_or_radio_set('checkbox', id, code, items) + end + + def self.form_element_select(id, code, items) + str = "' + end + + def self.form_element_select_multiline(id, code, text) + str = "' + end + + def self.form_element_multiline(id, code, text) + str = '' + end + + def self.form_element_check_or_radio_set(type, id, code, items) + str = '' + items.each do |item| + modifier = item[0] + + if item[1] =~ /^(\w*) -> (.*)$/ + value = $1 + label = $2 + else + value = label = item[1].strip + end + + str << self.form_element_check_or_radio(type, id, code, value, label, modifier) + end + str + end + + def self.form_element_check_or_radio(type, id, code, value, label, modifier) + # yes, value and id are conflated, because this is the id of the parent widget + checked = self.form_checked?(modifier) + classes = self.form_classes(modifier) + + name = (type == 'checkbox') ? "#{code}[]" : code + str = "" + str << "" + end + + def self.form_classes(modifier) + modifier.downcase! + classes = ['response'] + classes << 'correct' if modifier.include?('=') + + classes.join(' ') + end + + def self.form_checked?(modifier) + modifier.downcase.include?('x') ? "checked='checked'" : '' + end + +end diff --git a/lib/showoff/compiler/glossary.rb b/lib/showoff/compiler/glossary.rb new file mode 100644 index 000000000..90aab0852 --- /dev/null +++ b/lib/showoff/compiler/glossary.rb @@ -0,0 +1,164 @@ +# adds glossary processing to the compiler +class Showoff::Compiler::Glossary + + # Scan for glossary links and add definitions. This does not create the + # glossary page at the end. + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The slide document + # + # @return [Nokogiri::HTML::DocumentFragment] + # The slide DOM with all glossary entries rendered. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L650-L706 + def self.render!(doc) + + # Find all callout style definitions on the slide and add links to the glossary page + doc.search('.callout.glossary').each do |item| + next unless item.content =~ /^([^|]+)\|([^:]+):(.*)$/ + item['data-term'] = $1 + item['data-target'] = $2 + item['data-text'] = $3.strip + item.content = $3.strip + + glossary = (item.attr('class').split - ['callout', 'glossary']).first + address = glossary ? "#{glossary}/#{$2}" : $2 + + link = Nokogiri::XML::Node.new('a', doc) + link.add_class('processed label') + link.set_attribute('href', "glossary://#{address}") + link.content = $1 + + item.prepend_child(link) + end + + # Find glossary links and add definitions to the notes + doc.search('a').each do |link| + next unless link['href'] + next unless link['href'].start_with? 'glossary://' + next if link.classes.include? 'processed' + + link.add_class('term') + + term = link.content + text = link['title'] + href = link['href'] + + parts = href.split('/') + target = parts.pop + name = parts.pop # either the glossary name or nil + + label = link.clone + label.add_class('label processed') + + definition = Nokogiri::XML::Node.new('p', doc) + definition.add_class("callout glossary #{name}") + definition.set_attribute('data-term', term) + definition.set_attribute('data-text', text) + definition.set_attribute('data-target', target) + definition.content = text + definition.prepend_child(label) + + # @todo this duplication is annoying but it makes it less order dependent + doc.add_child '
' if doc.search('div.notes-section.notes').empty? + doc.add_child '
' if doc.search('div.notes-section.handouts').empty? + + [doc.css('div.notes-section.notes'), doc.css('div.notes-section.handouts')].each do |section| + section.first.add_child(definition.clone) + end + + end + + doc + end + + # Generate and add the glossary page + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The presentation document + # + # @return [Nokogiri::HTML::DocumentFragment] + # The presentation DOM with the glossary page rendered. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L770-L810 + def self.generatePage!(doc) + doc.search('.slide.glossary .content').each do |glossary| + name = (glossary.attr('class').split - ['content', 'glossary']).first + list = Nokogiri::XML::Node.new('ul', doc) + list.add_class('glossary terms') + seen = [] + + doc.search('.callout.glossary').each do |item| + target = (item.attr('class').split - ['callout', 'glossary']).first + + # if the name matches or if we didn't name it to begin with. + next unless target == name + + # the definition can exist in multiple places, so de-dup it here + term = item.attr('data-term') + next if seen.include? term + seen << term + + # excrutiatingly find the parent slide content and grab the ref + # in a library less shitty, this would be something like + # $(this).parent().siblings('.content').attr('ref') + href = nil + item.ancestors('.slide').first.traverse do |element| + next if element['class'].nil? + next unless element['class'].split.include? 'content' + + href = element.attr('ref').gsub('/', '_') + end + + text = item.attr('data-text') + link = item.attr('data-target') + page = glossary.attr('ref') + anchor = "#{page}+#{link}" + next if href.nil? or text.nil? or link.nil? + + entry = Nokogiri::XML::Node.new('li', doc) + + label = Nokogiri::XML::Node.new('a', doc) + label.add_class('label') + label.set_attribute('id', anchor) + label.content = term + + link = Nokogiri::XML::Node.new('a', doc) + label.add_class('return') + link.set_attribute('href', "##{href}") + link.content = '↩' + + entry.add_child(label) + entry.add_child(Nokogiri::XML::Text.new(text, doc)) + entry.add_child(link) + + list.add_child(entry) + end + + glossary.add_child(list) + end + + # now fix all the links to point to the glossary page + doc.search('a').each do |link| + next if link['href'].nil? + next unless link['href'].start_with? 'glossary://' + + href = link['href'] + href.slice!('glossary://') + + parts = href.split('/') + target = parts.pop + name = parts.pop # either the glossary name or nil + + classes = name.nil? ? ".slide.glossary" : ".slide.glossary.#{name}" + href = doc.at("#{classes} .content").attr('ref') rescue nil + + link['href'] = "##{href}+#{target}" + end + + doc + end + +end diff --git a/lib/showoff/compiler/i18n.rb b/lib/showoff/compiler/i18n.rb new file mode 100644 index 000000000..198399567 --- /dev/null +++ b/lib/showoff/compiler/i18n.rb @@ -0,0 +1,24 @@ +# Adds slide language selection to the compiler +class Showoff::Compiler::I18n + + def self.selectLanguage!(content) + translations = {} + content.scan(/^((~~~LANG:([\w-]+)~~~\n)(.+?)(\n~~~ENDLANG~~~))/m).each do |match| + markup, opentag, code, text, closetag = match + translations[code] = {:markup => markup, :content => text} + end + + lang = Showoff::Locale.resolve(translations.keys).to_s + + translations.each do |code, translation| + if code == lang + content.sub!(translation[:markup], translation[:content]) + else + content.sub!(translation[:markup], "\n") + end + end + + content + end + +end diff --git a/lib/showoff/compiler/notes.rb b/lib/showoff/compiler/notes.rb new file mode 100644 index 000000000..25e32cc84 --- /dev/null +++ b/lib/showoff/compiler/notes.rb @@ -0,0 +1,73 @@ +# adds presenter notes processing to the compiler +class Showoff::Compiler::Notes + + # Generate the presenter notes sections, including personal notes + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The slide document + # + # @param profile [String] + # The markdown engine profile to use when rendering + # + # @param options [Hash] Options used for rendering any embedded markdown + # @option options [String] :name The markdown slide name + # @option options [String] :seq The sequence number for multiple slides in one file + # + # @return [Nokogiri::HTML::DocumentFragment] + # The slide DOM with all notes sections rendered. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L616-L716 + # @note + # A ton of the functionality in the original method got refactored to its logical location + def self.render!(doc, profile, options = {}) + # Turn tags into classed divs. + doc.search('p').select {|p| p.text.start_with?('~~~SECTION:') }.each do |p| + klass = p.text.match(/~~~SECTION:([^~]*)~~~/)[1] + + # Don't bother creating this if we don't want to use it + next unless Showoff::Config.includeNotes?(klass) + + notes = Nokogiri::XML::Node.new('div', doc) + notes.add_class("notes-section #{klass}") + nodes = [] + iter = p.next_sibling + until iter.text == '~~~ENDSECTION~~~' do + nodes << iter + iter = iter.next_sibling + + # if the author forgot the closing tag, let's not crash, eh? + break unless iter + end + iter.remove if iter # remove the extraneous closing ~~~ENDSECTION~~~ tag + + # We need to collect the list before moving or the iteration crashes since the iterator no longer has a sibling + nodes.each {|n| n.parent = notes } + + p.replace(notes) + end + + filename = [ + File.join(Showoff::Config.root, '_notes', "#{options[:name]}.#{options[:seq]}.md"), + File.join(Showoff::Config.root, '_notes', "#{options[:name]}.md"), + ].find {|path| File.file?(path) } + + if filename and Showoff::Config.includeNotes?('notes') + # Make sure we've got a notes div to hang personal notes from + doc.add_child '
' if doc.search('div.notes-section.notes').empty? + doc.search('div.notes-section.notes').each do |section| + text = Tilt[:markdown].new(nil, nil, options[:profile]) { File.read(filename) }.render + frag = "

#{I18n.t('presenter.notes.personal')}

#{text}
" + section.prepend_child(frag) + end + end + + # return notes separately from content so that it can be rendered outside the slide + # @see https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L726-L732 + notes = doc.search('div.notes-section') + doc.search('div.notes-section').each {|n| n.remove } + + [doc, notes] + end + +end diff --git a/lib/showoff/compiler/table_of_contents.rb b/lib/showoff/compiler/table_of_contents.rb new file mode 100644 index 000000000..52b9639f3 --- /dev/null +++ b/lib/showoff/compiler/table_of_contents.rb @@ -0,0 +1,51 @@ +# adds table of content generation to the compiler +class Showoff::Compiler::TableOfContents + + # Render a table of contents + # + # @param doc [Nokogiri::HTML::DocumentFragment] + # The presentation document + # + # @return [Nokogiri::HTML::DocumentFragment] + # The presentation DOM with the table of contents rendered. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L747-L768 + def self.generate!(doc) + container = doc.search('p').find {|p| p.text == '~~~TOC~~~' } + return doc unless container + + section = nil + toc = Nokogiri::XML::Node.new('ol', doc) + toc.set_attribute('id', 'toc') + + doc.search('div.slide:not(.toc)').each do |slide| + next if slide.search('.content').first.classes.include? 'cover' + + heads = slide.search('div.content h1:not(.section_title)') + title = heads.empty? ? slide['data-title'] : heads.first.text + href = "##{slide['id']}" + + entry = Nokogiri::XML::Node.new('li', doc) + entry.add_class('tocentry') + link = Nokogiri::XML::Node.new('a', doc) + link.set_attribute('href', href) + link.content = title + entry.add_child(link) + + if (section and slide['data-section'] == section['data-section']) + section.add_child(entry) + else + section = Nokogiri::XML::Node.new('ol', doc) + section.add_class('major') + section.set_attribute('data-section', slide['data-section']) + entry.add_child(section) + toc.add_child(entry) + end + + end + container.replace(toc) + + doc + end +end diff --git a/lib/showoff/compiler/variables.rb b/lib/showoff/compiler/variables.rb new file mode 100644 index 000000000..629d36634 --- /dev/null +++ b/lib/showoff/compiler/variables.rb @@ -0,0 +1,71 @@ +# Adds variable interpolation to the compiler +class Showoff::Compiler::Variables + # + # + # @param content [String] + # A string of Markdown content which may contain Showoff variables. + # @return [String] + # The content with variables interpolated. + # @note + # Had side effects of altering state datastore. + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L557-L614 + def self.interpolate!(content) + # update counters, incrementing section:minor if needed + content.gsub!("~~~CURRENT_SLIDE~~~", Showoff::State.get(:slide_count).to_s) + content.gsub!("~~~SECTION:MAJOR~~~", Showoff::State.get(:section_major).to_s) + if content.include? "~~~SECTION:MINOR~~~" + Showoff::State.increment(:section_minor) + content.gsub!("~~~SECTION:MINOR~~~", Showoff::State.get(:section_minor).to_s) + end + + # scan for pagebreak tags. Should really only be used for handout notes or supplemental materials + content.gsub!("~~~PAGEBREAK~~~", '
continued...
') + + # replace with form rendering placeholder + content.gsub!(/~~~FORM:([^~]*)~~~/, '
') + + # Now check for any kind of options + content.scan(/(~~~CONFIG:(.*?)~~~)/).each do |match| + parts = match[1].split('.') # Use dots ('.') to separate Hash keys + value = Showoff::Config.get(*parts) + + unless value.is_a?(String) + msg = "#{match[0]} refers to a non-String data type (#{value.class})" + msg = "#{match[0]}: not found in settings data" if value.nil? + Showoff::Logger.warn(msg) + next + end + + content.gsub!(match[0], value) + end + + # Load and replace any file tags + content.scan(/(~~~FILE:([^:~]*):?(.*)?~~~)/).each do |match| + # make a list of code highlighting classes to include + css = match[2].split.collect {|i| "language-#{i.downcase}" }.join(' ') + + # get the file content and parse out html entities + name = match[1] + file = File.read(File.join(Showoff::Config.root, '_files', name)) rescue "Nonexistent file: #{name}" + file = "Empty file: #{name}" if file.empty? + file = HTMLEntities.new.encode(file) rescue "HTML encoding of #{name} failed" + + content.gsub!(match[0], "
#{file}
") + end + + # insert font awesome icons + content.gsub!(/\[(fa\w?)-(\S*) ?(.*)\]/, '') + + # For fenced code blocks, translate the space separated classes into one + # colon separated string so Commonmarker doesn't ignore the rest + content.gsub!(/^`{3} *(.+)$/) {|s| "``` #{$1.split.join(':')}"} + + # escape any tags left and ensure they're in distinctly separate p tags so + # that renderers that accept a string of tildes for fenced code blocks don't blow up. + # @todo This is terrible and we need to design a better tag syntax. + content.gsub!(/^~~~(.*?)~~~/, "\n\\~~~\\1~~~\n") + + content + end +end diff --git a/lib/showoff/config.rb b/lib/showoff/config.rb new file mode 100644 index 000000000..9f2aa34ee --- /dev/null +++ b/lib/showoff/config.rb @@ -0,0 +1,218 @@ +require 'json' + +class Showoff::Config + + def self.keys + @@config.keys + end + + # Retrieve settings from the config hash. + # If multiple arguments are given then it will dig down through data + # structures argument by argument. + # + # Returns the data type & value requested, nil on error. + def self.get(*setting) + @@config.dig(*setting) rescue nil + end + + def self.sections + @@sections + end + + # Absolute root of presentation + def self.root + @@root + end + + # Relative path to an item in the presentation directory structure + def self.path(path) + File.expand_path(File.join(@@root, path)).sub(/^#{@@root}\//, '') + end + + # Identifies whether we're including a given notes section + # + # @param section [String] The name of the notes section of interest. + # @return [Boolean] Whether to include this section in the output + def self.includeNotes?(section) + return true # todo make this work + end + + def self.load(path = 'showoff.json') + raise 'Presentation file does not exist at the specified path' unless File.exist? path + + @@root = File.dirname(path) + @@config = JSON.parse(File.read(path)) + @@sections = self.expand_sections + + self.load_defaults! + end + + # Expand and normalize all the different variations that the sections structure + # can exist in. When finished, this should return an ordered hash of one or more + # section titles pointing to an array of filenames, for example: + # + # { + # "Section name": [ "array.md, "of.md, "files.md"], + # "Another Section": [ "two/array.md, "two/of.md, "two/files.md"], + # } + # + # See valid input forms at + # https://puppetlabs.github.io/showoff/documentation/PRESENTATION_rdoc.html#label-Defining+slides+using+the+sections+setting. + # Source: + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff_utils.rb#L427-L475 + def self.expand_sections + begin + if @@config.is_a?(Hash) + # dup so we don't overwrite the original data structure and make it impossible to re-localize + sections = @@config['sections'].dup + else + sections = @@config.dup + end + + if sections.is_a? Array + sections = self.legacy_sections(sections) + elsif sections.is_a? Hash + raise "Named sections are unsupported on Ruby versions less than 1.9." if RUBY_VERSION.start_with? '1.8' + sections.each do |key, value| + next if value.is_a? Array + path = File.dirname(value) + data = JSON.parse(File.read(File.join(@@root, value))) + raise "The section file #{value} must contain an array of filenames." unless data.is_a? Array + + # get relative paths to each slide in the array + sections[key] = data.map do |filename| + Pathname.new("#{path}/#{filename}").cleanpath.to_path + end + end + else + raise "The `sections` key must be an Array or Hash, not a #{sections.class}." + end + + rescue => e + Showoff::Logger.error "There was a problem with the presentation file #{index}" + Showoff::Logger.error e.message + Showoff::Logger.debug e.backtrace + sections = {} + end + + sections + end + + # Source: + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff_utils.rb#L477-L545 + def self.legacy_sections(data) + # each entry in sections can be: + # - "filename.md" + # - "directory" + # - { "section": "filename.md" } + # - { "section": "directory" } + # - { "section": [ "array.md, "of.md, "files.md"] } + # - { "include": "sections.json" } + sections = {} + counters = {} + lastpath = nil + + data.map do |entry| + next entry if entry.is_a? String + next nil unless entry.is_a? Hash + next entry['section'] if entry.include? 'section' + + section = nil + if entry.include? 'include' + file = entry['include'] + path = File.dirname(file) + data = JSON.parse(File.read(File.join(@@root, file))) + if data.is_a? Array + if path == '.' + section = data + else + section = data.map do |source| + "#{path}/#{source}" + end + end + end + end + section + end.flatten.compact.each do |entry| + # We do this in two passes simply because most of it was already done + # and I don't want to waste time on legacy functionality. + + # Normalize to a proper path from presentation root + if File.directory? File.join(@@root, entry) + sections[entry] = Dir.glob("#{@@root}/#{entry}/**/*.md").map {|e| e.sub(/^#{@@root}\//, '') } + lastpath = entry + else + path = File.dirname(entry) + + # this lastpath business allows us to reference files in a directory that aren't + # necessarily contiguous. + if path != lastpath + counters[path] ||= 0 + counters[path] += 1 + end + + # now record the last path we've seen + lastpath = path + + # and if there are more than one disparate occurences of path, add a counter to this string + path = "#{path} (#{counters[path]})" unless counters[path] == 1 + + sections[path] ||= [] + sections[path] << entry + end + end + + sections + end + + def self.load_defaults! + # use a symbol which cannot clash with a string key loaded from json + @@config['markdown'] ||= :default + renderer = @@config['markdown'] + defaults = case renderer + when 'rdiscount' + { + :autolink => true, + } + when 'maruku' + { + :use_tex => false, + :png_dir => 'images', + :html_png_url => '/file/images/', + } + when 'bluecloth' + { + :auto_links => true, + :definition_lists => true, + :superscript => true, + :tables => true, + } + when 'kramdown' + {} + else + { + :autolink => true, + :no_intra_emphasis => true, + :superscript => true, + :tables => true, + :underline => true, + :escape_html => false, + } + end + + @@config[renderer] ||= {} + @@config[renderer] = defaults.merge!(@@config[renderer]) + + # run `wkhtmltopdf --extended-help` for a full list of valid options here + pdf_defaults = { + :page_size => 'Letter', + :orientation => 'Portrait', + :print_media_type => true, + :quiet => false} + pdf_options = @@config['pdf_options'] || {} + pdf_options = Hash[pdf_options.map {|k, v| [k.to_sym, v]}] + + @@config['pdf_options'] = pdf_defaults.merge!(pdf_options) + end + +end diff --git a/lib/showoff/locale.rb b/lib/showoff/locale.rb new file mode 100644 index 000000000..d42e63528 --- /dev/null +++ b/lib/showoff/locale.rb @@ -0,0 +1,132 @@ +require 'i18n' +require 'i18n/backend/fallbacks' +require 'iso-639' + +I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks) +I18n.load_path += Dir[File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'locales', '*.yml'))] +I18n.backend.load_translations +I18n.enforce_available_locales = false + +class Showoff::Locale + @@contentLocale = nil + + # Set the minimized canonical version of the specified content locale, selecting + # the nearest match to whatever exists in the presentation's locales directory. + # If the locale doesn't exist on disk, it will just default to no translation + # + # @todo: I don't think this is right at all -- it doesn't autoselect content + # languages, just built in Showoff languages. It only worked by accident before + # + # @param user_locale [String, Symbol] The locale to select. + # + # @returns [Symbol] The selected and saved locale. + def self.setContentLocale(user_locale = nil) + if [nil, '', 'auto'].include? user_locale + languages = I18n.available_locales + @@contentLocale = I18n.fallbacks[I18n.locale].select { |f| languages.include? f }.first + else + locales = Dir.glob("#{Showoff::Config.root}/locales/*").map {|e| File.basename e } + locales.delete 'strings.json' + + @@contentLocale = with_locale(user_locale) do |str| + str.to_sym if locales.include? str + end + end + end + + def self.contentLocale + @@contentLocale + end + + # Find the closest match to current locale in an array of possibilities + # + # @param items [Array] An array of possibilities to check + # @return [Symbol] The closest match to the current locale. + def self.resolve(items) + with_locale(contentLocale) do |str| + str.to_sym if items.include? str + end + end + + # Turns a locale code into a string name + # + # @param locale [String, Symbol] The code of the locale to translate + # @returns [String] The name of the locale. + def self.languageName(locale = contentLocale) + with_locale(locale) do |str| + result = ISO_639.find(str) + result[3] unless result.nil? + end + end + + # This function returns the directory containing translated *content*, defaulting + # to the presentation root. This works similarly to I18n fallback, but we cannot + # reuse that as it's a different translation mechanism. + + # @returns [String] Path to the translated content. + def self.contentPath + root = Showoff::Config.root + + with_locale(contentLocale) do |str| + path = "#{root}/locales/#{str}" + return path if File.directory?(path) + end || root + end + + # Generates a hash of all language codes available and the long name description of each + # + # @returns [Hash] The language code/name hash. + def self.contentLanguages + root = Showoff::Config.root + + strings = JSON.parse(File.read("#{root}/locales/strings.json")) rescue {} + locales = Dir.glob("#{root}/locales/*") + .select {|f| File.directory?(f) } + .map {|f| File.basename(f) } + + (strings.keys + locales).inject({}) do |memo, locale| + memo.update(locale => languageName(locale)) + end + end + + + # Generates a hash of all translations for the current language. This is used + # for the javascript half of the UI translations + # + # @returns [Hash] The locale code/strings hash. + def self.translations + languages = I18n.backend.send(:translations) + fallback = I18n.fallbacks[I18n.locale].select { |f| languages.keys.include? f }.first + languages[fallback] + end + + # Finds the language key from strings.json and returns the strings hash. This is + # used for user translations in the presentation content, e.g. SVG translations. + # + # @returns [Hash] The user translation code/strings hash. + def self.userTranslations + path = "#{Showoff::Config.root}/locales/strings.json" + return {} unless File.file? path + strings = JSON.parse(File.read(path)) rescue {} + + with_locale(contentLocale) do |key| + return strings[key] if strings.include? key + end + {} + end + + # This is just a unified lookup method that takes a full locale name + # and then resolves it to an available version of the name + def self.with_locale(locale) + locale = locale.to_s + until (locale.empty?) do + result = yield(locale) + return result unless result.nil? + + # if not found, chop off a section and try again + locale = locale.rpartition(/[-_]/).first + end + end + private_class_method :with_locale + +end diff --git a/lib/showoff/logger.rb b/lib/showoff/logger.rb new file mode 100644 index 000000000..34f39feea --- /dev/null +++ b/lib/showoff/logger.rb @@ -0,0 +1,15 @@ +require 'logger' +class Showoff::Logger + @@logger = Logger.new(STDERR) + @@logger.progname = 'Showoff' + @@logger.formatter = proc { |severity,datetime,progname,msg| "(#{progname}) #{severity}: #{msg}\n" } + @@logger.level = Showoff::State.get(:verbose) ? Logger::DEBUG : Logger::WARN + @@logger.level = Logger::WARN + + [:debug, :info, :warn, :error, :fatal].each do |meth| + define_singleton_method(meth) do |msg| + @@logger.send(meth, msg) + end + end + +end diff --git a/lib/showoff/monkeypatches.rb b/lib/showoff/monkeypatches.rb new file mode 100644 index 000000000..6fc586abf --- /dev/null +++ b/lib/showoff/monkeypatches.rb @@ -0,0 +1,28 @@ +# Yay for Ruby 2.0! +class Hash + unless Hash.method_defined? :dig + def dig(*args) + args.reduce(self) do |iter, arg| + break nil unless iter.is_a? Enumerable + break nil unless iter.include? arg + iter[arg] + end + end + end + +end + +class Nokogiri::XML::Element + unless Nokogiri::XML::Element.method_defined? :add_class + def add_class(classlist) + self[:class] = [self[:class], classlist].join(' ') + end + end + + unless Nokogiri::XML::Element.method_defined? :classes + def classes + self[:class] ? self[:class].split(' ') : [] + end + end + +end diff --git a/lib/showoff/presentation.rb b/lib/showoff/presentation.rb new file mode 100644 index 000000000..32982fb80 --- /dev/null +++ b/lib/showoff/presentation.rb @@ -0,0 +1,181 @@ +class Showoff::Presentation + require 'showoff/presentation/section' + require 'showoff/presentation/slide' + require 'showoff/compiler' + require 'keymap' + + attr_reader :sections + + def initialize(options) + @options = options + @sections = Showoff::Config.sections.map do |name, files| + Showoff::Presentation::Section.new(name, files) + end + + # weird magic variables the presentation expects + @baseurl = nil # this doesn't appear to have ever been used + @title = Showoff::Config.get('name') || I18n.t('name') + @favicon = Showoff::Config.get('favicon') || 'favicon.ico' + @feedback = Showoff::Config.get('feedback') # note: the params check is obsolete + @pause_msg = Showoff::Config.get('pause_msg') + @language = Showoff::Locale.translations + @edit = Showoff::Config.get('edit') if options[:review] + + # invert the logic to maintain backwards compatibility of interactivity on by default + @interactive = ! options[:standalone] + + # Load up the default keymap, then merge in any customizations + keymapfile = File.expand_path(File.join('~', '.showoff', 'keymap.json')) + @keymap = Keymap.default + @keymap.merge! JSON.parse(File.read(keymapfile)) rescue {} + + # map keys to the labels we're using + @keycode_dictionary = Keymap.keycodeDictionary + @keycode_shifted_keys = Keymap.shiftedKeyDictionary + + @highlightStyle = Showoff::Config.get('highlight') || 'default' + + if Showoff::State.get(:supplemental) + @wrapper_classes = ['supplemental'] + end + end + + def compile + Showoff::State.reset([:slide_count, :section_major, :section_minor]) + + # @todo For now, we reparse the html so that we can generate content via slide + # templates. This adds a bit of extra time, but not too much. Perhaps + # we'll change that at some point. + html = @sections.map(&:render).join("\n") + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + Showoff::Compiler::TableOfContents.generate!(doc) + Showoff::Compiler::Glossary.generatePage!(doc) + + doc + end + + # The index page does not contain content; just a placeholder div that's + # dynamically loaded after the page is displayed. This increases perceived + # responsiveness. + def index + ERB.new(File.read(File.join(Showoff::GEMROOT, 'views','index.erb')), nil, '-').result(binding) + end + + def slides + compile.to_html + end + + def static + # This singleton guard removes ordering coupling between assets() & static() + @doc ||= compile + @slides = @doc.to_html + + # All static snapshots should be non-interactive by definition + @interactive = false + + case Showoff::State.get(:format) + when 'web' + template = 'index.erb' + when 'print', 'supplemental', 'pdf' + template = 'onepage.erb' + end + + ERB.new(File.read(File.join(Showoff::GEMROOT, 'views', template)), nil, '-').result(binding) + end + + # Generates a list of all image/font/etc files used by the presentation. This + # will only identify the sources of tags and files referenced by the + # CSS url() function. + # + # @see + # https://github.com/puppetlabs/showoff/blob/220d6eef4c5942eda625dd6edc5370c7490eced7/lib/showoff.rb#L1509-L1573 + # @returns [Array] + # List of assets, such as images or fonts, used by the presentation. + def assets + # This singleton guard removes ordering coupling between assets() & static() + @doc ||= compile + + # matches url() and returns the path as a capture group + urlsrc = /url\([\"\']?(.*?)(?:[#\?].*)?[\"\']?\)/ + + # get all image and url() sources + files = @doc.search('img').map {|img| img[:src] } + @doc.search('*').each do |node| + next unless node[:style] + next unless matches = node[:style].match(urlsrc) + files << matches[1] + end + + # add in images from css files too + css_files.each do |css_path| + data = File.read(File.join(Showoff::Config.root, css_path)) + + # @todo: This isn't perfect. It will match commented out styles. But its + # worst case behavior is displaying a warning message, so that's ok for now. + data.scan(urlsrc).flatten.each do |path| + # resolve relative paths in the stylesheet + path = File.join(File.dirname(css_path), path) unless path.start_with? '/' + files << path + end + end + + # also all user-defined styles and javascript files + files.concat css_files + files.concat js_files + files.uniq + end + + def erb(template) + ERB.new(File.read(File.join(Showoff::GEMROOT, 'views', "#{template}.erb")), nil, '-').result(binding) + end + + def css_files + base = Dir.glob("#{Showoff::Config.root}/*.css").map { |path| File.basename(path) } + extra = Array(Showoff::Config.get('styles')) + base + extra + end + + def js_files + base = Dir.glob("#{Showoff::Config.root}/*.js").map { |path| File.basename(path) } + extra = Array(Showoff::Config.get('scripts')) + base + extra + end + + # return a list of keys associated with a given action in the keymap + def mapped_keys(action, klass='key') + list = @keymap.select { |key,value| value == action }.keys + + if klass + list.map { |val| "#{val}" }.join + else + list.join ', ' + end + end + + + + + # @todo: backwards compatibility shim + def user_translations + Showoff::Locale.userTranslations + end + + # @todo: backwards compatibility shim + def language_names + Showoff::Locale.contentLanguages + end + + + # @todo: this should be part of the server. Move there with the least disruption. + def master_presenter? + false + end + + # @todo: this should be part of the server. Move there with the least disruption. + def valid_presenter_cookie? + false + end + + +end diff --git a/lib/showoff/presentation/section.rb b/lib/showoff/presentation/section.rb new file mode 100644 index 000000000..0a6673f4b --- /dev/null +++ b/lib/showoff/presentation/section.rb @@ -0,0 +1,70 @@ +class Showoff::Presentation::Section + attr_reader :slides, :name + + def initialize(name, files) + @name = name + @slides = [] + files.each { |filename| loadSlides(filename) } + + # merged output means that we just want to generate *everything*. This is used by internal, + # methods such as content validation, where we want all content represented. + # https://github.com/puppetlabs/showoff/blob/220d6eef4c5942eda625dd6edc5370c7490eced7/lib/showoff.rb#L429-L453 + unless Showoff::State.get(:merged) + if Showoff::State.get(:supplemental) + # if we're looking for supplemental material, only include the content we want + @slides.select! {|slide| slide.classes.include? 'supplemental' } + @slides.select! {|slide| slide.classes.include? Showoff::State.get(:supplemental) } + else + # otherwise just skip all supplemental material completely + @slides.reject! {|slide| slide.classes.include? 'supplemental' } + end + + case Showoff::State.get(:format) + when 'web' + @slides.reject! {|slide| slide.classes.include? 'toc' } + @slides.reject! {|slide| slide.classes.include? 'printonly' } + when 'print', 'supplemental' + @slides.reject! {|slide| slide.classes.include? 'noprint' } + end + end + + end + + def render + @slides.map(&:render).join("\n") + end + + # Gets the raw file content from disk and partitions it by slide markers into + # content for each slide. + # + # Returns an array of Slide objects + # + # Source: + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L396-L414 + def loadSlides(filename) + return unless filename.end_with? '.md' + + content = File.read(File.join(Showoff::Locale.contentPath, filename)) + + # if there are no !SLIDE markers, then make every H1 define a new slide + unless content =~ /^\\n# ") + end + + slides = content.split(/^]*)>?/) + slides.shift # has an extra empty string because the regex matches the entire source string. + + # this is a counter keeping track of how many slides came from the file. + # It kicks in at 2 because at this point, slides are a tuple of (options, content) + seq = slides.size > 2 ? 1 : nil + + # iterate each slide tuple and add slide objects to the array + slides.each_slice(2) do |data| + options, content = data + @slides << Showoff::Presentation::Slide.new(options, content, :section => @name, :name => filename, :seq => seq) + seq +=1 if seq + end + + end + +end diff --git a/lib/showoff/presentation/slide.rb b/lib/showoff/presentation/slide.rb new file mode 100644 index 000000000..9db512c1e --- /dev/null +++ b/lib/showoff/presentation/slide.rb @@ -0,0 +1,113 @@ +require 'erb' + +class Showoff::Presentation::Slide + attr_reader :section, :section_title, :name, :seq, :id, :ref, :background, :transition, :form, :markdown, :classes + + def initialize(options, content, context={}) + @markdown = content + @transition = 'none' + @classes = [] + setOptions!(options) + setContext!(context) + end + + def render + Showoff::State.increment(:slide_count) + options = { :form => @form, + :name => @name, + :seq => @seq, + } + + content, notes = Showoff::Compiler.new(options).render(@markdown) + + # if a template file has been specified for this slide, load from disk and render it + # @todo How many people are actually using these limited templates?! + if tpl_file = Showoff::Config.get('template', @template) + template = File.read(tpl_file) + content = template.gsub(/~~~CONTENT~~~/, content) + end + + ERB.new(File.read(File.join(Showoff::GEMROOT, 'views','slide.erb')), nil, '-').result(binding) + end + + # This is a list of classes that we want applied *only* to content, and not to the slide, + # typically so that overly aggressive selectors don't match more than they should. + # + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L734-L737 + def slideClasses + blacklist = ['bigtext'] + @classes.reject { |klass| blacklist.include? klass } + end + + # options are key=value elements within the [] brackets + def setOptions!(options) + return unless options + return unless matches = options.match(/(\[(.*?)\])?(.*)/) + + if matches[2] + matches[2].split(",").each do |element| + key, val = element.split("=") + case key + when 'tpl', 'template' + @template = val + when 'bg', 'background' + @background = val + # For legacy reasons, the options below may also be specified in classes. + # Currently that takes priority. + # @todo: better define the difference between options and classes. + when 'form' + @form = val + when 'id' + @id = val + when 'transition' + @transition = val + else + Showoff::Logger.warn "Unknown slide option: #{key}=#{val}" + end + end + end + + if matches[3] + @classes = matches[3].split + end + end + + # currently a mishmash of passed in context and calculated valued extracted from classes + def setContext!(context) + @section = context[:section] || 'main' + @name = context[:name].chomp('.md') + @seq = context[:seq] + + #TODO: this should be in options + # extract id from classes if set, or default to the HTML sanitized name + @classes.delete_if { |x| x =~ /^#([\w-]+)/ && @id = $1 } + @id ||= @name.dup.gsub(/[^-A-Za-z0-9_]/, '_') + @id << seq.to_s if @seq + + # provide an href for the slide. If we've got multiple slides in this file, we'll have a sequence number + # include that sequence number to index directly into that content + @ref = @seq ? "#{@name}:#{@seq.to_s}" : @name + + #TODO: this should be in options + # extract transition from classes, or default to 'none' + @classes.delete_if { |x| x =~ /^transition=(.+)/ && @transition = $1 } + + #TODO: this should be in options + # extract form id from classes, or default to nil + @classes.delete_if { |x| x =~ /^form=(.+)/ && @form = $1 } + + # Extract a section title from subsection slides and add it to state so that it + # can be carried forward to subsequent slides until a new section title is discovered. + # @see + # https://github.com/puppetlabs/showoff/blob/3f43754c84f97be4284bb34f9bc7c42175d45226/lib/showoff.rb#L499-L508 + if @classes.include? 'subsection' + matches = @markdown.match(/#+ *(.*?)#*$/) + @section_title = matches[1] || @section + Showoff::State.set(:section_title, @section_title) + else + @section_title = Showoff::State.get(:section_title) || @section + end + end + +end diff --git a/lib/showoff/state.rb b/lib/showoff/state.rb new file mode 100644 index 000000000..06bf67220 --- /dev/null +++ b/lib/showoff/state.rb @@ -0,0 +1,89 @@ +# Just a very simple global key-value data store. +class Showoff::State + @@state = {} + + # @returns [Array] Array of keys in the datastore + def self.keys + @@state.keys + end + + # @returns [Hash] Hash dump of all data in the datastore + def self.dump + @@state + end + + # @param key [String] The key to look for. + # @returns[Boolean] Whether that key exists. + def include?(key) + @@state.include?(key) + end + + # @param key [String] The key to look for. + # @returns[Boolean] The value of that key. + def self.get(key) + @@state[key] + end + + # @param key [String] The key to set. + # @param [Any] The value to set for that key. + def self.set(key, value) + @@state[key] = value + end + + # @param key [String] The key to increment. + # @note The value stored must be an Integer. This will initialize at zero if needed. + # @return [Integer] The new value of the counter. + def self.increment(key) + # ensure that the key is initialized with an integer before incrementing. + # Don't bother catching errors, we want those to be crashers + @@state[key] ||= 0 + @@state[key] += 1 + end + + # @param key [String] The key of the array to manage. + # @param value [Any] The value to append to the array at that key. + def self.append(key, value) + @@state[key] ||= [] + @@state[key] << value + end + + # Return an indexed value from an array saved at a certain key. + # + # @param key [String] The key of the array to manage. + # @param pos [Integer] The position to retrieve. + # @param [Any] The value to set for that key. + def self.getAtIndex(key, pos) + @@state[key] ||= [] + @@state[key][pos] + end + + # Set an indexed value from an array saved at a certain key. + # + # @param key [String] The key of the array to manage. + # @param pos [Integer] The position to set at. + # @param [Any] The value to set for that key. + def self.setAtIndex(key, pos, value) + @@state[key] ||= [] + @@state[key][pos] = value + end + + # Append to an array saved at a certain position of an array at a certain key. + # + # @param key [String] The key of the top level array to manage. + # @param pos [Integer] The index where the array to append to exists. + # @param [Any] The value to append to the array at that key. + def self.appendAtIndex(key, pos, value) + @@state[key] ||= [] + @@state[key][pos] ||= [] + @@state[key][pos] << value + end + + + def self.reset(*keys) + if keys.empty? + @@state = {} + else + keys.each { |key| @@state.delete(key) } + end + end +end diff --git a/lib/showoff_ng.rb b/lib/showoff_ng.rb new file mode 100644 index 000000000..5174da85a --- /dev/null +++ b/lib/showoff_ng.rb @@ -0,0 +1,98 @@ +class Showoff + require 'showoff/config' + require 'showoff/compiler' + require 'showoff/presentation' + require 'showoff/state' + require 'showoff/locale' + require 'showoff/logger' + + require 'showoff/monkeypatches' + + GEMROOT = File.expand_path(File.join(File.dirname(__FILE__), '..')) + + def self.do_static(args, options) + Showoff::State.set(:format, args[0] || 'web') + Showoff::State.set(:supplemental, args[1]) if args[0] == 'supplemental' + + Showoff::Locale.setContentLocale(options[:language]) + presentation = Showoff::Presentation.new(options) + + makeSnapshot(presentation) + + generatePDF if Showoff::State.get(:format) == 'pdf' + +# puts '------------------' +# presentation.sections.each do |section| +# puts section.name +# section.slides.each do |slide| +# puts " - #{slide.name}" +# end +# end + end + + # Generate a static HTML snapshot of the presentation in the `static` directory. + # Note that the `Showoff::Presentation` determines the format of the generated + # presentation based on the content requested. + # + # @see + # https://github.com/puppetlabs/showoff/blob/220d6eef4c5942eda625dd6edc5370c7490eced7/lib/showoff.rb#L1506-L1574 + def self.makeSnapshot(presentation) + FileUtils.mkdir_p 'static' + File.write(File.join('static', 'index.html'), presentation.static) + + ['js', 'css'].each { |dir| + src = File.join(GEMROOT, 'public', dir) + dest = File.join('static', dir) + + FileUtils.copy_entry(src, dest, false, false, true) + } + + # now copy all the files we care about + presentation.assets.each do |path| + src = File.join(Showoff::Config.root, path) + dest = File.join('static', path) + + FileUtils.mkdir_p(File.dirname(dest)) + begin + FileUtils.copy(src, dest) + rescue Errno::ENOENT => e + Showoff::Logger.warn "Missing source file: #{path}" + end + end + end + + # Generate a PDF version of the presentation in the current directory. This + # requires that the HTML snaphot exists, and it will *remove* that snapshot + # if the PDF generation is successful. + # + # @note + # wkhtmltopdf is terrible and will often report hard failures even after + # successfully building a PDF. Therefore, we check file existence and + # display different error messaging. + # @see + # https://github.com/puppetlabs/showoff/blob/220d6eef4c5942eda625dd6edc5370c7490eced7/lib/showoff.rb#L1447-L1471 + def self.generatePDF + begin + require 'pdfkit' + output = Showoff::Config.get('name')+'.pdf' + + kit = PDFKit.new(File.new('static/index.html'), Showoff::Config.get('pdf_options')) + kit.to_file(output) + FileUtils.rm_rf('static') + + rescue RuntimeError => e + if File.exist? output + Showoff::Logger.warn "Your PDF was generated, but PDFkit reported an error. Inspect the file #{output} for suitability." + Showoff::Logger.warn "You might try loading `static/index.html` in a web browser and checking the developer console for 404 errors." + else + Showoff::Logger.error "Generating your PDF with wkhtmltopdf was not successful." + Showoff::Logger.error "Try running the following command manually to see what it's failing on." + Showoff::Logger.error e.message.sub('--quiet', '') + end + rescue LoadError + Showoff::Logger.error 'Generating a PDF version of your presentation requires the `pdfkit` gem.' + end + + end + +end diff --git a/public/css/showoff.css b/public/css/showoff.css index a2ebdf5e3..bc267fcc7 100644 --- a/public/css/showoff.css +++ b/public/css/showoff.css @@ -708,7 +708,7 @@ pre.highlight tr:not(:last-child) { text-decoration: none; } #toc a::after { - content: leader(".") target-counter(attr(href), page); + content: leader(".") target-counter(attr(href url), page); } /********************************** diff --git a/spec/fixtures/assets/assets/another.css b/spec/fixtures/assets/assets/another.css new file mode 100644 index 000000000..05ecbcad1 --- /dev/null +++ b/spec/fixtures/assets/assets/another.css @@ -0,0 +1,13 @@ +#preso, .slide { + width: 1400px; + height: 1024px; +} + +.content { + font-family: 'Calibre',Helvetica,Tahoma,Arial,sans-serif; + font-size: 1.5em; +} + +.slide { + background-image: url(tile.jpg) +} diff --git a/spec/fixtures/assets/assets/another.js b/spec/fixtures/assets/assets/another.js new file mode 100644 index 000000000..1e6bb0b2b --- /dev/null +++ b/spec/fixtures/assets/assets/another.js @@ -0,0 +1,3 @@ +$(document).ready(function(){ + alert('hello world'); +}); diff --git a/spec/fixtures/assets/assets/grumpycat.jpg b/spec/fixtures/assets/assets/grumpycat.jpg new file mode 100644 index 000000000..a83852675 Binary files /dev/null and b/spec/fixtures/assets/assets/grumpycat.jpg differ diff --git a/spec/fixtures/assets/assets/tile.jpg b/spec/fixtures/assets/assets/tile.jpg new file mode 100644 index 000000000..bbe9f6462 Binary files /dev/null and b/spec/fixtures/assets/assets/tile.jpg differ diff --git a/spec/fixtures/assets/assets/yellow-brick-road.jpg b/spec/fixtures/assets/assets/yellow-brick-road.jpg new file mode 100644 index 000000000..4f4882a69 Binary files /dev/null and b/spec/fixtures/assets/assets/yellow-brick-road.jpg differ diff --git a/spec/fixtures/assets/content.md b/spec/fixtures/assets/content.md new file mode 100644 index 000000000..686b3e8c8 --- /dev/null +++ b/spec/fixtures/assets/content.md @@ -0,0 +1,26 @@ + +# One + +This little piggy stayed home. + + + +# Two + +This little piggy had roast beef. + +![INAGL](grumpy_lawyer.jpg) + + + +# Three + +This little piggy had none. + +![Grumps](assets/grumpycat.jpg) + + + +# The Guidebook + +So here's how this works... diff --git a/spec/fixtures/assets/extra.json b/spec/fixtures/assets/extra.json new file mode 100644 index 000000000..8e5c0fffb --- /dev/null +++ b/spec/fixtures/assets/extra.json @@ -0,0 +1,11 @@ +{ + "name": "Slides and sections", + "description": "This just has some slides.", + "sections": [ + "first.md", + "content.md", + "last.md" + ], + "styles": ["assets/another.css"], + "scripts": ["assets/another.js"] +} diff --git a/spec/fixtures/assets/first.md b/spec/fixtures/assets/first.md new file mode 100644 index 000000000..409777137 --- /dev/null +++ b/spec/fixtures/assets/first.md @@ -0,0 +1,4 @@ + +# First slide + +This little piggy went to market. diff --git a/spec/fixtures/assets/grumpy_lawyer.jpg b/spec/fixtures/assets/grumpy_lawyer.jpg new file mode 100644 index 000000000..69fdff1c5 Binary files /dev/null and b/spec/fixtures/assets/grumpy_lawyer.jpg differ diff --git a/spec/fixtures/assets/last.md b/spec/fixtures/assets/last.md new file mode 100644 index 000000000..d1e79e4b1 --- /dev/null +++ b/spec/fixtures/assets/last.md @@ -0,0 +1,4 @@ + +# Last + +This little piggy cried wee wee wee all the way home. diff --git a/spec/fixtures/assets/scripts.js b/spec/fixtures/assets/scripts.js new file mode 100644 index 000000000..1e6bb0b2b --- /dev/null +++ b/spec/fixtures/assets/scripts.js @@ -0,0 +1,3 @@ +$(document).ready(function(){ + alert('hello world'); +}); diff --git a/spec/fixtures/assets/showoff.json b/spec/fixtures/assets/showoff.json new file mode 100644 index 000000000..ed58a587c --- /dev/null +++ b/spec/fixtures/assets/showoff.json @@ -0,0 +1,9 @@ +{ + "name": "Slides and sections", + "description": "This just has some slides.", + "sections": [ + "first.md", + "content.md", + "last.md" + ] +} diff --git a/spec/fixtures/assets/styles.css b/spec/fixtures/assets/styles.css new file mode 100644 index 000000000..1e07a17dc --- /dev/null +++ b/spec/fixtures/assets/styles.css @@ -0,0 +1,9 @@ +#preso, .slide { + width: 1400px; + height: 1024px; +} + +.content { + font-family: 'Calibre',Helvetica,Tahoma,Arial,sans-serif; + font-size: 1.5em; +} diff --git a/spec/fixtures/base.json b/spec/fixtures/base.json new file mode 100644 index 000000000..815741725 --- /dev/null +++ b/spec/fixtures/base.json @@ -0,0 +1,20 @@ +{ + "name": "Basic Showoff config file", + "description": "This is a description.", + "protected": [ + "presenter", + "onepage", + "print" + ], + "version": "0.20.0", + "feedback": true, + "parsers" : { + "shell": "cat" + }, + "sections": [ + "Overview.md", + "Content.md", + "slides", + "Conclusion.md" + ] +} diff --git a/spec/fixtures/complex/Overview/content.json b/spec/fixtures/complex/Overview/content.json new file mode 100644 index 000000000..f9e69a30a --- /dev/null +++ b/spec/fixtures/complex/Overview/content.json @@ -0,0 +1,4 @@ +[ + "objectives.md", + "overview.md" +] diff --git a/spec/fixtures/complex/Overview/objectives.md b/spec/fixtures/complex/Overview/objectives.md new file mode 100644 index 000000000..f57bedaa1 --- /dev/null +++ b/spec/fixtures/complex/Overview/objectives.md @@ -0,0 +1,7 @@ + +# Objectives + +1. one +1. two +1. three + diff --git a/spec/fixtures/complex/Overview/overview.md b/spec/fixtures/complex/Overview/overview.md new file mode 100644 index 000000000..5f3980354 --- /dev/null +++ b/spec/fixtures/complex/Overview/overview.md @@ -0,0 +1,5 @@ + +# Overview + +# doin some stuff + diff --git a/spec/fixtures/complex/Shared/Appendix/appendix.md b/spec/fixtures/complex/Shared/Appendix/appendix.md new file mode 100644 index 000000000..9a16ab343 --- /dev/null +++ b/spec/fixtures/complex/Shared/Appendix/appendix.md @@ -0,0 +1,3 @@ + +# The appendix + diff --git a/spec/fixtures/complex/Shared/Appendix/content.json b/spec/fixtures/complex/Shared/Appendix/content.json new file mode 100644 index 000000000..96d801e92 --- /dev/null +++ b/spec/fixtures/complex/Shared/Appendix/content.json @@ -0,0 +1,4 @@ +[ + "appendix.md" +] + diff --git a/spec/fixtures/complex/Shared/about.md b/spec/fixtures/complex/Shared/about.md new file mode 100644 index 000000000..60d4afbda --- /dev/null +++ b/spec/fixtures/complex/Shared/about.md @@ -0,0 +1,5 @@ + +# About + +About stuff and such. + diff --git a/spec/fixtures/complex/conclusion.md b/spec/fixtures/complex/conclusion.md new file mode 100644 index 000000000..43e1ffa3e --- /dev/null +++ b/spec/fixtures/complex/conclusion.md @@ -0,0 +1,5 @@ + +# Conclusion + +You might think this is the end, but it's just the conclusion. + diff --git a/spec/fixtures/complex/end.md b/spec/fixtures/complex/end.md new file mode 100644 index 000000000..a0c4c372e --- /dev/null +++ b/spec/fixtures/complex/end.md @@ -0,0 +1,5 @@ + +# The End + +This is the end. + diff --git a/spec/fixtures/complex/environment/one.md b/spec/fixtures/complex/environment/one.md new file mode 100644 index 000000000..907d593c4 --- /dev/null +++ b/spec/fixtures/complex/environment/one.md @@ -0,0 +1,5 @@ + +# env one + +something + diff --git a/spec/fixtures/complex/environment/two.md b/spec/fixtures/complex/environment/two.md new file mode 100644 index 000000000..2aa6d9ab9 --- /dev/null +++ b/spec/fixtures/complex/environment/two.md @@ -0,0 +1,5 @@ + +# env two + +or other + diff --git a/spec/fixtures/complex/showoff.json b/spec/fixtures/complex/showoff.json new file mode 100644 index 000000000..c0913d1bc --- /dev/null +++ b/spec/fixtures/complex/showoff.json @@ -0,0 +1,15 @@ +{ + "name": "Complext", + "description": "This presentation should have some weird ways you can define slides", + "pdf_options": { + "orientation": "Landscape", + "quiet": true + }, + "sections": { + "Overview": "Overview/content.json" , + "About": [ "Shared/about.md" ] , + "Environment": [ "environment/one.md", "environment/two.md" ] , + "Conclusion": [ "conclusion.md", "end.md" ] , + "Appendix": "Shared/Appendix/content.json" + } +} diff --git a/spec/fixtures/forms/elements.md b/spec/fixtures/forms/elements.md new file mode 100644 index 000000000..32c3ba5d9 --- /dev/null +++ b/spec/fixtures/forms/elements.md @@ -0,0 +1,57 @@ +# This is a slide with some questions + +correct -> This question has a correct answer. = + (=) True + () False + +none -> This question has no correct answer. = + () True + () False + +named -> This question has named answers. = + () one -> the first answer + (=) two -> the second answer + () three -> the third answer + +correctcheck -> This question has a correct answer. = + [=] True + [] False + +nonecheck -> This question has no correct answer. = + [] True + [] False + +namedcheck -> This question has named answers. = + [] one -> the first answer + [=] two -> the second answer + [] three -> the third answer + +name = ___ + +namelength = ___[50] + +nametoken -> What is your name? = ___[50] + +comments = [ ] + +commentsrows = [ 5] + +smartphone = () iPhone () Android () other -> Any other phone not listed + +awake -> Are you paying attention? = (x) No () Yes + +smartphonecheck = [] iPhone [] Android [x] other -> Any other phone not listed + +phoneos -> Which phone OS is developed by Google? = {iPhone, [Android], Other } + +smartphonecombo = {iPhone, Android, (Other) } + +smartphonetoken = {iPhone, Android, (other -> Any other phone not listed) } + +cuisine -> What is your favorite cuisine? = { American, Italian, French } + +cuisinetoken -> What is your favorite cuisine? = { + US -> American + IT -> Italian + FR -> French + } diff --git a/spec/fixtures/forms/radios.md b/spec/fixtures/forms/radios.md new file mode 100644 index 000000000..a37efa30b --- /dev/null +++ b/spec/fixtures/forms/radios.md @@ -0,0 +1,12 @@ +# Testing radio buttons + +smartphone = () iPhone () Android () other -> Any other phone not listed + +awake -> Are you paying attention? = (x) No () Yes + +continent -> Which continent is largest? = + () Africa + () Americas + (=) Asia + () Australia + () Europe diff --git a/spec/fixtures/glossary_toc/content.html b/spec/fixtures/glossary_toc/content.html new file mode 100644 index 000000000..5117f4b81 --- /dev/null +++ b/spec/fixtures/glossary_toc/content.html @@ -0,0 +1,75 @@ + +
+
+ +

.

+ +

Table of Contents

+ +

~~~TOC~~~

+ + +
+ +
+ +
+
+ +

.

+ +

Glossary and TOC Demo

+ +

This phrase will +appear directly in a paragraph.

+ +

By hand, yo!I made this one by hand and it stands alone.

+ +

This phrase +will appear directly in a paragraph and link to a named glossary.

+ +

By hand, yo!I made this one by hand and it also

+ + +
+
+

phraseThe definition of the term.

+

phraseThe definition of the term.

+
+
+

phraseThe definition of the term.

+

phraseThe definition of the term.

+
+ +
+ +
+
+ +

.

+ +

General Glossary

+ +

You can add any markdown you want up here. A list of glossary items will be added to the end.

+ + +
+ +
+ +
+
+ +

.

+ +

This is a named glossary

+ +

You can add any markdown you want up here. A list of glossary items will be added to the end.

+ + +
+ +
diff --git a/spec/fixtures/glossary_toc/content.md b/spec/fixtures/glossary_toc/content.md new file mode 100644 index 000000000..309bc1092 --- /dev/null +++ b/spec/fixtures/glossary_toc/content.md @@ -0,0 +1,32 @@ + +# Table of Contents + +~~~TOC~~~ + + + +# Glossary and TOC Demo + +This [phrase](glossary://term-with-no-spaces "The definition of the term.") will +appear directly in a paragraph. + +.callout.glossary By hand, yo!|by-hand: I made this one by hand and it stands alone. + +This [phrase](glossary://name/term-with-no-spaces "The definition of the term.") +will appear directly in a paragraph and link to a named glossary. + + +.callout.glossary.name By hand, yo!|by-hand: I made this one by hand and it also +goes to the named glossary. + + + +# General Glossary + +You can add any markdown you want up here. A list of glossary items will be added to the end. + + + +# This is a named glossary + +You can add any markdown you want up here. A list of glossary items will be added to the end. diff --git a/spec/fixtures/glossary_toc/showoff.json b/spec/fixtures/glossary_toc/showoff.json new file mode 100644 index 000000000..e1c1b66b9 --- /dev/null +++ b/spec/fixtures/glossary_toc/showoff.json @@ -0,0 +1,7 @@ +{ + "name": "Glossary and TOC test", + "description": "This is a simple presentation that generates a TOC and glossary.", + "sections": [ + "content.md" + ] +} diff --git a/spec/fixtures/i18n/content.md b/spec/fixtures/i18n/content.md new file mode 100644 index 000000000..4a2e20d55 --- /dev/null +++ b/spec/fixtures/i18n/content.md @@ -0,0 +1,16 @@ + +# One + +This little piggy stayed home. + + + +# Two + +This little piggy had roast beef. + + + +# Three + +This little piggy had none. diff --git a/spec/fixtures/i18n/locales/de/content.md b/spec/fixtures/i18n/locales/de/content.md new file mode 100644 index 000000000..f75a15a7b --- /dev/null +++ b/spec/fixtures/i18n/locales/de/content.md @@ -0,0 +1,16 @@ + +# Eins + +Dieses kleine Schweinchen blieb zu Hause. + + + +# Zwei + +Dieses kleine Schweinchen hatte Roastbeef. + + + +# Drei + +Dieses kleine Schweinchen hatte keine. diff --git a/spec/fixtures/i18n/locales/fr/content.md b/spec/fixtures/i18n/locales/fr/content.md new file mode 100644 index 000000000..f525515fd --- /dev/null +++ b/spec/fixtures/i18n/locales/fr/content.md @@ -0,0 +1,16 @@ + +# Un + +Ce petit cochon est resté à la maison. + + + +# Deux + +Ce petit cochon avait du rosbif. + + + +# Trois + +Ce petit cochon n'en avait pas. diff --git a/spec/fixtures/i18n/locales/strings.json b/spec/fixtures/i18n/locales/strings.json new file mode 100644 index 000000000..77a26a4ac --- /dev/null +++ b/spec/fixtures/i18n/locales/strings.json @@ -0,0 +1,17 @@ +{ + "en": { + "greeting": "Hello!" + }, + "es": { + "greeting": "Hola!" + }, + "fr": { + "greeting": "Bonjour!" + }, + "de": { + "greeting": "Hallo!" + }, + "ja": { + "greeting": "こんにちは!" + } +} diff --git a/spec/fixtures/i18n/showoff.json b/spec/fixtures/i18n/showoff.json new file mode 100644 index 000000000..fad0286e3 --- /dev/null +++ b/spec/fixtures/i18n/showoff.json @@ -0,0 +1,7 @@ +{ + "name": "Languages", + "description": "This has a few simple translations.", + "sections": [ + "content.md" + ] +} diff --git a/spec/fixtures/namedhash.json b/spec/fixtures/namedhash.json new file mode 100644 index 000000000..77fd6aedb --- /dev/null +++ b/spec/fixtures/namedhash.json @@ -0,0 +1,29 @@ +{ + "name": "Basic Showoff config file", + "description": "This is a description.", + "protected": [ + "presenter", + "onepage", + "print" + ], + "version": "0.20.0", + "feedback": true, + "parsers" : { + "shell": "cat" + }, + "sections": { + "Overview": [ + "title.md", + "intro.md", + "about.md" + ], + "Content": [ + "one.md", + "two.md" + ], + "Conclusion": [ + "summary.md", + "end.md" + ] + } +} diff --git a/spec/fixtures/notes/_notes/content.3.md b/spec/fixtures/notes/_notes/content.3.md new file mode 100644 index 000000000..dc7690605 --- /dev/null +++ b/spec/fixtures/notes/_notes/content.3.md @@ -0,0 +1 @@ +(3) These are ***personal notes*** diff --git a/spec/fixtures/notes/_notes/content.4.md b/spec/fixtures/notes/_notes/content.4.md new file mode 100644 index 000000000..486492efe --- /dev/null +++ b/spec/fixtures/notes/_notes/content.4.md @@ -0,0 +1 @@ +(4) These are ***personal notes*** diff --git a/spec/fixtures/notes/_notes/content.md b/spec/fixtures/notes/_notes/content.md new file mode 100644 index 000000000..10b0c47c4 --- /dev/null +++ b/spec/fixtures/notes/_notes/content.md @@ -0,0 +1 @@ +(nonum) These are fallback notes for multi-slides with no numbered personal notes. diff --git a/spec/fixtures/notes/_notes/separate.md b/spec/fixtures/notes/_notes/separate.md new file mode 100644 index 000000000..fd8c7223b --- /dev/null +++ b/spec/fixtures/notes/_notes/separate.md @@ -0,0 +1 @@ +(separate) These are personal notes that should be attached to the separate slide. diff --git a/spec/fixtures/notes/content.md b/spec/fixtures/notes/content.md new file mode 100644 index 000000000..1bff40c3f --- /dev/null +++ b/spec/fixtures/notes/content.md @@ -0,0 +1,54 @@ + +# Notes and handouts both + +blah blah blah + +~~~SECTION:notes~~~ +These are some notes, yo +~~~ENDSECTION~~~ +~~~SECTION:handouts~~~ +And some handouts, yeah +~~~ENDSECTION~~~ + + + +# Arbitrary + +This slide validates that arbitrarily named sections work. + +~~~SECTION:notes~~~ +These are some notes, yo +~~~ENDSECTION~~~ +~~~SECTION:arbitrary~~~ +And some arbitrarily named section +~~~ENDSECTION~~~ + + + +# Notes and personal + +This has personal notes and presenter notes. +This is a multi slide file. + +~~~SECTION:notes~~~ +notes and stuff +~~~ENDSECTION~~~ + + + +# Notes and personal + +This has personal notes only. +This is a multi slide file. + + +# Non numbered personal + +This has personal notes only. +This is a multi slide file, but the personal notes file is not numbered. + + +# Second non numbered personal + +This a second non-numbered personal notes slide. The non-numbered content +should be attached to both. diff --git a/spec/fixtures/notes/separate.md b/spec/fixtures/notes/separate.md new file mode 100644 index 000000000..5d553c94a --- /dev/null +++ b/spec/fixtures/notes/separate.md @@ -0,0 +1,4 @@ + +# Notes and personal + +This has personal notes attached to a separate file. diff --git a/spec/fixtures/notes/showoff.json b/spec/fixtures/notes/showoff.json new file mode 100644 index 000000000..33cb99401 --- /dev/null +++ b/spec/fixtures/notes/showoff.json @@ -0,0 +1,9 @@ +{ + "name": "Notes test", + "description": "This is a simple presentation with a variety of notes.", + "sections": [ + "standalone.md", + "content.md", + "separate.md" + ] +} diff --git a/spec/fixtures/notes/standalone.md b/spec/fixtures/notes/standalone.md new file mode 100644 index 000000000..f703d03d1 --- /dev/null +++ b/spec/fixtures/notes/standalone.md @@ -0,0 +1,4 @@ + +# This slide has no notes + +Just some boring content. diff --git a/spec/fixtures/renderer.json b/spec/fixtures/renderer.json new file mode 100644 index 000000000..7d33ab8fe --- /dev/null +++ b/spec/fixtures/renderer.json @@ -0,0 +1,20 @@ +{ + "name": "Basic Showoff config file", + "description": "This is a description.", + "protected": [ + "presenter", + "onepage", + "print" + ], + "version": "0.20.0", + "feedback": true, + "parsers" : { + "shell": "cat" + }, + "sections": [ + "Overview.md", + "Content.md", + "Conclusion.md" + ], + "markdown": "maruku" +} diff --git a/spec/fixtures/slides/content.md b/spec/fixtures/slides/content.md new file mode 100644 index 000000000..4a2e20d55 --- /dev/null +++ b/spec/fixtures/slides/content.md @@ -0,0 +1,16 @@ + +# One + +This little piggy stayed home. + + + +# Two + +This little piggy had roast beef. + + + +# Three + +This little piggy had none. diff --git a/spec/fixtures/slides/first.md b/spec/fixtures/slides/first.md new file mode 100644 index 000000000..409777137 --- /dev/null +++ b/spec/fixtures/slides/first.md @@ -0,0 +1,4 @@ + +# First slide + +This little piggy went to market. diff --git a/spec/fixtures/slides/last.md b/spec/fixtures/slides/last.md new file mode 100644 index 000000000..d1e79e4b1 --- /dev/null +++ b/spec/fixtures/slides/last.md @@ -0,0 +1,4 @@ + +# Last + +This little piggy cried wee wee wee all the way home. diff --git a/spec/fixtures/slides/showoff.json b/spec/fixtures/slides/showoff.json new file mode 100644 index 000000000..ed58a587c --- /dev/null +++ b/spec/fixtures/slides/showoff.json @@ -0,0 +1,9 @@ +{ + "name": "Slides and sections", + "description": "This just has some slides.", + "sections": [ + "first.md", + "content.md", + "last.md" + ] +} diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 000000000..46ce0fc0d --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,108 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration + +require 'showoff_ng' + +# helper method to return the base path of the fixtures directory +def fixtures + File.expand_path(File.join(File.dirname(__FILE__), 'fixtures')) +end + +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + +# The settings below are suggested to provide a good initial experience +# with RSpec, but feel free to customize to your heart's content. +=begin + # This allows you to limit a spec run to individual examples or groups + # you care about by tagging them with `:focus` metadata. When nothing + # is tagged with `:focus`, all examples get run. RSpec also provides + # aliases for `it`, `describe`, and `context` that include `:focus` + # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + config.filter_run_when_matching :focus + + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + + # Limits the available syntax to the non-monkey patched syntax that is + # recommended. For more details, see: + # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + config.disable_monkey_patching! + + # This setting enables warnings. It's recommended, but in some cases may + # be too noisy due to issues in dependencies. + config.warnings = true + + # Many RSpec users commonly either run the entire suite or an individual + # file, and it's useful to allow more verbose output when running an + # individual spec file. + if config.files_to_run.one? + # Use the documentation formatter for detailed output, + # unless a formatter has already been configured + # (e.g. via a command-line flag). + config.default_formatter = "doc" + end + + # Print the 10 slowest examples and example groups at the + # end of the spec run, to help surface which specs are running + # particularly slow. + config.profile_examples = 10 + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = :random + + # Seed global randomization in this process using the `--seed` CLI option. + # Setting this allows you to use `--seed` to deterministically reproduce + # test failures related to randomization by passing the same `--seed` value + # as the one that triggered the failure. + Kernel.srand config.seed +=end +end diff --git a/spec/unit/showoff/compiler/downloads_spec.rb b/spec/unit/showoff/compiler/downloads_spec.rb new file mode 100644 index 000000000..63ce9c3b7 --- /dev/null +++ b/spec/unit/showoff/compiler/downloads_spec.rb @@ -0,0 +1,84 @@ +RSpec.describe Showoff::Compiler::Downloads do + content = <<-EOF +

This is a simple HTML slide with download tags

+

Here are a few tags that should be transformed to attachments

+

link/to/one.txt +.download link/to/two.txt all +.download link/to/three.txt prev +.download link/to/four.txt current +.download link/to/five.txt next

+EOF + + tests = { + :all => {:slide => 0, :files => ['link/to/two.txt']}, + :pre => {:slide => 21, :files => []}, + :prev => {:slide => 22, :files => ['link/to/three.txt']}, + :curr => {:slide => 23, :files => ['link/to/four.txt']}, + :next => {:slide => 24, :files => ['link/to/one.txt', 'link/to/five.txt']}, + :post => {:slide => 25, :files => []}, + } + + tests.each do |period, data| + it "transforms download tags to #{period} slide attachments" do + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + Showoff::State.reset() + Showoff::State.set(:slide_count, 23) + + # This call mutates the passed in object + Showoff::Compiler::Downloads.scanForFiles!(doc, :name => 'foo') + elements = doc.search('p') + slide = data[:slide] + files = data[:files] + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(elements.length).to eq(1) + expect(Showoff::Compiler::Downloads.getFiles(slide)).to eq([]) + + Showoff::Compiler::Downloads.enableFiles(slide) + expect(Showoff::Compiler::Downloads.getFiles(slide).size).to eq(files.length) + expect(Showoff::Compiler::Downloads.getFiles(slide).map{|a| a[:source] }).to all eq('foo') + expect(Showoff::Compiler::Downloads.getFiles(slide).map{|a| a[:slidenum] }).to all eq(23) + expect(Showoff::Compiler::Downloads.getFiles(slide).map{|a| a[:file] }).to eq(files) + end + end + + it "removes a paragraph of download tags from document" do + doc = Nokogiri::HTML::DocumentFragment.parse(content) + Showoff::State.set(:slide_count, 23) + + # This call mutates the passed in object + Showoff::Compiler::Downloads.scanForFiles!(doc, :name => 'foo') + elements = doc.search('p') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(elements.length).to eq(1) + end + + it "returns an empty array for a blank stack" do + Showoff::State.reset() + + expect(Showoff::Compiler::Downloads.getFiles(12)).to eq([]) + end + + it "pushes a file onto the attachment stack" do + Showoff::State.reset() + + expect(Showoff::Compiler::Downloads.pushFile(12, 12, 'foo', 'path/to/file.txt')[:enabled]).to be_falsey + expect(Showoff::Compiler::Downloads.getFiles(12)).to eq([]) + Showoff::State.get(:downloads)[12] = {:enabled=>false, :slides=>[{:slidenum=>12, :source=>"foo", :file=>"path/to/file.txt"}]} + end + + it "enables a download properly" do + Showoff::State.reset() + + expect(Showoff::Compiler::Downloads.pushFile(12, 12, 'foo', 'path/to/file.txt')[:enabled]).to be_falsey + expect(Showoff::Compiler::Downloads.getFiles(12)).to eq([]) + + Showoff::Compiler::Downloads.enableFiles(12) + expect(Showoff::Compiler::Downloads.getFiles(12)).to eq([{:slidenum=>12, :source=>"foo", :file=>"path/to/file.txt"}]) + end + +end + + diff --git a/spec/unit/showoff/compiler/fixups_spec.rb b/spec/unit/showoff/compiler/fixups_spec.rb new file mode 100644 index 000000000..3503456e0 --- /dev/null +++ b/spec/unit/showoff/compiler/fixups_spec.rb @@ -0,0 +1,213 @@ +RSpec.describe Showoff::Compiler::Fixups do + + it "replaces paragraph classes" do + content = <<-EOF +

This is a simple HTML slide

+

.this.is.a.test This paragraph should have several classes applied.

+

.singular This should only have one.

+

And this has none.

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateClasses!(doc) + elements = doc.search('p') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(elements.length).to eq(3) + expect(elements[0].classes).to eq(['this', 'is', 'a', 'test']) + expect(elements[1].classes).to eq(['singular']) + expect(elements[2].classes).to eq([]) + end + + it "removes comments and breaks" do + content = <<-EOF +

This is a simple HTML slide

+

.this.is.a.test This paragraph should have several classes applied.

+

.comment This should be removed.

+

.break so should this.

+

And this has none.

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateClasses!(doc) + elements = doc.search('p') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(elements.length).to eq(2) + expect(elements[0].classes).to eq(['this', 'is', 'a', 'test']) + expect(elements[1].classes).to eq([]) + end + + it "replaces image classes" do + content = <<-EOF +

This is a simple HTML slide

+

.this.is.a.test

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateClasses!(doc) + paragraphs = doc.search('p') + images = doc.search('img') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(paragraphs.length).to eq(1) + expect(paragraphs.text).to eq('') + expect(images.length).to eq(1) + expect(images[0].classes).to eq(['this', 'is', 'a', 'test']) + end + + it "updates link targets" do + content = <<-EOF +

This is a simple HTML slide

+ +EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateLinks!(doc) + paragraphs = doc.search('p') + anchors = doc.search('a') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(paragraphs.length).to eq(0) + expect(anchors.length).to eq(4) + expect(anchors[0].attribute('target').value).to eq('_blank') + expect(anchors[1].attribute('target')).to be_nil + expect(anchors[2].attribute('target')).to be_nil + expect(anchors[3].attribute('target')).to be_nil + end + + it "correctly munges backtick fenced code blocks" do + content = <<-EOF +

This is a simple HTML slide with code

+
echo 'hello'
+
+
puts 'hello'
+
+
puts 'goodbye'
+
+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateSyntaxHighlighting!(doc) + paragraphs = doc.search('p') + pre = doc.search('pre') + code = doc.search('code') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(paragraphs.length).to eq(0) + expect(pre.length).to eq(3) + expect(code.length).to eq(3) + expect(pre[0].classes).to eq([]) + expect(pre[1].classes).to eq(['highlight']) + expect(pre[2].classes).to eq(['highlight']) + expect(code[0].classes).to eq([]) + expect(code[1].classes).to eq(['language-ruby']) + expect(code[2].classes).to eq(['language-ruby', 'goodbye']) + end + + it "correctly munges showoff syntax tags" do + content = <<-EOF +

This is a simple HTML slide with showoff syntax tags

+

+echo 'hello'
+
+
@@@ ruby
+puts 'hello'
+
+
@@@ ruby goodbye
+puts 'goodbye'
+
+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateSyntaxHighlighting!(doc) + paragraphs = doc.search('p') + pre = doc.search('pre') + code = doc.search('code') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(paragraphs.length).to eq(0) + expect(pre.length).to eq(3) + expect(code.length).to eq(3) + expect(pre[0].classes).to eq([]) + expect(pre[1].classes).to eq(['highlight']) + expect(pre[2].classes).to eq(['highlight']) + expect(code[0].classes).to eq([]) + expect(code[1].classes).to eq(['language-ruby']) + expect(code[2].classes).to eq(['language-ruby', 'goodbye']) + end + + context "image path cleanup" do + it "cleans up image paths for slide in presentation root" do + content = <<-EOF +

This is a simple HTML slide with image tags

+

Hackerrrr

+

Another silly picture

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateImagePaths!(doc, {:name => 'foo.md'}) + imgs = doc.search('img') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(imgs.length).to eq(2) + expect(imgs[0][:src]).to eq('_images/hackerrrr.jpg') + expect(imgs[1][:src]).to eq('_images/another.jpg') + end + + it "cleans up image paths for slide in directory" do + content = <<-EOF +

This is a simple HTML slide with image tags

+

Hackerrrr

+

Another silly picture

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateImagePaths!(doc, {:name => 'testing/foo.md'}) + imgs = doc.search('img') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(imgs.length).to eq(2) + expect(imgs[0][:src]).to eq('_images/hackerrrr.jpg') + expect(imgs[1][:src]).to eq('_images/another.jpg') + end + + it "cleans up image paths for slide in deeply nested directory" do + content = <<-EOF +

This is a simple HTML slide with image tags

+

Hackerrrr

+

Another silly picture

+EOF + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Fixups.updateImagePaths!(doc, {:name => 'foo/bar/baz/testing.md'}) + imgs = doc.search('img') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(imgs.length).to eq(2) + expect(imgs[0][:src]).to eq('_images/hackerrrr.jpg') + expect(imgs[1][:src]).to eq('_images/another.jpg') + end + end + + # I don't actually know precisely what this routine does yet..... + it "correctly munges commandline blocks" + +end + + + diff --git a/spec/unit/showoff/compiler/form/checkbox_spec.rb b/spec/unit/showoff/compiler/form/checkbox_spec.rb new file mode 100644 index 000000000..aaa7d8e76 --- /dev/null +++ b/spec/unit/showoff/compiler/form/checkbox_spec.rb @@ -0,0 +1,271 @@ +RSpec.describe Showoff::Compiler::Form do + + it "parses single line checkbox button markup" do +# markdown = File.read(File.join(fixtures, 'forms', 'radios.md')) +# content = Tilt[:markdown].new(nil, nil, {}) { markdown }.render + content = <<-EOF +

Testing checkbox buttons

+

smartphone = [] iPhone [] Android [] other -> Any other phone not listed

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '[] iPhone [] Android [] other -> Any other phone not listed', + 'smartphone = [] iPhone [] Android [] other -> Any other phone not listed', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders checkbox buttons from single line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_checkboxes).with( + 'foo_smartphone', + 'smartphone', + [["", "iPhone "], ["", "Android "], ["", "other -> Any other phone not listed"]], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '[] iPhone [] Android [] other -> Any other phone not listed', + 'smartphone = [] iPhone [] Android [] other -> Any other phone not listed', + ) + end + + it 'generates the proper HTML markup for a checkbox button set' do + html = Showoff::Compiler::Form.form_element_check_or_radio_set( + 'checkbox', + 'foo_smartphone', + 'smartphone', + [["", "iPhone "], ["", "Android "], ["", "other -> Any other phone not listed"]], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(6) + expect(doc.search('label.response').size).to eq(3) + expect(doc.search('input[type=checkbox].response').size).to eq(3) + expect(doc.search('#foo_smartphone_iPhone').size).to eq(1) + expect(doc.search('#foo_smartphone_Android').size).to eq(1) + expect(doc.search('#foo_smartphone_other').size).to eq(1) + expect(doc.search('input[type=checkbox].response.correct').empty?).to be_truthy + expect(doc.search('input[type=checkbox]').select {|i| i.attribute('checked') }.empty?).to be_truthy + end + +################################################################################ + + it "parses single line tokenized name checkbox button markup" do +# markdown = File.read(File.join(fixtures, 'forms', 'radios.md')) +# content = Tilt[:markdown].new(nil, nil, {}) { markdown }.render + content = <<-EOF +

Testing radio buttons

+

awake -> Are you paying attention? = [x] No [] Yes

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_awake', + 'awake', + 'Are you paying attention?', + false, + '[x] No [] Yes', + 'awake -> Are you paying attention? = [x] No [] Yes', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders checkbox buttons from single line markup with a tokenized name" do + expect(Showoff::Compiler::Form).to receive(:form_element_checkboxes).with( + 'foo_awake', + 'awake', + [["x", "No "], ["", "Yes"]], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_awake', + 'awake', + 'Are you paying attention?', + false, + '[x] No [] Yes', + 'awake -> Are you paying attention? = [x] No [] Yes', + ) + end + + it 'generates the proper HTML markup for a tokenized name checkbox set' do + html = Showoff::Compiler::Form.form_element_check_or_radio_set( + 'checkbox', + 'foo_awake', + 'awake', + [["x", "No "], ["", "Yes"]], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(4) + expect(doc.search('label.response').size).to eq(2) + expect(doc.search('input[type=checkbox].response').size).to eq(2) + expect(doc.search('#foo_awake_No').size).to eq(1) + expect(doc.search('#foo_awake_Yes').size).to eq(1) + expect(doc.search('input[type=checkbox].response.correct').empty?).to be_truthy + expect(doc.search('input[type=checkbox]').select {|i| i.attribute('checked') }.size).to eq(1) + end + +################################################################################ + + it "parses multi line checkbox markup" do + content = <<-EOF +

Testing radio buttons

+

continent -> Which continent is largest? = +[] Africa +[] Americas +[=] Asia +[] Australia +[] Europe

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_continent', + 'continent', + 'Which continent is largest?', + false, + '', + "continent -> Which continent is largest? =\n[] Africa\n[] Americas\n[=] Asia\n[] Australia\n[] Europe", + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders checkboxes from multi line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_multiline).with( + 'foo_continent', + 'continent', + "continent -> Which continent is largest? =\n[] Africa\n[] Americas\n[=] Asia\n[] Australia\n[] Europe", + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_continent', + 'continent', + 'Which continent is largest?', + false, + '', + "continent -> Which continent is largest? =\n[] Africa\n[] Americas\n[=] Asia\n[] Australia\n[] Europe", + ) + end + + it 'renders items for a multiline radio button set' do + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'checkbox', + 'foo_continent', + 'continent', + 'Africa', + 'Africa', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'checkbox', + 'foo_continent', + 'continent', + 'Americas', + 'Americas', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'checkbox', + 'foo_continent', + 'continent', + 'Asia', + 'Asia', + '=', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'checkbox', + 'foo_continent', + 'continent', + 'Australia', + 'Australia', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'checkbox', + 'foo_continent', + 'continent', + 'Europe', + 'Europe', + '', + ).and_return('x') + + html = Showoff::Compiler::Form.form_element_multiline( + 'foo_continent', + 'continent', + "continent -> Which continent is largest? =\n[] Africa\n[] Americas\n[=] Asia\n[] Australia\n[] Europe", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.search('li').size).to eq(5) + end + + it 'generates the proper HTML markup for a multiline checkbox element' do + html = Showoff::Compiler::Form.form_element_check_or_radio( + 'checkbox', + 'foo_continent', + 'continent', + 'Africa', + 'Africa', + '', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(2) + expect(doc.search('label.response').size).to eq(1) + expect(doc.search('label.response').first.text).to eq('Africa') + expect(doc.search('input[type=checkbox].response').size).to eq(1) + expect(doc.search('input[type=checkbox].response').first[:value]).to eq('Africa') + + expect(doc.search('#foo_continent_Africa').size).to eq(1) + expect(doc.search('input[type=checkbox].response.correct').empty?).to be_truthy + expect(doc.search('input[type=checkbox]').select {|i| i.attribute('checked') }.size).to eq(0) + end + + it 'generates the proper HTML markup for a multiline correct radio button element' do + html = Showoff::Compiler::Form.form_element_check_or_radio( + 'checkbox', + 'foo_continent', + 'continent', + 'Asia', + 'Asia', + '=', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(2) + expect(doc.search('label.response').size).to eq(1) + expect(doc.search('label.response').first.text).to eq('Asia') + expect(doc.search('input[type=checkbox].response').size).to eq(1) + expect(doc.search('input[type=checkbox].response').first[:value]).to eq('Asia') + + expect(doc.search('#foo_continent_Asia').size).to eq(1) + expect(doc.search('input[type=checkbox].response').size).to eq(1) + expect(doc.search('input[type=checkbox]').select {|i| i.attribute('checked') }.size).to eq(0) + end + + # @todo this test suite needs a lotta lotta work. This only scratches the surface +end + + + + diff --git a/spec/unit/showoff/compiler/form/radio_spec.rb b/spec/unit/showoff/compiler/form/radio_spec.rb new file mode 100644 index 000000000..0c22fe085 --- /dev/null +++ b/spec/unit/showoff/compiler/form/radio_spec.rb @@ -0,0 +1,271 @@ +RSpec.describe Showoff::Compiler::Form do + + it "parses single line radio button markup" do +# markdown = File.read(File.join(fixtures, 'forms', 'radios.md')) +# content = Tilt[:markdown].new(nil, nil, {}) { markdown }.render + content = <<-EOF +

Testing radio buttons

+

smartphone = () iPhone () Android () other -> Any other phone not listed

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '() iPhone () Android () other -> Any other phone not listed', + 'smartphone = () iPhone () Android () other -> Any other phone not listed', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders radio buttons from single line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_radio).with( + 'foo_smartphone', + 'smartphone', + [["", "iPhone "], ["", "Android "], ["", "other -> Any other phone not listed"]], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '() iPhone () Android () other -> Any other phone not listed', + 'smartphone = () iPhone () Android () other -> Any other phone not listed', + ) + end + + it 'generates the proper HTML markup for a radio button set' do + html = Showoff::Compiler::Form.form_element_check_or_radio_set( + 'radio', + 'foo_smartphone', + 'smartphone', + [["", "iPhone "], ["", "Android "], ["", "other -> Any other phone not listed"]], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(6) + expect(doc.search('label.response').size).to eq(3) + expect(doc.search('input[type=radio].response').size).to eq(3) + expect(doc.search('#foo_smartphone_iPhone').size).to eq(1) + expect(doc.search('#foo_smartphone_Android').size).to eq(1) + expect(doc.search('#foo_smartphone_other').size).to eq(1) + expect(doc.search('input[type=radio].response.correct').empty?).to be_truthy + expect(doc.search('input[type=radio]').select {|i| i.attribute('checked') }.empty?).to be_truthy + end + +################################################################################ + + it "parses single line tokenized name radio button markup" do +# markdown = File.read(File.join(fixtures, 'forms', 'radios.md')) +# content = Tilt[:markdown].new(nil, nil, {}) { markdown }.render + content = <<-EOF +

Testing radio buttons

+

awake -> Are you paying attention? = (x) No () Yes

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_awake', + 'awake', + 'Are you paying attention?', + false, + '(x) No () Yes', + 'awake -> Are you paying attention? = (x) No () Yes', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders radio buttons from single line markup with a tokenized name" do + expect(Showoff::Compiler::Form).to receive(:form_element_radio).with( + 'foo_awake', + 'awake', + [["x", "No "], ["", "Yes"]], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_awake', + 'awake', + 'Are you paying attention?', + false, + '(x) No () Yes', + 'awake -> Are you paying attention? = (x) No () Yes', + ) + end + + it 'generates the proper HTML markup for a tokenized name radio button set' do + html = Showoff::Compiler::Form.form_element_check_or_radio_set( + 'radio', + 'foo_awake', + 'awake', + [["x", "No "], ["", "Yes"]], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(4) + expect(doc.search('label.response').size).to eq(2) + expect(doc.search('input[type=radio].response').size).to eq(2) + expect(doc.search('#foo_awake_No').size).to eq(1) + expect(doc.search('#foo_awake_Yes').size).to eq(1) + expect(doc.search('input[type=radio].response.correct').empty?).to be_truthy + expect(doc.search('input[type=radio]').select {|i| i.attribute('checked') }.size).to eq(1) + end + +################################################################################ + + it "parses multi line radio button markup" do + content = <<-EOF +

Testing radio buttons

+

continent -> Which continent is largest? = +() Africa +() Americas +(=) Asia +() Australia +() Europe

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_continent', + 'continent', + 'Which continent is largest?', + false, + '', + "continent -> Which continent is largest? =\n() Africa\n() Americas\n(=) Asia\n() Australia\n() Europe", + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders radio buttons from multi line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_multiline).with( + 'foo_continent', + 'continent', + "continent -> Which continent is largest? =\n() Africa\n() Americas\n(=) Asia\n() Australia\n() Europe", + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_continent', + 'continent', + 'Which continent is largest?', + false, + '', + "continent -> Which continent is largest? =\n() Africa\n() Americas\n(=) Asia\n() Australia\n() Europe", + ) + end + + it 'renders items for a multiline radio button set' do + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'radio', + 'foo_continent', + 'continent', + 'Africa', + 'Africa', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'radio', + 'foo_continent', + 'continent', + 'Americas', + 'Americas', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'radio', + 'foo_continent', + 'continent', + 'Asia', + 'Asia', + '=', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'radio', + 'foo_continent', + 'continent', + 'Australia', + 'Australia', + '', + ).and_return('x') + expect(Showoff::Compiler::Form).to receive(:form_element_check_or_radio).with( + 'radio', + 'foo_continent', + 'continent', + 'Europe', + 'Europe', + '', + ).and_return('x') + + html = Showoff::Compiler::Form.form_element_multiline( + 'foo_continent', + 'continent', + "continent -> Which continent is largest? =\n() Africa\n() Americas\n(=) Asia\n() Australia\n() Europe", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.search('li').size).to eq(5) + end + + it 'generates the proper HTML markup for a multiline radio button element' do + html = Showoff::Compiler::Form.form_element_check_or_radio( + 'radio', + 'foo_continent', + 'continent', + 'Africa', + 'Africa', + '', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(2) + expect(doc.search('label.response').size).to eq(1) + expect(doc.search('label.response').first.text).to eq('Africa') + expect(doc.search('input[type=radio].response').size).to eq(1) + expect(doc.search('input[type=radio].response').first[:value]).to eq('Africa') + + expect(doc.search('#foo_continent_Africa').size).to eq(1) + expect(doc.search('input[type=radio].response.correct').empty?).to be_truthy + expect(doc.search('input[type=radio]').select {|i| i.attribute('checked') }.size).to eq(0) + end + + it 'generates the proper HTML markup for a multiline correct radio button element' do + html = Showoff::Compiler::Form.form_element_check_or_radio( + 'radio', + 'foo_continent', + 'continent', + 'Asia', + 'Asia', + '=', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(2) + expect(doc.search('label.response').size).to eq(1) + expect(doc.search('label.response').first.text).to eq('Asia') + expect(doc.search('input[type=radio].response').size).to eq(1) + expect(doc.search('input[type=radio].response').first[:value]).to eq('Asia') + + expect(doc.search('#foo_continent_Asia').size).to eq(1) + expect(doc.search('input[type=radio].response').size).to eq(1) + expect(doc.search('input[type=radio]').select {|i| i.attribute('checked') }.size).to eq(0) + end + + # @todo this test suite needs a lotta lotta work. This only scratches the surface +end + + + + diff --git a/spec/unit/showoff/compiler/form/select_spec.rb b/spec/unit/showoff/compiler/form/select_spec.rb new file mode 100644 index 000000000..86e530692 --- /dev/null +++ b/spec/unit/showoff/compiler/form/select_spec.rb @@ -0,0 +1,302 @@ +RSpec.describe Showoff::Compiler::Form do + + it "parses single line combo select markup" do + content = <<-EOF +

Testing combo selects

+

smartphone = {iPhone, Pixel, Galaxy, Moto, (Other) }

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '{iPhone, Pixel, Galaxy, Moto, (Other) }', + 'smartphone = {iPhone, Pixel, Galaxy, Moto, (Other) }', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders select widgets from single line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_select).with( + 'foo_smartphone', + 'smartphone', + ["iPhone", "Pixel", "Galaxy", "Moto", "(Other)"], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_smartphone', + 'smartphone', + 'smartphone', + false, + '{iPhone, Pixel, Galaxy, Moto, (Other) }', + 'smartphone = {iPhone, Pixel, Galaxy, Moto, (Other) }', + ) + end + + it 'generates the proper HTML markup for a select widget' do + html = Showoff::Compiler::Form.form_element_select( + 'foo_smartphone', + 'smartphone', + ["iPhone", "Pixel", "Galaxy", "Moto", "(Other)"], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(6) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').reject {|o| o[:selected] }.size).to eq(5) + expect(doc.search('option').find {|o| o[:selected] }.text).to eq('Other') + expect(doc.search('option').map{|o| o.text }).to eq(["----", "iPhone", "Pixel", "Galaxy", "Moto", "Other"]) + end + +################################################################################ + + it "parses single line combo select markup with tokenized name" do + content = <<-EOF +

Testing combo selects

+

phoneos -> Which phone OS is developed by Google? = {iOS, [Android], Other }

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_phoneos', + 'phoneos', + 'Which phone OS is developed by Google?', + false, + '{iOS, [Android], Other }', + 'phoneos -> Which phone OS is developed by Google? = {iOS, [Android], Other }', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders select widgets from single line markup with a tokenized name" do + expect(Showoff::Compiler::Form).to receive(:form_element_select).with( + 'foo_phoneos', + 'phoneos', + ['iOS', '[Android]', 'Other'], + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_phoneos', + 'phoneos', + 'Which phone OS is developed by Google?', + false, + '{iOS, [Android], Other }', + 'phoneos -> Which phone OS is developed by Google? = {iOS, [Android], Other }', + ) + end + + it 'generates the proper HTML markup for a tokenized name select widget' do + html = Showoff::Compiler::Form.form_element_select( + 'foo_phoneos', + 'phoneos', + ['iOS', '[Android]', 'Other'], + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(4) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').find {|o| o[:selected] }).to be_nil + expect(doc.search('option.correct').size).to eq(1) + expect(doc.search('option.correct').first.text).to eq('Android') + expect(doc.search('option').map{|o| o.text }).to eq(["----", "iOS", "Android", "Other"]) + end + +################################################################################ + + it "parses multi line select markup" do + content = <<-EOF +

Testing selects

+

phoneos -> Which phone OS is developed by Google? = { + iOS + [Android] + Other +}

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_phoneos', + 'phoneos', + 'Which phone OS is developed by Google?', + false, + '{', + "phoneos -> Which phone OS is developed by Google? = {\n iOS\n [Android]\n Other\n}", + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders selects widgets from multi line markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_select_multiline).with( + 'foo_phoneos', + 'phoneos', + "phoneos -> Which phone OS is developed by Google? = {\n iOS\n [Android]\n Other\n}", + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_phoneos', + 'phoneos', + 'Which phone OS is developed by Google?', + false, + '{', + "phoneos -> Which phone OS is developed by Google? = {\n iOS\n [Android]\n Other\n}", + ) + end + + + it 'generates the proper HTML markup for a multiline select widget' do + html = Showoff::Compiler::Form.form_element_select_multiline( + 'foo_phoneos', + 'phoneos', + "phoneos -> Which phone OS is developed by Google? = {\n iOS\n [Android]\n Other\n}", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(4) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').find {|o| o[:selected] }).to be_nil + expect(doc.search('option.correct').size).to eq(1) + expect(doc.search('option.correct').first.text).to eq('Android') + expect(doc.search('option').map{|o| o.text }).to eq(["----", "iOS", "Android", "Other"]) + end + + it 'generates the proper HTML markup for a multiline select widget with one selected' do + html = Showoff::Compiler::Form.form_element_select_multiline( + 'foo_phoneos', + 'phoneos', + "phoneos -> Which phone OS is developed by Google? = {\n iOS\n [Android]\n (Other)\n}", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(4) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').select {|o| o[:selected] }.size).to eq(1) + expect(doc.search('option').find {|o| o[:selected] }.text).to eq('Other') + expect(doc.search('option.correct').size).to eq(1) + expect(doc.search('option.correct').first.text).to eq('Android') + expect(doc.search('option').map{|o| o.text }).to eq(["----", "iOS", "Android", "Other"]) + end + +################################################################################ + + it "parses multi line select tokenized markup" do + content = <<-EOF +

Testing selects

+

cuisine -> What is your favorite cuisine? = { + US -> American + IT -> Italian + FR -> French +}

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_cuisine', + 'cuisine', + 'What is your favorite cuisine?', + false, + '{', + "cuisine -> What is your favorite cuisine? = {\n US -> American\n IT -> Italian\n FR -> French\n}", + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders selects widgets from multi line tokenized markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_select_multiline).with( + 'foo_cuisine', + 'cuisine', + "cuisine -> What is your favorite cuisine? = {\n US -> American\n IT -> Italian\n FR -> French\n}", + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_cuisine', + 'cuisine', + 'What is your favorite cuisine?', + false, + '{', + "cuisine -> What is your favorite cuisine? = {\n US -> American\n IT -> Italian\n FR -> French\n}", + ) + end + + + it 'generates the proper HTML markup for a tokenized multiline select widget' do + html = Showoff::Compiler::Form.form_element_select_multiline( + 'foo_cuisine', + 'cuisine', + "cuisine -> What is your favorite cuisine? = {\n US -> American\n IT -> Italian\n FR -> French\n}", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(4) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').find {|o| o[:selected] }).to be_nil + expect(doc.search('option.correct').empty?).to be_truthy + expect(doc.search('option').map{|o| o[:value] }).to eq(["", "US", "IT", "FR"]) + expect(doc.search('option').map{|o| o.text }).to eq(["----", "American", "Italian", "French"]) + end + + it 'generates the proper HTML markup for a multiline select widget with one selected' do + html = Showoff::Compiler::Form.form_element_select_multiline( + 'foo_cuisine', + 'cuisine', + "cuisine -> What is your favorite cuisine? = {\n US -> American\n IT -> Italian\n FR -> French\n\n (XX -> Other)\n}", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(5) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option.correct').empty?).to be_truthy + expect(doc.search('option').select {|o| o[:selected] }.size).to eq(1) + expect(doc.search('option').find {|o| o[:selected] }[:value]).to eq('XX') + expect(doc.search('option').find {|o| o[:selected] }.text).to eq('Other') + expect(doc.search('option').map{|o| o[:value] }).to eq(["", "US", "IT", "FR", "XX"]) + expect(doc.search('option').map{|o| o.text }).to eq(["----", "American", "Italian", "French", "Other"]) + end + + it 'generates the proper HTML markup for a multiline select widget with a correct answer' do + html = Showoff::Compiler::Form.form_element_select_multiline( + 'foo_cuisine', + 'cuisine', + "cuisine -> What type of cuisine is a baguette? = {\n US -> American\n IT -> Italian\n [FR -> French]\n\n XX -> Other\n}", + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + expect(doc.children.size).to eq(1) + expect(doc.search('option').size).to eq(5) + expect(doc.search('option').first.text).to eq('----') + expect(doc.search('option').select {|o| o[:selected] }.size).to eq(0) + expect(doc.search('option.correct').size).to eq(1) + expect(doc.search('option.correct').first.text).to eq('French') + expect(doc.search('option.correct').first[:value]).to eq('FR') + expect(doc.search('option').map{|o| o[:value] }).to eq(["", "US", "IT", "FR", "XX"]) + expect(doc.search('option').map{|o| o.text }).to eq(["----", "American", "Italian", "French", "Other"]) + end + +end + + + + diff --git a/spec/unit/showoff/compiler/form/text_spec.rb b/spec/unit/showoff/compiler/form/text_spec.rb new file mode 100644 index 000000000..ff68cb1f5 --- /dev/null +++ b/spec/unit/showoff/compiler/form/text_spec.rb @@ -0,0 +1,121 @@ +RSpec.describe Showoff::Compiler::Form do + + it "parses single line text field markup" do + content = <<-EOF +

What's your name?

+

name = ___

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_name', + 'name', + 'name', + false, + '___', + 'name = ___', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders text fields from markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_text).with( + 'foo_name', + 'name', + nil, + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_name', + 'name', + 'name', + false, + '___', + 'name = ___', + ) + end + + it 'generates the proper HTML markup for a text field' do + html = Showoff::Compiler::Form.form_element_text( + 'foo_name', + 'name', + nil, + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + text = doc.children.first + + expect(doc.children.size).to eq(1) + expect(text.node_name).to eq('input') + expect(text[:type]).to eq('text') + expect(text[:id]).to eq('foo_name_response') + expect(text[:name]).to eq('name') + expect(text[:size]).to eq('') + end + +################################################################################ + + it "parses single line text field markup with length" do + content = <<-EOF +

What's your name?

+

name = ___[50]

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_name', + 'name', + 'name', + false, + '___[50]', + 'name = ___[50]', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders text fields from markup with length" do + expect(Showoff::Compiler::Form).to receive(:form_element_text).with( + 'foo_name', + 'name', + '50', + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_name', + 'name', + 'name', + false, + '___[50]', + 'name = ___[50]', + ) + end + + it 'generates the proper HTML markup for a text field' do + html = Showoff::Compiler::Form.form_element_text( + 'foo_name', + 'name', + '50', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + text = doc.children.first + + expect(doc.children.size).to eq(1) + expect(text.node_name).to eq('input') + expect(text[:type]).to eq('text') + expect(text[:id]).to eq('foo_name_response') + expect(text[:name]).to eq('name') + expect(text[:size]).to eq('50') + end + +end + + + + diff --git a/spec/unit/showoff/compiler/form/textarea_spec.rb b/spec/unit/showoff/compiler/form/textarea_spec.rb new file mode 100644 index 000000000..06a1bf4e5 --- /dev/null +++ b/spec/unit/showoff/compiler/form/textarea_spec.rb @@ -0,0 +1,119 @@ +RSpec.describe Showoff::Compiler::Form do + + it "parses textarea markup" do + content = <<-EOF +

Got any comments?

+

comments = [ ]

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_comments', + 'comments', + 'comments', + false, + '[ ]', + 'comments = [ ]', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders textareas from markup" do + expect(Showoff::Compiler::Form).to receive(:form_element_textarea).with( + 'foo_comments', + 'comments', + '', + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_comments', + 'comments', + 'comments', + false, + '[ ]', + 'comments = [ ]', + ) + end + + it 'generates the proper HTML markup for a textarea' do + html = Showoff::Compiler::Form.form_element_textarea( + 'foo_comments', + 'comments', + '', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + text = doc.children.first + + expect(doc.children.size).to eq(1) + expect(text.node_name).to eq('textarea') + expect(text[:id]).to eq('foo_comments_response') + expect(text[:name]).to eq('comments') + expect(text[:rows]).to eq('3') + end + +################################################################################ + + it "parses textarea markup with rows" do + content = <<-EOF +

Got any comments?

+

comments = [ 5]

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(Showoff::Compiler::Form).to receive(:form_element).with( + 'foo_comments', + 'comments', + 'comments', + false, + '[ 5]', + 'comments = [ 5]', + ).and_return('') + + # This call mutates the passed in object and invokes the form rendering + Showoff::Compiler::Form.render!(doc, :form => 'foo') + end + + it "renders textareas from markup with rows" do + expect(Showoff::Compiler::Form).to receive(:form_element_textarea).with( + 'foo_comments', + 'comments', + '5', + ).and_return('') + + Showoff::Compiler::Form.form_element( + 'foo_comments', + 'comments', + 'comments', + false, + '[ 5]', + 'comments = [ 5]', + ) + end + + it 'generates the proper HTML markup for a textarea with rows' do + html = Showoff::Compiler::Form.form_element_textarea( + 'foo_comments', + 'comments', + '5', + ) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + text = doc.children.first + + expect(doc.children.size).to eq(1) + expect(text.node_name).to eq('textarea') + expect(text[:id]).to eq('foo_comments_response') + expect(text[:name]).to eq('comments') + expect(text[:rows]).to eq('5') + end + +end + + + + diff --git a/spec/unit/showoff/compiler/form_spec.rb b/spec/unit/showoff/compiler/form_spec.rb new file mode 100644 index 000000000..0398bdb9f --- /dev/null +++ b/spec/unit/showoff/compiler/form_spec.rb @@ -0,0 +1,70 @@ +RSpec.describe Showoff::Compiler::Form do + + # This is a pretty boring quick "integration" test of the full form. + # The individual widgets should each be tested individually. + it "renders examples of all elements" do +# markdown = File.read(File.join(fixtures, 'forms', 'elements.md')) +# content = Tilt[:markdown].new(nil, nil, {}) { markdown }.render + content = <<-EOF +

This is a slide with some questions

+

correct -> This question has a correct answer. = +(=) True +() False

+

none -> This question has no correct answer. = +() True +() False

+

named -> This question has named answers. = +() one -> the first answer +(=) two -> the second answer +() three -> the third answer

+

correctcheck -> This question has a correct answer. = +[=] True +[] False

+

nonecheck -> This question has no correct answer. = +[] True +[] False

+

namedcheck -> This question has named answers. = +[] one -> the first answer +[=] two -> the second answer +[] three -> the third answer

+

name = ___

+

namelength = ___[50]

+

nametoken -> What is your name? = ___[50]

+

comments = [ ]

+

commentsrows = [ 5]

+

smartphone = () iPhone () Android () other -> Any other phone not listed

+

awake -> Are you paying attention? = (x) No () Yes

+

smartphonecheck = [] iPhone [] Android [x] other -> Any other phone not listed

+

phoneos -> Which phone OS is developed by Google? = {iPhone, [Android], Other }

+

smartphonecombo = {iPhone, Android, (Other) }

+

smartphonetoken = {iPhone, Android, (other -> Any other phone not listed) }

+

cuisine -> What is your favorite cuisine? = { American, Italian, French }

+

cuisinetoken -> What is your favorite cuisine? = { +US -> American +IT -> Italian +FR -> French +}

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Form.render!(doc, :form => 'foo') + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(doc.search('ul').size).to eq(6) # each long form radio/check question + expect(doc.search('li').size).to eq(14) # all long form radio/check answers + expect(doc.search('label').size).to eq(41) # labels for every question/response widget + expect(doc.search('input').size).to eq(27) # answers, plus the tool buttons + expect(doc.search('input[type=radio]').size).to eq(12) # includes the single line widget + expect(doc.search('input[type=checkbox]').size).to eq(10) # includes the single line widget + expect(doc.search('input[type=text]').size).to eq(3) + expect(doc.search('textarea').size).to eq(2) + expect(doc.search('select').size).to eq(5) + end + + # @todo this test suite needs a lotta lotta work. This only scratches the surface +end + + + diff --git a/spec/unit/showoff/compiler/glossary_spec.rb b/spec/unit/showoff/compiler/glossary_spec.rb new file mode 100644 index 000000000..60ccf1876 --- /dev/null +++ b/spec/unit/showoff/compiler/glossary_spec.rb @@ -0,0 +1,80 @@ +RSpec.describe Showoff::Compiler::Glossary do + content = <<-EOF +

This is a simple HTML slide with glossary entries

+

This will have a phrase in the paragraph.

+

By hand, yo!|by-hand: I made this one by hand.

+

This entry is attached to a named glossary.

+

By hand, yo!|by-hand: I made this one by hand.

+EOF + + it "generates glossary entries on a slide" do + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Glossary.render!(doc) + + callouts = doc.search('.callout.glossary').select {|n| n.ancestors.size == 1} + links = doc.search('a').select {|n| n.ancestors.size == 2} + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(doc.search('.callout.glossary').length).to eq(6) + expect(callouts.length).to eq(2) + expect(callouts.first.classes).to eq(["callout", "glossary"]) + expect(callouts.first.element_children.size).to eq(1) + expect(callouts.first.element_children.first[:href]).to eq('glossary://by-hand') + + expect(callouts.last.classes).to eq(["callout", "glossary", "name"]) + expect(callouts.last.element_children.size).to eq(1) + expect(callouts.last.element_children.first[:href]).to eq('glossary://name/by-hand') + + expect(links.length).to eq(4) + expect(links.select {|link| link[:href].start_with? 'glossary://'}.size).to eq(4) + expect(links.select {|link| link.classes.include? 'term'}.size).to eq(2) + expect(links.select {|link| link.classes.include? 'label'}.size).to eq(2) + end + + it "generates glossary entries in the presenter notes section of a slide" do + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Glossary.render!(doc) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(doc.search('.notes-section.notes').length).to eq(1) + expect(doc.search('.notes-section.notes > .callout.glossary').length).to eq(2) + expect(doc.search('.notes-section.handouts > .callout.glossary').length).to eq(2) + end + + it "generates glossary entries in the handout notes section of a slide" do + doc = Nokogiri::HTML::DocumentFragment.parse(content) + + # This call mutates the passed in object + Showoff::Compiler::Glossary.render!(doc) + + expect(doc).to be_a(Nokogiri::HTML::DocumentFragment) + expect(doc.search('.notes-section.handouts').length).to eq(1) + expect(doc.search('.notes-section.handouts > .callout.glossary').length).to eq(2) + end + + it "generates a glossary page" do + html = File.read(File.join(fixtures, 'glossary_toc', 'content.html')) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + Showoff::Compiler::Glossary.generatePage!(doc) + + expect(doc.search('.slide.glossary:not(.name)').size).to eq(1) + expect(doc.search('.slide.glossary:not(.name) li a').size).to eq(4) + expect(doc.search('.slide.glossary:not(.name) li a')[0][:id]).to eq('content:3+by-hand') + expect(doc.search('.slide.glossary:not(.name) li a')[1][:href]).to eq('#content:2') + expect(doc.search('.slide.glossary:not(.name) li a')[2][:id]).to eq('content:3+term-with-no-spaces') + expect(doc.search('.slide.glossary:not(.name) li a')[3][:href]).to eq('#content:2') + + expect(doc.search('.slide.glossary.name').size).to eq(1) + expect(doc.search('.slide.glossary.name li a').size).to eq(4) + expect(doc.search('.slide.glossary.name li a')[0][:id]).to eq('content:4+by-hand') + expect(doc.search('.slide.glossary.name li a')[1][:href]).to eq('#content:2') + expect(doc.search('.slide.glossary.name li a')[2][:id]).to eq('content:4+term-with-no-spaces') + expect(doc.search('.slide.glossary.name li a')[3][:href]).to eq('#content:2') + end + +end diff --git a/spec/unit/showoff/compiler/i18n_spec.rb b/spec/unit/showoff/compiler/i18n_spec.rb new file mode 100644 index 000000000..f3a54f0e9 --- /dev/null +++ b/spec/unit/showoff/compiler/i18n_spec.rb @@ -0,0 +1,79 @@ +RSpec.describe Showoff::Compiler::I18n do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'i18n', 'showoff.json')) + end + + it "selects the correct language" do + content = <<-EOF +# This is a simple markdown slide + +~~~LANG:en~~~ +Hello, world! +~~~ENDLANG~~~ + +~~~LANG:fr~~~ +Bonjour tout le monde! +~~~ENDLANG~~~ +EOF + + Showoff::Locale.setContentLocale(:fr) + + # This call mutates the passed in object + Showoff::Compiler::I18n.selectLanguage!(content) + + expect(content).to be_a(String) + expect(content).to match(/Bonjour tout le monde!/) + expect(content).not_to match(/Hello, world!/) + expect(content).not_to match(/~~~LANG:[\w-]+~~~/) + expect(content).not_to match(/~~~ENDLANG~~~/) + end + + it "includes no languages if they don't match" do + content = <<-EOF +# This is a simple markdown slide + +~~~LANG:en~~~ +Hello, world! +~~~ENDLANG~~~ + +~~~LANG:fr~~~ +Bonjour tout le monde! +~~~ENDLANG~~~ +EOF + + Showoff::Locale.setContentLocale(:js) + + # This call mutates the passed in object + Showoff::Compiler::I18n.selectLanguage!(content) + + expect(content).to be_a(String) + expect(content).not_to match(/Bonjour tout le monde!/) + expect(content).not_to match(/Hello, world!/) + expect(content).not_to match(/~~~LANG:[\w-]+~~~/) + expect(content).not_to match(/~~~ENDLANG~~~/) + end + + it "includes no languages if local is unset" do + content = <<-EOF +# This is a simple markdown slide + +~~~LANG:en~~~ +Hello, world! +~~~ENDLANG~~~ + +~~~LANG:fr~~~ +Bonjour tout le monde! +~~~ENDLANG~~~ +EOF + + # This call mutates the passed in object + Showoff::Compiler::I18n.selectLanguage!(content) + + expect(content).to be_a(String) + expect(content).not_to match(/Bonjour tout le monde!/) + expect(content).not_to match(/Hello, world!/) + expect(content).not_to match(/~~~LANG:[\w-]+~~~/) + expect(content).not_to match(/~~~ENDLANG~~~/) + end + +end diff --git a/spec/unit/showoff/compiler/notes_spec.rb b/spec/unit/showoff/compiler/notes_spec.rb new file mode 100644 index 000000000..274a08d05 --- /dev/null +++ b/spec/unit/showoff/compiler/notes_spec.rb @@ -0,0 +1,199 @@ +RSpec.describe Showoff::Compiler::Notes do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'notes', 'showoff.json')) + end + + it 'handles slides with no notes sections' do + options = {:form=>nil, :name=>"standalone", :seq=>nil} + content = <<-EOF +

This slide has no notes

+ +

Just some boring content.

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.empty?).to be_truthy + end + + it 'creates notes and handout sections' do + options = {:form=>nil, :name=>"content", :seq=>1} + content = <<-EOF +

Notes and handouts both

+ +

blah blah blah

+ +

~~~SECTION:notes~~~

+ +

These are some notes, yo

+ +

~~~ENDSECTION~~~

+ +

~~~SECTION:handouts~~~

+ +

And some handouts, yeah

+ +

~~~ENDSECTION~~~

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(2) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(nonum)') + + expect(notes.search('.notes-section.handouts').size).to eq(1) + expect(notes.search('.notes-section.handouts .personal').empty?).to be_truthy + end + + it 'creates arbitrarily named sections' do + options = {:form=>nil, :name=>"content", :seq=>2} + content = <<-EOF +

Arbitrary

+ +

This slide validates that arbitrarily named sections work.

+ +

~~~SECTION:notes~~~

+ +

These are some notes, yo

+ +

~~~ENDSECTION~~~

+ +

~~~SECTION:arbitrary~~~

+ +

And some arbitrarily named section

+ +

~~~ENDSECTION~~~

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(2) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(nonum)') + + expect(notes.search('.notes-section.arbitrary').size).to eq(1) + expect(notes.search('.notes-section.arbitrary .personal').empty?).to be_truthy + end + + it 'generates personal notes with presenter notes' do + options = {:form=>nil, :name=>"content", :seq=>3} + content = <<-EOF +

Notes and personal

+ +

This has personal notes and presenter notes. +This is a multi slide file.

+ +

~~~SECTION:notes~~~

+ +

notes and stuff

+ +

~~~ENDSECTION~~~

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(1) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(3)') + end + + it 'generates personal notes without specificed presenter notes' do + options = {:form=>nil, :name=>"content", :seq=>4} + content = <<-EOF +

Notes and personal

+ +

This has personal notes only. +This is a multi slide file.

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(1) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(4)') + end + + it 'generates personal notes for multi-slides when the notes are not numbered' do + options = {:form=>nil, :name=>"content", :seq=>5} + content = <<-EOF +

Non numbered personal

+ +

This has personal notes only. +This is a multi slide file, but the personal notes file is not numbered.

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(1) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(nonum)') + end + + it 'attaches non-numbered personal notes to all slides in a multi-slide file' do + options = {:form=>nil, :name=>"content", :seq=>6} + content = <<-EOF +

Second non numbered personal

+ +

This a second non-numbered personal notes slide. The non-numbered content +should be attached to both.

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(1) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(nonum)') + end + + it 'generates personal notes for numbered slides in a single slide file' do + options = {:form=>nil, :name=>"separate", :seq=>nil} + content = <<-EOF +

Notes and personal

+ +

This has personal notes attached to a separate file.

+EOF + + doc = Nokogiri::HTML::DocumentFragment.parse(content) + doc, notes = Showoff::Compiler::Notes.render!(doc, {}, options) + + expect(doc.element_children.size).to eq(2) + expect(doc.element_children[0].name).to eq('h1') + expect(doc.element_children[1].name).to eq('p') + expect(notes.size).to eq(1) + expect(notes.search('.notes-section.notes').size).to eq(1) + expect(notes.search('.notes-section.notes .personal p').text).to start_with('(separate)') + end + +end + + + diff --git a/spec/unit/showoff/compiler/table_of_contents_spec.rb b/spec/unit/showoff/compiler/table_of_contents_spec.rb new file mode 100644 index 000000000..c43b7d28f --- /dev/null +++ b/spec/unit/showoff/compiler/table_of_contents_spec.rb @@ -0,0 +1,20 @@ +RSpec.describe Showoff::Compiler::TableOfContents do + + it "generates a table of contents" do + html = File.read(File.join(fixtures, 'glossary_toc', 'content.html')) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + + Showoff::Compiler::TableOfContents.generate!(doc) + + expect(doc.search('.slide.toc').size).to eq(1) + expect(doc.search('ol#toc').size).to eq(1) + expect(doc.search('ol#toc li').size).to eq(3) + expect(doc.search('ol#toc li a')[0][:href]).to eq('#content2') + expect(doc.search('ol#toc li a')[1][:href]).to eq('#content3') + expect(doc.search('ol#toc li a')[2][:href]).to eq('#content4') + expect(doc.search('ol#toc li a')[0].text).to eq('Glossary and TOC Demo') + expect(doc.search('ol#toc li a')[1].text).to eq('General Glossary') + expect(doc.search('ol#toc li a')[2].text).to eq('This is a named glossary') + end + +end diff --git a/spec/unit/showoff/compiler/variables_spec.rb b/spec/unit/showoff/compiler/variables_spec.rb new file mode 100644 index 000000000..1bdebe63b --- /dev/null +++ b/spec/unit/showoff/compiler/variables_spec.rb @@ -0,0 +1,64 @@ +RSpec.describe Showoff::Compiler::Variables do + + it "interpolates simple tokens" do + content = <<-EOF +# This is a simple markdown slide + +~~~PAGEBREAK~~~ + +~~~FORM:boogerwoog~~~ + +[fa-podcast] +[fa-smile-o some classes] + +``` Ruby and some other classes +puts "hello" +``` + +~~~TEST~~~ +~~~TEST2~~~ +EOF + + # This call mutates the passed in object + Showoff::Compiler::Variables.interpolate!(content) + + expect(content).to be_a(String) + expect(content).to match(/
continued...<\/div>/) + expect(content).to match(/
<\/div>/) + expect(content).to match(/<\/i>/) + expect(content).to match(/<\/i>/) + expect(content).to match(/``` Ruby:and:some:other:classes/) + expect(content).to match(/\\~~~TEST~~~/) + expect(content).to match(/\n\n\\~~~TEST~~~\n\n/) + end + + it "interpolates slide counters" do + content = <<-EOF +# This is a simple markdown slide + +current:~~~CURRENT_SLIDE~~~ + +major:~~~SECTION:MAJOR~~~ + +minor:~~~SECTION:MINOR~~~ + +EOF + content2 = content.dup + + Showoff::State.set(:slide_count, 23) + Showoff::State.set(:section_major, 1) + + # This call mutates the passed in object + Showoff::Compiler::Variables.interpolate!(content) + expect(content).to be_a(String) + expect(content).to match(/current:23/) + expect(content).to match(/major:1/) + expect(content).to match(/minor:1/) + + # now interpolate the "second slide" and ensure that the minor counter incremented + Showoff::Compiler::Variables.interpolate!(content2) + expect(content2).to match(/major:1/) + expect(content2).to match(/minor:2/) + end + +end diff --git a/spec/unit/showoff/compiler_spec.rb b/spec/unit/showoff/compiler_spec.rb new file mode 100644 index 000000000..8981f1d79 --- /dev/null +++ b/spec/unit/showoff/compiler_spec.rb @@ -0,0 +1,43 @@ +RSpec.describe Showoff::Compiler do + + it "resolves the default renderer properly" do + expect(Showoff::Config).to receive(:get).with('markdown').and_return(:default) + expect(Showoff::Config).to receive(:get).with(:default).and_return({}) + expect(Tilt).to receive(:prefer).with(Tilt::RedcarpetTemplate, 'markdown') + #expect(Tilt.template_for('markdown')).to eq(Tilt::RedcarpetTemplate) # polluted state doesn't allow this to succeed deterministically + + Showoff::Compiler.new({:name => 'foo'}) + end + + it "resolves a configured renderer" do + expect(Showoff::Config).to receive(:get).with('markdown').and_return('commonmarker') + expect(Showoff::Config).to receive(:get).with('commonmarker').and_return({}) + expect(Tilt).to receive(:prefer).with(Tilt::CommonMarkerTemplate, 'markdown') + #expect(Tilt.template_for('markdown')).to eq(Tilt::CommonMarkerTemplate) # polluted state doesn't allow this to succeed deterministically + + Showoff::Compiler.new({:name => 'foo'}) + end + + it "errors when configured with an unknown renderer" do + expect(Showoff::Config).to receive(:get).with('markdown').and_return('wrong') + expect(Showoff::Config).to receive(:get).with('wrong').and_return({}) + + expect { Showoff::Compiler.new({:name => 'foo'}) }.to raise_error(StandardError, 'Unsupported markdown renderer') + end + + # note that this test is basically a simple integration test of all the compiler components. + it "renders content as expected" do + Showoff::Config.load(File.join(fixtures, 'base.json')) + + content, notes = Showoff::Compiler.new({:name => 'foo'}).render("#Hi there!\n\n.callout The Internet is serious business.") + + expect(content).to be_a(Nokogiri::HTML::DocumentFragment) + expect(notes).to be_a(Nokogiri::XML::NodeSet) + expect(notes.empty?).to be_truthy + + expect(content.search('h1').first.text).to eq('Hi there!') + expect(content.search('p').first.text).to eq('The Internet is serious business.') + expect(content.search('p').first.classes).to eq(['callout']) + end + +end diff --git a/spec/unit/showoff/config_spec.rb b/spec/unit/showoff/config_spec.rb new file mode 100644 index 000000000..76b97217c --- /dev/null +++ b/spec/unit/showoff/config_spec.rb @@ -0,0 +1,97 @@ +RSpec.describe Showoff::Config do + context 'base configuration' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'base.json')) + end + + it "loads configuration from disk" do + expect(Showoff::Config.root).to eq(fixtures) + expect(Showoff::Config.keys).to eq(['name', 'description', 'protected', 'version', 'feedback', 'parsers', 'sections', 'markdown', :default, 'pdf_options']) + expect(Showoff::Config.get('pdf_options')).to eq({:page_size=>"Letter", :orientation=>"Portrait", :print_media_type=>true, :quiet=>false}) + end + + it "calculates relative paths" do + expect(Showoff::Config.path('foo/bar')).to eq('foo/bar') + expect(Showoff::Config.path('../fixtures')).to eq(fixtures) + end + + it "loads proper markdown profile" do + expect(Showoff::Config.get('markdown')).to eq(:default) + expect(Showoff::Config.get(:default)).to be_a(Hash) + expect(Showoff::Config.get(:default)).to eq({ + :autolink => true, + :no_intra_emphasis => true, + :superscript => true, + :tables => true, + :underline => true, + :escape_html => false, + }) + end + + it "expands sections" do + expect(Showoff::Config.sections).to be_a(Hash) + expect(Showoff::Config.sections['.']).to be_an(Array) + expect(Showoff::Config.sections['.']).to all be_a(String) + + expect(Showoff::Config.sections['.']).to eq(['Overview.md', 'Content.md']) + expect(Showoff::Config.sections.keys).to eq(['.', 'slides', '. (2)']) + end + end + + context 'with named hash sections' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'namedhash.json')) + end + + it "loads configuration from disk" do + expect(Showoff::Config.root).to eq(fixtures) + expect(Showoff::Config.keys).to eq(['name', 'description', 'protected', 'version', 'feedback', 'parsers', 'sections', 'markdown', :default, 'pdf_options']) + expect(Showoff::Config.get('pdf_options')).to eq({:page_size=>"Letter", :orientation=>"Portrait", :print_media_type=>true, :quiet=>false}) + end + + it "expands sections" do + expect(Showoff::Config.sections).to be_a(Hash) + expect(Showoff::Config.sections.keys).to eq(['Overview', 'Content', 'Conclusion']) + expect(Showoff::Config.sections['Overview']).to all be_a(String) + expect(Showoff::Config.sections['Overview']).to eq(['title.md', 'intro.md', 'about.md']) + end + end + + context 'with configured markdown renderer' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'renderer.json')) + end + + it "loads configuration from disk" do + expect(Showoff::Config.root).to eq(fixtures) + expect(Showoff::Config.keys).to eq(['name', 'description', 'protected', 'version', 'feedback', 'parsers', 'sections', 'markdown', 'maruku', 'pdf_options']) + end + + it "loads proper markdown profile" do + expect(Showoff::Config.get('markdown')).to eq('maruku') + expect(Showoff::Config.get('maruku')).to be_a(Hash) + expect(Showoff::Config.get('maruku')).to eq({ + :use_tex => false, + :png_dir => 'images', + :html_png_url => '/file/images/', + }) + end + end + + context 'complex config file' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'complex', 'showoff.json')) + end + + it "loads configuration from disk" do + expect(Showoff::Config.root).to eq(File.join(fixtures, 'complex')) + + expect(Showoff::Config.keys).to eq(["name", "description", "pdf_options", "sections", "markdown", :default]) + expect(Showoff::Config.sections['Overview']).to eq(['Overview/objectives.md', 'Overview/overview.md']) + expect(Showoff::Config.sections['Environment']).to eq(["environment/one.md", "environment/two.md"]) + expect(Showoff::Config.sections['Appendix']).to eq(['Shared/Appendix/appendix.md']) + expect(Showoff::Config.get('pdf_options')).to eq({:page_size=>"Letter", :orientation=>"Landscape", :print_media_type=>true, :quiet=>true}) + end + + end +end diff --git a/spec/unit/showoff/locale_spec.rb b/spec/unit/showoff/locale_spec.rb new file mode 100644 index 000000000..50d0ee2f3 --- /dev/null +++ b/spec/unit/showoff/locale_spec.rb @@ -0,0 +1,53 @@ +RSpec.describe Showoff::Locale do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'i18n', 'showoff.json')) + end + + it "selects a default content language" do + expect(I18n.available_locales.include?(Showoff::Locale.setContentLocale)).to be_truthy + end + + it "allows user to set content language" do + Showoff::Locale.setContentLocale(:de) + expect(Showoff::Locale.contentLocale).to eq(:de) + end + + it "allows user to set content language with extended codes" do + Showoff::Locale.setContentLocale('de-li') + expect(Showoff::Locale.contentLocale).to eq(:de) + end + + it "returns the name of a language code" do + Showoff::Locale.setContentLocale(:de) + expect(Showoff::Locale.languageName).to eq('German') + end + + it "interpolates the proper content path when it exists" do + Showoff::Locale.setContentLocale(:de) + expect(Showoff::Locale.contentPath).to eq(File.join(fixtures, 'i18n', 'locales', 'de')) + end + + it "interpolates the proper content path when it does not exist" do + Showoff::Locale.setContentLocale(:ja) + expect(Showoff::Locale.contentPath).to eq(File.join(fixtures, 'i18n')) + end + + it "returns the appropriate content language hash" do + expect(Showoff::Locale.contentLanguages).to eq({"de"=>"German", "en"=>"English", "es"=>"Spanish; Castilian", "fr"=>"French", "ja"=>"Japanese"}) + end + + it "returns UI string translations" do + expect(Showoff::Locale.translations[:menu][:title]).to be_a(String) + end + + it "retrieves the proper translations from strings.json" do + Showoff::Locale.setContentLocale(:de) + expect(Showoff::Locale.userTranslations).to eq({'greeting' => 'Hallo!'}) + end + + it "retrieves an empty hash from strings.json when the key doesn't exist" do + Showoff::Locale.setContentLocale(:nl) + expect(Showoff::Locale.userTranslations).to eq({}) + end + +end diff --git a/spec/unit/showoff/presentation/section_spec.rb b/spec/unit/showoff/presentation/section_spec.rb new file mode 100644 index 000000000..2982aeab0 --- /dev/null +++ b/spec/unit/showoff/presentation/section_spec.rb @@ -0,0 +1,13 @@ +RSpec.describe Showoff::Presentation::Section do + + it 'loads files from disk and splits them into slides' do + Showoff::Config.load(File.join(fixtures, 'slides', 'showoff.json')) + name, files = Showoff::Config.sections.first + section = Showoff::Presentation::Section.new(name, files) + + expect(section.name).to eq('.') + expect(section.slides.size).to eq(5) + expect(section.slides.map {|slide| slide.id }).to eq(["first", "content1", "content2", "content3", "last"]) + end + +end diff --git a/spec/unit/showoff/presentation/slide_spec.rb b/spec/unit/showoff/presentation/slide_spec.rb new file mode 100644 index 000000000..602dd9e6e --- /dev/null +++ b/spec/unit/showoff/presentation/slide_spec.rb @@ -0,0 +1,133 @@ +RSpec.describe Showoff::Presentation::Slide do + + it 'parses class and form metadata settings' do + context = {:section=>".", :name=>"first.md", :seq=>nil} + options = "first title form=noodles" + content = <<-EOF +# First slide + +This little piggy went to market. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["first", "title"]) + expect(subject.form).to eq('noodles') + expect(subject.id).to eq('first') + expect(subject.name).to eq('first') + expect(subject.ref).to eq('first') + expect(subject.section).to eq('.') + expect(subject.section_title).to eq('.') + expect(subject.seq).to be_nil + expect(subject.transition).to eq('none') + end + + it 'parses a background metadata setting' do + context = {:section=>".", :name=>"content.md", :seq=>1} + options = "[bg=bg.png] one" + content = <<-EOF +# One + +This little piggy stayed home. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["one"]) + expect(subject.id).to eq('content1') + expect(subject.name).to eq('content') + expect(subject.ref).to eq('content:1') + expect(subject.seq).to eq(1) + expect(subject.background).to eq('bg.png') + end + + it 'parses a slide class and sets section title' do + context = {:section=>".", :name=>"content.md", :seq=>2} + options = "two piggy subsection" + content = <<-EOF +# Two + +This little piggy had roast beef. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["two", "piggy", "subsection"]) + expect(subject.id).to eq('content2') + expect(subject.name).to eq('content') + expect(subject.ref).to eq('content:2') + expect(subject.section).to eq('.') + expect(subject.section_title).to eq('Two') + expect(subject.seq).to eq(2) + end + + it 'parses a transition as an option and maintains section title' do + context = {:section=>".", :name=>"content.md", :seq=>3} + options = "[transition=fade] three" + content = <<-EOF +# Three + +This little piggy had none. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["three"]) + expect(subject.id).to eq('content3') + expect(subject.name).to eq('content') + expect(subject.ref).to eq('content:3') + expect(subject.section).to eq('.') + expect(subject.section_title).to eq('Two') + expect(subject.seq).to eq(3) + expect(subject.transition).to eq('fade') + end + + it 'parses a transition as a weirdo class' do + context = {:section=>".", :name=>"last.md", :seq=>nil} + options = "last bigtext transition=fade" + content = <<-EOF +# Last + +This little piggy cried wee wee wee all the way home. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["last", "bigtext"]) + expect(subject.id).to eq('last') + expect(subject.name).to eq('last') + expect(subject.ref).to eq('last') + expect(subject.seq).to be_nil + expect(subject.transition).to eq('fade') + end + + it 'blacklists known bad classes' do + context = {:section=>".", :name=>"last.md", :seq=>nil} + options = "last bigtext transition=fade" + content = <<-EOF +# Last + +This little piggy cried wee wee wee all the way home. +EOF + + subject = Showoff::Presentation::Slide.new(options, content, context) + + expect(subject.classes).to eq(["last", "bigtext"]) + expect(subject.slideClasses).to eq(["last"]) + end + + it 'maintains proper slide counts' do + content = <<-EOF +# First slide +EOF + + Showoff::State.reset + Showoff::Presentation::Slide.new('', content, {:section=>".", :name=>"state.md", :seq=>1}).render + Showoff::Presentation::Slide.new('', content, {:section=>".", :name=>"state.md", :seq=>2}).render + Showoff::Presentation::Slide.new('', content, {:section=>".", :name=>"state.md", :seq=>3}).render + Showoff::Presentation::Slide.new('', content, {:section=>".", :name=>"state.md", :seq=>4}).render + + expect(Showoff::State.get(:slide_count)).to eq(4) + end + +end diff --git a/spec/unit/showoff/presentation_spec.rb b/spec/unit/showoff/presentation_spec.rb new file mode 100644 index 000000000..4e257ce54 --- /dev/null +++ b/spec/unit/showoff/presentation_spec.rb @@ -0,0 +1,98 @@ +RSpec.describe Showoff::Presentation do + context 'asset management base' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'assets', 'showoff.json')) + Showoff::State.set(:format, 'web') + Showoff::State.set(:supplemental, nil) + end + + it "lists all user styles" do + presentation = Showoff::Presentation.new({}) + expect(presentation.css_files).to eq ['styles.css'] + end + + it "lists all user scripts" do + presentation = Showoff::Presentation.new({}) + expect(presentation.js_files).to eq ['scripts.js'] + end + + it "generates a list of all assets" do + presentation = Showoff::Presentation.new({}) + assets = presentation.assets + + [ 'grumpy_lawyer.jpg', + 'assets/grumpycat.jpg', + 'assets/yellow-brick-road.jpg', + 'styles.css', + 'scripts.js', + ].each { |file| expect(assets.include? file).to be_truthy } + + [ 'assets/tile.jpg', + 'assets/another.css', + 'assets/another.js', + ].each { |file| expect(assets.include? file).to be_falsey } + end + end + + context 'asset management with additional configs' do + before(:each) do + Showoff::Config.load(File.join(fixtures, 'assets', 'extra.json')) + Showoff::State.set(:format, 'web') + Showoff::State.set(:supplemental, nil) + end + + it "lists all user styles" do + presentation = Showoff::Presentation.new({}) + expect(presentation.css_files).to eq ['styles.css', 'assets/another.css'] + end + + it "lists all user scripts" do + presentation = Showoff::Presentation.new({}) + expect(presentation.js_files).to eq ['scripts.js', 'assets/another.js'] + end + + it "generates a list of all assets" do + presentation = Showoff::Presentation.new({}) + assets = presentation.assets + + [ 'grumpy_lawyer.jpg', + 'assets/grumpycat.jpg', + 'assets/yellow-brick-road.jpg', + 'styles.css', + 'scripts.js', + 'assets/tile.jpg', + 'assets/another.css', + 'assets/another.js', + ].each { |file| expect(assets.include? file).to be_truthy } + end + end + + it "generates a web format static presentation" do + Showoff::Config.load(File.join(fixtures, 'assets', 'showoff.json')) + Showoff::State.set(:format, 'web') + presentation = Showoff::Presentation.new({}) + + expect(presentation.static).to match(/ <% css_files.each do |css_file| %> - + <% end %> <% js_files.each do |js_file| %> - + <% end %> <% css_files.each do |css_file| %> - + <% end %> <% js_files.each do |js_file| %> - + <% end %> diff --git a/views/onepage.erb b/views/onepage.erb index e64d5e053..78cc8183c 100644 --- a/views/onepage.erb +++ b/views/onepage.erb @@ -18,7 +18,6 @@ <%= inline_js(['jquery-2.1.4.min.js', 'showoff.js', 'highlight.pack-9.15.10.js', 'highlightjs-line-numbers.min.js'], 'public/js') %> <%= inline_js(['bigtext-0.1.8.js', 'simpleStrings-0.0.1.js', 'mermaid-6.0.0-min.js'], 'public/js') %> - <%= inline_js(@languages, 'public/js') if @languages %> <%= inline_js(js_files) %> <% else %> @@ -28,7 +27,7 @@ <% end %> <% css_files.each do |css_file| %> - + <% end %> <% ['jquery-2.1.4.min.js', 'showoff.js', 'highlight.pack-9.15.10.js', @@ -36,12 +35,7 @@ <% end %> <% js_files.each do |js_file| %> - - <% end %> - <% if @languages %> - <% @languages.each do |l| %> - - <% end %> + <% end %> <% end %> diff --git a/views/slide.erb b/views/slide.erb new file mode 100644 index 000000000..dad065429 --- /dev/null +++ b/views/slide.erb @@ -0,0 +1,29 @@ +<% + if background + bgstyle="background-image: url('#{background}');" + else + bgstyle='' + end +-%> +
+
+ <% if section_title -%> + +

<%= section_title %>

+ <% end -%> + + <%= content %> + +
+ <% notes.each do |section| -%> + <%= section %> + <% end -%> + <% if classes.include? 'activity' -%> + + + + + <% end -%> + +