smartdown 0.0.1
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.
- data/LICENSE.md +21 -0
- data/README.md +204 -0
- data/bin/smartdown +41 -0
- data/lib/smartdown/engine/errors.rb +5 -0
- data/lib/smartdown/engine/predicate_evaluator.rb +23 -0
- data/lib/smartdown/engine/state.rb +63 -0
- data/lib/smartdown/engine/transition.rb +65 -0
- data/lib/smartdown/engine.rb +33 -0
- data/lib/smartdown/model/element/markdown_heading.rb +7 -0
- data/lib/smartdown/model/element/markdown_paragraph.rb +7 -0
- data/lib/smartdown/model/element/multiple_choice.rb +7 -0
- data/lib/smartdown/model/element/start_button.rb +7 -0
- data/lib/smartdown/model/flow.rb +24 -0
- data/lib/smartdown/model/front_matter.rb +37 -0
- data/lib/smartdown/model/nested_rule.rb +5 -0
- data/lib/smartdown/model/next_node_rules.rb +5 -0
- data/lib/smartdown/model/node.rb +47 -0
- data/lib/smartdown/model/predicate/equality.rb +7 -0
- data/lib/smartdown/model/predicate/named.rb +7 -0
- data/lib/smartdown/model/predicate/set_membership.rb +7 -0
- data/lib/smartdown/model/rule.rb +5 -0
- data/lib/smartdown/parser/base.rb +35 -0
- data/lib/smartdown/parser/directory_input.rb +61 -0
- data/lib/smartdown/parser/element/front_matter.rb +17 -0
- data/lib/smartdown/parser/element/markdown_heading.rb +14 -0
- data/lib/smartdown/parser/element/markdown_paragraph.rb +19 -0
- data/lib/smartdown/parser/element/multiple_choice_question.rb +24 -0
- data/lib/smartdown/parser/element/start_button.rb +15 -0
- data/lib/smartdown/parser/flow_interpreter.rb +50 -0
- data/lib/smartdown/parser/node_interpreter.rb +29 -0
- data/lib/smartdown/parser/node_parser.rb +37 -0
- data/lib/smartdown/parser/node_transform.rb +83 -0
- data/lib/smartdown/parser/predicates.rb +36 -0
- data/lib/smartdown/parser/rules.rb +51 -0
- data/lib/smartdown/version.rb +3 -0
- data/lib/smartdown.rb +9 -0
- data/spec/acceptance/parsing_spec.rb +109 -0
- data/spec/acceptance/smartdown_cli_spec.rb +16 -0
- data/spec/engine/predicate_evaluator_spec.rb +98 -0
- data/spec/engine/state_spec.rb +106 -0
- data/spec/engine/transition_spec.rb +150 -0
- data/spec/engine_spec.rb +79 -0
- data/spec/fixtures/acceptance/cover-sheet/cover-sheet.txt +14 -0
- data/spec/fixtures/acceptance/one-question/one-question.txt +3 -0
- data/spec/fixtures/acceptance/one-question/questions/q1.txt +9 -0
- data/spec/fixtures/acceptance/question-and-outcome/outcomes/o1.txt +3 -0
- data/spec/fixtures/acceptance/question-and-outcome/question-and-outcome.txt +3 -0
- data/spec/fixtures/acceptance/question-and-outcome/questions/q1.txt +9 -0
- data/spec/fixtures/directory_input/cover-sheet.txt +1 -0
- data/spec/fixtures/directory_input/outcomes/o1.txt +1 -0
- data/spec/fixtures/directory_input/questions/q1.txt +1 -0
- data/spec/fixtures/directory_input/scenarios/s1.txt +1 -0
- data/spec/fixtures/example.sd +17 -0
- data/spec/model/flow_spec.rb +42 -0
- data/spec/model/node_spec.rb +32 -0
- data/spec/parser/base_spec.rb +49 -0
- data/spec/parser/directory_input_spec.rb +56 -0
- data/spec/parser/element/front_matter_spec.rb +21 -0
- data/spec/parser/element/markdown_heading_spec.rb +24 -0
- data/spec/parser/element/markdown_paragraph_spec.rb +28 -0
- data/spec/parser/element/multiple_choice_question_spec.rb +31 -0
- data/spec/parser/element/start_button_parser_spec.rb +30 -0
- data/spec/parser/integration/cover_sheet_spec.rb +30 -0
- data/spec/parser/node_parser_spec.rb +133 -0
- data/spec/parser/predicates_spec.rb +65 -0
- data/spec/parser/rules_spec.rb +244 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/support/model_builder.rb +83 -0
- data/spec/support_specs/model_builder_spec.rb +90 -0
- metadata +218 -0
data/LICENSE.md
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (C) 2014 HM Government (Government Digital Service)
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
9
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
10
|
+
so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,204 @@
|
|
1
|
+
# Smartdown
|
2
|
+
|
3
|
+
Smartdown is an [external
|
4
|
+
DSL](http://www.martinfowler.com/bliki/DomainSpecificLanguage.html) for
|
5
|
+
representing a series of questions and logical rules which determine the order
|
6
|
+
in which the questions are asked, according to user input.
|
7
|
+
|
8
|
+
The language is designed to look like
|
9
|
+
[Markdown](http://daringfireball.net/projects/markdown/), with some extensions
|
10
|
+
to allow expression of logical rules, questions and conditional blocks of
|
11
|
+
text.
|
12
|
+
|
13
|
+
## Overview
|
14
|
+
|
15
|
+
A single smartdown flow has a cover sheet, a set of questions, a set of
|
16
|
+
outcomes and a set of test scenarios. Cover sheets, questions and outcomes are
|
17
|
+
all types of node. A node represents a single user interaction (normally a web
|
18
|
+
page, but in other media may be presented differently).
|
19
|
+
|
20
|
+
Each question and outcome is held in a separate file. The name of the files
|
21
|
+
are used to identify each question. Here's an example of the check-uk-visa
|
22
|
+
flow:
|
23
|
+
|
24
|
+
```
|
25
|
+
-- check-uk-visa
|
26
|
+
|-- outcomes
|
27
|
+
| |-- general_y.txt
|
28
|
+
| |-- joining_family_m.txt
|
29
|
+
| |-- joining_family_y.txt
|
30
|
+
| |-- marriage.txt
|
31
|
+
| |-- medical_n.txt
|
32
|
+
| |-- medical_y.txt
|
33
|
+
| `-- ...
|
34
|
+
|-- scenarios
|
35
|
+
| |-- 1.test
|
36
|
+
| |-- 2.test
|
37
|
+
| `-- 3.test
|
38
|
+
|-- questions
|
39
|
+
| |-- planning_to_leave_airport.txt
|
40
|
+
| |-- purpose_of_visit.txt
|
41
|
+
| |-- staying_for_how_long.txt
|
42
|
+
| |-- what_passport_do_you_have.txt
|
43
|
+
`-- check-uk-visa.txt
|
44
|
+
```
|
45
|
+
|
46
|
+
## General node file syntax
|
47
|
+
|
48
|
+
Each file has three parts: front-matter, a model definition, rules/logic. Only the model definition is required.
|
49
|
+
|
50
|
+
* **front-matter** defines metadata in the form `property: value`
|
51
|
+
* the **model definition** is a markdown-like block which defines a flow, question or outcome.
|
52
|
+
* **rules/logic** defines 'next node' transition rules or other logic/predicate definitions
|
53
|
+
|
54
|
+
## Cover sheet node
|
55
|
+
|
56
|
+
The cover sheet starts the flow off, its filename should match the flow name, e.g. 'check-uk-visa.txt'.
|
57
|
+
|
58
|
+
It has initial 'front matter' which defines metadata for the flow, including
|
59
|
+
the first question. It then defines the copy for the cover sheet in markdown
|
60
|
+
format. The h1 title is compulsory and used as the title for the smart answer.
|
61
|
+
|
62
|
+
```
|
63
|
+
meta_description: You may need a visa to come to the UK to visit, study or work.
|
64
|
+
satisfies_need: 100982
|
65
|
+
start_with: what_passport_do_you_have
|
66
|
+
|
67
|
+
# Check if you need a UK visa
|
68
|
+
|
69
|
+
You may need a visa to come to the UK to visit, study or work.
|
70
|
+
```
|
71
|
+
|
72
|
+
## Question nodes
|
73
|
+
|
74
|
+
A question model definition has optional front matter, followed by a title and
|
75
|
+
question type.
|
76
|
+
|
77
|
+
The next sections define the various question types
|
78
|
+
|
79
|
+
### Multiple choice
|
80
|
+
|
81
|
+
```markdown
|
82
|
+
# Will you pass through UK Border Control?
|
83
|
+
|
84
|
+
You might pass through UK Border Control even if you don't leave the airport -
|
85
|
+
eg your bags aren't checked through and you need to collect them before transferring
|
86
|
+
to your outbound flight.
|
87
|
+
|
88
|
+
* yes: Yes
|
89
|
+
* no: No
|
90
|
+
```
|
91
|
+
|
92
|
+
### Country
|
93
|
+
|
94
|
+
```markdown
|
95
|
+
# What passport do you have?
|
96
|
+
|
97
|
+
[country]
|
98
|
+
```
|
99
|
+
|
100
|
+
Presents a drop-down list of countries from the built-in list.
|
101
|
+
|
102
|
+
Use front matter to exclude/include countries
|
103
|
+
|
104
|
+
```
|
105
|
+
exclude_countries: country1, country2
|
106
|
+
include_countries: {country3: "Country 3", country4: "Country 4"}
|
107
|
+
|
108
|
+
[country]
|
109
|
+
```
|
110
|
+
|
111
|
+
### Date
|
112
|
+
|
113
|
+
```markdown
|
114
|
+
# What is the baby’s due date?
|
115
|
+
|
116
|
+
[d/m/y: -1...+1]
|
117
|
+
```
|
118
|
+
|
119
|
+
Asks for a specific date in the given range. Ranges can be expressed as a relative number of years or absolute dates in YYYY-MM-DD format.
|
120
|
+
|
121
|
+
### Value
|
122
|
+
|
123
|
+
```markdown
|
124
|
+
[value]
|
125
|
+
```
|
126
|
+
|
127
|
+
Asks for an arbitrary text input.
|
128
|
+
|
129
|
+
### Money
|
130
|
+
|
131
|
+
```markdown
|
132
|
+
[money]
|
133
|
+
```
|
134
|
+
|
135
|
+
Asks for a numerical input which can have decimals and optional thousand-separating commas.
|
136
|
+
|
137
|
+
### Salary
|
138
|
+
|
139
|
+
```markdown
|
140
|
+
[salary]
|
141
|
+
```
|
142
|
+
|
143
|
+
Asks for salary which can be expressed as either a weekly or monthly money amount. The user chooses between weekly/monthly
|
144
|
+
|
145
|
+
### Checkbox
|
146
|
+
|
147
|
+
```markdown
|
148
|
+
# Will you pass through UK Border Control?
|
149
|
+
|
150
|
+
* [ ] yes: Yes
|
151
|
+
* [ ] no: No
|
152
|
+
```
|
153
|
+
|
154
|
+
## Next node rules
|
155
|
+
|
156
|
+
Logical rules for transitioning to the next node are defined in 'Next node' section. This is declared using a markdown h1 'Next node'.
|
157
|
+
|
158
|
+
There are two constructs for defining rules:
|
159
|
+
|
160
|
+
```
|
161
|
+
# Next node
|
162
|
+
|
163
|
+
* predicate => outcome
|
164
|
+
```
|
165
|
+
|
166
|
+
defines a conditional transition
|
167
|
+
|
168
|
+
```
|
169
|
+
# Next node
|
170
|
+
|
171
|
+
* predicate1
|
172
|
+
* predicate2 => outcome1
|
173
|
+
* predicate3 => outcome2
|
174
|
+
```
|
175
|
+
|
176
|
+
defines nested rules.
|
177
|
+
|
178
|
+
## Predicates
|
179
|
+
|
180
|
+
```
|
181
|
+
variable_name is 'string'
|
182
|
+
```
|
183
|
+
|
184
|
+
## Conditional blocks in outcomes
|
185
|
+
|
186
|
+
## Processing model
|
187
|
+
|
188
|
+
Each response to a question is assigned to a variable which corresponds to the question name (as determined by the filename).
|
189
|
+
|
190
|
+
## Named predicates
|
191
|
+
|
192
|
+
Named predicates
|
193
|
+
|
194
|
+
## Plugin API
|
195
|
+
|
196
|
+
A plugin API will be provided to allow more complex calculations to be defined
|
197
|
+
in an external ruby class.
|
198
|
+
|
199
|
+
## Software design
|
200
|
+
|
201
|
+
The software design can be seen in this diagram:
|
202
|
+
|
203
|
+

|
204
|
+
|
data/bin/smartdown
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH << File.expand_path("../lib/", File.dirname(__FILE__))
|
4
|
+
|
5
|
+
require 'smartdown/parser/directory_input'
|
6
|
+
require 'smartdown/parser/flow_interpreter'
|
7
|
+
require 'smartdown/engine'
|
8
|
+
require 'pp'
|
9
|
+
|
10
|
+
def usage
|
11
|
+
$stderr.puts "USAGE: <coversheet path> [responses...]"
|
12
|
+
end
|
13
|
+
|
14
|
+
if ARGV.size == 0
|
15
|
+
usage
|
16
|
+
exit(1)
|
17
|
+
else
|
18
|
+
coversheet_path, *responses = ARGV
|
19
|
+
begin
|
20
|
+
input = Smartdown::Parser::DirectoryInput.new(coversheet_path)
|
21
|
+
flow = Smartdown::Parser::FlowInterpreter.new(input).interpret
|
22
|
+
engine = Smartdown::Engine.new(flow)
|
23
|
+
end_state = engine.process(responses)
|
24
|
+
|
25
|
+
puts "RESPONSES: " + end_state.get(:responses).join(" / ")
|
26
|
+
puts "PATH: " + (end_state.get(:path) + [end_state.get(:current_node)]).join(" -> ")
|
27
|
+
node = flow.node(end_state.get(:current_node))
|
28
|
+
puts "# #{node.title}\n\n"
|
29
|
+
node.questions.each do |q|
|
30
|
+
pp q.choices
|
31
|
+
end
|
32
|
+
|
33
|
+
puts node.body
|
34
|
+
rescue Smartdown::Parser::ParseError => error
|
35
|
+
$stderr.puts error
|
36
|
+
exit(1)
|
37
|
+
rescue Smartdown::Engine::IndeterminateNextNode => error
|
38
|
+
$stderr.puts error.message
|
39
|
+
exit(1)
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Smartdown
|
2
|
+
class Engine
|
3
|
+
class PredicateEvaluator
|
4
|
+
def evaluate(predicate, state)
|
5
|
+
evaluator_for(predicate).call(state)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
def evaluator_for(predicate)
|
10
|
+
case predicate
|
11
|
+
when Smartdown::Model::Predicate::Equality
|
12
|
+
->(state) { state.get(predicate.varname) == predicate.expected_value }
|
13
|
+
when Smartdown::Model::Predicate::SetMembership
|
14
|
+
->(state) { predicate.values.include?(state.get(predicate.varname)) }
|
15
|
+
when Smartdown::Model::Predicate::Named
|
16
|
+
->(state) { state.get(predicate.name) }
|
17
|
+
else
|
18
|
+
raise "Unknown predicate type #{predicate.class}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Smartdown
|
4
|
+
class Engine
|
5
|
+
class UndefinedValue < StandardError; end
|
6
|
+
|
7
|
+
class State
|
8
|
+
def initialize(data = {})
|
9
|
+
@data = duplicate_and_normalize_hash(data)
|
10
|
+
@data["path"] ||= []
|
11
|
+
@data["responses"] ||= []
|
12
|
+
@cached = {}
|
13
|
+
raise ArgumentError, "must specify current_node" unless has_key?("current_node")
|
14
|
+
end
|
15
|
+
|
16
|
+
def has_key?(key)
|
17
|
+
@data.has_key?(key.to_s)
|
18
|
+
end
|
19
|
+
|
20
|
+
def has_value?(key, expected_value)
|
21
|
+
has_key?(key) && fetch(key) == expected_value
|
22
|
+
end
|
23
|
+
|
24
|
+
def get(key)
|
25
|
+
value = fetch(key)
|
26
|
+
if value.respond_to?(:call)
|
27
|
+
evaluate(value)
|
28
|
+
else
|
29
|
+
value
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def put(name, value)
|
34
|
+
State.new(@data.merge(name.to_s => value))
|
35
|
+
end
|
36
|
+
|
37
|
+
def keys
|
38
|
+
Set.new(@data.keys)
|
39
|
+
end
|
40
|
+
|
41
|
+
def ==(other)
|
42
|
+
other.is_a?(self.class) && other.keys == self.keys && @data.all? { |k, v| other.has_value?(k, v) }
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
def duplicate_and_normalize_hash(hash)
|
47
|
+
::Hash[hash.map { |key, value| [key.to_s, value] }]
|
48
|
+
end
|
49
|
+
|
50
|
+
def fetch(key)
|
51
|
+
if has_key?(key)
|
52
|
+
@data.fetch(key.to_s)
|
53
|
+
else
|
54
|
+
raise UndefinedValue
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def evaluate(callable)
|
59
|
+
@cached[callable] ||= callable.call(self)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'smartdown/engine/predicate_evaluator'
|
2
|
+
require 'smartdown/engine/errors'
|
3
|
+
|
4
|
+
module Smartdown
|
5
|
+
class Engine
|
6
|
+
class Transition
|
7
|
+
attr_reader :state, :node, :input
|
8
|
+
|
9
|
+
def initialize(state, node, input, options = {})
|
10
|
+
@state = state
|
11
|
+
@node = node
|
12
|
+
@input = input
|
13
|
+
@predicate_evaluator = options[:predicate_evaluator] || PredicateEvaluator.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_node
|
17
|
+
first_matching_rule(next_node_rules.rules).outcome
|
18
|
+
end
|
19
|
+
|
20
|
+
def next_state
|
21
|
+
state_with_input
|
22
|
+
.put(:path, state.get(:path) + [node.name])
|
23
|
+
.put(:responses, state.get(:responses) + [input])
|
24
|
+
.put(node.name, input)
|
25
|
+
.put(:current_node, next_node)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
attr_reader :predicate_evaluator
|
30
|
+
|
31
|
+
def next_node_rules
|
32
|
+
node.elements.find { |e| e.is_a?(Smartdown::Model::NextNodeRules) } or \
|
33
|
+
raise Smartdown::Engine::IndeterminateNextNode, "No next node rules defined for '#{node.name}'"
|
34
|
+
end
|
35
|
+
|
36
|
+
def first_matching_rule(rules)
|
37
|
+
catch(:match) do
|
38
|
+
throw_first_matching_rule_in(rules)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def throw_first_matching_rule_in(rules)
|
43
|
+
rules.each do |rule|
|
44
|
+
case rule
|
45
|
+
when Smartdown::Model::Rule
|
46
|
+
if predicate_evaluator.evaluate(rule.predicate, state_with_input)
|
47
|
+
throw(:match, rule)
|
48
|
+
end
|
49
|
+
when Smartdown::Model::NestedRule
|
50
|
+
if predicate_evaluator.evaluate(rule.predicate, state_with_input)
|
51
|
+
throw_first_matching_rule_in(rule.children)
|
52
|
+
end
|
53
|
+
else
|
54
|
+
raise "Unknown rule type"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
raise Smartdown::Engine::IndeterminateNextNode
|
58
|
+
end
|
59
|
+
|
60
|
+
def state_with_input
|
61
|
+
state.put(node.name, input)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'smartdown/engine/transition'
|
2
|
+
require 'smartdown/engine/state'
|
3
|
+
|
4
|
+
module Smartdown
|
5
|
+
class Engine
|
6
|
+
attr_reader :flow
|
7
|
+
|
8
|
+
def initialize(flow)
|
9
|
+
@flow = flow
|
10
|
+
end
|
11
|
+
|
12
|
+
def default_start_state
|
13
|
+
Smartdown::Engine::State.new(
|
14
|
+
default_predicates.merge(
|
15
|
+
current_node: flow.name
|
16
|
+
)
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def default_predicates
|
21
|
+
{
|
22
|
+
otherwise: ->(_) { true }
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def process(responses, start_state = nil)
|
27
|
+
responses.inject(start_state || default_start_state) do |state, input|
|
28
|
+
current_node = flow.node(state.get(:current_node))
|
29
|
+
Transition.new(state, current_node, input).next_state
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Smartdown
|
2
|
+
module Model
|
3
|
+
class Flow
|
4
|
+
attr_reader :name, :nodes
|
5
|
+
|
6
|
+
def initialize(name, nodes = [])
|
7
|
+
@name = name
|
8
|
+
@nodes = nodes
|
9
|
+
end
|
10
|
+
|
11
|
+
def coversheet
|
12
|
+
node(name)
|
13
|
+
end
|
14
|
+
|
15
|
+
def node(node_name)
|
16
|
+
@nodes.find {|n| n.name.to_s == node_name.to_s } || raise("Unable to find #{node_name}")
|
17
|
+
end
|
18
|
+
|
19
|
+
def ==(other)
|
20
|
+
other.is_a?(self.class) && other.nodes == self.nodes && other.name == self.name
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Smartdown
|
2
|
+
module Model
|
3
|
+
class FrontMatter
|
4
|
+
def initialize(attributes = {})
|
5
|
+
@attributes = attributes
|
6
|
+
end
|
7
|
+
|
8
|
+
def method_missing(method_name, *args, &block)
|
9
|
+
if has_attribute?(method_name)
|
10
|
+
fetch(method_name)
|
11
|
+
else
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def respond_to_missing?(method_name, include_private = false)
|
17
|
+
has_attribute?(method_name)
|
18
|
+
end
|
19
|
+
|
20
|
+
def has_attribute?(name)
|
21
|
+
@attributes.has_key?(name.to_s)
|
22
|
+
end
|
23
|
+
|
24
|
+
def fetch(name)
|
25
|
+
@attributes.fetch(name.to_s)
|
26
|
+
end
|
27
|
+
|
28
|
+
def to_hash
|
29
|
+
@attributes.dup
|
30
|
+
end
|
31
|
+
|
32
|
+
def ==(other)
|
33
|
+
other.is_a?(self.class) && self.to_hash == other.to_hash
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'smartdown/model/front_matter'
|
2
|
+
|
3
|
+
module Smartdown
|
4
|
+
module Model
|
5
|
+
Node = Struct.new(:name, :elements, :front_matter) do
|
6
|
+
def initialize(name, elements, front_matter = nil)
|
7
|
+
super(name, elements, front_matter || Smartdown::Model::FrontMatter.new)
|
8
|
+
end
|
9
|
+
|
10
|
+
def questions
|
11
|
+
elements_of_kind(Smartdown::Model::Element::MultipleChoice)
|
12
|
+
end
|
13
|
+
|
14
|
+
def title
|
15
|
+
h1s.first ? h1s.first.content : ""
|
16
|
+
end
|
17
|
+
|
18
|
+
def markdown_blocks
|
19
|
+
elements_of_kind(Smartdown::Model::Element::MarkdownHeading, Smartdown::Model::Element::MarkdownParagraph)
|
20
|
+
end
|
21
|
+
|
22
|
+
def h1s
|
23
|
+
elements_of_kind(Smartdown::Model::Element::MarkdownHeading)
|
24
|
+
end
|
25
|
+
|
26
|
+
def body
|
27
|
+
markdown_blocks[1..-1].map { |block| as_markdown(block) }.compact.join("\n")
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
def elements_of_kind(*kinds)
|
32
|
+
elements.select {|e| kinds.any? {|k| e.is_a?(k)} }
|
33
|
+
end
|
34
|
+
|
35
|
+
def as_markdown(block)
|
36
|
+
case block
|
37
|
+
when Smartdown::Model::Element::MarkdownHeading
|
38
|
+
"# #{block.content}\n"
|
39
|
+
when Smartdown::Model::Element::MarkdownParagraph
|
40
|
+
block.content
|
41
|
+
else
|
42
|
+
raise "Unknown markdown block type '#{block.class.to_s}'"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'parslet'
|
2
|
+
|
3
|
+
module Smartdown
|
4
|
+
module Parser
|
5
|
+
class Base < Parslet::Parser
|
6
|
+
rule(:eof) { any.absent? }
|
7
|
+
rule(:ws_char) { match('\s') }
|
8
|
+
rule(:space_char) { str(" ") }
|
9
|
+
rule(:non_ws_char) { match('\S') }
|
10
|
+
rule(:newline) { str("\r\n") | str("\n\r") | str("\n") | str("\r") }
|
11
|
+
rule(:line_ending) { eof | newline }
|
12
|
+
|
13
|
+
rule(:optional_space) { space_char.repeat }
|
14
|
+
rule(:some_space) { space_char.repeat(1) }
|
15
|
+
rule(:ws) { ws_char.repeat }
|
16
|
+
rule(:non_ws) { non_ws.repeat }
|
17
|
+
|
18
|
+
rule(:whitespace_terminated_string) {
|
19
|
+
non_ws_char >> (non_ws_char | space_char.repeat(1) >> non_ws_char).repeat
|
20
|
+
}
|
21
|
+
|
22
|
+
rule(:identifier) {
|
23
|
+
match('[a-zA-Z_0-9-]').repeat(1)
|
24
|
+
}
|
25
|
+
|
26
|
+
rule(:question_identifier) {
|
27
|
+
identifier >> str('?').maybe
|
28
|
+
}
|
29
|
+
|
30
|
+
rule(:bullet) {
|
31
|
+
match('[*-]')
|
32
|
+
}
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|