propolize 0.1.0

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.
Files changed (4) hide show
  1. data/LICENSE.txt +674 -0
  2. data/Rakefile +32 -0
  3. data/lib/propolize.rb +963 -0
  4. metadata +49 -0
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require "bundler"
3
+ Bundler.setup
4
+ rescue LoadError
5
+ $stderr.puts "You need to have Bundler installed to be able build this gem."
6
+ end
7
+
8
+ gemspec = eval(File.read("propolize.gemspec"))
9
+
10
+ desc "Validate the gemspec"
11
+ task :gemspec do
12
+ gemspec.validate
13
+ end
14
+
15
+ desc "Build gem locally"
16
+ task :build => :gemspec do
17
+ system "gem build #{gemspec.name}.gemspec"
18
+ FileUtils.mkdir_p "pkg"
19
+ FileUtils.mv "#{gemspec.name}-#{gemspec.version}.gem", "pkg"
20
+ end
21
+
22
+ desc "Install gem locally"
23
+ task :install => :build do
24
+ system "gem install pkg/#{gemspec.name}-#{gemspec.version}"
25
+ end
26
+
27
+ desc "Clean automatically generated files"
28
+ task :clean do
29
+ FileUtils.rm_rf "pkg"
30
+ end
31
+
32
+ task :default => :build
data/lib/propolize.rb ADDED
@@ -0,0 +1,963 @@
1
+ require 'erb'
2
+
3
+ module Propolize
4
+
5
+ # Propolize defines a very specific and constrained document structure which supports "propositional writing"
6
+ # It is similiar to Markdown, but with a limited set of Markdown features, and with some extensions
7
+ # particular to this document structure.
8
+ #
9
+ # Propolize source code consists of the following types of 'chunk', where each chunk is one or more lines:
10
+ #
11
+ # 1. A special property definition or command (starting with '##' at the beginning of the line)
12
+ # 2. A list (which will map to an HTML list) which starts with '* ' at the beginning of the line for each list item.
13
+ # (The end of the list is marked by a blank line.)
14
+ # 3. A 'proposition' (a special type of heading), which starts with '# ' at the beginning of the line
15
+ # 4. A secondary heading - a line with a following line containing only '---------' characters
16
+ # 5. A paragraph - starting with a line which is not any of the above
17
+ #
18
+ # (Note: secondary headings are only allowed in the appendix, because if they were in either the introduction
19
+ # or the propositions, this would distract from the main idea that propositions in a propositional document
20
+ # _are_ the headings. The appendix can be thought of as additional explanatory material that does not fit into
21
+ # the main propositional format.)
22
+ #
23
+ # Special property definitions and commands are terminated by a following blank line or end of file _or_ by the start
24
+ # of another special property definition.
25
+ # All other chunks are terminated by a following blank line or the end of the file.
26
+ #
27
+ # A propositional document also has a higher level structure in that it consists of an introduction followed
28
+ # by a sequence of propositions-with-explanations, followed by an optional appendix.
29
+ #
30
+ # The introduction consists only of lists or paragraphs (i.e. no secondary headings)
31
+ #
32
+ # Each proposition can be followed by zero or more lists of paragraphs (there are no secondary headings)
33
+ #
34
+ # The appendix consists of a sequence of secondary headings, lists or paragraphs.
35
+ # The appendix is started by a special '##appendix' command
36
+ #
37
+ # Special property tags can occur anywhere. They are of the form
38
+ # ##date 23 May, 2014
39
+ #
40
+ # (which defines the 'date' property to be '23 May, 2014')
41
+ #
42
+ # Required properties are 'date', 'author' and 'title'.
43
+ # (Note: properties can be passed in via the 'properties' argument of the 'propolize' method,
44
+ # in which case they do not need to appear in the source code.)
45
+ #
46
+ # Detailed markup occurs within 'text' sections, these occur in the following contexts:
47
+ # * Propositions
48
+ # * Paragraphs
49
+ # * List items
50
+ # * Secondary headings
51
+ # * Text inside link definitions (see below)
52
+ #
53
+ # The detailed markup includes the following:
54
+ #
55
+ # * '*' to delineate italic text
56
+ # * '**' to delineate bold text
57
+ # * '[]' for anchor targets for the form '[name:]' for normal anchors, and '[name::]' for numbered footnote anchors
58
+ # * '[]()' for links as in '[http://example.com/](An example website)'. Three types of URL definition exist -
59
+ # * [name:] for normal anchors
60
+ # * [name::] for numbered footnote anchors
61
+ # * [url] for all other URL's
62
+ #
63
+ # There is also a post-processing step where '--' is converted into '–'
64
+ #
65
+ # Two other special items parsed are:
66
+ # * '\' followed by a character will output the HTML-escaped version of that character
67
+ # * HTML entities (e.g. '–') are output as is
68
+ #
69
+ # All other text is output as HTML-escaped text.
70
+ #
71
+ # There are also special qualifier prefixes:
72
+ #
73
+ # 1. The special tag qualifier may occur at the beginning of a paragraph, in the form '#:<tag> ',
74
+ # where <tag> is a special tag (currently the only option is "bq" for "blockquote").
75
+ #
76
+ # 2. The 'critique' qualifier '?? ' can occur at the beginning of a paragraph or a list, and it qualifies
77
+ # the list or paragraph as being part of the 'critique' where a propositional document
78
+ # is being written as a critique of some other propositional document.
79
+ #
80
+
81
+ module Helpers
82
+ def html_escape(s)
83
+ s.to_s.gsub(/&/, "&amp;").gsub(/>/, "&gt;").gsub(/</, "&lt;")
84
+ end
85
+
86
+ alias h html_escape
87
+ end
88
+
89
+ # A very simple string buffer that maintains data as an array of strings
90
+ # and joins them all together when the final result is required.
91
+ class StringBuffer
92
+ def initialize
93
+ @strings = []
94
+ end
95
+
96
+ def write(string)
97
+ @strings.push(string)
98
+ end
99
+
100
+ def to_string
101
+ return @strings.join("")
102
+ end
103
+ end
104
+
105
+ # A proposition with an explanation. The proposition is effectively the headline,
106
+ # and the explanation is a sequence of "explanation items" (i.e. paragraphs or lists).
107
+ class PropositionWithExplanation
108
+ # Create with initial proposition (and empty list of explanation items)
109
+ def initialize(proposition)
110
+ @proposition = proposition
111
+ @explanationItems = []
112
+ end
113
+
114
+ # Add one explanation item to the list of explanation items
115
+ def addExplanationItem(item)
116
+ @explanationItems.push(item)
117
+ end
118
+
119
+ # Dump to stdout (for debugging/tracing)
120
+ def dump(indent)
121
+ puts ("#{indent}#{@proposition}")
122
+ for item in @explanationItems do
123
+ puts ("#{indent} #{item}")
124
+ end
125
+ end
126
+
127
+ # Output as HTML (Each proposition is one item in a list of propositions, so it is output as a <li> item.)
128
+ def toHtml
129
+ return "<li>\n#{@proposition.toHtml}\n#{@explanationItems.map(&:toHtml).join("\n")}\n</li>"
130
+ end
131
+
132
+ end
133
+
134
+ # A section of source text being processed as part of a document
135
+ class TextBeingProcessed
136
+
137
+ include Helpers
138
+
139
+ # Parsers in order of priority - each one is a pair consisting of:
140
+ # 1. regex to greedily match as much as possible of the text being parsed, and
141
+ # 2. the name of the processing method to call, passing in the match values
142
+
143
+ # Plain text, any text not containing '\', '*', '[' or '&'
144
+ @@plainTextParser = [/\A[^\\\*\[&]+/m, :processPlainText]
145
+
146
+ # Backslash item, '\' followed by the quoted character
147
+ @@backslashParser = [/\A\\(.)/m, :processBackslash]
148
+
149
+ # An HTML entity, starts with '&', then an alphanumerical identifier, or, '#' + a number, followed by ';'
150
+ @@entityParser = [/\A&(([A-Za-z0-9]+)|(#[0-9]+));/m, :processEntity]
151
+
152
+ # A pair of asterisks
153
+ @@doubleAsterixParser = [/\A\*\*/m, :processDoubleAsterix]
154
+
155
+ # A single asterisk
156
+ @@singleAsterixParser = [/\A\*/m, :processSingleAsterix]
157
+
158
+ # text enclosed by '[' and ']', with an optional following section enclosed by '(' and ')'
159
+ @@linkOrAnchorParser = [/\A\[([^\]]*)\](\(([^\)]+)\)|)/m, :processLinkOrAnchor]
160
+
161
+ # Parsers to be applied inside link text (everything _except_ the link/anchor parser)
162
+ @@linkTextParsers = [@@plainTextParser, @@backslashParser, @@entityParser,
163
+ @@doubleAsterixParser, @@singleAsterixParser]
164
+
165
+ # Parsers to be applied outside link text
166
+ @@fullTextParsers = @@linkTextParsers + [@@linkOrAnchorParser]
167
+
168
+ # Initialise -
169
+ # document - source document
170
+ # text - the actual text string
171
+ # writer - to which the output is written
172
+ # weAreInsideALink - are we inside a link? (if so, don't attempt to parse any inner links)
173
+ def initialize(document, text, writer, weAreInsideALink)
174
+ @document = document
175
+ @text = text
176
+ @writer = writer
177
+ @pos = 0
178
+ @italic = false
179
+ @bold = false
180
+ # if we are inside a link (i.e. to be output as <a> tag), _don't_ attempt to parse any links within that link
181
+ @parsers = if weAreInsideALink then @@linkTextParsers else @@fullTextParsers end
182
+ end
183
+
184
+ # Process plain text by writing out HTML-escaped text
185
+ def processPlainText(match)
186
+ @writer.write(html_escape(match[0]))
187
+ end
188
+
189
+ # Process a backslash-quoted character by writing it out as HTML-escaped text
190
+ def processBackslash(match)
191
+ @writer.write(html_escape(match[1]))
192
+ end
193
+
194
+ # Process an HTML entity by writing it out as is
195
+ def processEntity(match)
196
+ @writer.write(match[0])
197
+ end
198
+
199
+ # Process a double asterix by either starting or finishing an HTML bold section.
200
+ def processDoubleAsterix(match)
201
+ if @bold then
202
+ @writer.write("</b>")
203
+ @bold = false
204
+ else
205
+ @writer.write("<b>")
206
+ @bold = true
207
+ end
208
+ end
209
+
210
+ # Process a single asterix by either starting or finishing an HTML italic section.
211
+ def processSingleAsterix(match)
212
+ if @italic then
213
+ @writer.write("</i>")
214
+ @italic = false
215
+ else
216
+ @writer.write("<i>")
217
+ @italic = true
218
+ end
219
+ end
220
+
221
+ # Process a link definition which consists of a URL definition followed by a text definition
222
+ # Special cases of a URL definition are
223
+ # 1. Footnote, represented by a unique footnote identifier followed by '::'
224
+ # 2. Anchor link, represented by the anchor name followed by ':'
225
+ # The text definition is recursively parsed, except that link and anchor definitions
226
+ # cannot occur inside the text definition (or rather, they are just ignored).
227
+ def processLink (text, url)
228
+ anchorMatch = /^([^\/:]*):$/.match(url)
229
+ footnoteMatch = /^([^\/:]*)::$/.match(url)
230
+ linkTextHtml = @document.processText(text, :weAreInsideALink => true)
231
+ if footnoteMatch then
232
+ footnoteName = footnoteMatch[1]
233
+ # The footnote has a name (i.e. unique identifier) in the source code, but the footnotes
234
+ # are assigned sequential numbers in the output text.
235
+ footnoteNumber = @document.getNewFootnoteNumberFor(footnoteName)
236
+ @writer.write("<a href=\"##{footnoteName}\" class=\"footnote\">#{footnoteNumber}</a>")
237
+ elsif anchorMatch then
238
+ @writer.write("<a href=\"##{anchorMatch[1]}\">#{linkTextHtml}</a>")
239
+ else
240
+ @writer.write("<a href=\"#{url}\">#{linkTextHtml}</a>")
241
+ end
242
+ end
243
+
244
+ # Process an anchor definition which consists of either:
245
+ # 1. An normal anchor definition consisting of the anchor name followed by ':', or,
246
+ # 2. A footnote, consisting of the footnote identifier followed by '::' (the footnote identifier is also
247
+ # the anchor name) This is output as the actual footnote number (assigned previously when a link to the
248
+ # footnote was given), and the HTML anchor.
249
+ def processAnchor(url)
250
+ anchorMatch = /^([^\/:]*):$/.match(url)
251
+ if anchorMatch
252
+ @writer.write("<a name=\"#{anchorMatch[1]}\"></a>")
253
+ else
254
+ footnoteMatch = /^([^\/:]*)::$/.match(url)
255
+ if footnoteMatch
256
+ footnoteName = footnoteMatch[1]
257
+ footnoteNumberString = @document.footnoteNumberFor(footnoteName)
258
+ @writer.write("<span class=\"footnoteNumber\">#{footnoteNumberString}</span>" +
259
+ "<a name=\"#{footnoteName}\"></a>")
260
+ else
261
+ raise DocumentError, "Invalid URL for anchor: #{url.inspect}"
262
+ end
263
+ end
264
+ end
265
+
266
+ # Process a link consisting of [] and optional () section. If the () section is not given,
267
+ # then it is an HTML anchor definition (<a name>), otherwise it represents an HTML link (<a href>).
268
+ def processLinkOrAnchor(match)
269
+ if match[3] then
270
+ processLink(match[1], match[3])
271
+ else
272
+ processAnchor(match[1])
273
+ end
274
+ end
275
+
276
+ # If the '*' or '**' values are not balanced, complain.
277
+ def checkValidAtEnd
278
+ if @bold then
279
+ raise DocumentError, "unclosed bold span"
280
+ end
281
+ if @italic then
282
+ raise DocumentError, "unclosed italic span"
283
+ end
284
+ end
285
+
286
+ # Having parsed some of the text, how much is left to be parsed?
287
+ def textNotYetParsed
288
+ return @text[@pos..-1]
289
+ end
290
+
291
+ # Parse the source text by repeatedly parsing however much is matched by the first parsing
292
+ # rule in the list of parsing rules that matches anything.
293
+ # Each time, the parsers are applied in order of priority,
294
+ # until a first match is found. This match uses up whatever amount of source
295
+ # text it matched.
296
+ # This is repeated until the source code is all used up.
297
+ def parse
298
+ #puts "\nPARSING text #{@text.inspect} ..."
299
+ while @pos < @text.length do
300
+ #puts " parsing remaining text #{textNotYetParsed.inspect} ..."
301
+ match = nil
302
+ i = 0
303
+ textToParse = textNotYetParsed
304
+ # Try the specified parsers in order of priority, stopping at the first match
305
+ while i < @parsers.length and not match
306
+ parser = @parsers[i]
307
+ #puts " trying #{parser[1]} ..."
308
+ match = parser[0].match(textToParse)
309
+ i += 1
310
+ end
311
+ if match then
312
+ send(parser[1], match)
313
+ fullMatchOffsets = match.offset(0)
314
+ #puts " matched at #{fullMatchOffsets.inspect}, i.e. #{textToParse[fullMatchOffsets[0]...fullMatchOffsets[1]].inspect}"
315
+ @pos += fullMatchOffsets[1]
316
+ else
317
+ raise Exception, "No match on #{textNotYetParsed.inspect}"
318
+ end
319
+ end
320
+ end
321
+
322
+ end
323
+
324
+ # An error object representing an error in the source code
325
+ class DocumentError < Exception
326
+ end
327
+
328
+ # An object representing the propositional document which will be created by
329
+ # passing in source code, and then invoking the parsers to process the source code
330
+ # and output the HTML.
331
+ # The propositional document has three sections:
332
+ # 1. The introduction ("intro")
333
+ # 2. The list of propositions
334
+ # 3. An (optional) appendix
335
+ # The document also keeps track of named and numbered footnotes.
336
+ # The document has three additional required properties, which are "author", "title" and "date".
337
+
338
+ class PropositionalDocument
339
+ attr_reader :cursor, :fileName
340
+
341
+ include Helpers
342
+
343
+ def initialize(properties = {})
344
+ @properties = properties
345
+ @cursor = :intro #where the document is being written to currently
346
+ @intro = []
347
+ @propositions = []
348
+ @appendix = []
349
+ @footnoteCount = 0
350
+ @footnoteCountByName = {}
351
+ end
352
+
353
+ def getNewFootnoteNumberFor(footnoteName)
354
+ @footnoteCount += 1
355
+ footnoteCountString = @footnoteCount.to_s.to_s
356
+ @footnoteCountByName[footnoteName] = footnoteCountString
357
+ return footnoteCountString
358
+ end
359
+
360
+ def footnoteNumberFor(footnoteName)
361
+ return @footnoteCountByName[footnoteName] || "?"
362
+ end
363
+
364
+ def checkForProperty(name)
365
+ if not @properties.has_key?(name)
366
+ raise DocumentError, "No property #{name} given for document"
367
+ end
368
+ end
369
+
370
+ # Check that all the required properties were defined, and that at least one proposition occurred
371
+ def checkIsValid
372
+ checkForProperty("title")
373
+ checkForProperty("author")
374
+ checkForProperty("date")
375
+ if @cursor == :intro
376
+ raise DocumentError, "There are no propositions in the document"
377
+ end
378
+ end
379
+
380
+ # Dump to stdout (for debugging/tracing)
381
+ def dump
382
+ puts "======================================================="
383
+ puts "Title: #{title.inspect}"
384
+ puts "Author: #{author.inspect}"
385
+ puts "Date: #{date.inspect}"
386
+ puts ""
387
+ if @intro.length > 0 then
388
+ puts "Introduction:"
389
+ for item in @intro do
390
+ puts " #{item}"
391
+ end
392
+ end
393
+ puts "Propositions:"
394
+ for item in @propositions do
395
+ item.dump(" ")
396
+ end
397
+ if @appendix.length > 0 then
398
+ puts "Appendix:"
399
+ for item in @appendix do
400
+ puts " #{item}"
401
+ end
402
+ end
403
+ puts "======================================================="
404
+ end
405
+
406
+ # Call this method in HTML template to write the title (and main heading)
407
+ def title
408
+ return @properties["title"]
409
+ end
410
+
411
+ # Call this method in HTML template to write the author's name
412
+ def author
413
+ return @properties["author"]
414
+ end
415
+
416
+ # Call this method in HTML template to show the date
417
+ def date
418
+ return @properties["date"]
419
+ end
420
+
421
+ # Call this method in HTML template to render the introduction
422
+ def introHtml
423
+ return "<div class=\"intro\">\n#{@intro.map(&:toHtml).join("\n")}\n</div>"
424
+ end
425
+
426
+ # Call this method in HTML template to render the list of propositions
427
+ def propositionsHtml
428
+ return "<ul class=\"propositions\">\n#{@propositions.map(&:toHtml).join("\n")}\n</ul>"
429
+ end
430
+
431
+ # Call this method in HTML template to render the appendix
432
+ def appendixHtml
433
+ if @appendix.length == 0 then
434
+ return ""
435
+ else
436
+ return "<div class=\"appendix\">\n#{@appendix.map(&:toHtml).join("\n")}\n</div>"
437
+ end
438
+ end
439
+
440
+ # Call this method in HTML template to render the 'original link' (for an critique)
441
+ def originalLinkHtml
442
+ if @properties.has_key? "original-link" then
443
+ originalLinkText = @properties["original-link"]
444
+ html = "<div class=\"original-link\">#{processText(originalLinkText)}</div>"
445
+ puts " html = #{html.inspect}"
446
+ return html
447
+ else
448
+ return ""
449
+ end
450
+ end
451
+
452
+ # Generate the output HTML using an ERB template file, where the template references
453
+ # the methods title, author, date, propositionsHtml, appendixHtml, originalLinkHtml and fileName
454
+ # (applying them to 'self').
455
+ def generateHtml(templateFileName, fileName)
456
+ @fileName = fileName
457
+ puts " using template file #{templateFileName} ..."
458
+ templateText = File.read(templateFileName, encoding: 'UTF-8')
459
+ template = ERB.new(templateText)
460
+ @binding = binding
461
+ html = template.result(@binding)
462
+ return html
463
+ end
464
+
465
+ # Set a property value on the document
466
+ def setProperty(name, value)
467
+ @properties[name] = value
468
+ end
469
+
470
+ # Add a new proposition (only if we are currently in either the introduction or the propositions)
471
+ def addProposition(proposition)
472
+ case @cursor
473
+ when :intro
474
+ @cursor = :propositions
475
+ when :appendix
476
+ raise DocumentError, "Cannot add proposition, already in appendix"
477
+ end
478
+ proposition = PropositionWithExplanation.new(proposition)
479
+ @propositions.push(proposition)
480
+ end
481
+
482
+ # Start the appendix (but only if we are currently in the propositions)
483
+ def startAppendix
484
+ case @cursor
485
+ when :intro
486
+ raise DocumentError, "Cannot start appendix before any propositions occur"
487
+ when :propositions
488
+ @cursor = :appendix
489
+ when :appendix
490
+ raise DocumentError, "Cannot start appendix, already in appendix"
491
+ end
492
+ end
493
+
494
+ # Add a textual item, depending on where we are, to the introduction, or the current proposition,
495
+ # or to the appendix
496
+ def addText(text)
497
+ case @cursor
498
+ when :intro
499
+ @intro.push(text)
500
+ when :propositions
501
+ @propositions[-1].addExplanationItem(text)
502
+ when :appendix
503
+ @appendix.push(text)
504
+ end
505
+ end
506
+
507
+ # Add a heading (but only to the appendix - note that propositional headings are dealt with separately,
508
+ # because the heading of a proposition defines a new proposition)
509
+ def addHeading(heading)
510
+ case @cursor
511
+ when :intro
512
+ raise DocumentError, "Headings are not allowed in the introduction"
513
+ when :propositions
514
+ raise DocumentError, "Headings are not allowed in propositions"
515
+ when :appendix
516
+ @appendix.push(heading)
517
+ end
518
+ end
519
+
520
+ # Special text replacements done after all other processing,
521
+ # currently just "--" => &ndash;
522
+ def doExtraTextReplacements(text)
523
+ #puts "doExtraTextReplacements on #{text.inspect} ..."
524
+ text.gsub!(/--/m, "&ndash;")
525
+ end
526
+
527
+ # Process text. "text" can occur in five different contexts:
528
+ # 1. Inside a link (where :weAreInsideALink gets set to true)
529
+ # 2. A proposition
530
+ # 3. A list item
531
+ # 4. A paragraph
532
+ # 5. A secondary heading (i.e. other than a proposition - currently these can only appear in the appendix)
533
+ def processText(text, options = {})
534
+ weAreInsideALink = options[:weAreInsideALink]
535
+ stringBuffer = StringBuffer.new()
536
+ TextBeingProcessed.new(self, text, stringBuffer, weAreInsideALink).parse()
537
+ processedText = stringBuffer.to_string()
538
+ doExtraTextReplacements(processedText)
539
+ return processedText
540
+ end
541
+
542
+ end
543
+
544
+ # Base class for top-level document components
545
+ class DocumentComponent
546
+ end
547
+
548
+ # A document property value definition, such as date = '23 May, 2014'
549
+ class DocumentProperty < DocumentComponent
550
+ def initialize(name, value)
551
+ @name = name
552
+ @value = value
553
+ end
554
+
555
+ def to_s
556
+ return "DocumentProperty #{@name} = #{@value.inspect}"
557
+ end
558
+
559
+ # 'write' to the document by setting the specified property value
560
+ def writeToDocument(document)
561
+ document.setProperty(@name, @value)
562
+ end
563
+ end
564
+
565
+ # Instruction to start the appendix
566
+ class StartAppendix < DocumentComponent
567
+ def to_s
568
+ return "StartAppendix"
569
+ end
570
+
571
+ # 'write' to the document by updating document state to being in the appendix
572
+ def writeToDocument(document)
573
+ document.startAppendix
574
+ end
575
+ end
576
+
577
+ # A proposition (just the heading, without the explanation)
578
+ class Proposition < DocumentComponent
579
+ def initialize(text)
580
+ @text = text
581
+ end
582
+
583
+ def to_s
584
+ return "Proposition: #{@text.inspect}"
585
+ end
586
+
587
+ # write to document, by adding a proposition to the document
588
+ # (this will set state to :proposition if we were in the :intro, close any previous proposition,
589
+ # and start a new one)
590
+ def writeToDocument(document)
591
+ document.addProposition(self)
592
+ @document = document
593
+ end
594
+
595
+ def toHtml
596
+ return "<div class=\"proposition\">#{@document.processText(@text)}</div>"
597
+ end
598
+ end
599
+
600
+ # Base text is either a list of list items, or, a paragraph.
601
+ class BaseText < DocumentComponent
602
+ attr_reader :document
603
+
604
+ # write to document, by adding to the document as a text item. Depending on the
605
+ # current document state, this will be added to the introduction, or to the explanation of the
606
+ # current proposition, or to the appendix
607
+ def writeToDocument(document)
608
+ document.addText(self)
609
+ @document = document
610
+ end
611
+
612
+ def critiqueClassHtml
613
+ if @isCritique then
614
+ return " class=\"critique\""
615
+ else
616
+ return ""
617
+ end
618
+ end
619
+ end
620
+
621
+ # A list item (note: this is not a top-level component, rather it is part of an ItemList component)
622
+ class ListItem
623
+ attr_accessor :list
624
+
625
+ def initialize(text)
626
+ @text = text
627
+ @list = nil
628
+ end
629
+
630
+ def to_s
631
+ return "ListItem: #{@text.inspect}"
632
+ end
633
+
634
+ def document
635
+ return list.document
636
+ end
637
+
638
+ def toHtml
639
+ return "<li>#{document.processText(@text)}</li>"
640
+ end
641
+ end
642
+
643
+ # A list of list items
644
+ class ItemList < BaseText
645
+ def initialize(options = {})
646
+ @isCritique = options[:isCritique] || false
647
+ @items = []
648
+ end
649
+
650
+ def addItem(listItem)
651
+ @items.push(listItem)
652
+ listItem.list = self
653
+ end
654
+
655
+ def to_s
656
+ return "ItemList: #{@items.inspect}"
657
+ end
658
+
659
+ def toHtml
660
+ return "<ul#{critiqueClassHtml}>\n#{@items.map(&:toHtml).join("\n")}\n</ul>"
661
+ end
662
+ end
663
+
664
+ # A paragraph.
665
+ class Paragraph < BaseText
666
+ @@tagsMap = {"bq" => {:tag => "blockquote"}}
667
+
668
+ def initialize(text, options = {})
669
+ @text = text
670
+ @isCritique = options[:isCritique] || false
671
+ @tag = options[:tag]
672
+ initializeStartEndTags
673
+ end
674
+
675
+ def initializeStartEndTags
676
+ @tagName = "p"
677
+ className = nil
678
+ if @tag then
679
+ tagDescriptor = @@tagsMap[@tag]
680
+ if tagDescriptor == nil then
681
+ raise DocumentError, "Unknown tag: #{@tag.inspect}"
682
+ end
683
+ @tagName = tagDescriptor[:tag]
684
+ @className = tagDescriptor[:class]
685
+ end
686
+ classNames = []
687
+ if @isCritique then
688
+ classNames.push("critique")
689
+ end
690
+ if @classname then
691
+ classNames.push(@className)
692
+ end
693
+ @startTag = "<#{@tagName}#{classesHtml(classNames)}>"
694
+ @endTag = "</#{@tagName}>"
695
+ end
696
+
697
+ def classesHtml(classNames)
698
+ if classNames.length == 0 then
699
+ return ""
700
+ else
701
+ return " class=\"#{classNames.join(" ")}\""
702
+ end
703
+ end
704
+
705
+ def to_s
706
+ return "Paragraph: #{@text.inspect}"
707
+ end
708
+
709
+ def toHtml
710
+ return "#{@startTag}#{@document.processText(@text)}#{@endTag}"
711
+ end
712
+ end
713
+
714
+ # A secondary heading (which can appear in the appendix)
715
+ class Heading < DocumentComponent
716
+ def initialize(text)
717
+ @text = text
718
+ end
719
+
720
+ def to_s
721
+ return "Heading: #{@text}"
722
+ end
723
+
724
+ # write to document by adding a heading (which is only valid if we are in the appendix)
725
+ def writeToDocument(document)
726
+ document.addHeading(self)
727
+ @document = document
728
+ end
729
+
730
+ def toHtml
731
+ return "<h2>#{@document.processText(@text)}</h2>"
732
+ end
733
+ end
734
+
735
+ # A "chunk" of text from the source, corresponding to a group of lines, either delimited
736
+ # by a blank line, or, identifiable as a special one-line chunk from the contents of the line
737
+ class LinesChunk
738
+ attr_reader :lines
739
+
740
+ def initialize(line, options = {})
741
+ @lines = [line]
742
+ @isCritique = options[:isCritique] || false
743
+ @tag = options[:tag]
744
+ end
745
+
746
+ def addLine(line)
747
+ @lines.push(line)
748
+ end
749
+
750
+ # a post-processing step in which a chunk might redefine itself as a different type of chunk
751
+ # based on information available only _after_ all the lines have been added to it
752
+ # (in particular the case where a "heading" is defined by the occurrence of "--------" in the second and last line).
753
+ def postProcess
754
+ return self
755
+ end
756
+ end
757
+
758
+ # Special chunk contains a name and an optional value
759
+ # examples - "##appendix", "##date 23 May, 2014" "##author John Smith"
760
+ class SpecialChunk < LinesChunk
761
+ attr_reader :name
762
+ def initialize(name, line)
763
+ super(line)
764
+ @name = name
765
+ end
766
+
767
+ def to_s
768
+ return "SpecialChunk: (#{name}) #{lines.inspect}"
769
+ end
770
+
771
+ # "special chunk" is terminated by a blank line, or by the start of the next special chunk
772
+ def isTerminatedBy?(line)
773
+ return (/^\#\#/.match(line) or /^\s*$/.match(line))
774
+ end
775
+
776
+ def getDocumentComponent
777
+ if name == "appendix"
778
+ return StartAppendix.new() # special "start appendix" command, or,
779
+ else
780
+ return DocumentProperty.new(name, lines.join("\n")) # a named property value
781
+ end
782
+ end
783
+
784
+ end
785
+
786
+ # A chunk which is a group of lines terminated by a following blank line
787
+ class BlankTerminatedChunk < LinesChunk
788
+ def isTerminatedBy?(line)
789
+ return /^\s*$/.match(line)
790
+ end
791
+
792
+ def critiqueValue
793
+ if @isCritique then
794
+ return "(is critique) "
795
+ else
796
+ return ""
797
+ end
798
+ end
799
+ end
800
+
801
+ # A heading chunk is a group of two lines, the second of which is repeated '-' characters (i.e. the underlining of the heading)
802
+ class HeadingChunk
803
+ attr_reader :text
804
+ def initialize(text)
805
+ @text = text
806
+ end
807
+
808
+ def to_s
809
+ return "HeadingChunk: #{text.inspect}"
810
+ end
811
+
812
+ def getDocumentComponent
813
+ return Heading.new(text)
814
+ end
815
+ end
816
+
817
+ # A paragraph chunk is a group of lines representing a paragraph (because it is not recognisable
818
+ # as anything else)
819
+ class ParagraphChunk < BlankTerminatedChunk
820
+
821
+ def to_s
822
+ return "ParagraphChunk: #{critiqueValue}#{lines.inspect}"
823
+ end
824
+
825
+ # Check if this paragraph is actually a heading, as determined by the 2nd line being an underline sequence
826
+ def postProcess
827
+ if lines.length == 2 and lines[1].match(/^[-]+$/) then
828
+ return HeadingChunk.new(lines[0])
829
+ else
830
+ return self
831
+ end
832
+ end
833
+
834
+ def getDocumentComponent
835
+ return Paragraph.new(lines.join("\n"), :isCritique => @isCritique, :tag => @tag)
836
+ end
837
+ end
838
+
839
+ # A proposition chunk represents a proposition (recognised because the first line starts with "# ")
840
+ class PropositionChunk < BlankTerminatedChunk
841
+ def to_s
842
+ return "PropositionChunk: #{lines.inspect}"
843
+ end
844
+
845
+ def getDocumentComponent
846
+ return Proposition.new(lines.join("\n"))
847
+ end
848
+ end
849
+
850
+ # A list chunk represents a list of list items (recognised because the first line starts with "* ")
851
+ class ListChunk < BlankTerminatedChunk
852
+ def to_s
853
+ return "ListChunk: #{critiqueValue}#{lines.inspect}"
854
+ end
855
+
856
+ def addItemToList(itemList, currentItemLines)
857
+ itemList.addItem(ListItem.new(currentItemLines.join("\n")))
858
+ end
859
+
860
+ def getDocumentComponent
861
+ itemList = ItemList.new(:isCritique => @isCritique)
862
+ currentItemLines = nil
863
+ # loop over lines
864
+ for line in lines do
865
+ itemStartMatch = line.match(/^\*\s+(.*)$/) # if a line starts with "* ",
866
+ if itemStartMatch # we have found the start of a new item
867
+ if currentItemLines != nil
868
+ addItemToList(itemList, currentItemLines) # save the previous list item if any
869
+ end
870
+ currentItemLines = [itemStartMatch[1]] # start the new list of lines for this item
871
+ else
872
+ currentItemLines.push(line.strip) # add this line to the existing list of lines for the current list item
873
+ end
874
+ end
875
+ if currentItemLines != nil
876
+ addItemToList(itemList, currentItemLines) # save any final list item not yet saved
877
+ end
878
+ return itemList
879
+ end
880
+ end
881
+
882
+ # Propolize source code is parsed as a series of "document chunks".
883
+ # (Each chunk is then converted to a "document component" by calling getDocumentComponent
884
+ # and each component is then "written" to the output document by calling writeToDocument.)
885
+ class DocumentChunks
886
+ def initialize(srcText)
887
+ @srcText = srcText
888
+ end
889
+
890
+ # Knowing that we are starting a new "chunk", determine type of chunk based on the first line
891
+ def createInitialChunkFromLine(line)
892
+ specialLineMatch = line.match(/^\#\#([a-z\-]*)\s*(.*)$/) # does it start with "##<identifier>" with optional addition value?, and identifier is alphabetic or "-"?
893
+ if specialLineMatch then
894
+ return SpecialChunk.new(specialLineMatch[1], specialLineMatch[2])
895
+ else
896
+ specialTagMatch = line.match(/^\#:([a-z\-0-9]+)\s*(.*)$/) # does it start with "#:<alphanumeric-identifier>"?
897
+ if specialTagMatch then
898
+ return ParagraphChunk.new(specialTagMatch[2], :tag => specialTagMatch[1]) # paragraph with special tag
899
+ else
900
+ propositionMatch = line.match(/^\#\s*(.*)$/) # does it start with "# "?
901
+ if propositionMatch then
902
+ return PropositionChunk.new(propositionMatch[1]) # proposition
903
+ else
904
+ critiqueMatch = line.match(/^\?\?\s(.*)$/) # does it start with "?? " ?
905
+ isCritique = critiqueMatch != nil # if so, this is a "critique" item
906
+ if isCritique then
907
+ line = critiqueMatch[1] # strip off the "?? " prefix to process the rest of the line
908
+ end
909
+ listMatch = line.match(/^\*\s+/) # does it start with "* " ?
910
+ if listMatch then
911
+ return ListChunk.new(line, :isCritique => isCritique) # list of items
912
+ else
913
+ blankLineMatch = line.match(/^\s*$/) # is it a blank line
914
+ if blankLineMatch then
915
+ return nil # blank line, so actually we don't start a new chunk yet
916
+ else
917
+ return ParagraphChunk.new(line, :isCritique => isCritique) # anything else, must be a paragraph
918
+ end
919
+ end
920
+ end
921
+ end
922
+ end
923
+ end
924
+
925
+ def each
926
+ currentChunk = nil
927
+ for line in @srcText.split("\n") do
928
+ if currentChunk == nil then
929
+ currentChunk = createInitialChunkFromLine(line)
930
+ else
931
+ if currentChunk.isTerminatedBy?(line)
932
+ yield currentChunk.postProcess
933
+ currentChunk = createInitialChunkFromLine(line)
934
+ else
935
+ currentChunk.addLine(line)
936
+ end
937
+ end
938
+ end
939
+ if currentChunk
940
+ yield currentChunk.postProcess
941
+ end
942
+ end
943
+ end
944
+
945
+ # Top-level class to provide the "propolize" method to be called by client code
946
+ class Propolizer
947
+
948
+ # Main method to generated the HTML document from the provided source text
949
+ def propolize(templateFileName, srcText, fileName, properties = {})
950
+ document = PropositionalDocument.new(properties)
951
+ for chunk in DocumentChunks.new(srcText) do
952
+ #puts "#{chunk}"
953
+ component = chunk.getDocumentComponent
954
+ #puts " => #{component}"
955
+ component.writeToDocument(document)
956
+ end
957
+ document.checkIsValid()
958
+ #document.dump
959
+
960
+ return document.generateHtml(templateFileName, File.basename(fileName))
961
+ end
962
+ end
963
+ end