smartdown 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![Software design](https://raw.githubusercontent.com/alphagov/smartdown/master/doc/design.png)
|
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
|