spirit 0.1.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,90 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ require 'sanitize'
3
+
4
+ module Spirit
5
+
6
+ module Render
7
+
8
+ # Encapsulate sanitization options.
9
+ # @see https://github.com/github/gollum/blob/master/lib/gollum/sanitization.rb
10
+ class Sanitize < ::Sanitize
11
+
12
+ # white-listed elements
13
+ ELEMENTS = [
14
+ 'a', 'abbr', 'acronym', 'address', 'area', 'b', 'big',
15
+ 'blockquote', 'br', 'button', 'caption', 'center', 'cite',
16
+ 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir',
17
+ 'div', 'dl', 'dt', 'em', 'fieldset', 'font', 'form', 'h1',
18
+ 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input',
19
+ 'ins', 'kbd', 'label', 'legend', 'li', 'map', 'menu',
20
+ 'ol', 'optgroup', 'option', 'p', 'pre', 'q', 's', 'samp',
21
+ 'select', 'small', 'span', 'strike', 'strong', 'sub',
22
+ 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', 'th',
23
+ 'thead', 'tr', 'tt', 'u', 'ul', 'var'
24
+ ].freeze
25
+
26
+ # white-listed attributes
27
+ ATTRIBUTES = {
28
+ 'a' => ['href', 'name', 'data-magellan-destination'],
29
+ 'dd' => ['data-magellan-arrival'],
30
+ 'dl' => ['data-magellan-expedition'],
31
+ 'img' => ['src'],
32
+ :all => ['abbr', 'accept', 'accept-charset',
33
+ 'accesskey', 'action', 'align', 'alt', 'axis',
34
+ 'border', 'cellpadding', 'cellspacing', 'char',
35
+ 'charoff', 'class', 'charset', 'checked', 'cite',
36
+ 'clear', 'cols', 'colspan', 'color',
37
+ 'compact', 'coords', 'datetime', 'dir',
38
+ 'disabled', 'enctype', 'for', 'frame',
39
+ 'headers', 'height', 'hreflang',
40
+ 'hspace', 'id', 'ismap', 'label', 'lang',
41
+ 'longdesc', 'maxlength', 'media', 'method',
42
+ 'multiple', 'name', 'nohref', 'noshade',
43
+ 'nowrap', 'prompt', 'readonly', 'rel', 'rev',
44
+ 'rows', 'rowspan', 'rules', 'scope',
45
+ 'selected', 'shape', 'size', 'span',
46
+ 'start', 'summary', 'tabindex', 'target',
47
+ 'title', 'type', 'usemap', 'valign', 'value',
48
+ 'vspace', 'width']
49
+ }.freeze
50
+
51
+ # white-listed protocols
52
+ PROTOCOLS = {
53
+ 'a' => {'href' => ['http', 'https', 'mailto', 'ftp', 'irc', 'apt', :relative]},
54
+ 'img' => {'src' => ['http', 'https', :relative]}
55
+ }.freeze
56
+
57
+ # elements to remove (incl. contents)
58
+ REMOVE_CONTENTS = [
59
+ 'script',
60
+ 'style'
61
+ ].freeze
62
+
63
+ # attributes to add to elements
64
+ ADD_ATTRIBUTES = {
65
+ 'a' => {'rel' => 'nofollow'}
66
+ }
67
+
68
+ # Creates a new sanitizer with {Spirit}'s configuration.
69
+ def initialize
70
+ super config
71
+ end
72
+
73
+ private
74
+
75
+ # @return [Hash] configuration hash.
76
+ def config
77
+ { elements: ELEMENTS.dup,
78
+ attributes: ATTRIBUTES.dup,
79
+ protocols: PROTOCOLS.dup,
80
+ add_attributes: ADD_ATTRIBUTES.dup,
81
+ remove_contents: REMOVE_CONTENTS.dup,
82
+ allow_comments: false
83
+ }
84
+ end
85
+
86
+ end
87
+
88
+ end
89
+
90
+ end
@@ -0,0 +1,6 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+
3
+ # require all template types
4
+ %w(template header image problem multi short table navigation).each do |type|
5
+ require File.join 'spirit', 'render', 'templates', type
6
+ end
@@ -0,0 +1,52 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ require 'active_support/core_ext/string/inflections'
3
+
4
+ module Spirit
5
+
6
+ module Render
7
+
8
+ # Keeps track of headers within the same document. It's responsible for
9
+ # assigning unique names that can be used in the anchors.
10
+ class Headers
11
+
12
+ def initialize
13
+ @headers = {}
14
+ end
15
+
16
+ # Adds a new header to the set.
17
+ # @return [Header] header
18
+ def add(text, level=1)
19
+ name = text.parameterize
20
+ if @headers.include? name
21
+ name += '-%d' % (@headers[name] += 1)
22
+ else @headers[name] = 0 end
23
+ Header.new(text, level, name)
24
+ end
25
+
26
+ end
27
+
28
+ # Renders a header (e.g. +h1+, +h2+, ...) with anchors.
29
+ class Header < Template
30
+
31
+ # Name of template file for rendering headers.
32
+ TEMPLATE = 'header.haml'
33
+
34
+ attr_reader :name
35
+
36
+ # Creates a new header.
37
+ # @param [String] text header text
38
+ # @param [Fixnum] level 1 to 6
39
+ # @param [String] name anchor name
40
+ def initialize(text, level, name)
41
+ @text, @level, @name = text, level, name
42
+ end
43
+
44
+ def render(locals={})
45
+ super locals.merge(text: @text, level: @level, name: @name)
46
+ end
47
+
48
+ end
49
+
50
+ end
51
+
52
+ end
@@ -0,0 +1,54 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ require 'nokogiri'
3
+
4
+ module Spirit
5
+
6
+ module Render
7
+
8
+ # Renders a block image with a figure number.
9
+ class Image < Template
10
+
11
+ # <img ...>
12
+ IMAGE_TAG = 'img'
13
+
14
+ # Name of template file for rendering block images
15
+ TEMPLATE = 'img.haml'
16
+
17
+ class << self
18
+
19
+ # Parses the given text for a block image.
20
+ def parse(text)
21
+ Image.new text
22
+ end
23
+
24
+ end
25
+
26
+ # Creates a new image.
27
+ def initialize(html)
28
+ @html = html
29
+ parse_or_raise
30
+ end
31
+
32
+ def render(locals={})
33
+ super locals.merge(img: @html, caption: @node['alt'])
34
+ end
35
+
36
+ private
37
+
38
+ # Parses the given HTML, or raise {RenderError} if it is invalid.
39
+ def parse_or_raise
40
+ frag = Nokogiri::HTML::DocumentFragment.parse(@html)
41
+ if 1 == frag.children.count and
42
+ node = frag.children.first and
43
+ node.is_a? Nokogiri::XML::Element and
44
+ node.name == IMAGE_TAG
45
+ @node = node
46
+ else raise RenderError, 'Not really a block image.'
47
+ end
48
+ end
49
+
50
+ end
51
+
52
+ end
53
+
54
+ end
@@ -0,0 +1,40 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ module Spirit
3
+
4
+ module Render
5
+
6
+ # Renders multiple choice questions marked up in YAML as HTML.
7
+ # @example
8
+ #
9
+ # {
10
+ # "format": "multi",
11
+ # "question": "How tall is Mount Everest?",
12
+ # "answer": "A",
13
+ # "options": {
14
+ # "A": "452 inches",
15
+ # "B": "8.85 kilometers"
16
+ # }
17
+ # }
18
+ class Multi < Problem
19
+
20
+ # Required key in YAML markup. Associated value should be a dictionary of
21
+ # label -> choices.
22
+ OPTIONS = 'options'
23
+
24
+ # Name of template file for rendering multiple choice questions.
25
+ TEMPLATE = 'multi.haml'
26
+
27
+ # Checks if the given yaml contains a valid MCQ.
28
+ # @return [Boolean] true iff the yaml contains a valid MCQ.
29
+ def valid?
30
+ super and
31
+ @yaml[ANSWER].is_a? String and
32
+ @yaml.has_key?(OPTIONS) and
33
+ @yaml[OPTIONS].is_a? Hash
34
+ end
35
+
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -0,0 +1,34 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ module Spirit
3
+
4
+ module Render
5
+
6
+ # Keeps track of document sections and renders a navigation bar.
7
+ class Navigation < Template
8
+
9
+ # HAML template for navigation bar
10
+ TEMPLATE = 'nav.haml'
11
+
12
+ # Creates a new navigation bar.
13
+ def initialize
14
+ @sections = {}
15
+ end
16
+
17
+ # Adds a new section.
18
+ # @param [String] heading section heading
19
+ # @param [String] name anchor name
20
+ # @return [void]
21
+ def append(heading, name)
22
+ @sections[name] = heading
23
+ end
24
+
25
+ # Renders the navigation bar in HTML.
26
+ def render(locals={})
27
+ super locals.merge(sections: @sections)
28
+ end
29
+
30
+ end
31
+
32
+ end
33
+
34
+ end
@@ -0,0 +1,126 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ require 'spirit/constants'
3
+ module Spirit
4
+
5
+ module Render
6
+
7
+ # Renders a single problem. This class doesn't do anything useful; use the
8
+ # child classes (e.g. {Spirit::Render::Multi}) instead. Child classes should
9
+ # override {#valid?}.
10
+ class Problem < Template
11
+
12
+ # Required key in YAML markup. Value indicates type of problem.
13
+ FORMAT = 'format'
14
+
15
+ # Required key in YAML markup. Value contains question body.
16
+ QUESTION = 'question'
17
+
18
+ # Required key in YAML markup. Value contains answers.
19
+ ANSWER = 'answer'
20
+
21
+ # Optional key in YAML markup. Value contains problem ID.
22
+ ID = 'id'
23
+
24
+ # Required keys.
25
+ KEYS = [FORMAT, QUESTION, ANSWER]
26
+
27
+ # Stateless markdown renderer.
28
+ MARKDOWN = ::Redcarpet::Markdown.new(::Redcarpet::Render::HTML, MARKDOWN_EXTENSIONS)
29
+
30
+ class << self
31
+
32
+ # Parses the given text for questions and answers. If the given text
33
+ # does not contain valid YAML or does not contain the format key, raises
34
+ # an {Spirit::Render::RenderError}.
35
+ # @param [String] text embedded yaml
36
+ # @return [Problem] problem
37
+ def parse(text)
38
+ yaml = YAML.load text
39
+ get_instance(yaml)
40
+ rescue ::Psych::SyntaxError => e
41
+ raise RenderError, e.message
42
+ end
43
+
44
+ # Dynamically creates accessor methods for YAML values.
45
+ # @example
46
+ # accessor :id
47
+ def accessor(*args)
48
+ args.each { |arg| define_method(arg) { @yaml[arg] } }
49
+ end
50
+
51
+ # @return [Problem] problem
52
+ def get_instance(yaml)
53
+ raise RenderError, "Missing 'format' key in given YAML" unless instantiable? yaml
54
+ klass = Spirit::Render.const_get(yaml[FORMAT].capitalize)
55
+ raise NameError unless klass < Problem
56
+ klass.new(yaml)
57
+ rescue NameError
58
+ raise RenderError, 'Unrecognized format: %p' % yaml[FORMAT]
59
+ end
60
+
61
+ private
62
+
63
+ def instantiable?(yaml)
64
+ yaml.is_a?(Hash) and yaml.has_key?(FORMAT)
65
+ end
66
+
67
+ end
68
+
69
+ accessor ID, *KEYS
70
+
71
+ # Creates a new problem from the given YAML.
72
+ # @param [Hash] yaml parsed yaml object
73
+ def initialize(yaml)
74
+ @yaml = yaml
75
+ @yaml[ID] ||= SecureRandom.uuid
76
+ raise RenderError.new('Invalid problem.') unless @yaml[QUESTION].is_a? String
77
+ @yaml[QUESTION] = MARKDOWN.render @yaml[QUESTION]
78
+ end
79
+
80
+ # @todo TODO should probably show some error message in the preview,
81
+ # so that the author doesn't have to read the logs.
82
+ def render(locals={})
83
+ raise RenderError.new('Invalid problem.') unless valid?
84
+ yaml = @yaml.merge(locals)
85
+ super yaml
86
+ end
87
+
88
+ # Saves the answer to a file on disk.
89
+ # @todo TODO should probably show some error message in the preview,
90
+ # so that the author doesn't have to read the logs.
91
+ def save!
92
+ raise RenderError.new('Invalid problem.') unless valid?
93
+ solution = File.join(Spirit::SOLUTION_DIR, id + Spirit::SOLUTION_EXT)
94
+ File.open(solution, 'wb+') { |file| Marshal.dump answer, file }
95
+ end
96
+
97
+ # Retrieves the answer from the given YAML object in a serializable form.
98
+ # @see #serializable
99
+ # @return [String, Numeric, TrueClass, FalseClass] answer
100
+ def answer
101
+ serialize @yaml[ANSWER]
102
+ end
103
+
104
+ private
105
+
106
+ # If +obj+ is one of String, Numeric, +true+, or +false+, the it's
107
+ # returned. Otherwise, +to_s+ is invoked on the object and returned.
108
+ # @return [String, Numeric, TrueClass, FalseClass] answer
109
+ def serialize(obj)
110
+ case obj
111
+ when String, Numeric, TrueClass, FalseClass then obj
112
+ else obj.to_s end
113
+ end
114
+
115
+ # Checks that all required {KEYS} exist in the YAML, and that the
116
+ # question is given as a string.
117
+ # @return [Boolean] true iff the parsed yaml contains a valid problem.
118
+ def valid?
119
+ KEYS.all? { |key| @yaml.has_key? key } and question.is_a? String
120
+ end
121
+
122
+ end
123
+
124
+ end
125
+
126
+ end
@@ -0,0 +1,30 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ module Spirit
3
+
4
+ module Render
5
+
6
+ # Renders short questions marked up in YAML as HTML.
7
+ # @example
8
+ # {
9
+ # "format": "short",
10
+ # "question": "What is the most commonly used word in English?",
11
+ # "answer": "the"
12
+ # }
13
+ class Short < Problem
14
+
15
+ # Name of template file for rendering short answer questions.
16
+ TEMPLATE = 'short.haml'
17
+
18
+ # Checks if the given yaml contains a valid MCQ.
19
+ # @return [Boolean] true iff the yaml contains a valid MCQ.
20
+ def valid?
21
+ super and
22
+ not @yaml[ANSWER].nil?
23
+ end
24
+
25
+ end
26
+
27
+ end
28
+
29
+ end
30
+
@@ -0,0 +1,109 @@
1
+ # ~*~ encoding: utf-8 ~*~
2
+ module Spirit
3
+
4
+ module Render
5
+
6
+ # Renders table problems marked up in YAML as HTML.
7
+ #
8
+ # The grid should be given as a 2-dimensional array that represents the
9
+ # table to be filled in. +"?"+ is a special token used in the grid to
10
+ # indicate cells that require student input.
11
+ #
12
+ # The answer should also be given as a 2-dimensional array. However, a
13
+ # dummy token may be used in cells that do not require student input to
14
+ # cut redundancy. In the example below, the +"-"+ token is used.
15
+ #
16
+ # @example
17
+ # {
18
+ # "format": "table",
19
+ # "question": "fill me in",
20
+ # "grid": [[0, "?", 2], [3, "?", 5]],
21
+ # "answer": [["-", 1, "-"], ["-", 4, "-"]
22
+ # }
23
+ class Table < Problem
24
+
25
+ # Name of template file for rendering table problems.
26
+ TEMPLATE = 'table.haml'
27
+
28
+ # Optional headings key.
29
+ HEADINGS = 'headings'
30
+
31
+ # Required grid key.
32
+ GRID = 'grid'
33
+
34
+ # Special token indicating that the cell should be filled in.
35
+ FILL_ME_IN = '?'
36
+
37
+ accessor HEADINGS, GRID
38
+
39
+ # Ensures that the +headings+ key exists.
40
+ def initialize(yaml)
41
+ yaml[HEADINGS] ||= nil
42
+ super
43
+ end
44
+
45
+ # Checks if the given yaml contains a valid table.
46
+ # @return [Boolean] true iff the yaml contains a valid table.
47
+ def valid?
48
+ super and
49
+ valid_grid? and
50
+ valid_answer?
51
+ end
52
+
53
+ # Gets the expected answer, in www-form-urlencoded format.
54
+ # @return [Hash] answers, as expected from student's form submission
55
+ def answer
56
+ return @answer unless @answer.nil?
57
+ @answer = encode_answer
58
+ end
59
+
60
+ # @return [Boolean] true iff the given cell is an input cell
61
+ def self.input?(cell)
62
+ cell == FILL_ME_IN
63
+ end
64
+
65
+ private
66
+
67
+ # Iterates through each cell in the provided grid to look for answers
68
+ # cells that require input and takes the answer from the answers array.
69
+ # For example, if the answer for a cell at [0][1] is 6, the returned
70
+ # hash will contain
71
+ #
72
+ # {'0' => {'1' => 6}}
73
+ #
74
+ # @return [Hash] answers
75
+ def encode_answer
76
+ encoded, ans = {}, @yaml[ANSWER]
77
+ grid.each_with_index do |row, i|
78
+ row.each_with_index do |cell, j|
79
+ next unless Table.input? cell
80
+ encoded[i.to_s] ||= {}
81
+ encoded[i.to_s][j.to_s] = serialize ans[i][j]
82
+ end
83
+ end
84
+ encoded
85
+ end
86
+
87
+ # @return [Boolean] true iff the yaml contains a valid grid.
88
+ def valid_grid?
89
+ @yaml.has_key? GRID and is_2d_array? grid
90
+ end
91
+
92
+ # @return [Boolean] true iff +answer+ is a valid 2D array and has the
93
+ # same number of rows as +grid+.
94
+ def valid_answer?
95
+ ans = @yaml[ANSWER]
96
+ is_2d_array? ans and ans.size == grid.size
97
+ end
98
+
99
+ # @return [Boolean] true iff +t+ is a 2-dimensional array.
100
+ def is_2d_array?(t)
101
+ t.is_a? Array and t.all? { |row| row.is_a? Array }
102
+ end
103
+
104
+ end
105
+
106
+ end
107
+
108
+ end
109
+