showoff 0.7.0 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.rdoc +53 -475
  3. data/Rakefile +17 -18
  4. data/bin/showoff +29 -7
  5. data/lib/commandline_parser.rb +1 -1
  6. data/lib/showoff/version.rb +3 -0
  7. data/lib/showoff.rb +600 -91
  8. data/lib/showoff_utils.rb +110 -4
  9. data/public/css/disconnected-large.png +0 -0
  10. data/public/css/disconnected.png +0 -0
  11. data/public/css/fast.png +0 -0
  12. data/public/css/grippy-close.png +0 -0
  13. data/public/css/grippy.png +0 -0
  14. data/public/css/onepage.css +6 -0
  15. data/public/css/pace.png +0 -0
  16. data/public/css/paceMarker.png +0 -0
  17. data/public/css/presenter.css +333 -43
  18. data/public/css/sh_style.css +15 -0
  19. data/public/css/showoff.css +373 -48
  20. data/public/css/slow.png +0 -0
  21. data/public/css/spinner.gif +0 -0
  22. data/public/css/tipsy.css +26 -0
  23. data/public/favicon.ico +0 -0
  24. data/public/js/jquery.parsequery.min.js +2 -0
  25. data/public/js/jquery.tipsy.js +260 -0
  26. data/public/js/onepage.js +2 -3
  27. data/public/js/presenter.js +384 -33
  28. data/public/js/sh_lang/sh_gherkin.js +112 -0
  29. data/public/js/sh_lang/sh_gherkin.min.js +1 -0
  30. data/public/js/sh_lang/sh_ini.js +87 -0
  31. data/public/js/sh_lang/sh_ini.min.js +87 -0
  32. data/public/js/sh_lang/sh_puppet.js +182 -0
  33. data/public/js/sh_lang/sh_puppet.min.js +182 -0
  34. data/public/js/sh_lang/sh_puppet_output.js +22 -0
  35. data/public/js/sh_lang/sh_puppet_output.min.js +22 -0
  36. data/public/js/sh_lang/sh_shell.min.js +1 -0
  37. data/public/js/showoff.js +423 -51
  38. data/views/404.erb +19 -0
  39. data/views/download.erb +36 -0
  40. data/views/header.erb +35 -25
  41. data/views/header_mini.erb +22 -0
  42. data/views/index.erb +46 -1
  43. data/views/onepage.erb +35 -14
  44. data/views/presenter.erb +63 -21
  45. data/views/stats.erb +73 -0
  46. metadata +170 -131
  47. data/public/css/960.css +0 -653
  48. data/public/css/pdf.css +0 -12
data/lib/showoff.rb CHANGED
@@ -4,6 +4,8 @@ require 'json'
4
4
  require 'nokogiri'
5
5
  require 'fileutils'
6
6
  require 'logger'
7
+ require 'htmlentities'
8
+ require 'sinatra-websocket'
7
9
 
8
10
  here = File.expand_path(File.dirname(__FILE__))
9
11
  require "#{here}/showoff_utils"
@@ -12,57 +14,98 @@ require "#{here}/commandline_parser"
12
14
  begin
13
15
  require 'RMagick'
14
16
  rescue LoadError
15
- $stderr.puts 'image sizing disabled - install rmagick'
17
+ $stderr.puts 'WARN: image sizing disabled - install rmagick'
16
18
  end
17
19
 
18
20
  begin
19
21
  require 'pdfkit'
20
22
  rescue LoadError
21
- $stderr.puts 'pdf generation disabled - install pdfkit'
23
+ $stderr.puts 'WARN: pdf generation disabled - install pdfkit'
22
24
  end
23
25
 
24
- begin
25
- require 'rdiscount'
26
- rescue LoadError
27
- require 'bluecloth'
28
- Object.send(:remove_const,:Markdown)
29
- Markdown = BlueCloth
30
- end
26
+ require 'tilt'
31
27
 
32
28
  class ShowOff < Sinatra::Application
33
29
 
34
- Version = VERSION = '0.7.0'
35
-
36
30
  attr_reader :cached_image_size
37
31
 
32
+ # Set up application variables
33
+
38
34
  set :views, File.dirname(__FILE__) + '/../views'
39
- set :public, File.dirname(__FILE__) + '/../public'
35
+ set :public_folder, File.dirname(__FILE__) + '/../public'
36
+
37
+ set :statsdir, "stats"
38
+ set :viewstats, "viewstats.json"
39
+ set :feedback, "feedback.json"
40
+
41
+ set :server, 'thin'
42
+ set :sockets, []
43
+ set :presenters, []
40
44
 
41
45
  set :verbose, false
42
46
  set :pres_dir, '.'
43
47
  set :pres_file, 'showoff.json'
48
+ set :page_size, "Letter"
49
+ set :pres_template, nil
50
+ set :showoff_config, {}
51
+ set :encoding, nil
52
+
53
+ FileUtils.mkdir settings.statsdir unless File.directory? settings.statsdir
54
+
55
+ # Page view time accumulator. Tracks how often slides are viewed by the audience
56
+ begin
57
+ @@counter = JSON.parse(File.read("#{settings.statsdir}/#{settings.viewstats}"))
58
+ rescue
59
+ @@counter = Hash.new
60
+ end
61
+
62
+ @@downloads = Hash.new # Track downloadable files
63
+ @@cookie = nil # presenter cookie. Identifies the presenter for control messages
64
+ @@current = Hash.new # The current slide that the presenter is viewing
44
65
 
45
66
  def initialize(app=nil)
46
67
  super(app)
47
68
  @logger = Logger.new(STDOUT)
48
69
  @logger.formatter = proc { |severity,datetime,progname,msg| "#{progname} #{msg}\n" }
49
- @logger.level = options.verbose ? Logger::DEBUG : Logger::WARN
70
+ @logger.level = settings.verbose ? Logger::DEBUG : Logger::WARN
50
71
 
51
72
  dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
52
73
  @logger.debug(dir)
53
74
 
54
75
  showoff_dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
55
- options.pres_dir ||= Dir.pwd
76
+ settings.pres_dir ||= Dir.pwd
56
77
  @root_path = "."
57
78
 
58
- options.pres_dir = File.expand_path(options.pres_dir)
59
- if (options.pres_file)
60
- ShowOffUtils.presentation_config_file = options.pres_file
79
+ settings.pres_dir = File.expand_path(settings.pres_dir)
80
+ if (settings.pres_file)
81
+ ShowOffUtils.presentation_config_file = settings.pres_file
82
+ end
83
+
84
+ # Load configuration for page size and template from the
85
+ # configuration JSON file
86
+ if File.exists?(ShowOffUtils.presentation_config_file)
87
+ showoff_json = JSON.parse(File.read(ShowOffUtils.presentation_config_file))
88
+ settings.showoff_config = showoff_json
89
+
90
+ # Set options for encoding, template and page size
91
+ settings.encoding = showoff_json["encoding"]
92
+ settings.page_size = showoff_json["page-size"] || "Letter"
93
+ settings.pres_template = showoff_json["templates"]
61
94
  end
95
+
96
+ @logger.debug settings.pres_template
97
+
62
98
  @cached_image_size = {}
63
- @logger.debug options.pres_dir
64
- @pres_name = options.pres_dir.split('/').pop
99
+ @logger.debug settings.pres_dir
100
+ @pres_name = settings.pres_dir.split('/').pop
65
101
  require_ruby_files
102
+
103
+ # Default asset path
104
+ @asset_path = "./"
105
+
106
+
107
+ # Initialize Markdown Configuration
108
+ MarkdownConfig::setup(settings.pres_dir)
66
109
  end
67
110
 
68
111
  def self.pres_dir_current
@@ -71,12 +114,12 @@ class ShowOff < Sinatra::Application
71
114
  end
72
115
 
73
116
  def require_ruby_files
74
- Dir.glob("#{options.pres_dir}/*.rb").map { |path| require path }
117
+ Dir.glob("#{settings.pres_dir}/*.rb").map { |path| require path }
75
118
  end
76
119
 
77
120
  helpers do
78
121
  def load_section_files(section)
79
- section = File.join(options.pres_dir, section)
122
+ section = File.join(settings.pres_dir, section)
80
123
  files = if File.directory? section
81
124
  Dir.glob("#{section}/**/*").sort
82
125
  else
@@ -87,23 +130,34 @@ class ShowOff < Sinatra::Application
87
130
  end
88
131
 
89
132
  def css_files
90
- Dir.glob("#{options.pres_dir}/*.css").map { |path| File.basename(path) }
133
+ Dir.glob("#{settings.pres_dir}/*.css").map { |path| File.basename(path) }
91
134
  end
92
135
 
93
136
  def js_files
94
- Dir.glob("#{options.pres_dir}/*.js").map { |path| File.basename(path) }
137
+ Dir.glob("#{settings.pres_dir}/*.js").map { |path| File.basename(path) }
95
138
  end
96
139
 
97
140
 
98
141
  def preshow_files
99
- Dir.glob("#{options.pres_dir}/_preshow/*").map { |path| File.basename(path) }.to_json
142
+ Dir.glob("#{settings.pres_dir}/_preshow/*").map { |path| File.basename(path) }.to_json
100
143
  end
101
144
 
102
145
  # todo: move more behavior into this class
103
146
  class Slide
104
- attr_reader :classes, :text
105
- def initialize classes = ""
106
- @classes = ["content"] + classes.strip.chomp('>').split
147
+ attr_reader :classes, :text, :tpl, :bg
148
+ def initialize( context = "")
149
+
150
+ @tpl = "default"
151
+ @classes = []
152
+
153
+ # Parse the context string for options and content classes
154
+ if context and context.match(/(\[(.*?)\])?(.*)/)
155
+ options = ShowOffUtils.parse_options($2)
156
+ @tpl = options["tpl"] if options["tpl"]
157
+ @bg = options["bg"] if options["bg"]
158
+ @classes += $3.strip.chomp('>').split if $3
159
+ end
160
+
107
161
  @text = ""
108
162
  end
109
163
  def <<(s)
@@ -115,8 +169,13 @@ class ShowOff < Sinatra::Application
115
169
  end
116
170
  end
117
171
 
118
-
119
- def process_markdown(name, content, static=false, pdf=false)
172
+ def process_markdown(name, content, opts={:static=>false, :pdf=>false, :print=>false, :toc=>false, :supplemental=>nil})
173
+ if settings.encoding and content.respond_to?(:force_encoding)
174
+ content.force_encoding(settings.encoding)
175
+ end
176
+ engine_options = ShowOffUtils.showoff_renderer_options(settings.pres_dir)
177
+ @logger.debug "renderer: #{Tilt[:markdown].name}"
178
+ @logger.debug "render options: #{engine_options.inspect}"
120
179
 
121
180
  # if there are no !SLIDE markers, then make every H1 define a new slide
122
181
  unless content =~ /^\<?!SLIDE/m
@@ -131,7 +190,8 @@ class ShowOff < Sinatra::Application
131
190
  until lines.empty?
132
191
  line = lines.shift
133
192
  if line =~ /^<?!SLIDE(.*)>?/
134
- slides << (slide = Slide.new($1))
193
+ ctx = $1 ? $1.strip : $1
194
+ slides << (slide = Slide.new(ctx))
135
195
  else
136
196
  slide << line
137
197
  end
@@ -144,7 +204,35 @@ class ShowOff < Sinatra::Application
144
204
  seq = 1
145
205
  end
146
206
  slides.each do |slide|
147
- md = ''
207
+ # update section counters before we reject slides so the numbering is consistent
208
+ if slide.classes.include? 'subsection'
209
+ @section_major += 1
210
+ @section_minor = 0
211
+ end
212
+
213
+ if opts[:supplemental]
214
+ # if we're looking for supplemental material, only include the content we want
215
+ next unless slide.classes.include? 'supplemental'
216
+ next unless slide.classes.include? opts[:supplemental]
217
+ else
218
+ # otherwise just skip all supplemental material completely
219
+ next if slide.classes.include? 'supplemental'
220
+ end
221
+
222
+ unless opts[:toc]
223
+ # just drop the slide if we're not generating a table of contents
224
+ next if slide.classes.include? 'toc'
225
+ end
226
+
227
+ if opts[:print]
228
+ # drop all slides not intended for the print version
229
+ next if slide.classes.include? 'noprint'
230
+ else
231
+ # drop slides that are intended for the print version only
232
+ next if slide.classes.include? 'printonly'
233
+ end
234
+
235
+ @slide_count += 1
148
236
  content_classes = slide.classes
149
237
 
150
238
  # extract transition, defaulting to none
@@ -153,43 +241,203 @@ class ShowOff < Sinatra::Application
153
241
  # extract id, defaulting to none
154
242
  id = nil
155
243
  content_classes.delete_if { |x| x =~ /^#([\w-]+)/ && id = $1 }
244
+ id = name unless id
156
245
  @logger.debug "id: #{id}" if id
157
246
  @logger.debug "classes: #{content_classes.inspect}"
158
247
  @logger.debug "transition: #{transition}"
159
- # create html
160
- md += "<div"
161
- md += " id=\"#{id}\"" if id
162
- md += " class=\"slide\" data-transition=\"#{transition}\">"
248
+ @logger.debug "tpl: #{slide.tpl} " if slide.tpl
249
+ @logger.debug "bg: #{slide.bg}" if slide.bg
250
+
251
+
252
+ template = "~~~CONTENT~~~"
253
+ # Template handling
254
+ if settings.pres_template
255
+ # We allow specifying a new template even when default is
256
+ # not given.
257
+ if settings.pres_template.include?(slide.tpl) and
258
+ File.exists?(settings.pres_template[slide.tpl])
259
+ template = File.open(settings.pres_template[slide.tpl], "r").read()
260
+ end
261
+ end
262
+
263
+ # create html for the slide
264
+ classes = content_classes.join(' ')
265
+ content = "<div"
266
+ content += " id=\"#{id}\"" if id
267
+ content += " style=\"background: url('file/#{slide.bg}') center no-repeat;\"" if slide.bg
268
+ content += " class=\"slide #{classes}\" data-transition=\"#{transition}\">"
269
+
270
+ # name the slide. If we've got multiple slides in this file, we'll have a sequence number
271
+ # include that sequence number to index directly into that content
163
272
  if seq
164
- md += "<div class=\"#{content_classes.join(' ')}\" ref=\"#{name}/#{seq.to_s}\">\n"
165
- seq += 1
273
+ content += "<div class=\"content #{classes}\" ref=\"#{name}/#{seq.to_s}\">\n"
166
274
  else
167
- md += "<div class=\"#{content_classes.join(' ')}\" ref=\"#{name}\">\n"
275
+ content += "<div class=\"content #{classes}\" ref=\"#{name}\">\n"
276
+ end
277
+
278
+ # Apply the template to the slide and replace the key to generate the content of the slide
279
+ sl = process_content_for_replacements(template.gsub(/~~~CONTENT~~~/, slide.text))
280
+ sl = Tilt[:markdown].new(nil, nil, engine_options) { sl }.render
281
+ sl = update_p_classes(sl)
282
+ sl = process_content_for_section_tags(sl)
283
+ sl = update_special_content(sl, @slide_count, name) # TODO: deprecated
284
+ sl = update_image_paths(name, sl, opts)
285
+
286
+ content += sl
287
+ content += "</div>\n"
288
+ content += "</div>\n"
289
+
290
+ final += update_commandline_code(content)
291
+
292
+ if seq
293
+ seq += 1
168
294
  end
169
- sl = Markdown.new(slide.text).to_html
170
- sl = update_image_paths(name, sl, static, pdf)
171
- md += sl
172
- md += "</div>\n"
173
- md += "</div>\n"
174
- final += update_commandline_code(md)
175
- final = update_p_classes(final)
176
295
  end
177
296
  final
178
297
  end
179
298
 
299
+ # This method processes the content of the slide and replaces
300
+ # content markers with their actual value information
301
+ def process_content_for_replacements(content)
302
+ # update counters, incrementing section:minor if needed
303
+ result = content.gsub("~~~CURRENT_SLIDE~~~", @slide_count.to_s)
304
+ result.gsub!("~~~SECTION:MAJOR~~~", @section_major.to_s)
305
+ if result.include? "~~~SECTION:MINOR~~~"
306
+ @section_minor += 1
307
+ result.gsub!("~~~SECTION:MINOR~~~", @section_minor.to_s)
308
+ end
309
+
310
+ # scan for pagebreak tags. Should really only be used for handout notes or supplemental materials
311
+ result.gsub!("~~~PAGEBREAK~~~", '<div class="break">continued...</div>')
312
+
313
+ # Now check for any kind of options
314
+ content.scan(/(~~~CONFIG:(.*?)~~~)/).each do |match|
315
+ result.gsub!(match[0], settings.showoff_config[match[1]]) if settings.showoff_config.key?(match[1])
316
+ end
317
+
318
+ # Load and replace any file tags
319
+ content.scan(/(~~~FILE:([^:]*):?(.*)?~~~)/).each do |match|
320
+ # get the file content and parse out html entities
321
+ file = HTMLEntities.new.encode(File.read(File.join(settings.pres_dir, '_files', match[1])))
322
+
323
+ # make a list of sh_highlight classes to include
324
+ css = match[2].split.collect {|i| "sh_#{i.downcase}" }.join(' ')
325
+
326
+ result.gsub!(match[0], "<pre class=\"#{css}\"><code>#{file}</code></pre>")
327
+ end
328
+
329
+ result
330
+ end
331
+
332
+ # replace section tags with classed div tags
333
+ def process_content_for_section_tags(content)
334
+ return unless content
335
+
336
+ # because this is post markdown rendering, we may need to shift a <p> tag around
337
+ # remove the tags if they're by themselves
338
+ result = content.gsub(/<p>~~~SECTION:([^~]*)~~~<\/p>/, '<div class="\1">')
339
+ result.gsub!(/<p>~~~ENDSECTION~~~<\/p>/, '</div>')
340
+
341
+ # shove it around the div if it belongs to the contained element
342
+ result.gsub!(/(<p>)?~~~SECTION:([^~]*)~~~/, '<div class="\2">\1')
343
+ result.gsub!(/~~~ENDSECTION~~~(<\/p>)?/, '\1</div>')
344
+
345
+ result
346
+ end
347
+
348
+ def process_content_for_all_slides(content, num_slides, opts={})
349
+ content.gsub!("~~~NUM_SLIDES~~~", num_slides.to_s)
350
+
351
+ # Should we build a table of contents?
352
+ if opts[:toc]
353
+ frag = Nokogiri::HTML::DocumentFragment.parse ""
354
+ toc = Nokogiri::XML::Node.new('div', frag)
355
+ toc['id'] = 'toc'
356
+ frag.add_child(toc)
357
+
358
+ Nokogiri::HTML(content).css('div.subsection > h1').each do |section|
359
+ entry = Nokogiri::XML::Node.new('div', frag)
360
+ entry['class'] = 'tocentry'
361
+ toc.add_child(entry)
362
+
363
+ link = Nokogiri::XML::Node.new('a', frag)
364
+ link['href'] = "##{section.parent.parent['id']}"
365
+ link.content = section.content
366
+ entry.add_child(link)
367
+ end
368
+
369
+ # swap out the tag, if found, with the table of contents
370
+ content.gsub!("~~~TOC~~~", frag.to_html)
371
+ end
372
+
373
+ content
374
+ end
375
+
180
376
  # find any lines that start with a <p>.(something) and turn them into <p class="something">
181
377
  def update_p_classes(markdown)
182
378
  markdown.gsub(/<p>\.(.*?) /, '<p class="\1">')
183
379
  end
184
380
 
185
- def update_image_paths(path, slide, static=false, pdf=false)
381
+ # TODO: deprecated
382
+ def update_special_content(content, seq, name)
383
+ doc = Nokogiri::HTML::DocumentFragment.parse(content)
384
+ %w[notes handouts instructor solguide].each { |mark| update_special_content_mark(doc, mark) }
385
+ update_download_links(doc, seq, name)
386
+
387
+ # TODO: what the bloody hell. Figure out how to either make Nokogiri output closed
388
+ # tags or figure out how to get its XML output to quit adding gratuitious spaces.
389
+ doc.to_html.gsub(/(<img [^>]*)>/, '\1 />')
390
+ end
391
+
392
+ # TODO: deprecated
393
+ def update_special_content_mark(doc, mark)
394
+ container = doc.css("p.#{mark}").first
395
+ return unless container
396
+
397
+ @logger.warn "Special mark (#{mark}) is deprecated. Please replace with section tags. See the README for details."
398
+
399
+ # only allow localhost to print the instructor guide
400
+ if mark == 'instructor' and request.env['REMOTE_HOST'] != 'localhost'
401
+ container.remove
402
+ else
403
+ raw = container.inner_html
404
+ fixed = raw.gsub(/^\.#{mark} ?/, '')
405
+ markdown = Tilt[:markdown].new { fixed }.render
406
+
407
+ container.name = 'div'
408
+ container.inner_html = markdown
409
+ end
410
+ end
411
+ private :update_special_content_mark
412
+
413
+ def update_download_links(doc, seq, name)
414
+ container = doc.css("p.download").first
415
+ return unless container
416
+
417
+ raw = container.text
418
+ fixed = raw.gsub(/^\.download ?/, '')
419
+
420
+ # first create the data structure
421
+ # [ enabled, slide name, [array, of, files] ]
422
+ @@downloads[seq] = [ false, name, [] ]
423
+
424
+ fixed.split("\n").each { |file|
425
+ # then push each file onto the list
426
+ @@downloads[seq][2].push(file.strip)
427
+ }
428
+
429
+ container.remove
430
+ end
431
+ private :update_download_links
432
+
433
+ def update_image_paths(path, slide, opts={:static=>false, :pdf=>false})
186
434
  paths = path.split('/')
187
435
  paths.pop
188
436
  path = paths.join('/')
189
- replacement_prefix = static ?
190
- ( pdf ? %(img src="file://#{options.pres_dir}/#{path}) : %(img src="./file/#{path}) ) :
191
- %(img src="/image/#{path})
192
- slide.gsub(/img src=\"([^\/].*?)\"/) do |s|
437
+ replacement_prefix = opts[:static] ?
438
+ ( opts[:pdf] ? %(img src="file://#{settings.pres_dir}/#{path}) : %(img src="./file/#{path}) ) :
439
+ %(img src="#{@asset_path}image/#{path})
440
+ slide.gsub(/img src=[\"\'](?!https?:\/\/)([^\/].*?)[\"\']/) do |s|
193
441
  img_path = File.join(path, $1)
194
442
  w, h = get_image_size(img_path)
195
443
  src = %(#{replacement_prefix}/#{$1}")
@@ -228,7 +476,7 @@ class ShowOff < Sinatra::Application
228
476
  lines = out.split("\n")
229
477
  if lines.first.strip[0, 3] == '@@@'
230
478
  lang = lines.shift.gsub('@@@', '').strip
231
- pre.set_attribute('class', 'sh_' + lang.downcase)
479
+ pre.set_attribute('class', 'sh_' + lang.downcase) if !lang.empty?
232
480
  code.content = lines.join("\n")
233
481
  end
234
482
  end
@@ -264,28 +512,32 @@ class ShowOff < Sinatra::Application
264
512
  html.root.to_s
265
513
  end
266
514
 
267
- def get_slides_html(static=false, pdf=false)
268
- sections = ShowOffUtils.showoff_sections(options.pres_dir, @logger)
515
+ def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil})
516
+ @slide_count = 0
517
+ @section_major = 0
518
+ @section_minor = 0
519
+
520
+ sections = ShowOffUtils.showoff_sections(settings.pres_dir, @logger)
269
521
  files = []
270
522
  if sections
271
523
  data = ''
272
524
  sections.each do |section|
273
525
  if section =~ /^#/
274
526
  name = section.each_line.first.gsub(/^#*/,'').strip
275
- data << process_markdown(name, "<!SLIDE subsection>\n" + section, static, pdf)
527
+ data << process_markdown(name, "<!SLIDE subsection>\n" + section, opts)
276
528
  else
277
529
  files = []
278
530
  files << load_section_files(section)
279
531
  files = files.flatten
280
- files = files.select { |f| f =~ /.md/ }
532
+ files = files.select { |f| f =~ /.md$/ }
281
533
  files.each do |f|
282
- fname = f.gsub(options.pres_dir + '/', '').gsub('.md', '')
283
- data << process_markdown(fname, File.read(f), static, pdf)
534
+ fname = f.gsub(settings.pres_dir + '/', '').gsub('.md', '')
535
+ data << process_markdown(fname, File.read(f), opts)
284
536
  end
285
537
  end
286
538
  end
287
539
  end
288
- data
540
+ process_content_for_all_slides(data, @slide_count, opts)
289
541
  end
290
542
 
291
543
  def inline_css(csses, pre = nil)
@@ -294,7 +546,7 @@ class ShowOff < Sinatra::Application
294
546
  if pre
295
547
  css_file = File.join(File.dirname(__FILE__), '..', pre, css_file)
296
548
  else
297
- css_file = File.join(options.pres_dir, css_file)
549
+ css_file = File.join(settings.pres_dir, css_file)
298
550
  end
299
551
  css_content += File.read(css_file)
300
552
  end
@@ -308,9 +560,15 @@ class ShowOff < Sinatra::Application
308
560
  if pre
309
561
  js_file = File.join(File.dirname(__FILE__), '..', pre, js_file)
310
562
  else
311
- js_file = File.join(options.pres_dir, js_file)
563
+ js_file = File.join(settings.pres_dir, js_file)
564
+ end
565
+
566
+ begin
567
+ js_content += File.read(js_file)
568
+ rescue Errno::ENOENT
569
+ $stderr.puts "WARN: Failed to inline JS. No such file: #{js_file}"
570
+ next
312
571
  end
313
- js_content += File.read(js_file)
314
572
  end
315
573
  js_content += '</script>'
316
574
  js_content
@@ -322,14 +580,25 @@ class ShowOff < Sinatra::Application
322
580
 
323
581
  def index(static=false)
324
582
  if static
325
- @title = ShowOffUtils.showoff_title
326
- @slides = get_slides_html(static)
583
+ @title = ShowOffUtils.showoff_title(settings.pres_dir)
584
+ @slides = get_slides_html(:static=>static)
585
+ @pause_msg = ShowOffUtils.pause_msg
586
+
587
+ # Identify which languages to bundle for highlighting
588
+ @languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "sh_lang/#{w[0]}.min.js"}
589
+
327
590
  @asset_path = "./"
328
591
  end
592
+
593
+ # Check to see if the presentation has enabled feedback
594
+ @feedback = settings.showoff_config['feedback']
329
595
  erb :index
330
596
  end
331
597
 
332
598
  def presenter
599
+ @issues = settings.showoff_config['issues']
600
+ @@cookie ||= guid()
601
+ response.set_cookie('presenter', @@cookie)
333
602
  erb :presenter
334
603
  end
335
604
 
@@ -361,33 +630,89 @@ class ShowOff < Sinatra::Application
361
630
  assets << href if href
362
631
  end
363
632
 
364
- css = Dir.glob("#{options.public}/**/*.css").map { |path| path.gsub(options.public + '/', '') }
633
+ css = Dir.glob("#{settings.public_folder}/**/*.css").map { |path| path.gsub(settings.public_folder + '/', '') }
365
634
  assets << css
366
635
 
367
- js = Dir.glob("#{options.public}/**/*.js").map { |path| path.gsub(options.public + '/', '') }
636
+ js = Dir.glob("#{settings.public_folder}/**/*.js").map { |path| path.gsub(settings.public_folder + '/', '') }
368
637
  assets << js
369
638
 
370
639
  assets.uniq.join("\n")
371
640
  end
372
641
 
373
642
  def slides(static=false)
374
- get_slides_html(static)
643
+ get_slides_html(:static=>static)
375
644
  end
376
645
 
377
646
  def onepage(static=false)
378
- @slides = get_slides_html(static)
647
+ @slides = get_slides_html(:static=>static, :toc=>true)
648
+ #@languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "/sh_lang/#{w[0]}.min.js"}
379
649
  erb :onepage
380
650
  end
381
651
 
652
+ def print(static=false)
653
+ @slides = get_slides_html(:static=>static, :toc=>true, :print=>true)
654
+ erb :onepage
655
+ end
656
+
657
+ def supplemental(content, static=false)
658
+ @slides = get_slides_html(:static=>static, :supplemental=>content)
659
+ @wrapper_classes = ['supplemental']
660
+ erb :onepage
661
+ end
662
+
663
+ def download()
664
+ begin
665
+ shared = Dir.glob("#{settings.pres_dir}/_files/share/*").map { |path| File.basename(path) }
666
+ # We use the icky -999 magic index because it has to be comparable for the view sort
667
+ @downloads = { -999 => [ true, 'Shared Files', shared ] }
668
+ rescue Errno::ENOENT => e
669
+ # don't fail if the directory doesn't exist
670
+ @downloads = {}
671
+ end
672
+ @downloads.merge! @@downloads
673
+ erb :download
674
+ end
675
+
676
+ def stats()
677
+ if request.env['REMOTE_HOST'] == 'localhost'
678
+ # the presenter should have full stats
679
+ @counter = @@counter
680
+ end
681
+
682
+ @all = Hash.new
683
+ @@counter.each do |slide, stats|
684
+ @all[slide] = 0
685
+ stats.map { |host, count| @all[slide] += count }
686
+ end
687
+
688
+ # most and least five viewed slides
689
+ @least = @all.sort_by {|slide, time| time}[0..4]
690
+ @most = @all.sort_by {|slide, time| -time}[0..4]
691
+
692
+ erb :stats
693
+ end
694
+
382
695
  def pdf(static=true)
383
- @slides = get_slides_html(static, true)
384
- @no_js = false
696
+ @slides = get_slides_html(:static=>static, :pdf=>true)
697
+ @inline = true
698
+
699
+ # Identify which languages to bundle for highlighting
700
+ @languages = @slides.scan(/<pre class=".*(?!sh_sourceCode)(sh_[\w-]+).*"/).uniq.map{ |w| "/sh_lang/#{w[0]}.min.js"}
701
+
385
702
  html = erb :onepage
386
703
  # TODO make a random filename
387
704
 
705
+ # Process inline css and js for included images
706
+ # The css uses relative paths for images and we prepend the file url
707
+ html.gsub!(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/) do |s|
708
+ "url(file://#{settings.pres_dir}/#{$1})"
709
+ end
710
+
711
+ # Todo fix javascript path
712
+
388
713
  # PDFKit.new takes the HTML and any options for wkhtmltopdf
389
714
  # run `wkhtmltopdf --extended-help` for a full list of options
390
- kit = PDFKit.new(html, :page_size => 'Letter', :orientation => 'Landscape')
715
+ kit = PDFKit.new(html, ShowOffUtils.showoff_pdf_options(settings.pres_dir))
391
716
 
392
717
  # Save the PDF to a file
393
718
  file = kit.to_file('/tmp/preso.pdf')
@@ -399,14 +724,16 @@ class ShowOff < Sinatra::Application
399
724
  def self.do_static(what)
400
725
  what = "index" if !what
401
726
 
402
- # Nasty hack to get the actual ShowOff module
403
- showoff = ShowOff.new
404
- while !showoff.is_a?(ShowOff)
405
- showoff = showoff.instance_variable_get(:@app)
406
- end
727
+ # Sinatra now aliases new to new!
728
+ # https://github.com/sinatra/sinatra/blob/v1.3.3/lib/sinatra/base.rb#L1369
729
+ showoff = ShowOff.new!
730
+
407
731
  name = showoff.instance_variable_get(:@pres_name)
408
732
  path = showoff.instance_variable_get(:@root_path)
733
+ logger = showoff.instance_variable_get(:@logger)
734
+
409
735
  data = showoff.send(what, true)
736
+
410
737
  if data.is_a?(File)
411
738
  FileUtils.cp(data.path, "#{name}.pdf")
412
739
  else
@@ -433,7 +760,7 @@ class ShowOff < Sinatra::Application
433
760
  # Set up file dir
434
761
  file_dir = File.join(out, 'file')
435
762
  FileUtils.makedirs(file_dir)
436
- pres_dir = showoff.options.pres_dir
763
+ pres_dir = showoff.settings.pres_dir
437
764
 
438
765
  # ..., copy all user-defined styles and javascript files
439
766
  Dir.glob("#{pres_dir}/*.{css,js}").each { |path|
@@ -441,17 +768,21 @@ class ShowOff < Sinatra::Application
441
768
  }
442
769
 
443
770
  # ... and copy all needed image files
444
- data.scan(/img src=\".\/file\/(.*?)\"/).flatten.each do |path|
445
- dir = File.dirname(path)
446
- FileUtils.makedirs(File.join(file_dir, dir))
447
- FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path))
771
+ [/img src=[\"\'].\/file\/(.*?)[\"\']/, /style=[\"\']background: url\(\'file\/(.*?)'/].each do |regex|
772
+ data.scan(regex).flatten.each do |path|
773
+ dir = File.dirname(path)
774
+ FileUtils.makedirs(File.join(file_dir, dir))
775
+ FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path))
776
+ end
448
777
  end
449
778
  # copy images from css too
450
779
  Dir.glob("#{pres_dir}/*.css").each do |css_path|
451
780
  File.open(css_path) do |file|
452
781
  data = file.read
453
- data.scan(/url\((.*)\)/).flatten.each do |path|
454
- @logger.debug path
782
+ data.scan(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/).flatten.each do |path|
783
+ path.gsub!(/(\#.*)$/, '') # get rid of the anchor
784
+ path.gsub!(/(\?.*)$/, '') # get rid of the query
785
+ logger.debug path
455
786
  dir = File.dirname(path)
456
787
  FileUtils.makedirs(File.join(file_dir, dir))
457
788
  FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path))
@@ -467,6 +798,35 @@ class ShowOff < Sinatra::Application
467
798
  e.message
468
799
  end
469
800
 
801
+ # Basic auth boilerplate
802
+ def protected!
803
+ unless authorized?
804
+ response['WWW-Authenticate'] = %(Basic realm="#{@title}: Protected Area")
805
+ throw(:halt, [401, "Not authorized\n"])
806
+ end
807
+ end
808
+
809
+ def authorized?
810
+ if not settings.showoff_config.has_key? 'password'
811
+ # if no password is set, then default to allowing access to localhost
812
+ request.env['REMOTE_HOST'] == 'localhost' or request.ip == '127.0.0.1'
813
+ else
814
+ auth ||= Rack::Auth::Basic::Request.new(request.env)
815
+ user = settings.showoff_config['user'] || ''
816
+ password = settings.showoff_config['password']
817
+ auth.provided? && auth.basic? && auth.credentials && auth.credentials == [user, password]
818
+ end
819
+ end
820
+
821
+ def guid
822
+ # this is a terrifyingly simple GUID generator
823
+ (0..15).to_a.map{|a| rand(16).to_s(16)}.join
824
+ end
825
+
826
+ def valid_cookie
827
+ (request.cookies['presenter'] == @@cookie)
828
+ end
829
+
470
830
  get '/eval_ruby' do
471
831
  return eval_ruby(params[:code]) if ENV['SHOWOFF_EVAL_RUBY']
472
832
 
@@ -475,20 +835,169 @@ class ShowOff < Sinatra::Application
475
835
 
476
836
  get %r{(?:image|file)/(.*)} do
477
837
  path = params[:captures].first
478
- full_path = File.join(options.pres_dir, path)
479
- send_file full_path
838
+ full_path = File.join(settings.pres_dir, path)
839
+ if File.exist?(full_path)
840
+ send_file full_path
841
+ else
842
+ raise Sinatra::NotFound
843
+ end
844
+ end
845
+
846
+ get '/control' do
847
+ if !request.websocket?
848
+ raise Sinatra::NotFound
849
+ else
850
+ request.websocket do |ws|
851
+ ws.onopen do
852
+ ws.send( { 'current' => @@current[:number] }.to_json )
853
+ settings.sockets << ws
854
+
855
+ @logger.warn "Open sockets: #{settings.sockets.size}"
856
+ end
857
+ ws.onmessage do |data|
858
+ begin
859
+ control = JSON.parse(data)
860
+
861
+ @logger.warn "#{control.inspect}"
862
+
863
+ case control['message']
864
+ when 'update'
865
+ # websockets don't use the same auth standards
866
+ # we use a session cookie to identify the presenter
867
+ if valid_cookie()
868
+ name = control['name']
869
+ slide = control['slide'].to_i
870
+
871
+ # check to see if we need to enable a download link
872
+ if @@downloads.has_key?(slide)
873
+ @logger.debug "Enabling file download for slide #{name}"
874
+ @@downloads[slide][0] = true
875
+ end
876
+
877
+ # update the current slide pointer
878
+ @logger.debug "Updated current slide to #{name}"
879
+ @@current = { :name => name, :number => slide }
880
+
881
+ # schedule a notification for all clients
882
+ EM.next_tick { settings.sockets.each{|s| s.send({ 'current' => @@current[:number] }.to_json) } }
883
+ end
884
+
885
+ when 'register'
886
+ # save a list of presenters
887
+ if valid_cookie()
888
+ remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR']
889
+ settings.presenters << ws
890
+ @logger.warn "Registered new presenter: #{remote}"
891
+ end
892
+
893
+ when 'track'
894
+ remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR']
895
+ slide = control['slide']
896
+ time = control['time'].to_f
897
+
898
+ @logger.debug "Logged #{time} on slide #{slide} for #{remote}"
899
+
900
+ # a bucket for this slide
901
+ @@counter[slide] ||= Hash.new
902
+ # a bucket of slideviews for this address
903
+ @@counter[slide][remote] ||= Array.new
904
+ # and add this slide viewing to the bucket
905
+ @@counter[slide][remote] << { :elapsed => time, :timestamp => Time.now.to_i, :presenter => @@current[:name] }
906
+
907
+ when 'position'
908
+ ws.send( { 'current' => @@current[:number] }.to_json ) unless @@cookie.nil?
909
+
910
+ when 'pace', 'question'
911
+ # just forward to the presenter(s)
912
+ EM.next_tick { settings.presenters.each{|s| s.send(data) } }
913
+
914
+ when 'feedback'
915
+ filename = "#{settings.statsdir}/#{settings.feedback}"
916
+ slide = control['slide']
917
+ rating = control['rating']
918
+ feedback = control['feedback']
919
+
920
+ begin
921
+ log = JSON.parse(File.read(filename))
922
+ rescue
923
+ # do nothing
924
+ end
925
+
926
+ log ||= Hash.new
927
+ log[slide] ||= Array.new
928
+ log[slide] << { :rating => rating, :feedback => feedback }
929
+
930
+ if settings.verbose then
931
+ File.write(filename, JSON.pretty_generate(log))
932
+ else
933
+ File.write(filename, log.to_json)
934
+ end
935
+
936
+ else
937
+ @logger.warn "Unknown message <#{control['message']}> received."
938
+ @logger.warn control.inspect
939
+ end
940
+
941
+ rescue Exception => e
942
+ @logger.warn "Messaging error: #{e}"
943
+ end
944
+ end
945
+ ws.onclose do
946
+ @logger.warn("websocket closed")
947
+ settings.sockets.delete(ws)
948
+ end
949
+ end
950
+ end
480
951
  end
481
952
 
482
- get %r{/(.*)} do
483
- @title = ShowOffUtils.showoff_title
953
+ # gawd, this whole routing scheme is bollocks
954
+ get %r{/([^/]*)/?([^/]*)} do
955
+ @title = ShowOffUtils.showoff_title(settings.pres_dir)
956
+ @pause_msg = ShowOffUtils.pause_msg
484
957
  what = params[:captures].first
958
+ opt = params[:captures][1]
485
959
  what = 'index' if "" == what
486
- if (what != "favicon.ico")
487
- data = send(what)
488
- if data.is_a?(File)
489
- send_file data.path
960
+
961
+ if settings.showoff_config.has_key? 'protected'
962
+ protected! if settings.showoff_config['protected'].include? what
963
+ end
964
+
965
+ # this hasn't been set to anything remotely interesting for a long time now
966
+ @asset_path = nil
967
+
968
+ begin
969
+ if (what != "favicon.ico")
970
+ if what == 'supplemental'
971
+ data = send(what, opt)
972
+ else
973
+ data = send(what)
974
+ end
975
+ if data.is_a?(File)
976
+ send_file data.path
977
+ else
978
+ data
979
+ end
980
+ end
981
+ rescue NoMethodError => e
982
+ @logger.warn "Invalid object #{what} requested."
983
+ raise Sinatra::NotFound
984
+ end
985
+ end
986
+
987
+ not_found do
988
+ # Why does the asset path start from cwd??
989
+ @asset_path.slice!(/^./)
990
+ @env = request.env
991
+ erb :'404'
992
+ end
993
+
994
+ at_exit do
995
+ if defined?(@@counter)
996
+ filename = "#{settings.statsdir}/#{settings.viewstats}"
997
+ if settings.verbose then
998
+ File.write(filename, JSON.pretty_generate(@@counter))
490
999
  else
491
- data
1000
+ File.write(filename, @@counter.to_json)
492
1001
  end
493
1002
  end
494
1003
  end