showoff 0.7.0 → 0.9.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.rdoc +53 -475
- data/Rakefile +17 -18
- data/bin/showoff +29 -7
- data/lib/commandline_parser.rb +1 -1
- data/lib/showoff/version.rb +3 -0
- data/lib/showoff.rb +600 -91
- data/lib/showoff_utils.rb +110 -4
- data/public/css/disconnected-large.png +0 -0
- data/public/css/disconnected.png +0 -0
- data/public/css/fast.png +0 -0
- data/public/css/grippy-close.png +0 -0
- data/public/css/grippy.png +0 -0
- data/public/css/onepage.css +6 -0
- data/public/css/pace.png +0 -0
- data/public/css/paceMarker.png +0 -0
- data/public/css/presenter.css +333 -43
- data/public/css/sh_style.css +15 -0
- data/public/css/showoff.css +373 -48
- data/public/css/slow.png +0 -0
- data/public/css/spinner.gif +0 -0
- data/public/css/tipsy.css +26 -0
- data/public/favicon.ico +0 -0
- data/public/js/jquery.parsequery.min.js +2 -0
- data/public/js/jquery.tipsy.js +260 -0
- data/public/js/onepage.js +2 -3
- data/public/js/presenter.js +384 -33
- data/public/js/sh_lang/sh_gherkin.js +112 -0
- data/public/js/sh_lang/sh_gherkin.min.js +1 -0
- data/public/js/sh_lang/sh_ini.js +87 -0
- data/public/js/sh_lang/sh_ini.min.js +87 -0
- data/public/js/sh_lang/sh_puppet.js +182 -0
- data/public/js/sh_lang/sh_puppet.min.js +182 -0
- data/public/js/sh_lang/sh_puppet_output.js +22 -0
- data/public/js/sh_lang/sh_puppet_output.min.js +22 -0
- data/public/js/sh_lang/sh_shell.min.js +1 -0
- data/public/js/showoff.js +423 -51
- data/views/404.erb +19 -0
- data/views/download.erb +36 -0
- data/views/header.erb +35 -25
- data/views/header_mini.erb +22 -0
- data/views/index.erb +46 -1
- data/views/onepage.erb +35 -14
- data/views/presenter.erb +63 -21
- data/views/stats.erb +73 -0
- metadata +170 -131
- data/public/css/960.css +0 -653
- 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
|
-
|
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 :
|
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 =
|
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
|
-
|
76
|
+
settings.pres_dir ||= Dir.pwd
|
56
77
|
@root_path = "."
|
57
78
|
|
58
|
-
|
59
|
-
if (
|
60
|
-
ShowOffUtils.presentation_config_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
|
64
|
-
@pres_name =
|
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("#{
|
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(
|
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("#{
|
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("#{
|
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("#{
|
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
|
106
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
160
|
-
|
161
|
-
|
162
|
-
|
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
|
-
|
165
|
-
seq += 1
|
273
|
+
content += "<div class=\"content #{classes}\" ref=\"#{name}/#{seq.to_s}\">\n"
|
166
274
|
else
|
167
|
-
|
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
|
-
|
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://#{
|
191
|
-
%(img src="
|
192
|
-
slide.gsub(/img src
|
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
|
268
|
-
|
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,
|
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(
|
283
|
-
data << process_markdown(fname, File.read(f),
|
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(
|
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(
|
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("#{
|
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("#{
|
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
|
-
@
|
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,
|
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
|
-
#
|
403
|
-
|
404
|
-
|
405
|
-
|
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.
|
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
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
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\((
|
454
|
-
|
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(
|
479
|
-
|
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
|
-
|
483
|
-
|
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
|
-
|
487
|
-
|
488
|
-
if
|
489
|
-
|
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
|
-
|
1000
|
+
File.write(filename, @@counter.to_json)
|
492
1001
|
end
|
493
1002
|
end
|
494
1003
|
end
|