fountainrb 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7c4e3a96631f3e62fb3011f17bda182be0cb2154a9d60bfd269afc10b6af31b9
4
+ data.tar.gz: 8992168df09c6cad811720c44cc94e6654f78a3f8559eb18ab12cd95b0ad3f2b
5
+ SHA512:
6
+ metadata.gz: 6b55bc1931fdf5bd7bc96a4b17e33a88c1bfd2467c85dbd3f4cb70994b9fecdd7c6adc75d27a920e7f1890ac9e076351397576eb2723d6b27cfb4a44cb3084ca
7
+ data.tar.gz: 191b0da8606a8bce16c6f100aafb393c503099e3061c344568934d80f3a572271666673fe537412661c2bba93f6714ef5fa9cc3a635d102853c084c457def22f
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to `fountainrb` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.0.0] - 2025-01-01
8
+
9
+ ### Added
10
+
11
+ - Pure-Ruby Fountain parser with no runtime dependencies.
12
+ - Title page parsing with multi-line values, arbitrary keys and convenience
13
+ readers (`title`, `authors`, `credit`, `source`, `draft_date`, `contact`,
14
+ `copyright`, `notes`).
15
+ - Scene headings, including `INT`, `EXT`, `EST`, `INT./EXT`, `INT/EXT`, `I/E`,
16
+ forced headings (`.`), and scene numbers (`#1A#`).
17
+ - Action with preserved indentation, tab expansion and blank-line metadata.
18
+ - Character cues with extensions, forced cues (`@`) and dual-dialogue carets.
19
+ - Dialogue, parentheticals, lyrics (`~`), transitions, centered text, page
20
+ breaks, sections, synopses, notes and boneyards.
21
+ - Inline emphasis parser and renderer for italics, bold, bold italics,
22
+ underline, escaping and notes.
23
+ - `Screenplay` object model with enumerable elements, scene/character/dialogue
24
+ helpers, dialogue-block grouping and dual-dialogue pairing.
25
+ - HTML renderer with semantic classes and configurable sections, synopses,
26
+ notes, title page and page breaks.
27
+ - Plain-text renderer.
28
+ - JSON serialisation via `Screenplay#to_h` and `Screenplay#to_json`.
29
+ - Validation API: `FountainRB.valid?`, `FountainRB.validate`,
30
+ `Screenplay#valid?`, `Screenplay#issues`, with line-numbered
31
+ `FountainRB::Issue` results for unclosed notes/boneyards, stray closers,
32
+ malformed scene numbers and orphan dual-dialogue carets.
33
+ - `fountainrb` command line interface with HTML, text, JSON and AST output.
34
+ - 500+ RSpec examples, including two full integration fixtures.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Distark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do 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,373 @@
1
+ # fountainrb
2
+
3
+ [![CI](https://github.com/jtoy/fountainrb/actions/workflows/ci.yml/badge.svg)](https://github.com/jtoy/fountainrb/actions/workflows/ci.yml)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-cc342d)](https://www.ruby-lang.org/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+
7
+ A dependency-free, pure-Ruby parser for [Fountain](https://fountain.io/syntax/)
8
+ screenplays.
9
+
10
+ `fountainrb` turns Fountain source into a structured, enumerable object model
11
+ that you can render, inspect, transform, or serialise to JSON. It implements
12
+ the complete Fountain syntax — title pages, scene headings, action, characters
13
+ and extensions, dialogue, parentheticals, dual dialogue, lyrics, transitions,
14
+ centered text, page breaks, sections, synopses, notes, boneyards, and inline
15
+ emphasis — and never raises on malformed markup.
16
+
17
+ ```ruby
18
+ require "fountainrb"
19
+
20
+ screenplay = FountainRB.parse(File.read("brick_and_steel.fountain"))
21
+
22
+ screenplay.title_page.title #=> ["BRICK & STEEL", "FULL RETIRED"]
23
+ screenplay.title_page.authors #=> ["Stu Maschwitz"]
24
+ screenplay.scenes.first.text #=> "EXT. BRICK'S PATIO - DAY"
25
+ screenplay.character_names #=> ["STEEL", "BRICK", ...]
26
+ screenplay.dialogue_blocks.first.text #=> "Beer's ready!"
27
+
28
+ puts screenplay.to_html
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ Add it to your Gemfile:
34
+
35
+ ```ruby
36
+ gem "fountainrb"
37
+ ```
38
+
39
+ Until the gem is published on RubyGems, install it straight from GitHub:
40
+
41
+ ```ruby
42
+ gem "fountainrb", github: "jtoy/fountainrb"
43
+ ```
44
+
45
+ Or install it directly:
46
+
47
+ ```sh
48
+ gem install fountainrb
49
+ ```
50
+
51
+ There are no runtime dependencies. Ruby 2.7 or newer is required.
52
+
53
+ ## Quick start
54
+
55
+ ```ruby
56
+ require "fountainrb"
57
+
58
+ source = <<~FOUNTAIN
59
+ Title: My First Script
60
+ Author: A. Writer
61
+
62
+ INT. KITCHEN - MORNING
63
+
64
+ A KETTLE boils. Steam fogs the window.
65
+
66
+ MOM (O. S.)
67
+ (calling upstairs)
68
+ Breakfast!
69
+
70
+ SAM
71
+ Coming!
72
+ FOUNTAIN
73
+
74
+ screenplay = FountainRB.parse(source)
75
+
76
+ screenplay.title_page.title #=> ["My First Script"]
77
+ screenplay.scenes.first.text #=> "INT. KITCHEN - MORNING"
78
+ screenplay.characters.map(&:name) #=> ["MOM", "SAM"]
79
+
80
+ screenplay.elements.each do |element|
81
+ puts "#{element.type}: #{element.text.inspect}"
82
+ end
83
+ ```
84
+
85
+ Parse a file instead:
86
+
87
+ ```ruby
88
+ screenplay = FountainRB.parse_file("script.fountain")
89
+ ```
90
+
91
+ ## The object model
92
+
93
+ `FountainRB.parse` returns a `FountainRB::Screenplay`. It is `Enumerable` over
94
+ its `elements`, which appear in source order.
95
+
96
+ Every element offers:
97
+
98
+ | Method | Description |
99
+ | --- | --- |
100
+ | `#type` | A symbol such as `:scene_heading` or `:dialogue`. |
101
+ | `#text` | The element text, with the Fountain prefix removed but inline markup intact. |
102
+ | `#line` | 1-based source line where the element begins. |
103
+ | `#blank_before` | Number of blank source lines immediately before it. |
104
+ | `#forced?` | Whether the writer used an explicit prefix (`.`, `@`, `!`, `>` or `~`). |
105
+ | `#notes` | Inline note texts found in the element. |
106
+ | `#to_h` | A JSON-friendly hash. |
107
+
108
+ Element classes:
109
+
110
+ | Class | Type | Notes |
111
+ | --- | --- | --- |
112
+ | `SceneHeading` | `:scene_heading` | `#scene_number` for `#1A#` style numbers. |
113
+ | `Action` | `:action` | Multi-line; `#lines`. |
114
+ | `Character` | `:character` | `#name`, `#extensions`, `#extension`, `#dual?`. |
115
+ | `Parenthetical` | `:parenthetical` | `#content` without the parentheses. |
116
+ | `Dialogue` | `:dialogue` | Multi-line; `#lines`. |
117
+ | `Lyrics` | `:lyrics` | Multi-line. |
118
+ | `Transition` | `:transition` | |
119
+ | `Centered` | `:centered` | Fountain's centered text. |
120
+ | `PageBreak` | `:page_break` | |
121
+ | `Section` | `:section` | `#level` (number of `#`). |
122
+ | `Synopsis` | `:synopsis` | |
123
+ | `Note` | `:note` | A note on its own line. |
124
+
125
+ ### Screenplay helpers
126
+
127
+ ```ruby
128
+ screenplay.title_page # TitlePage or nil
129
+ screenplay.elements # Array<Element>
130
+ screenplay.scenes # Array<SceneHeading> (alias: scene_headings)
131
+ screenplay.action # Array<Action>
132
+ screenplay.characters # Array<Character>
133
+ screenplay.character_names
134
+ screenplay.dialogues # Array<Dialogue> (alias: dialogue)
135
+ screenplay.dialogue_blocks
136
+ screenplay.units # elements + dialogue blocks + dual-dialogue pairs
137
+ screenplay.parentheticals
138
+ screenplay.lyrics
139
+ screenplay.transitions
140
+ screenplay.centered
141
+ screenplay.page_breaks
142
+ screenplay.sections
143
+ screenplay.synopses
144
+ screenplay.notes # every note, standalone and inline
145
+ screenplay.boneyards # the text of every /* ... */ comment
146
+ screenplay.of_type(:dialogue)
147
+ ```
148
+
149
+ Dialogue is grouped into `DialogueBlock`s:
150
+
151
+ ```ruby
152
+ block = screenplay.dialogue_blocks.first
153
+ block.character # Character
154
+ block.parts # Parentheticals, Dialogue and Notes
155
+ block.dialogue # Array<Dialogue>
156
+ block.parentheticals # Array<Parenthetical>
157
+ block.text # all spoken text joined with newlines
158
+ block.dual? # true when marked with ^
159
+ ```
160
+
161
+ A caret on a cue pairs that block with the previous one. `screenplay.units`
162
+ returns a `FountainRB::DualDialogue` for such pairs, with `#left` and `#right`.
163
+ The flat `#elements` list still contains every element individually.
164
+
165
+ ### Title page
166
+
167
+ ```ruby
168
+ page = screenplay.title_page
169
+
170
+ page["title"] #=> ["BRICK & STEEL", "FULL RETIRED"]
171
+ page.title #=> ["BRICK & STEEL", "FULL RETIRED"]
172
+ page.fetch("title") #=> "BRICK & STEEL"
173
+ page.authors # "Author" + "Authors"
174
+ page.credit
175
+ page.source
176
+ page.draft_date
177
+ page.contact
178
+ page.copyright
179
+ page.notes
180
+ page.keys
181
+ page.to_h
182
+ ```
183
+
184
+ Keys are normalised to lowercase, and a key may appear more than once. Values
185
+ on following lines must be indented with three or more spaces or a tab.
186
+
187
+ ## Inline markup
188
+
189
+ `FountainRB::Inline` parses and renders the markup that lives inside elements.
190
+ Fountain uses Markdown-style emphasis, except that underscores mean underline:
191
+
192
+ | Markup | Result |
193
+ | --- | --- |
194
+ | `*italics*` | `<em>italics</em>` |
195
+ | `**bold**` | `<strong>bold</strong>` |
196
+ | `***bold italics***` | `<strong><em>bold italics</em></strong>` |
197
+ | `_underline_` | `<u>underline</u>` |
198
+ | `[[a note]]` | a note (omitted from formatted output) |
199
+ | `\*escaped\*` | literal asterisks |
200
+
201
+ ```ruby
202
+ FountainRB::Inline.to_html("_Steel's face FILLS the *scope*._")
203
+ #=> "<u>Steel&#39;s face FILLS the <em>scope</em>.</u>"
204
+
205
+ FountainRB::Inline.to_plain_text("A *very* good day.")
206
+ #=> "A very good day."
207
+
208
+ nodes = FountainRB::Inline.parse("A *very* good day.")
209
+ nodes.map(&:to_h)
210
+ ```
211
+
212
+ Emphasis never crosses a line break, and a backslash escapes the following
213
+ markup character. Notes may span a single line break, but not a blank line.
214
+ Notes are omitted from rendered output unless you ask for them.
215
+
216
+ ## Rendering
217
+
218
+ ### HTML
219
+
220
+ ```ruby
221
+ screenplay.to_html
222
+ # or
223
+ FountainRB::HTMLRenderer.new.render(screenplay)
224
+ ```
225
+
226
+ Options:
227
+
228
+ ```ruby
229
+ screenplay.to_html(
230
+ notes: true, # render notes (default: false)
231
+ sections: true, # render sections (default: false)
232
+ synopses: true, # render synopses (default: false)
233
+ title_page: true, # render the title page (default: true)
234
+ page_breaks: true # render page breaks (default: true)
235
+ )
236
+ ```
237
+
238
+ The output uses semantic classes such as `.fountain`, `.title-page`,
239
+ `.scene-heading`, `.action`, `.dialogue-block`, `.character`, `.dialogue`,
240
+ `.parenthetical`, `.transition`, `.centered`, `.lyrics`, `.dual-dialogue`,
241
+ `.page-break`, `.section`, `.synopsis` and `.note`. Bring your own CSS.
242
+
243
+ ### Plain text
244
+
245
+ ```ruby
246
+ screenplay.to_plain_text
247
+ FountainRB::PlainTextRenderer.new(notes: true).render(screenplay)
248
+ ```
249
+
250
+ This renderer is intentionally simple — it indents characters, dialogue and
251
+ parentheticals and does not wrap lines — but it is handy for smoke tests and
252
+ quick inspection.
253
+
254
+ ### JSON
255
+
256
+ ```ruby
257
+ screenplay.to_h
258
+ screenplay.to_json
259
+ require "json"
260
+ JSON.pretty_generate(screenplay.to_h)
261
+ ```
262
+
263
+ ## Validation
264
+
265
+ Fountain is deliberately forgiving: `FountainRB.parse` always succeeds and
266
+ falls back to Action for anything it does not understand. If you want to warn
267
+ writers about likely mistakes, use the validator:
268
+
269
+ ```ruby
270
+ FountainRB.valid?(source) #=> false
271
+
272
+ FountainRB.validate(source).each do |issue|
273
+ puts "#{issue.line}: #{issue.severity}: #{issue.message}"
274
+ end
275
+
276
+ screenplay = FountainRB.parse(source)
277
+ screenplay.valid?
278
+ screenplay.issues
279
+ ```
280
+
281
+ `valid?` is true when validation finds no issues. `validate` returns an array
282
+ of `FountainRB::Issue` objects, ordered by line. Each issue exposes `code`,
283
+ `message`, `line`, `severity` (`:error` or `:warning`), `error?`, `warning?`,
284
+ `to_h` and `to_s`.
285
+
286
+ | Code | Severity | Meaning |
287
+ | --- | --- | --- |
288
+ | `unclosed_boneyard` | error | A `/*` comment is never closed and swallows the rest of the file. |
289
+ | `unexpected_boneyard_close` | warning | A `*/` has no matching `/*`. |
290
+ | `unclosed_note` | error | A `[[` note is never closed. |
291
+ | `unexpected_note_close` | warning | A `]]` has no matching `[[`. |
292
+ | `note_spans_blank_line` | warning | A note crosses a true blank line, so it will not be parsed as a note (use a two-space line to connect it). |
293
+ | `malformed_scene_number` | warning | A scene heading ends with a `#` number that is missing its closing `#`. |
294
+ | `orphan_dual_dialogue` | warning | A `^` caret has no preceding dialogue block. |
295
+
296
+ Validation is conservative: it only reports problems that the parser would
297
+ otherwise silently recover from. In particular, emphasis is not validated,
298
+ because asterisks and underscores are common literal text.
299
+
300
+ ## Command line interface
301
+
302
+ The gem ships with a `fountainrb` executable.
303
+
304
+ ```sh
305
+ fountainrb script.fountain # HTML
306
+ fountainrb --text script.fountain # plain text
307
+ fountainrb --json script.fountain # JSON AST
308
+ fountainrb --ast script.fountain # human-readable element dump
309
+
310
+ fountainrb --notes --sections --synopses script.fountain
311
+ ```
312
+
313
+ ## Syntax coverage
314
+
315
+ | Fountain feature | Supported |
316
+ | --- | --- |
317
+ | Title page, multi-line values, arbitrary keys | ✅ |
318
+ | Scene headings (`INT`, `EXT`, `EST`, `INT./EXT`, `INT/EXT`, `I/E`, plus `EXT/INT`) | ✅ |
319
+ | Forced scene headings (`.`) and scene numbers (`#1A#`) | ✅ |
320
+ | Action, forced action (`!`), indentation, tabs, vertical whitespace | ✅ |
321
+ | Character cues, extensions, forced cues (`@`) | ✅ |
322
+ | Dialogue, multi-line dialogue, two-space blank lines | ✅ |
323
+ | Parentheticals | ✅ |
324
+ | Dual dialogue (`^`) | ✅ |
325
+ | Lyrics (`~`) | ✅ |
326
+ | Transitions (`... TO:` and forced `>`) | ✅ |
327
+ | Centered text (`> ... <`) | ✅ |
328
+ | Emphasis (`*`, `**`, `***`, `_`) | ✅ |
329
+ | Escaping with backslashes | ✅ |
330
+ | Page breaks (`===`) | ✅ |
331
+ | Sections (`#`) and synopses (`=`) | ✅ |
332
+ | Notes (`[[...]]`), inline and multi-line | ✅ |
333
+ | Boneyards (`/* ... */`), inline and multi-line | ✅ |
334
+ | Punctuation is left exactly as written | ✅ |
335
+
336
+ Error handling follows Fountain's philosophy: when in doubt, return the text as
337
+ Action. Unclosed emphasis, notes, and boneyards are handled gracefully —
338
+ unclosed boneyards comment out the remainder of the file.
339
+
340
+ ### Implementation notes
341
+
342
+ * Boneyards are removed before parsing. A multi-line boneyard is replaced by
343
+ the same number of newlines, which keeps source line numbers accurate and
344
+ lets the comment behave as whitespace. The removed text is available through
345
+ `screenplay.boneyards`.
346
+ * A line consisting of two or more spaces keeps dialogue open and produces an
347
+ empty line inside the dialogue, as described by the spec. Outside dialogue it
348
+ is treated as a normal blank line.
349
+ * Sections, synopses and standalone notes are ignored in formatted output and
350
+ do not disturb the blank-line context used to classify the following line.
351
+ * Centered text is exposed as its own `:centered` element rather than being
352
+ indistinguishable from Action.
353
+ * In addition to the scene-heading prefixes listed by the spec, `EXT/INT` and
354
+ `EXT./INT.` are recognised, because they appear in real screenplays (such as
355
+ the official Big Fish sample).
356
+
357
+ ## Development
358
+
359
+ ```sh
360
+ bundle install
361
+ bundle exec rake # run the specs
362
+ bundle exec rspec # run the specs directly
363
+ bundle exec rake doc # documentation formatter
364
+ bundle exec rake console # IRB with fountainrb loaded
365
+ ```
366
+
367
+ The test suite contains 500+ examples covering every element type, inline
368
+ markup, validation, edge cases, both renderers, the CLI, and two full
369
+ integration fixtures.
370
+
371
+ ## License
372
+
373
+ fountainrb is available under the MIT license. See [LICENSE.txt](LICENSE.txt).
data/exe/fountainrb ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ # Allow running the executable directly from a source checkout.
7
+ lib = File.expand_path("../lib", __dir__)
8
+ $LOAD_PATH.unshift(lib) if File.exist?(File.join(lib, "fountainrb.rb"))
9
+
10
+ require "fountainrb"
11
+
12
+ options = {
13
+ format: :html,
14
+ notes: false,
15
+ sections: false,
16
+ synopses: false
17
+ }
18
+
19
+ parser = OptionParser.new do |opts|
20
+ opts.banner = "Usage: fountainrb [options] FILE"
21
+ opts.separator ""
22
+ opts.separator "Output formats:"
23
+ opts.on("--html", "Render HTML (default)") { options[:format] = :html }
24
+ opts.on("--text", "Render plain text") { options[:format] = :text }
25
+ opts.on("--json", "Emit the parsed screenplay as JSON") { options[:format] = :json }
26
+ opts.on("--ast", "Print a human-readable element tree") { options[:format] = :ast }
27
+
28
+ opts.separator ""
29
+ opts.separator "Options:"
30
+ opts.on("--notes", "Include notes in the output") { options[:notes] = true }
31
+ opts.on("--sections", "Include sections in the output") { options[:sections] = true }
32
+ opts.on("--synopses", "Include synopses in the output") { options[:synopses] = true }
33
+
34
+ opts.separator ""
35
+ opts.separator "Other:"
36
+ opts.on("-v", "--version", "Print the version and exit") do
37
+ puts FountainRB::VERSION
38
+ exit
39
+ end
40
+ opts.on("-h", "--help", "Show this help and exit") do
41
+ puts opts
42
+ exit
43
+ end
44
+ end
45
+
46
+ begin
47
+ parser.parse!
48
+ rescue OptionParser::ParseError => error
49
+ warn "fountainrb: #{error.message}"
50
+ warn parser
51
+ exit 1
52
+ end
53
+
54
+ if ARGV.empty?
55
+ warn "fountainrb: no input file given"
56
+ warn parser
57
+ exit 1
58
+ end
59
+
60
+ path = ARGV.shift
61
+
62
+ begin
63
+ screenplay = FountainRB.parse_file(path)
64
+ rescue Errno::ENOENT
65
+ warn "fountainrb: no such file: #{path}"
66
+ exit 1
67
+ rescue SystemCallError => error
68
+ warn "fountainrb: #{error.message}"
69
+ exit 1
70
+ end
71
+
72
+ case options[:format]
73
+ when :html
74
+ puts screenplay.to_html(
75
+ notes: options[:notes],
76
+ sections: options[:sections],
77
+ synopses: options[:synopses]
78
+ )
79
+ when :text
80
+ puts screenplay.to_plain_text(
81
+ notes: options[:notes],
82
+ sections: options[:sections],
83
+ synopses: options[:synopses]
84
+ )
85
+ when :json
86
+ require "json"
87
+ puts JSON.pretty_generate(screenplay.to_h)
88
+ when :ast
89
+ if screenplay.title_page
90
+ puts "title_page #{screenplay.title_page.to_h.inspect}"
91
+ end
92
+ screenplay.elements.each do |element|
93
+ hash = element.to_h
94
+ details = hash.slice(*(hash.keys - %w[type line text]))
95
+ puts format("%4s %-14s %p%s", element.line, element.type, element.text,
96
+ details.empty? ? "" : " #{details.inspect}")
97
+ end
98
+ end