spirit 0.1.0.pre

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.
@@ -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
+