├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── cf-worker ├── .appveyor.yml ├── .cargo-ok ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Cargo.toml ├── LICENSE_APACHE ├── LICENSE_MIT ├── README.md ├── src │ ├── lib.rs │ └── utils.rs ├── test.json ├── tests │ └── web.rs └── worker │ ├── metadata_wasm.json │ └── worker.js ├── fountain-cli ├── .gitignore ├── Cargo.toml └── src │ ├── error.rs │ ├── main.rs │ └── style.css ├── fountain ├── .gitignore ├── Cargo.toml ├── README.md └── src │ ├── data.rs │ ├── html.rs │ ├── lib.rs │ ├── parse.rs │ └── utils.rs └── frontend ├── .babelrc ├── .circleci └── config.yml ├── .gitignore ├── elm.json ├── package.json ├── src ├── Main.elm ├── assets │ ├── .gitkeep │ └── images │ │ ├── .gitkeep │ │ └── logo.png ├── index.html ├── index.js └── styles.scss ├── tests └── Example.elm ├── webpack.config.js └── workers-site ├── .cargo-ok ├── .gitignore ├── index.js └── package.json /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Lint 20 | run: cargo clippy --features use_serde 21 | - name: Run tests 22 | run: cargo test --features use_serde 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /examples 3 | **/*.rs.bk 4 | wrangler.toml 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bumpalo" 7 | version = "3.6.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "0.1.10" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "console_error_panic_hook" 25 | version = "0.1.6" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211" 28 | dependencies = [ 29 | "cfg-if 0.1.10", 30 | "wasm-bindgen", 31 | ] 32 | 33 | [[package]] 34 | name = "fountain" 35 | version = "0.1.12" 36 | dependencies = [ 37 | "nom", 38 | "serde", 39 | ] 40 | 41 | [[package]] 42 | name = "fountain-cli" 43 | version = "0.1.0" 44 | dependencies = [ 45 | "fountain", 46 | ] 47 | 48 | [[package]] 49 | name = "fountain-wrangler" 50 | version = "0.1.0" 51 | dependencies = [ 52 | "cfg-if 1.0.0", 53 | "console_error_panic_hook", 54 | "fountain", 55 | "wasm-bindgen", 56 | "wasm-bindgen-test", 57 | "wee_alloc", 58 | ] 59 | 60 | [[package]] 61 | name = "futures" 62 | version = "0.1.31" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" 65 | 66 | [[package]] 67 | name = "js-sys" 68 | version = "0.3.49" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "dc15e39392125075f60c95ba416f5381ff6c3a948ff02ab12464715adf56c821" 71 | dependencies = [ 72 | "wasm-bindgen", 73 | ] 74 | 75 | [[package]] 76 | name = "lazy_static" 77 | version = "1.4.0" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 80 | 81 | [[package]] 82 | name = "libc" 83 | version = "0.2.91" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "8916b1f6ca17130ec6568feccee27c156ad12037880833a3b842a823236502e7" 86 | 87 | [[package]] 88 | name = "log" 89 | version = "0.4.14" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 92 | dependencies = [ 93 | "cfg-if 1.0.0", 94 | ] 95 | 96 | [[package]] 97 | name = "memchr" 98 | version = "2.3.4" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 101 | 102 | [[package]] 103 | name = "memory_units" 104 | version = "0.4.0" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" 107 | 108 | [[package]] 109 | name = "minimal-lexical" 110 | version = "0.2.1" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 113 | 114 | [[package]] 115 | name = "nom" 116 | version = "7.1.0" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" 119 | dependencies = [ 120 | "memchr", 121 | "minimal-lexical", 122 | "version_check", 123 | ] 124 | 125 | [[package]] 126 | name = "proc-macro2" 127 | version = "0.4.30" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" 130 | dependencies = [ 131 | "unicode-xid 0.1.0", 132 | ] 133 | 134 | [[package]] 135 | name = "proc-macro2" 136 | version = "1.0.24" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" 139 | dependencies = [ 140 | "unicode-xid 0.2.1", 141 | ] 142 | 143 | [[package]] 144 | name = "quote" 145 | version = "0.6.13" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" 148 | dependencies = [ 149 | "proc-macro2 0.4.30", 150 | ] 151 | 152 | [[package]] 153 | name = "quote" 154 | version = "1.0.9" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 157 | dependencies = [ 158 | "proc-macro2 1.0.24", 159 | ] 160 | 161 | [[package]] 162 | name = "scoped-tls" 163 | version = "1.0.0" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" 166 | 167 | [[package]] 168 | name = "serde" 169 | version = "1.0.125" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" 172 | dependencies = [ 173 | "serde_derive", 174 | ] 175 | 176 | [[package]] 177 | name = "serde_derive" 178 | version = "1.0.125" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" 181 | dependencies = [ 182 | "proc-macro2 1.0.24", 183 | "quote 1.0.9", 184 | "syn", 185 | ] 186 | 187 | [[package]] 188 | name = "syn" 189 | version = "1.0.64" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "3fd9d1e9976102a03c542daa2eff1b43f9d72306342f3f8b3ed5fb8908195d6f" 192 | dependencies = [ 193 | "proc-macro2 1.0.24", 194 | "quote 1.0.9", 195 | "unicode-xid 0.2.1", 196 | ] 197 | 198 | [[package]] 199 | name = "unicode-xid" 200 | version = "0.1.0" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" 203 | 204 | [[package]] 205 | name = "unicode-xid" 206 | version = "0.2.1" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 209 | 210 | [[package]] 211 | name = "version_check" 212 | version = "0.9.3" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 215 | 216 | [[package]] 217 | name = "wasm-bindgen" 218 | version = "0.2.72" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "8fe8f61dba8e5d645a4d8132dc7a0a66861ed5e1045d2c0ed940fab33bac0fbe" 221 | dependencies = [ 222 | "cfg-if 1.0.0", 223 | "wasm-bindgen-macro", 224 | ] 225 | 226 | [[package]] 227 | name = "wasm-bindgen-backend" 228 | version = "0.2.72" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "046ceba58ff062da072c7cb4ba5b22a37f00a302483f7e2a6cdc18fedbdc1fd3" 231 | dependencies = [ 232 | "bumpalo", 233 | "lazy_static", 234 | "log", 235 | "proc-macro2 1.0.24", 236 | "quote 1.0.9", 237 | "syn", 238 | "wasm-bindgen-shared", 239 | ] 240 | 241 | [[package]] 242 | name = "wasm-bindgen-futures" 243 | version = "0.3.27" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "83420b37346c311b9ed822af41ec2e82839bfe99867ec6c54e2da43b7538771c" 246 | dependencies = [ 247 | "cfg-if 0.1.10", 248 | "futures", 249 | "js-sys", 250 | "wasm-bindgen", 251 | "web-sys", 252 | ] 253 | 254 | [[package]] 255 | name = "wasm-bindgen-macro" 256 | version = "0.2.72" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "0ef9aa01d36cda046f797c57959ff5f3c615c9cc63997a8d545831ec7976819b" 259 | dependencies = [ 260 | "quote 1.0.9", 261 | "wasm-bindgen-macro-support", 262 | ] 263 | 264 | [[package]] 265 | name = "wasm-bindgen-macro-support" 266 | version = "0.2.72" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "96eb45c1b2ee33545a813a92dbb53856418bf7eb54ab34f7f7ff1448a5b3735d" 269 | dependencies = [ 270 | "proc-macro2 1.0.24", 271 | "quote 1.0.9", 272 | "syn", 273 | "wasm-bindgen-backend", 274 | "wasm-bindgen-shared", 275 | ] 276 | 277 | [[package]] 278 | name = "wasm-bindgen-shared" 279 | version = "0.2.72" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "b7148f4696fb4960a346eaa60bbfb42a1ac4ebba21f750f75fc1375b098d5ffa" 282 | 283 | [[package]] 284 | name = "wasm-bindgen-test" 285 | version = "0.2.50" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "a2d9693b63a742d481c7f80587e057920e568317b2806988c59cd71618bc26c1" 288 | dependencies = [ 289 | "console_error_panic_hook", 290 | "futures", 291 | "js-sys", 292 | "scoped-tls", 293 | "wasm-bindgen", 294 | "wasm-bindgen-futures", 295 | "wasm-bindgen-test-macro", 296 | ] 297 | 298 | [[package]] 299 | name = "wasm-bindgen-test-macro" 300 | version = "0.2.50" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "0789dac148a8840bbcf9efe13905463b733fa96543bfbf263790535c11af7ba5" 303 | dependencies = [ 304 | "proc-macro2 0.4.30", 305 | "quote 0.6.13", 306 | ] 307 | 308 | [[package]] 309 | name = "web-sys" 310 | version = "0.3.49" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "59fe19d70f5dacc03f6e46777213facae5ac3801575d56ca6cbd4c93dcd12310" 313 | dependencies = [ 314 | "js-sys", 315 | "wasm-bindgen", 316 | ] 317 | 318 | [[package]] 319 | name = "wee_alloc" 320 | version = "0.4.5" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" 323 | dependencies = [ 324 | "cfg-if 0.1.10", 325 | "libc", 326 | "memory_units", 327 | "winapi", 328 | ] 329 | 330 | [[package]] 331 | name = "winapi" 332 | version = "0.3.9" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 335 | dependencies = [ 336 | "winapi-i686-pc-windows-gnu", 337 | "winapi-x86_64-pc-windows-gnu", 338 | ] 339 | 340 | [[package]] 341 | name = "winapi-i686-pc-windows-gnu" 342 | version = "0.4.0" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 345 | 346 | [[package]] 347 | name = "winapi-x86_64-pc-windows-gnu" 348 | version = "0.4.0" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 351 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "fountain", 5 | "fountain-cli", 6 | "cf-worker", 7 | ] 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fountain-rs 2 | A library for parsing [Fountain](http://fountain.io) markup. Fountain is used for screen- or stageplays. 3 | 4 | [![Fountain's crates.io badge](https://img.shields.io/crates/v/fountain.svg)](https://crates.io/crates/fountain) 5 | [![Fountain's docs.rs badge](https://docs.rs/fountain/badge.svg)](https://docs.rs/fountain) 6 | 7 | ## Usage 8 | This repo contains a binary and a library. Use `cargo build` to build the binary, then run it like so: 9 | ```bash 10 | $ fountain MY_FOUNTAIN_DOC.fountain 11 | ``` 12 | The binary will output HTML to stdout. You can redirect that to a file (to open in a browser) or to a PDF-creating program. My current workflow is to write the output to a file, open the file in Chrome, then print the page into a PDF. There's probably some nifty CLI util that can read HTML from stdin and output a PDF. If you can suggest one, I'll put it here. 13 | 14 | ## Progress 15 | Eventually I would like `fountain-rs` to be fully compliant with the Fountain spec. Only a subset of the spec has currently been implemented. So far these Fountain elements are implemented: 16 | - Action 17 | - Character 18 | - Dialogue 19 | - Scene 20 | - Parenthetical 21 | - Title page 22 | 23 | This project's goal is to replace Amazon's recently-deprecated Storywriter as an easy way to write Fountain docs in a browser. Features will get added when I need them for my own personal use. If `fountain-rs` doesn't support your particular use-case, please open an issue. 24 | 25 | ## License 26 | This is licensed under the MIT License or the Unlicense, whichever is most permissive and legally recognized in your jurisdiction. 27 | -------------------------------------------------------------------------------- /cf-worker/.appveyor.yml: -------------------------------------------------------------------------------- 1 | install: 2 | - appveyor-retry appveyor DownloadFile https://win.rustup.rs/ -FileName rustup-init.exe 3 | - if not defined RUSTFLAGS rustup-init.exe -y --default-host x86_64-pc-windows-msvc --default-toolchain nightly 4 | - set PATH=%PATH%;C:\Users\appveyor\.cargo\bin 5 | - rustc -V 6 | - cargo -V 7 | 8 | build: false 9 | 10 | test_script: 11 | - cargo test --locked 12 | -------------------------------------------------------------------------------- /cf-worker/.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchalmers/fountain-rs/4946649a3153f5678b115337f94a3e9c0044a480/cf-worker/.cargo-ok -------------------------------------------------------------------------------- /cf-worker/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | worker/generated/ 8 | -------------------------------------------------------------------------------- /cf-worker/.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | sudo: false 3 | 4 | cache: cargo 5 | 6 | matrix: 7 | include: 8 | 9 | # Builds with wasm-pack. 10 | - rust: beta 11 | env: RUST_BACKTRACE=1 12 | addons: 13 | firefox: latest 14 | chrome: stable 15 | before_script: 16 | - (test -x $HOME/.cargo/bin/cargo-install-update || cargo install cargo-update) 17 | - (test -x $HOME/.cargo/bin/cargo-generate || cargo install --vers "^0.2" cargo-generate) 18 | - cargo install-update -a 19 | - curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -f 20 | script: 21 | - cargo generate --git . --name testing 22 | # Having a broken Cargo.toml (in that it has curlies in fields) anywhere 23 | # in any of our parent dirs is problematic. 24 | - mv Cargo.toml Cargo.toml.tmpl 25 | - cd testing 26 | - wasm-pack build 27 | - wasm-pack test --chrome --firefox --headless 28 | 29 | # Builds on nightly. 30 | - rust: nightly 31 | env: RUST_BACKTRACE=1 32 | before_script: 33 | - (test -x $HOME/.cargo/bin/cargo-install-update || cargo install cargo-update) 34 | - (test -x $HOME/.cargo/bin/cargo-generate || cargo install --vers "^0.2" cargo-generate) 35 | - cargo install-update -a 36 | - rustup target add wasm32-unknown-unknown 37 | script: 38 | - cargo generate --git . --name testing 39 | - mv Cargo.toml Cargo.toml.tmpl 40 | - cd testing 41 | - cargo check 42 | - cargo check --target wasm32-unknown-unknown 43 | - cargo check --no-default-features 44 | - cargo check --target wasm32-unknown-unknown --no-default-features 45 | - cargo check --no-default-features --features console_error_panic_hook 46 | - cargo check --target wasm32-unknown-unknown --no-default-features --features console_error_panic_hook 47 | - cargo check --no-default-features --features "console_error_panic_hook wee_alloc" 48 | - cargo check --target wasm32-unknown-unknown --no-default-features --features "console_error_panic_hook wee_alloc" 49 | 50 | # Builds on beta. 51 | - rust: beta 52 | env: RUST_BACKTRACE=1 53 | before_script: 54 | - (test -x $HOME/.cargo/bin/cargo-install-update || cargo install cargo-update) 55 | - (test -x $HOME/.cargo/bin/cargo-generate || cargo install --vers "^0.2" cargo-generate) 56 | - cargo install-update -a 57 | - rustup target add wasm32-unknown-unknown 58 | script: 59 | - cargo generate --git . --name testing 60 | - mv Cargo.toml Cargo.toml.tmpl 61 | - cd testing 62 | - cargo check 63 | - cargo check --target wasm32-unknown-unknown 64 | - cargo check --no-default-features 65 | - cargo check --target wasm32-unknown-unknown --no-default-features 66 | - cargo check --no-default-features --features console_error_panic_hook 67 | - cargo check --target wasm32-unknown-unknown --no-default-features --features console_error_panic_hook 68 | # Note: no enabling the `wee_alloc` feature here because it requires 69 | # nightly for now. 70 | -------------------------------------------------------------------------------- /cf-worker/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ag_dubs@cloudflare.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /cf-worker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fountain-wrangler" 3 | version = "0.1.0" 4 | authors = ["Adam Chalmers "] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "rlib"] 9 | 10 | [features] 11 | default = ["console_error_panic_hook"] 12 | 13 | [dependencies] 14 | cfg-if = "1" 15 | fountain = { path = "../fountain" } 16 | wasm-bindgen = "0.2" 17 | 18 | # The `console_error_panic_hook` crate provides better debugging of panics by 19 | # logging them with `console.error`. This is great for development, but requires 20 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 21 | # code size when deploying. 22 | console_error_panic_hook = { version = "0.1.1", optional = true } 23 | 24 | # `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size 25 | # compared to the default allocator's ~10K. It is slower than the default 26 | # allocator, however. 27 | # 28 | # Unfortunately, `wee_alloc` requires nightly Rust when targeting wasm for now. 29 | wee_alloc = { version = "0.4.2", optional = true } 30 | 31 | [dev-dependencies] 32 | wasm-bindgen-test = "0.2" 33 | 34 | [profile.release] 35 | # Tell `rustc` to optimize for small code size. 36 | opt-level = "s" 37 | -------------------------------------------------------------------------------- /cf-worker/LICENSE_APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /cf-worker/LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Ashley Williams 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /cf-worker/README.md: -------------------------------------------------------------------------------- 1 | # 👷‍♀️🦀🕸️ `rustwasm-worker-template` 2 | 3 | A template for kick starting a Cloudflare worker project using 4 | [`wasm-pack`](https://github.com/rustwasm/wasm-pack). 5 | 6 | This template is designed for compiling Rust libraries into WebAssembly and 7 | publishing the resulting worker to Cloudflare's worker infrastructure. 8 | 9 | ## 🔋 Batteries Included 10 | 11 | * [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating 12 | between WebAssembly and JavaScript. 13 | * [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook) 14 | for logging panic messages to the developer console. 15 | * [`wee_alloc`](https://github.com/rustwasm/wee_alloc), an allocator optimized 16 | for small code size. 17 | 18 | ## 🚴 Usage 19 | 20 | ### 🐑 Use `wrangler generate` to Clone this Template 21 | 22 | [Learn more about `wrangler generate` here.](https://github.com/cloudflare/wrangler) 23 | 24 | ``` 25 | wrangler generate wasm-worker https://github.com/cloudflare/rustwasm-worker-template.git 26 | cd wasm-worker 27 | ``` 28 | 29 | ### 🛠️ Build with `wasm-pack build` 30 | 31 | ``` 32 | wasm-pack build 33 | ``` 34 | 35 | ### 🔬 Test in Headless Browsers with `wasm-pack test` 36 | 37 | ``` 38 | wasm-pack test --headless --firefox 39 | ``` 40 | -------------------------------------------------------------------------------- /cf-worker/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | use cfg_if::cfg_if; 4 | 5 | use wasm_bindgen::prelude::*; 6 | cfg_if! { 7 | // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global 8 | // allocator. 9 | if #[cfg(feature = "wee_alloc")] { 10 | extern crate wee_alloc; 11 | #[global_allocator] 12 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 13 | } 14 | } 15 | 16 | #[wasm_bindgen] 17 | pub fn parse(screenplay: &str) -> String { 18 | println!("Parsing the following Fountain doc:\n{}", screenplay); 19 | 20 | // Write to String buffer. 21 | match fountain::parse_document::<(&str, _)>(screenplay) { 22 | Err(e) => format!( 23 | "\ 24 |

Error

25 |

{:?}

", 26 | e 27 | ), 28 | Ok(("", parsed)) => parsed.as_html(), 29 | Ok((unparsed, parsed)) => format!( 30 | "\ 31 |

Unparsed

32 |

'{}'

33 | {}", 34 | unparsed, 35 | parsed.as_html() 36 | ), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cf-worker/src/utils.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { 4 | // When the `console_error_panic_hook` feature is enabled, we can call the 5 | // `set_panic_hook` function at least once during initialization, and then 6 | // we will get better error messages if our code ever panics. 7 | // 8 | // For more details see 9 | // https://github.com/rustwasm/console_error_panic_hook#readme 10 | if #[cfg(feature = "console_error_panic_hook")] { 11 | extern crate console_error_panic_hook; 12 | pub use self::console_error_panic_hook::set_once as set_panic_hook; 13 | } else { 14 | #[inline] 15 | pub fn set_panic_hook() {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cf-worker/test.json: -------------------------------------------------------------------------------- 1 | {"screenplay": "Title:\n\tAlien\nAuthor:\n\tDan O'Bannon\nINT. MESS\n\nThe entire crew is seated. Hungrily swallowing huge portions of artificial food. The cat eats from a dish on the table.\n\nKANE\nFirst thing I'm going to do when we get back is eat some decent food.\n"} 2 | -------------------------------------------------------------------------------- /cf-worker/tests/web.rs: -------------------------------------------------------------------------------- 1 | //! Test suite for the Web and headless browsers. 2 | 3 | #![cfg(target_arch = "wasm32")] 4 | 5 | use wasm_bindgen_test::*; 6 | 7 | wasm_bindgen_test_configure!(run_in_browser); 8 | 9 | #[wasm_bindgen_test] 10 | fn pass() { 11 | assert_eq!(1 + 1, 2); 12 | } 13 | -------------------------------------------------------------------------------- /cf-worker/worker/metadata_wasm.json: -------------------------------------------------------------------------------- 1 | { 2 | "body_part": "script", 3 | "bindings": [ 4 | { 5 | "name": "wasm", 6 | "type": "wasm_module", 7 | "part": "wasmprogram" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /cf-worker/worker/worker.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | /** 6 | * Fetch and log a request 7 | * @param {Request} request 8 | */ 9 | async function handleRequest(request) { 10 | // Extract screenplay from POST request body 11 | if (request.method !== 'POST') { 12 | return new Response( 13 | `HTTP verb ${request.method} isn't supported`, 14 | { status: 400 } 15 | ) 16 | } 17 | let j = await request.json(); 18 | const screenplay = j.screenplay 19 | if (screenplay === null || screenplay === undefined || screenplay == "") { 20 | return new Response( 21 | `Body must contain a 'screenplay' field and it cannot be ${screenplay}`, 22 | { status: 400 } 23 | ) 24 | } 25 | 26 | // Respond 27 | const { parse } = wasm_bindgen; 28 | await wasm_bindgen(wasm) 29 | const output = parse(screenplay) 30 | let res = new Response(output, { status: 200 }) 31 | res.headers.set("Content-type", "text/html") 32 | return res 33 | } -------------------------------------------------------------------------------- /fountain-cli/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | -------------------------------------------------------------------------------- /fountain-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fountain-cli" 3 | version = "0.1.0" 4 | authors = ["Adam Chalmers "] 5 | edition = "2018" 6 | description = "A CLI for parsing Fountain and outputting screenplay-formatted scripts" 7 | homepage = "https://github.com/adamchalmers/fountain-rs" 8 | repository = "https://github.com/adamchalmers/fountain-rs" 9 | readme = "README.md" 10 | keywords = ["fountain", "parsing", "markup"] 11 | categories = ["command-line-utilities", "text-processing"] 12 | license = "Unlicense OR MIT" 13 | 14 | [dependencies] 15 | fountain = { path = "../fountain" } 16 | 17 | [[bin]] 18 | name = "fountain" 19 | path = "src/main.rs" 20 | -------------------------------------------------------------------------------- /fountain-cli/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | #[derive(Debug)] 4 | pub enum FountainError { 5 | ParseError(String), 6 | IOError(io::Error), 7 | } 8 | 9 | impl From for FountainError { 10 | fn from(err: io::Error) -> FountainError { 11 | FountainError::IOError(err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /fountain-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | 3 | use error::FountainError; 4 | use std::env; 5 | use std::fs::File; 6 | use std::io; 7 | use std::io::prelude::*; 8 | 9 | const ERR_UNPARSED: &str = "Parsing stopped before the document ended. Check the formatting of the following section. Unparsed text"; 10 | 11 | fn main() -> Result<(), FountainError> { 12 | let args: Vec = env::args().collect(); 13 | if let Some(path) = args.get(1) { 14 | println!("{}", in_html(&fountain_to_html(path)?)) 15 | } else { 16 | eprintln!("Missing FILEPATH arg"); 17 | eprintln!("usage: $ fountain FILEPATH"); 18 | } 19 | Ok(()) 20 | } 21 | 22 | // Parse the .fountain file at the given filepath 23 | fn fountain_to_html(filepath: &str) -> Result { 24 | let text = read(filepath)?; 25 | match fountain::parse_document::<(&str, _)>(&text) { 26 | Err(e) => Err(FountainError::ParseError(format!("{:?}", e))), 27 | Ok(("", parsed)) => Ok(parsed), 28 | Ok((unparsed, parsed)) => { 29 | eprintln!("{}: {}", ERR_UNPARSED, unparsed); 30 | Ok(parsed) 31 | } 32 | } 33 | } 34 | 35 | fn in_html(parsed: &fountain::data::Document) -> String { 36 | format!( 37 | " 38 | 39 | 40 | 43 | 44 | 45 | {} 46 | 47 | 48 | ", 49 | include_str!("style.css"), 50 | parsed.as_html(), 51 | ) 52 | } 53 | 54 | // Read a file's contents into a string 55 | fn read(filepath: &str) -> Result { 56 | let mut f = File::open(filepath)?; 57 | let mut contents = String::new(); 58 | f.read_to_string(&mut contents)?; 59 | Ok(contents) 60 | } 61 | -------------------------------------------------------------------------------- /fountain-cli/src/style.css: -------------------------------------------------------------------------------- 1 | .scene { 2 | font-weight: bold; 3 | text-align: center; 4 | } 5 | 6 | .dialogue { 7 | width: 400px; 8 | margin: 0 auto; 9 | } 10 | 11 | .titlepage { 12 | text-align: center; 13 | } 14 | 15 | .speaker { 16 | text-align: center; 17 | } 18 | 19 | .parenthetical { 20 | text-align: center; 21 | font-style: italic; 22 | } 23 | 24 | .transition { 25 | text-align: right; 26 | } 27 | 28 | .page-break { 29 | page-break-after: always; 30 | } 31 | 32 | div.dual-dialogue { 33 | display: grid; 34 | grid-template-columns: 1fr 1fr; 35 | grid-template-areas: 36 | "hl hr" 37 | "dl dr"; 38 | width: 400px; 39 | margin: 0 auto; 40 | } 41 | 42 | div.dual-dialogue p.dialogue { 43 | width: auto; 44 | } 45 | 46 | div.dual-dialogue :nth-child(1) { 47 | grid-area: hl; 48 | } 49 | 50 | div.dual-dialogue :nth-child(2) { 51 | grid-area: dl; 52 | } 53 | 54 | div.dual-dialogue :nth-child(3) { 55 | grid-area: hr; 56 | } 57 | 58 | div.dual-dialogue :nth-child(4) { 59 | grid-area: dr; 60 | } 61 | 62 | body { 63 | font-family: monospace, monospace; 64 | max-width: 800px; 65 | } -------------------------------------------------------------------------------- /fountain/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /fountain/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fountain" 3 | version = "0.1.12" 4 | authors = ["Adam Chalmers "] 5 | edition = "2018" 6 | description = "Parser and formatter for the Fountain screenplay markup language" 7 | homepage = "https://github.com/adamchalmers/fountain-rs" 8 | repository = "https://github.com/adamchalmers/fountain-rs" 9 | readme = "README.md" 10 | keywords = ["fountain", "parsing", "markup"] 11 | categories = ["command-line-utilities", "text-processing"] 12 | license = "Unlicense OR MIT" 13 | 14 | [dependencies] 15 | nom = {version = "7", features = ["alloc"], default-features = false } 16 | serde = { version = "1", features = ["derive"], optional = true } 17 | 18 | [features] 19 | use_serde = ["serde"] -------------------------------------------------------------------------------- /fountain/README.md: -------------------------------------------------------------------------------- 1 | # fountain-rs 2 | A library for parsing [Fountain](http://fountain.io) markup. Fountain is used for screen- or stageplays. 3 | 4 | ## Usage 5 | This library parses Fountain markup and can render it into print-friendly HTML. From there, some other program can print it or convert it into a PDF. We use [Nom 5](https://crates.io/crates/nom) for parsing. 6 | 7 | ## Progress 8 | Eventually I would like `fountain-rs` to be fully compliant with the Fountain spec. Only a subset of the spec has currently been implemented. So far these Fountain elements are implemented: 9 | - Action 10 | - Character 11 | - Dialogue 12 | - Scene 13 | - Parenthetical 14 | - Title page 15 | 16 | This project's goal is to replace Amazon's recently-deprecated Storywriter as an easy way to write Fountain docs in a browser. Features will get added when I need them for my own personal use. If `fountain-rs` doesn't support your particular use-case, please open an issue. 17 | 18 | ## License 19 | This is licensed under the MIT License or the Unlicense, whichever is most permissive and legally recognized in your jurisdiction. 20 | -------------------------------------------------------------------------------- /fountain/src/data.rs: -------------------------------------------------------------------------------- 1 | //! Datatypes for storing Fountain documents. 2 | //! If you'd like these types to derive `Serialize` and `Deserialize` using `serde`, please set your 3 | //! dependency on `fountain` to use the `use_serde` feature: 4 | //! `fountain = { version = , features = ["use_serde"] }` 5 | #[cfg(feature = "use_serde")] 6 | use serde::{Deserialize, Serialize}; 7 | 8 | /// A Line represents a line of a screenplay, as defined in the [Fountain spec](https://fountain.io/syntax) 9 | /// This will impl Serialize and Deserialize if the feature "use_serde" is specified. 10 | #[derive(PartialEq, Eq, Clone, Debug)] 11 | #[cfg_attr(feature = "use_serde", derive(Serialize, Deserialize))] 12 | pub enum Line { 13 | /// A [Scene Heading](https://fountain.io/syntax#section-slug) is any line that has a blank line 14 | /// following it, and either begins with INT or EXT. A Scene Heading always has at least one 15 | /// blank line preceding it. 16 | Scene(String), 17 | /// [Action](https://fountain.io/syntax#section-action), or scene description, is any paragraph 18 | /// that doesn't meet criteria for another element (e.g. Scene Heading, Speaker, etc.) 19 | Action(String), 20 | /// [Dialogue](https://fountain.io/syntax#section-dialogue) is any text following a Speaker or 21 | /// Parenthetical element. 22 | Dialogue(String), 23 | /// A [Speaker](https://fountain.io/syntax#section-character) is any line entirely in uppercase. 24 | /// The Fountain spec defines this as a "Character" but this library calls it a Speaker to avoid 25 | /// confusion, as in computer science a character means something different. 26 | /// The `is_dual` field indicates whether this is [Dual Dialogue](https://fountain.io/syntax#section-dual) 27 | /// i.e. the character speaking simultaneously with the previous character. 28 | Speaker { name: String, is_dual: bool }, 29 | /// [Parentheticals](https://fountain.io/syntax#section-paren) are wrapped in parentheses () 30 | /// and end in newline. 31 | Parenthetical(String), 32 | /// [Transitions](https://fountain.io/syntax#section-trans) end in TO. or start with > 33 | Transition(String), 34 | /// [Lyrics](https://fountain.io/syntax#section-lyrics) are lines starting with a tilde (~). 35 | Lyric(String), 36 | } 37 | 38 | impl Line { 39 | pub fn is_scene(&self) -> bool { 40 | matches!(self, Line::Scene(_)) 41 | } 42 | pub fn is_dialogue(&self) -> bool { 43 | matches!(self, Line::Dialogue(_)) 44 | } 45 | pub fn is_action(&self) -> bool { 46 | matches!(self, Line::Action(_)) 47 | } 48 | pub fn is_speaker(&self) -> bool { 49 | matches!(self, Line::Speaker { .. }) 50 | } 51 | pub fn is_parenthetical(&self) -> bool { 52 | matches!(self, Line::Parenthetical(_)) 53 | } 54 | pub fn is_transition(&self) -> bool { 55 | matches!(self, Line::Transition(_)) 56 | } 57 | pub fn is_lyric(&self) -> bool { 58 | matches!(self, Line::Lyric(_)) 59 | } 60 | } 61 | 62 | /// Defines a document's title page. 63 | /// This will impl Serialize and Deserialize if the feature "use_serde" is specified. 64 | /// 65 | /// TitlePage should appear at the start of a screenplay and look 66 | /// like this: 67 | /// ``` 68 | /// use fountain::parse_document; 69 | /// use fountain::data::{TitlePage, Document}; 70 | /// let titlepage = "\ 71 | /// Title: 72 | /// Alien 73 | /// Author: 74 | /// Dan O'Bannon 75 | /// Revision: 76 | /// 8 77 | /// "; 78 | /// let expected_titlepage = TitlePage { 79 | /// title: Some("Alien".to_owned()), 80 | /// author: Some("Dan O'Bannon".to_owned()), 81 | /// other: vec![("Revision".to_owned(), "8".to_owned())], 82 | /// }; 83 | /// let doc = fountain::parse_document::<(&str, _)>(&titlepage); 84 | /// let parsed_titlepage = doc.unwrap().1.titlepage; 85 | /// assert_eq!(parsed_titlepage, expected_titlepage); 86 | /// ``` 87 | #[derive(PartialEq, Eq, Clone, Debug, Default)] 88 | #[cfg_attr(feature = "use_serde", derive(Serialize, Deserialize))] 89 | pub struct TitlePage { 90 | /// Document author 91 | pub author: Option, 92 | /// Document title 93 | pub title: Option, 94 | /// Other items, stored as a vec of key-value pairs. 95 | pub other: Vec<(String, String)>, 96 | } 97 | 98 | /// A Document is the entire screenplay, both title page and its actual contents (stored as Lines). 99 | /// This will impl Serialize and Deserialize if the feature "use_serde" is specified. 100 | #[derive(PartialEq, Eq, Clone, Debug, Default)] 101 | #[cfg_attr(feature = "use_serde", derive(Serialize, Deserialize))] 102 | pub struct Document { 103 | pub lines: Vec, 104 | pub titlepage: TitlePage, 105 | } 106 | -------------------------------------------------------------------------------- /fountain/src/html.rs: -------------------------------------------------------------------------------- 1 | use super::data::*; 2 | use super::utils::*; 3 | 4 | const DD_START: &str = "
"; 5 | const DD_END: &str = "
"; 6 | 7 | fn line_as_html(line: &Line) -> String { 8 | match line { 9 | Line::Scene(s) => format!("

{}

", s), 10 | Line::Action(s) => format!("

{}

", s), 11 | Line::Dialogue(s) => format!("

{}

", s), 12 | Line::Speaker { name, is_dual: _ } => format!("

{}

", name), 13 | Line::Parenthetical(s) => format!("

({})

", s), 14 | Line::Transition(s) => format!("

({})

", s), 15 | Line::Lyric(s) => format!("

({})

", s), 16 | } 17 | } 18 | 19 | impl TitlePage { 20 | fn as_html(&self) -> String { 21 | let title = format!( 22 | "

{}

", 23 | self.title.clone().unwrap_or_else(|| "Untitled".to_string()) 24 | ); 25 | let author = format!( 26 | "

By {}

", 27 | self.author 28 | .clone() 29 | .unwrap_or_else(|| "Author unknown".to_string()) 30 | ); 31 | let other: Vec<_> = self 32 | .other 33 | .iter() 34 | .map(|(k, v)| format!("
{}: {}", k, v)) 35 | .collect(); 36 | let pagebreak = "

".to_string(); 37 | format!( 38 | "{}\n{}\n{}\n{}\n", 39 | title, 40 | author, 41 | other.join("\n"), 42 | pagebreak 43 | ) 44 | } 45 | } 46 | 47 | /// Renders HTML representation of a Fountain document. Root element is a div. 48 | impl Document { 49 | pub fn as_html(&self) -> String { 50 | format!( 51 | "
\n{}\n{}\n
\n", 52 | if self.titlepage == TitlePage::default() { 53 | "".to_owned() 54 | } else { 55 | self.titlepage.as_html() 56 | }, 57 | as_nodes(&self.lines).join("\n") 58 | ) 59 | } 60 | } 61 | 62 | fn as_nodes(lines: &[Line]) -> Vec { 63 | // Render all the lines 64 | let mut nodes: Vec = lines.iter().map(line_as_html).collect(); 65 | 66 | // Now go back and add dual dialogue elements 67 | let n = lines.len(); 68 | for i in 0..n { 69 | if let Line::Speaker { is_dual: true, .. } = lines[i] { 70 | if let Some(dd) = dual_dialogue_bounds(lines, i) { 71 | nodes.insert(dd.start, DD_START.to_owned()); 72 | nodes.insert(dd.end + 1, DD_END.to_owned()); 73 | } 74 | } 75 | } 76 | nodes 77 | } 78 | 79 | // Find the start/end bounds of the dual dialogue block, indicated by a carated Speaker block 80 | // at the index provided. 81 | fn dual_dialogue_bounds(lines: &[Line], dual_dialogue_carat: usize) -> Option { 82 | let start = position_before(lines, dual_dialogue_carat, |line| line.is_speaker()); 83 | let end = position_after(lines, dual_dialogue_carat, |line| line.is_dialogue()); 84 | match (start, end) { 85 | (Some(start), Some(end)) => Some(DualDialogue { 86 | start, 87 | end: end + 1, 88 | }), 89 | _ => None, 90 | } 91 | } 92 | 93 | struct DualDialogue { 94 | pub start: usize, 95 | pub end: usize, 96 | } 97 | -------------------------------------------------------------------------------- /fountain/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `fountain` parses and renders [Fountain markdown](https://fountain.io), which allows you to 2 | //! write screenplays in plain text and render them into beautiful screenplay-formatted documents. 3 | //! This crate currently only implements a subset of the full Fountain spec, but aims to eventually 4 | //! be fully compliant. 5 | //! 6 | //! ## Quick Example 7 | //! 8 | //! ```no_run 9 | //! // Parses a plain text Fountain-markup document and outputs HTML. 10 | //! use nom::error::ErrorKind; 11 | //! 12 | //! const SCREENPLAY: &str = "\ 13 | //! INT. MESS 14 | //! 15 | //! The entire crew is seated. Hungrily swallowing huge portions of artificial food. The cat eats \ 16 | //! from a dish on the table. 17 | //! 18 | //! KANE 19 | //! First thing I'm going to do when we get back is eat some decent food. 20 | //! "; 21 | //! 22 | //! // Parse the Fountain-structured plaintext into a fountain::data::Document 23 | //! let parse_result = fountain::parse_document::<(&str, ErrorKind)>(&SCREENPLAY); 24 | //! match parse_result { 25 | //! Err(e) => eprintln!("Error while parsing the screenplay: {:?}", e), 26 | //! Ok(("", parsed)) => { 27 | //! eprintln!("Successfully parsed the document"); 28 | //! println!("{}", parsed.as_html()); 29 | //! } 30 | //! Ok((unparsed, parsed)) => { 31 | //! eprintln!("Couldn't parse the entire document. Unparsed section:"); 32 | //! eprintln!("{}", unparsed); 33 | //! println!("{}", parsed.as_html()); 34 | //! } 35 | //! } 36 | //! ``` 37 | 38 | pub mod data; 39 | mod html; 40 | mod parse; 41 | mod utils; 42 | pub use parse::document as parse_document; 43 | -------------------------------------------------------------------------------- /fountain/src/parse.rs: -------------------------------------------------------------------------------- 1 | use super::data::*; 2 | use nom::{ 3 | branch::alt, 4 | bytes::complete::{is_not, tag, take_while1}, 5 | character::complete::{char, line_ending, multispace1, not_line_ending}, 6 | combinator::{cut, map, opt, verify}, 7 | error::{context, ContextError, ParseError}, 8 | multi::{many0, separated_list0}, 9 | sequence::{delimited, pair, preceded, terminated, tuple}, 10 | IResult, 11 | }; 12 | 13 | /// Matches strings that contain no lower-case English letters. 14 | fn no_lower<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 15 | i: &'a str, 16 | ) -> IResult<&'a str, &'a str, E> { 17 | let chars = "abcdefghijklmnopqrstuvwxyz\n\r"; 18 | context("no_lower", take_while1(move |c| !chars.contains(c)))(i) 19 | } 20 | 21 | /// Parses an Action. Action, or scene description, is any paragraph that doesn't meet criteria for another 22 | /// element (e.g. Scene Heading, Character, Dialogue, etc.) 23 | /// https://fountain.io/syntax#section-action 24 | fn action<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 25 | i: &'a str, 26 | ) -> IResult<&'a str, Line, E> { 27 | map(context("action", some_line), |s: &str| { 28 | Line::Action(s.to_string()) 29 | })(i) 30 | } 31 | 32 | /// Matches any sequence of non-line-ending characters, terminated by a line ending. 33 | fn some_line<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 34 | i: &'a str, 35 | ) -> IResult<&'a str, &'a str, E> { 36 | terminated(not_line_ending, line_ending)(i) 37 | } 38 | 39 | /// Parses a Dialogue. Dialogue is any text following a Character or Parenthetical element. 40 | /// https://fountain.io/syntax#section-dialogue 41 | fn dialogue<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 42 | i: &'a str, 43 | ) -> IResult<&'a str, Line, E> { 44 | map(terminated(not_line_ending, line_ending), |s: &str| { 45 | Line::Dialogue(s.to_string()) 46 | })(i) 47 | } 48 | 49 | /// Parses a Parenthetical. Parentheticals are wrapped in parentheses () and end in newline. 50 | /// https://fountain.io/syntax#section-paren 51 | fn parenthetical<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 52 | i: &'a str, 53 | ) -> IResult<&'a str, Line, E> { 54 | let parser = terminated(in_parens, cut(line_ending)); 55 | map(context("parenthetical", parser), |s: &str| { 56 | Line::Parenthetical(s.to_string()) 57 | })(i) 58 | } 59 | 60 | /// Matches "(x)" and returns "x" 61 | fn in_parens<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 62 | i: &'a str, 63 | ) -> IResult<&'a str, &'a str, E> { 64 | delimited(char('('), is_not(")"), char(')'))(i) 65 | } 66 | 67 | /// Parses a Speaker. A speaker is simply a Fountain "Character" element, 68 | /// i.e. any line entirely in uppercase and ends in newline. I renamed it "Speaker" interally 69 | /// to avoid confusion with a CS character i.e. a byte. 70 | /// https://fountain.io/syntax#section-character 71 | fn speaker<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 72 | i: &'a str, 73 | ) -> IResult<&'a str, Line, E> { 74 | let parser = terminated(no_lower, line_ending); 75 | map(context("speaker", parser), |s| Line::Speaker { 76 | name: strip_suffix(" ^", s), 77 | is_dual: s.ends_with('^'), 78 | })(i) 79 | } 80 | 81 | /// Parses a Transition, which ends with "TO:" 82 | /// https://fountain.io/syntax#section-trans 83 | fn transition_to<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 84 | i: &'a str, 85 | ) -> IResult<&'a str, Line, E> { 86 | let p = verify(terminated(no_lower, line_ending), |s: &str| { 87 | s.ends_with("TO:") 88 | }); 89 | let parser = map(p, |s| Line::Transition(s.to_owned())); 90 | context("transition_to", parser)(i) 91 | } 92 | 93 | /// Parses a Forced Transition, which either starts with > 94 | /// https://fountain.io/syntax#section-trans 95 | fn transition_forced<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 96 | i: &'a str, 97 | ) -> IResult<&'a str, Line, E> { 98 | let p = preceded(tag("> "), some_line); 99 | let parser = map(p, |s| Line::Transition(s.to_owned())); 100 | context("transition_forced", parser)(i) 101 | } 102 | 103 | /// Parses a Scene Heading. A Scene Heading is any line that has a blank line following it, and either begins with INT or EXT. 104 | /// A Scene Heading always has at least one blank line preceding it. 105 | /// https://fountain.io/syntax#section-slug 106 | fn scene<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 107 | i: &'a str, 108 | ) -> IResult<&'a str, Line, E> { 109 | let parse_scene_type = alt((tag("INT"), tag("EXT"))); 110 | let parser = tuple((parse_scene_type, tag(". "), not_line_ending, line_ending)); 111 | map(context("scene", parser), |(scene_type, _, desc, _)| { 112 | Line::Scene(format!("{}. {}", scene_type, desc)) 113 | })(i) 114 | } 115 | 116 | /// Parses a Lyric. You create a Lyric by starting with a line with a tilde ~. Fountain will remove 117 | /// the '~' and leave it up to the app to style the Lyric appropriately. Lyrics are always forced. 118 | /// There is no "automatic" way to get them. 119 | /// https://fountain.io/syntax#section-lyric 120 | fn lyric<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 121 | i: &'a str, 122 | ) -> IResult<&'a str, Line, E> { 123 | let parser = preceded(char('~'), some_line); 124 | map(context("lyric", parser), |s| Line::Lyric(s.to_owned()))(i) 125 | } 126 | fn titlepage_val<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 127 | i: &'a str, 128 | ) -> IResult<&'a str, &'a str, E> { 129 | let chars = "\n\r:"; 130 | let parser = take_while1(move |c| !chars.contains(c)); 131 | context("titlepage_val", parser)(i) 132 | } 133 | 134 | /// Match a single key-value titlepage item, e.g. 135 | /// Title: 136 | /// THE RING 137 | fn titlepage_item<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 138 | i: &'a str, 139 | ) -> IResult<&'a str, (&str, &str), E> { 140 | let parser = tuple((titlepage_val, char(':'), multispace1, some_line)); 141 | map(context("titlepage_item", parser), |(key, _, _, val)| { 142 | (key, val) 143 | })(i) 144 | } 145 | 146 | /// Matches the document's TitlePage 147 | fn titlepage<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 148 | i: &'a str, 149 | ) -> IResult<&'a str, TitlePage, E> { 150 | map(context("Title page", many0(titlepage_item)), |items| { 151 | let mut m = TitlePage::default(); 152 | for (k, v) in items { 153 | match k { 154 | "Title" => m.title = Some(v.to_string()), 155 | "Author" => m.author = Some(v.to_string()), 156 | _ => m.other.push((k.to_string(), v.to_string())), 157 | } 158 | } 159 | m 160 | })(i) 161 | } 162 | 163 | /// Parses a string slice into a Fountain document. Your input string should end in a 164 | /// newline for parsing to succeed. 165 | /// ``` 166 | /// use fountain::data::{Document, Line}; 167 | /// use nom::error::VerboseError; 168 | /// 169 | /// const SCREENPLAY: &str = "\ 170 | /// KANE 171 | /// First thing I'm going to do when we get back is eat some decent food. 172 | /// "; 173 | /// 174 | /// // Parse the Fountain-structured plaintext into a fountain::data::Document 175 | /// let parse_result = fountain::parse_document::>(&SCREENPLAY); 176 | /// let expected_lines = vec![ 177 | /// Line::Speaker{name: "KANE".to_owned(), is_dual: false}, 178 | /// Line::Dialogue("First thing I'm going to do when we get back is eat some decent \ 179 | /// food.".to_owned()), 180 | /// ]; 181 | /// let expected = Document { lines: expected_lines, ..Default::default() }; 182 | /// assert_eq!(Ok(("", expected)), parse_result); 183 | /// ``` 184 | pub fn document<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 185 | text: &'a str, 186 | ) -> IResult<&'a str, Document, E> { 187 | let parser = pair( 188 | opt(terminated(titlepage, opt(line_ending))), // Documents may begin with a title page 189 | separated_list0(line_ending, block), // Documents must then contain screenplay lines 190 | ); 191 | 192 | map(parser, |(titlepage, blocks)| { 193 | let lines: Vec<_> = blocks.into_iter().flatten().collect(); 194 | Document { 195 | lines, 196 | titlepage: titlepage.unwrap_or_default(), 197 | } 198 | })(text) 199 | } 200 | 201 | /// A block is either: 202 | /// - Speaker then dialogue 203 | /// - Speaker then parenthetical then dialogue 204 | /// - Some Fountain element which is not speaker, dialogue or parenthetical. 205 | fn block<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 206 | i: &'a str, 207 | ) -> IResult<&'a str, Vec, E> { 208 | context( 209 | "block", 210 | alt(( 211 | map(transition_forced, singleton), 212 | map(transition_to, singleton), 213 | map(lyric, singleton), 214 | map(scene, singleton), 215 | spd_block, 216 | sd_block, 217 | map(action, singleton), 218 | )), 219 | )(i) 220 | } 221 | 222 | /// Creates a vector containing only the given element. 223 | fn singleton(t: T) -> Vec { 224 | vec![t] 225 | } 226 | 227 | // Speaker then dialogue 228 | fn sd_block<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 229 | i: &'a str, 230 | ) -> IResult<&'a str, Vec, E> { 231 | let parser = context("sd block", pair(speaker, dialogue)); 232 | map(parser, |lines| vec![lines.0, lines.1])(i) 233 | } 234 | 235 | // Speaker then parenthetical then dialogue 236 | fn spd_block<'a, E: ParseError<&'a str> + ContextError<&'a str>>( 237 | i: &'a str, 238 | ) -> IResult<&'a str, Vec, E> { 239 | let parser = context("spd block", tuple((speaker, parenthetical, dialogue))); 240 | map(parser, |lines| vec![lines.0, lines.1, lines.2])(i) 241 | } 242 | 243 | fn strip_suffix(suffix: &str, string: &str) -> String { 244 | if let Some(stripped) = string.strip_suffix(suffix) { 245 | stripped.to_owned() 246 | } else { 247 | string.to_owned() 248 | } 249 | } 250 | 251 | #[cfg(test)] 252 | mod tests { 253 | use super::*; 254 | use nom::error::{ErrorKind, VerboseError}; 255 | 256 | #[test] 257 | fn test_strip_suffix() { 258 | assert_eq!(strip_suffix(" ^", "Adam ^"), "Adam"); 259 | assert_eq!(strip_suffix(" ^", "Adam"), "Adam"); 260 | } 261 | 262 | #[test] 263 | fn test_titlepage() { 264 | let input_text = "\ 265 | Title: MUPPET TREASURE ISLAND 266 | Author: 267 | Jerry Juhl 268 | Pages: 269 | 223 270 | "; 271 | let output = titlepage::>(input_text); 272 | let expected = TitlePage { 273 | title: Some("MUPPET TREASURE ISLAND".to_string()), 274 | author: Some("Jerry Juhl".to_string()), 275 | other: vec![("Pages".to_string(), "223".to_string())], 276 | }; 277 | let expected = Ok(("", expected)); 278 | assert_eq!(output, expected) 279 | } 280 | 281 | #[test] 282 | fn test_no_lower() { 283 | let input_text = "ADAM CHALMERS"; 284 | let output = no_lower::<(&str, ErrorKind)>(input_text); 285 | let expected = Ok(("", "ADAM CHALMERS")); 286 | assert_eq!(output, expected); 287 | } 288 | 289 | #[test] 290 | fn test_speaker() { 291 | let input_text = "MRS. THOMPSON\nWhat really caused the fall of Rome?\n"; 292 | let output = speaker::<(&str, ErrorKind)>(input_text); 293 | let expected = Ok(( 294 | "What really caused the fall of Rome?\n", 295 | Line::Speaker { 296 | name: "MRS. THOMPSON".to_owned(), 297 | is_dual: false, 298 | }, 299 | )); 300 | assert_eq!(output, expected); 301 | } 302 | 303 | #[test] 304 | fn test_transition() { 305 | let input_text = "FADE TO:\n"; 306 | let output = transition_to::>(input_text); 307 | let expected = Ok(("", Line::Transition("FADE TO:".to_owned()))); 308 | assert_eq!(output, expected); 309 | } 310 | 311 | #[test] 312 | fn test_forced_transition() { 313 | let input_text = "> Burn to white.\n"; 314 | let output = transition_forced::<(&str, ErrorKind)>(input_text); 315 | let expected = Ok(("", Line::Transition("Burn to white.".to_owned()))); 316 | assert_eq!(output, expected); 317 | } 318 | 319 | #[test] 320 | fn test_int_scene() { 321 | let input_text = "INT. Michael's house\n"; 322 | let output = scene::<(&str, ErrorKind)>(input_text); 323 | let expected = Ok(("", Line::Scene("INT. Michael's house".to_owned()))); 324 | assert_eq!(output, expected); 325 | } 326 | 327 | #[test] 328 | fn test_ext_scene() { 329 | let input_text = "EXT. Michael's garden\n"; 330 | let output = scene::<(&str, ErrorKind)>(input_text); 331 | let expected = Ok(("", Line::Scene("EXT. Michael's garden".to_owned()))); 332 | assert_eq!(output, expected); 333 | } 334 | 335 | #[test] 336 | fn test_lyric() { 337 | let input_text = "~For he is an Englishman!\n"; 338 | let output = lyric::<(&str, ErrorKind)>(input_text); 339 | let expected = Ok(("", Line::Lyric("For he is an Englishman!".to_owned()))); 340 | assert_eq!(output, expected); 341 | } 342 | 343 | #[test] 344 | fn test_action() { 345 | let input_text = "MICHAEL drops the plate.\n"; 346 | let output = action::>(input_text); 347 | let expected = Ok(("", Line::Action("MICHAEL drops the plate.".to_owned()))); 348 | assert_eq!(output, expected); 349 | } 350 | 351 | #[test] 352 | fn test_some_line() { 353 | let input_text = "MICHAEL drops the glass\n"; 354 | let output = some_line::>(input_text); 355 | let expected = Ok(("", "MICHAEL drops the glass")); 356 | assert_eq!(output, expected); 357 | } 358 | 359 | #[test] 360 | fn test_in_parens() { 361 | let input_text = "(gasping)"; 362 | let output = in_parens::<(&str, ErrorKind)>(input_text); 363 | assert_eq!(output, Ok(("", "gasping"))); 364 | } 365 | 366 | #[test] 367 | fn test_sd_block() { 368 | let input_text = "LIBRARIAN\nIs anyone there?\n"; 369 | let output = sd_block::<(&str, ErrorKind)>(input_text); 370 | let expected = vec![ 371 | Line::Speaker { 372 | name: "LIBRARIAN".to_string(), 373 | is_dual: false, 374 | }, 375 | Line::Dialogue("Is anyone there?".to_string()), 376 | ]; 377 | assert_eq!(output, Ok(("", expected))); 378 | } 379 | 380 | #[test] 381 | fn test_spd_block() { 382 | let input_text = "LIBRARIAN\n(scared)\nIs anyone there?\n"; 383 | let output = spd_block::<(&str, ErrorKind)>(input_text); 384 | let expected = vec![ 385 | Line::Speaker { 386 | name: "LIBRARIAN".to_string(), 387 | is_dual: false, 388 | }, 389 | Line::Parenthetical("scared".to_string()), 390 | Line::Dialogue("Is anyone there?".to_string()), 391 | ]; 392 | assert_eq!(output, Ok(("", expected))); 393 | } 394 | 395 | #[test] 396 | fn test_parenthetical() { 397 | let input_text = "(gasping)\n"; 398 | let output = parenthetical::<(&str, ErrorKind)>(input_text); 399 | let expected = Ok(("", Line::Parenthetical("gasping".to_owned()))); 400 | assert_eq!(output, expected); 401 | } 402 | 403 | #[test] 404 | fn test_document_tiny() { 405 | let input_text = "INT. Public library 406 | 407 | Lights up on a table, totally empty except for a book. 408 | "; 409 | let output = document::>(input_text); 410 | assert!(output.is_ok()); 411 | let (unparsed, output) = output.unwrap(); 412 | dbg!(&output); 413 | dbg!(&unparsed); 414 | assert_eq!(output.lines.len(), 2); 415 | } 416 | 417 | #[test] 418 | fn test_document_small() { 419 | let input_text = "INT. Public library 420 | 421 | Lights up on a table, totally empty except for a book. 422 | 423 | LIBRARIAN 424 | (scared) 425 | Is anyone there? 426 | 427 | CUT TO: 428 | 429 | EXT. YOGA RETREAT 430 | 431 | > Fade out 432 | "; 433 | let output = document::>(input_text); 434 | assert!(output.is_ok()); 435 | let (unparsed, output) = output.unwrap(); 436 | dbg!(&output); 437 | dbg!(&unparsed); 438 | assert_eq!( 439 | output.lines, 440 | vec![ 441 | Line::Scene("INT. Public library".to_owned()), 442 | Line::Action("Lights up on a table, totally empty except for a book.".to_owned(),), 443 | Line::Speaker { 444 | name: "LIBRARIAN".to_owned(), 445 | is_dual: false 446 | }, 447 | Line::Parenthetical("scared".to_owned(),), 448 | Line::Dialogue("Is anyone there?".to_owned(),), 449 | Line::Transition("CUT TO:".to_owned(),), 450 | Line::Scene("EXT. YOGA RETREAT".to_owned(),), 451 | Line::Transition("Fade out".to_owned(),), 452 | ] 453 | ); 454 | } 455 | 456 | #[test] 457 | fn test_alien() { 458 | let input_text = "\ 459 | INT. MESS 460 | 461 | The entire crew is seated. Hungrily swallowing huge portions of artificial food. The cat eats from a dish on the table. 462 | 463 | KANE 464 | First thing I'm going to do when we get back is eat some decent food. 465 | "; 466 | let output = document::>(input_text); 467 | dbg!(&output); 468 | assert!(output.is_ok()); 469 | let (unparsed, output) = output.unwrap(); 470 | dbg!(&output); 471 | dbg!(&unparsed); 472 | assert_eq!(output.lines.len(), 4); 473 | } 474 | 475 | #[test] 476 | fn test_document() { 477 | let input_text = "\ 478 | Title: 479 | Stephen King Interview 480 | 481 | INT. Set of some morning TV show. 482 | 483 | PAULINE 484 | (cheerily) 485 | Welcome back to In Conversation, I'm your host Pauline Rogers and today we're talking to renowned horror writer Stephen King. Great to have you here, Stephen. 486 | 487 | STEPHEN KING 488 | Thanks for having me, Pauline. 489 | 490 | PAULINE 491 | My pleasure. Now, I'm sure you get asked this all the time, but, where do you get your ideas from? 492 | "; 493 | let output = document::>(input_text); 494 | let expected_lines = vec![ 495 | Line::Scene("INT. Set of some morning TV show.".to_string()), 496 | Line::Speaker{name: "PAULINE".to_string(), is_dual: false}, 497 | Line::Parenthetical("cheerily".to_string()), 498 | Line::Dialogue("Welcome back to In Conversation, I'm your host Pauline Rogers and today we're talking to renowned horror writer Stephen King. Great to have you here, Stephen.".to_string()), 499 | Line::Speaker{name: "STEPHEN KING".to_string(), is_dual: false}, 500 | Line::Dialogue("Thanks for having me, Pauline.".to_string()), 501 | Line::Speaker{name: "PAULINE".to_string(), is_dual: false}, 502 | Line::Dialogue("My pleasure. Now, I'm sure you get asked this all the time, but, where do you get your ideas from?".to_string()), 503 | ]; 504 | let expected_titlepage = TitlePage { 505 | title: Some("Stephen King Interview".to_string()), 506 | ..Default::default() 507 | }; 508 | let expected = Document { 509 | lines: expected_lines, 510 | titlepage: expected_titlepage, 511 | }; 512 | assert!(output.is_ok()); 513 | let (unparsed, output) = output.unwrap(); 514 | dbg!(&output); 515 | dbg!(&unparsed); 516 | assert_eq!(output, expected); 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /fountain/src/utils.rs: -------------------------------------------------------------------------------- 1 | pub fn position_before(slice: &[T], end: usize, predicate: P) -> Option 2 | where 3 | P: Fn(&T) -> bool, 4 | { 5 | (0..end).rev().find(|&i| predicate(&slice[i])) 6 | } 7 | 8 | pub fn position_after(slice: &[T], start: usize, predicate: P) -> Option 9 | where 10 | P: Fn(&T) -> bool, 11 | { 12 | for (i, item) in slice.iter().enumerate().skip(start + 1) { 13 | if predicate(item) { 14 | return Some(i); 15 | } 16 | } 17 | None 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::*; 23 | 24 | #[test] 25 | fn test_position_before() { 26 | let v: Vec<_> = (0..10).collect(); 27 | assert_eq!(Some(3), position_before(&v, 5, |x| x % 2 == 1)); 28 | } 29 | 30 | #[test] 31 | fn test_position_after() { 32 | let v: Vec<_> = (0..10).collect(); 33 | assert_eq!(Some(8), position_after(&v, 6, |x| x % 2 == 0)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env" 5 | ] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # A simple Elm 0.19 docker - https://github.com/simonh1000/docker-elm 10 | - image: hotbelgo/docker-elm 11 | 12 | working_directory: ~/repo 13 | 14 | steps: 15 | - checkout 16 | 17 | # Download and cache dependencies 18 | - restore_cache: 19 | keys: 20 | - v1-dependencies-{{ checksum "package.json" }} 21 | # fallback to using the latest cache if no exact match is found 22 | - v1-dependencies- 23 | 24 | - run: yarn install 25 | 26 | - save_cache: 27 | paths: 28 | - node_modules 29 | key: v1-dependencies-{{ checksum "package.json" }} 30 | 31 | # run (elm) tests! 32 | - run: yarn test 33 | 34 | workflows: 35 | version: 2 36 | build_and_test: 37 | jobs: 38 | - build 39 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | .idea 5 | npm-debug.log* 6 | 7 | 8 | # Compiled binary addons (http://nodejs.org/api/addons.html) 9 | build/Release 10 | 11 | # Dependency directory 12 | node_modules 13 | 14 | # Optional npm cache directory 15 | .npm 16 | yarn.lock 17 | 18 | # Optional REPL history 19 | .node_repl_history 20 | 21 | # elm-package generated files 22 | elm-stuff/ 23 | # elm-repl generated files 24 | repl-temp-* 25 | 26 | .DS_Store 27 | example/dist 28 | 29 | ignore 30 | dist 31 | tests/VerifyExamples 32 | package-lock.json 33 | -------------------------------------------------------------------------------- /frontend/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.0", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.1", 10 | "elm/core": "1.0.2", 11 | "elm/html": "1.0.0", 12 | "elm/http": "2.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/parser": "1.1.0", 15 | "hecrj/html-parser": "2.3.4" 16 | }, 17 | "indirect": { 18 | "elm/bytes": "1.0.7", 19 | "elm/file": "1.0.1", 20 | "elm/time": "1.0.0", 21 | "elm/url": "1.0.0", 22 | "elm/virtual-dom": "1.0.2", 23 | "rtfeldman/elm-hex": "1.0.0" 24 | } 25 | }, 26 | "test-dependencies": { 27 | "direct": { 28 | "elm-explorations/test": "1.2.0" 29 | }, 30 | "indirect": { 31 | "elm/random": "1.0.0" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Adam Chalmers", 3 | "name": "fountain-rs-frontend", 4 | "version": "0.1.0", 5 | "description": "Write plain text, get formatted screenplay", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "elm-test", 9 | "start": "npm run dev", 10 | "dev": "webpack-dev-server --hot --colors --port 3000", 11 | "build": "webpack", 12 | "prod": "webpack -p", 13 | "analyse": "elm-analyse -s -p 3001 -o" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/adamchalmers/fountain-rs" 18 | }, 19 | "license": "Unlicense or MIT", 20 | "devDependencies": { 21 | "@babel/core": "^7.3.4", 22 | "@babel/preset-env": "^7.3.4", 23 | "babel-loader": "^8.0.5", 24 | "clean-webpack-plugin": "^2.0.0", 25 | "copy-webpack-plugin": "^5.0.0", 26 | "css-loader": "^2.1.0", 27 | "elm": "^0.19.0-no-deps", 28 | "elm-analyse": "^0.16.3", 29 | "elm-hot-webpack-loader": "^1.0.2", 30 | "elm-minify": "^2.0.4", 31 | "elm-test": "^0.19.0-rev5", 32 | "elm-webpack-loader": "^5.0.0", 33 | "file-loader": "^3.0.1", 34 | "html-webpack-plugin": "^3.2.0", 35 | "mini-css-extract-plugin": "^0.5.0", 36 | "node-sass": "^4.11.0", 37 | "resolve-url-loader": "^3.0.1", 38 | "sass-loader": "^7.1.0", 39 | "style-loader": "^0.23.1", 40 | "url-loader": "^1.1.2", 41 | "webpack": "^4.29.6", 42 | "webpack-cli": "^3.2.3", 43 | "webpack-dev-server": "^3.2.1", 44 | "webpack-merge": "^4.2.1" 45 | }, 46 | "dependencies": { 47 | "purecss": "^1.0.0" 48 | }, 49 | "prettier": { 50 | "tabWidth": 4 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (Model, Msg(..), ensureTrailingNewline, init, main, update, view) 2 | 3 | import Browser 4 | import Html exposing (..) 5 | import Html.Attributes exposing (..) 6 | import Html.Events exposing (onClick, onInput) 7 | import Html.Parser 8 | import Html.Parser.Util exposing (toVirtualDom) 9 | import Http exposing (Error(..)) 10 | import Json.Decode as Decode 11 | import Json.Encode as Enc 12 | import Parser exposing (deadEndsToString) 13 | import String exposing (endsWith) 14 | 15 | 16 | 17 | -- --------------------------- 18 | -- MODEL 19 | -- --------------------------- 20 | 21 | 22 | {-| The entire app's state. Similar to the Store in React/Redux. 23 | -} 24 | type alias Model = 25 | { plaintextScreenplay : String -- Plain text the user types in, encoded in Fountain markup 26 | , renderedScreenplay : String -- The styled text, generated from the plaintext 27 | , serverMessage : String -- Error messages if the user's markup was invalid 28 | , viewMode : ViewMode 29 | } 30 | 31 | 32 | type ViewMode 33 | = Dual 34 | | Write 35 | | Print 36 | 37 | 38 | {-| What should the Model be when the user starts the app? 39 | -} 40 | init : String -> ( Model, Cmd Msg ) 41 | init flags = 42 | ( { plaintextScreenplay = flags 43 | , serverMessage = "" 44 | , renderedScreenplay = "" 45 | , viewMode = Dual 46 | } 47 | , postScreenplay flags 48 | ) 49 | 50 | 51 | 52 | -- --------------------------- 53 | -- UPDATE 54 | -- --------------------------- 55 | 56 | 57 | {-| Union/enum/ADT of every event that could happen in the app. 58 | -} 59 | type Msg 60 | = ChangeScreenplay String -- User edited their plaintext screenplay 61 | | RenderBtnPress -- User pressed the Render button 62 | | SetViewMode ViewMode -- Change which View Mode to render the UI in 63 | | RenderResponse (Result Http.Error String) -- The backend returned with rendered screenplay 64 | 65 | 66 | {-| Given some Msg, and the current Model, output the new model and a side-effect to execute. 67 | -} 68 | update : Msg -> Model -> ( Model, Cmd Msg ) 69 | update message model = 70 | case message of 71 | ChangeScreenplay s -> 72 | ( { model | plaintextScreenplay = s }, Cmd.none ) 73 | 74 | RenderBtnPress -> 75 | ( model, postScreenplay model.plaintextScreenplay ) 76 | 77 | SetViewMode vm -> 78 | ( { model | viewMode = vm }, postScreenplay model.plaintextScreenplay ) 79 | 80 | RenderResponse res -> 81 | case res of 82 | Ok r -> 83 | ( { model | renderedScreenplay = r }, Cmd.none ) 84 | 85 | Err err -> 86 | ( { model | serverMessage = "Error: " ++ httpErrorToString err }, Cmd.none ) 87 | 88 | 89 | httpErrorToString : Http.Error -> String 90 | httpErrorToString err = 91 | case err of 92 | BadUrl _ -> 93 | "BadUrl" 94 | 95 | Timeout -> 96 | "Timeout" 97 | 98 | NetworkError -> 99 | "NetworkError" 100 | 101 | BadStatus _ -> 102 | "BadStatus" 103 | 104 | BadBody s -> 105 | "BadBody: " ++ s 106 | 107 | 108 | 109 | -- --------------------------- 110 | -- HTTP 111 | -- --------------------------- 112 | 113 | 114 | {-| Send HTTP request to the Fountain backend. Request contains the plaintext screenplay, 115 | response will contain the rendered screenplay. 116 | -} 117 | postScreenplay : String -> Cmd Msg 118 | postScreenplay s = 119 | Http.post 120 | { url = "https://screenplay.page/renderfountain" 121 | , body = 122 | Http.jsonBody <| 123 | Enc.object 124 | [ ( "screenplay", Enc.string <| ensureTrailingNewline s ) 125 | ] 126 | , expect = Http.expectString RenderResponse 127 | } 128 | 129 | 130 | ensureTrailingNewline s = 131 | if endsWith "\n" s then 132 | s 133 | 134 | else 135 | s ++ "\n" 136 | 137 | 138 | 139 | -- --------------------------- 140 | -- VIEW 141 | -- --------------------------- 142 | 143 | 144 | view : Model -> Html Msg 145 | view model = 146 | case model.viewMode of 147 | Print -> 148 | printViewMode model 149 | 150 | Dual -> 151 | dualViewMode model 152 | 153 | Write -> 154 | writeViewMode model 155 | 156 | 157 | writeViewMode : Model -> Html Msg 158 | writeViewMode model = 159 | div [ class "container-write-pane" ] 160 | [ pageHeader model 161 | , div [ class "editor editor-in" ] [ userTextInput model ] 162 | ] 163 | 164 | 165 | printViewMode : Model -> Html Msg 166 | printViewMode model = 167 | div [ class "container-print-pane" ] 168 | [ pageHeader model 169 | , div [ class "editor editor-out" ] (outputPane model) 170 | ] 171 | 172 | 173 | dualViewMode : Model -> Html Msg 174 | dualViewMode model = 175 | div [ class "container-two-pane" ] 176 | [ pageHeader model 177 | , div [ class "editor editor-in" ] 178 | [ userTextInput model 179 | , br [] [] 180 | ] 181 | , div [ class "editor editor-out" ] 182 | (outputPane model) 183 | , footerDiv 184 | ] 185 | 186 | 187 | pageHeader model = 188 | let 189 | maybeBtn = 190 | case model.viewMode of 191 | Write -> 192 | [] 193 | 194 | Dual -> 195 | [ renderBtn ] 196 | 197 | Print -> 198 | [] 199 | 200 | buttons = 201 | maybeBtn 202 | ++ [ viewModeBtn model Dual 203 | , viewModeBtn model Print 204 | , viewModeBtn model Write 205 | ] 206 | in 207 | header [] 208 | [ h1 [] [ text "Screenplay Editor" ] 209 | , div [] buttons 210 | ] 211 | 212 | 213 | viewModeBtn : Model -> ViewMode -> Html Msg 214 | viewModeBtn model viewMode = 215 | let 216 | buttonClass = 217 | if model.viewMode == viewMode then 218 | "pure-button-selected" 219 | 220 | else 221 | "pure-button" 222 | in 223 | button 224 | [ class buttonClass, onClick (SetViewMode viewMode) ] 225 | [ text <| toString viewMode ] 226 | 227 | 228 | toString : ViewMode -> String 229 | toString vm = 230 | case vm of 231 | Write -> 232 | "Write View" 233 | 234 | Print -> 235 | "Print View" 236 | 237 | Dual -> 238 | "Two-Panel View" 239 | 240 | 241 | footerDiv = 242 | footer [] 243 | [ p [] 244 | [ text "Made by " 245 | , link "https://twitter.com/adam_chal" "@adam_chal" 246 | , text ". Parsing done in Rust via my " 247 | , link "https://crates.io/crates/fountain" "Fountain" 248 | , text " crate, which is compiled into WebAssembly and run in the browser via " 249 | , link "https://blog.cloudflare.com/introducing-wrangler-cli/" "Cloudflare Workers" 250 | , text ". Frontend written in Elm. Functionality also available via " 251 | , link "https://github.com/adamchalmers/fountain-rs" "CLI" 252 | , text ". Want to save a PDF? Switch to Print View then use your in-browser print to save as PDF." 253 | ] 254 | ] 255 | 256 | 257 | {-| Convenience function for simpler links 258 | -} 259 | link to txt = 260 | a [ href to, target "_blank" ] [ text txt ] 261 | 262 | 263 | {-| When users click this button, the backend will style their screenplay 264 | -} 265 | renderBtn = 266 | button 267 | [ class "pure-button pure-button-primary", onClick RenderBtnPress ] 268 | [ text "Render screenplay" ] 269 | 270 | 271 | {-| This is where users type their plaintext screenplays 272 | -} 273 | userTextInput model = 274 | textarea 275 | [ onInput ChangeScreenplay 276 | , rows 20 277 | , cols 40 278 | ] 279 | [ text model.plaintextScreenplay ] 280 | 281 | 282 | {-| This is where users see their rendered screenplay 283 | -} 284 | outputPane model = 285 | if model.serverMessage == "" then 286 | case Html.Parser.run model.renderedScreenplay of 287 | Ok html -> 288 | toVirtualDom html 289 | 290 | Err errs -> 291 | [ text <| deadEndsToString errs ] 292 | 293 | else 294 | [ text <| model.serverMessage ] 295 | 296 | 297 | 298 | -- --------------------------- 299 | -- MAIN 300 | -- --------------------------- 301 | 302 | 303 | {-| Wire all the various components together 304 | -} 305 | main : Program String Model Msg 306 | main = 307 | Browser.document 308 | { init = init 309 | , update = update 310 | , view = 311 | \m -> 312 | { title = "Write a screenplay in Elm" 313 | , body = [ view m ] 314 | } 315 | , subscriptions = \_ -> Sub.none 316 | } 317 | -------------------------------------------------------------------------------- /frontend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchalmers/fountain-rs/4946649a3153f5678b115337f94a3e9c0044a480/frontend/src/assets/.gitkeep -------------------------------------------------------------------------------- /frontend/src/assets/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchalmers/fountain-rs/4946649a3153f5678b115337f94a3e9c0044a480/frontend/src/assets/images/.gitkeep -------------------------------------------------------------------------------- /frontend/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchalmers/fountain-rs/4946649a3153f5678b115337f94a3e9c0044a480/frontend/src/assets/images/logo.png -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Write a screenplay with Fountain 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require("./styles.scss"); 4 | 5 | const { Elm } = require('./Main'); 6 | 7 | var app = Elm.Main.init({ 8 | flags: `\ 9 | Title: 10 | Alien 11 | Author: 12 | Dan O'Bannon 13 | 14 | INT. MESS 15 | 16 | The entire crew is seated. Hungrily swallowing huge portions of artificial food. The cat eats from a dish on the table. 17 | 18 | KANE 19 | First thing I'm going to do when we get back is eat some decent food. 20 | 21 | PARKER 22 | I've had worse than this, but I've had better too, if you know what I mean. 23 | 24 | LAMBERT 25 | Christ, you're pounding down this stuff like there's no tomorrow. 26 | 27 | Pause. 28 | 29 | PARKER 30 | I mean I like it. 31 | 32 | KANE 33 | No kidding. 34 | 35 | PARKER 36 | Yeah. It grows on you. 37 | 38 | KANE 39 | It should. You know what they make this stuff out of... 40 | 41 | PARKER 42 | I know what they make it out of. So what. It's food now. You're eating it. 43 | 44 | Suddenly Kane grimaces. 45 | 46 | RIPLEY 47 | What's wrong? 48 | `}); 49 | 50 | app.ports.toJs.subscribe(data => { 51 | console.log(data); 52 | }) 53 | // Use ES2015 syntax and let Babel compile it for you 54 | var testFn = (inp) => { 55 | let a = inp + 1; 56 | return a; 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | @import '~purecss/build/pure.css'; 2 | html { 3 | margin: 0; 4 | height: 95%; 5 | } 6 | 7 | body { 8 | background-color: #222; 9 | color: #eee; 10 | height: 100%; 11 | } 12 | 13 | header { 14 | grid-area: header; 15 | align-items: center; 16 | flex-direction: row; 17 | justify-content: space-between; 18 | padding: 10px; 19 | } 20 | 21 | @media print { 22 | header { 23 | display: none; 24 | } 25 | } 26 | 27 | @media screen { 28 | header { 29 | display: flex; 30 | } 31 | } 32 | 33 | header button { 34 | height: 40px; 35 | } 36 | 37 | footer { 38 | font-size: small; 39 | grid-area: footer; 40 | } 41 | 42 | a { 43 | color: lightblue; 44 | } 45 | 46 | div.dual-dialogue { 47 | display: grid; 48 | grid-template-columns: 1fr 1fr; 49 | grid-template-areas: 50 | "hl hr" 51 | "dl dr"; 52 | width: 400px; 53 | margin: 0 auto; 54 | } 55 | 56 | div.dual-dialogue p.dialogue { 57 | width: auto; 58 | } 59 | 60 | div.dual-dialogue :nth-child(1) { 61 | grid-area: hl; 62 | } 63 | 64 | div.dual-dialogue :nth-child(2) { 65 | grid-area: dl; 66 | } 67 | 68 | div.dual-dialogue :nth-child(3) { 69 | grid-area: hr; 70 | } 71 | 72 | div.dual-dialogue :nth-child(4) { 73 | grid-area: dr; 74 | } 75 | 76 | @media (min-width: 600px) { 77 | .container-two-pane { 78 | display: grid; 79 | width: 100%; 80 | height: 100%; 81 | grid-template-columns: 1fr 1fr; 82 | grid-template-rows: 40px 1fr 40px; 83 | grid-gap: 1rem; 84 | grid-template-areas: 85 | "header header" 86 | "editor-in editor-out" 87 | "footer footer"; 88 | } 89 | 90 | .container-print-pane { 91 | display: grid; 92 | width: 100%; 93 | height: 100%; 94 | grid-gap: 1rem; 95 | grid-template-rows: 40px 1fr; 96 | grid-template-areas: 97 | "header" 98 | "editor-out" 99 | } 100 | 101 | .container-write-pane { 102 | display: grid; 103 | width: 100%; 104 | height: 100%; 105 | grid-gap: 1rem; 106 | grid-template-rows: 40px 1fr; 107 | grid-template-areas: 108 | "header" 109 | "editor-in" 110 | } 111 | 112 | .editor-in textarea { 113 | width: 100%; 114 | height: 100%; 115 | } 116 | } 117 | 118 | .editor { 119 | font-family: monospace; 120 | color: black; 121 | } 122 | 123 | .editor-in { 124 | grid-area: editor-in; 125 | } 126 | 127 | .editor-in textarea { 128 | width: 100%; 129 | } 130 | 131 | .editor-out { 132 | grid-area: editor-out; 133 | background-color: #eee; 134 | padding: 0 20px; 135 | } 136 | 137 | @media screen { 138 | editor-out { 139 | border: 1px solid gray; 140 | } 141 | } 142 | 143 | // Fountain styling 144 | 145 | .scene { 146 | font-weight: bold; 147 | text-align: center; 148 | } 149 | 150 | .dialogue { 151 | width: 400px; 152 | margin: 0 auto; 153 | } 154 | 155 | .titlepage { 156 | text-align: center; 157 | } 158 | 159 | .speaker { 160 | text-align: center; 161 | } 162 | 163 | .parenthetical { 164 | text-align: center; 165 | font-style: italic; 166 | } 167 | 168 | .transition { 169 | text-align: right; 170 | } 171 | 172 | .page-break { 173 | page-break-after: always; 174 | } 175 | -------------------------------------------------------------------------------- /frontend/tests/Example.elm: -------------------------------------------------------------------------------- 1 | module Example exposing (unitTest) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (Fuzzer, int, list, string) 5 | import Main exposing (..) 6 | import Test exposing (..) 7 | import Test.Html.Query as Query 8 | import Test.Html.Selector exposing (tag, text) 9 | 10 | 11 | {-| See 12 | -} 13 | unitTest : Test 14 | unitTest = 15 | describe "ensureTrailingNewline" 16 | [ test "string ends with newline" <| 17 | \() -> 18 | ensureTrailingNewline "adam\n" 19 | |> Expect.equal "adam\n" 20 | , test "string without newline" <| 21 | \() -> 22 | ensureTrailingNewline "adam" 23 | |> Expect.equal "adam\n" 24 | ] 25 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const merge = require("webpack-merge"); 4 | const elmMinify = require("elm-minify"); 5 | 6 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 7 | const HTMLWebpackPlugin = require("html-webpack-plugin"); 8 | const CleanWebpackPlugin = require("clean-webpack-plugin"); 9 | // to extract the css as a separate file 10 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 11 | 12 | var MODE = 13 | process.env.npm_lifecycle_event === "prod" ? "production" : "development"; 14 | var withDebug = !process.env["npm_config_nodebug"]; 15 | // this may help for Yarn users 16 | // var withDebug = !npmParams.includes("--nodebug"); 17 | console.log('\x1b[36m%s\x1b[0m', `** elm-webpack-starter: mode "${MODE}", withDebug: ${withDebug}\n`); 18 | 19 | var common = { 20 | mode: MODE, 21 | entry: "./src/index.js", 22 | output: { 23 | path: path.join(__dirname, "dist"), 24 | publicPath: "/", 25 | // FIXME webpack -p automatically adds hash when building for production 26 | filename: MODE == "production" ? "[name]-[hash].js" : "index.js" 27 | }, 28 | plugins: [ 29 | new HTMLWebpackPlugin({ 30 | // Use this template to get basic responsive meta tags 31 | template: "src/index.html", 32 | // inject details of output file at end of body 33 | inject: "body" 34 | }) 35 | ], 36 | resolve: { 37 | modules: [path.join(__dirname, "src"), "node_modules"], 38 | extensions: [".js", ".elm", ".scss", ".png"] 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.js$/, 44 | exclude: /node_modules/, 45 | use: { 46 | loader: "babel-loader" 47 | } 48 | }, 49 | { 50 | test: /\.scss$/, 51 | exclude: [/elm-stuff/, /node_modules/], 52 | // see https://github.com/webpack-contrib/css-loader#url 53 | loaders: ["style-loader", "css-loader?url=false", "sass-loader"] 54 | }, 55 | { 56 | test: /\.css$/, 57 | exclude: [/elm-stuff/, /node_modules/], 58 | loaders: ["style-loader", "css-loader?url=false"] 59 | }, 60 | { 61 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 62 | exclude: [/elm-stuff/, /node_modules/], 63 | loader: "url-loader", 64 | options: { 65 | limit: 10000, 66 | mimetype: "application/font-woff" 67 | } 68 | }, 69 | { 70 | test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 71 | exclude: [/elm-stuff/, /node_modules/], 72 | loader: "file-loader" 73 | }, 74 | { 75 | test: /\.(jpe?g|png|gif|svg)$/i, 76 | exclude: [/elm-stuff/, /node_modules/], 77 | loader: "file-loader" 78 | } 79 | ] 80 | } 81 | }; 82 | 83 | if (MODE === "development") { 84 | module.exports = merge(common, { 85 | plugins: [ 86 | // Suggested for hot-loading 87 | new webpack.NamedModulesPlugin(), 88 | // Prevents compilation errors causing the hot loader to lose state 89 | new webpack.NoEmitOnErrorsPlugin() 90 | ], 91 | module: { 92 | rules: [ 93 | { 94 | test: /\.elm$/, 95 | exclude: [/elm-stuff/, /node_modules/], 96 | use: [ 97 | { loader: "elm-hot-webpack-loader" }, 98 | { 99 | loader: "elm-webpack-loader", 100 | options: { 101 | // add Elm's debug overlay to output 102 | debug: withDebug, 103 | // 104 | forceWatch: true 105 | } 106 | } 107 | ] 108 | } 109 | ] 110 | }, 111 | devServer: { 112 | inline: true, 113 | stats: "errors-only", 114 | contentBase: path.join(__dirname, "src/assets"), 115 | historyApiFallback: true, 116 | // feel free to delete this section if you don't need anything like this 117 | before(app) { 118 | // on port 3000 119 | app.get("/test", function(req, res) { 120 | res.json({ result: "OK" }); 121 | }); 122 | } 123 | } 124 | }); 125 | } 126 | if (MODE === "production") { 127 | module.exports = merge(common, { 128 | plugins: [ 129 | // Minify elm code 130 | new elmMinify.WebpackPlugin(), 131 | // Delete everything from output-path (/dist) and report to user 132 | new CleanWebpackPlugin({ 133 | root: __dirname, 134 | exclude: [], 135 | verbose: true, 136 | dry: false 137 | }), 138 | // Copy static assets 139 | new CopyWebpackPlugin([ 140 | { 141 | from: "src/assets" 142 | } 143 | ]), 144 | new MiniCssExtractPlugin({ 145 | // Options similar to the same options in webpackOptions.output 146 | // both options are optional 147 | filename: "[name]-[hash].css" 148 | }) 149 | ], 150 | module: { 151 | rules: [ 152 | { 153 | test: /\.elm$/, 154 | exclude: [/elm-stuff/, /node_modules/], 155 | use: { 156 | loader: "elm-webpack-loader", 157 | options: { 158 | optimize: true 159 | } 160 | } 161 | }, 162 | { 163 | test: /\.css$/, 164 | exclude: [/elm-stuff/, /node_modules/], 165 | loaders: [ 166 | MiniCssExtractPlugin.loader, 167 | "css-loader?url=false" 168 | ] 169 | }, 170 | { 171 | test: /\.scss$/, 172 | exclude: [/elm-stuff/, /node_modules/], 173 | loaders: [ 174 | MiniCssExtractPlugin.loader, 175 | "css-loader?url=false", 176 | "sass-loader" 177 | ] 178 | } 179 | ] 180 | } 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /frontend/workers-site/.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamchalmers/fountain-rs/4946649a3153f5678b115337f94a3e9c0044a480/frontend/workers-site/.cargo-ok -------------------------------------------------------------------------------- /frontend/workers-site/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | worker 3 | -------------------------------------------------------------------------------- /frontend/workers-site/index.js: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler' 2 | 3 | /** 4 | * The DEBUG flag will do two things that help during development: 5 | * 1. we will skip caching on the edge, which makes it easier to 6 | * debug. 7 | * 2. we will return an error message on exception in your Response rather 8 | * than the default 404.html page. 9 | */ 10 | const DEBUG = false 11 | 12 | addEventListener('fetch', event => { 13 | try { 14 | event.respondWith(handleEvent(event)) 15 | } catch (e) { 16 | if (DEBUG) { 17 | return event.respondWith( 18 | new Response(e.message || e.toString(), { 19 | status: 500, 20 | }), 21 | ) 22 | } 23 | event.respondWith(new Response('Internal Error', { status: 500 })) 24 | } 25 | }) 26 | 27 | async function handleEvent(event) { 28 | const url = new URL(event.request.url) 29 | let options = {} 30 | 31 | /** 32 | * You can add custom logic to how we fetch your assets 33 | * by configuring the function `mapRequestToAsset` 34 | */ 35 | // options.mapRequestToAsset = handlePrefix(/^\/gui/) 36 | 37 | try { 38 | if (DEBUG) { 39 | // customize caching 40 | options.cacheControl = { 41 | bypassCache: true, 42 | } 43 | } 44 | return await getAssetFromKV(event, options) 45 | } catch (e) { 46 | // if an error is thrown try to serve the asset at 404.html 47 | if (!DEBUG) { 48 | try { 49 | let notFoundResponse = await getAssetFromKV(event, { 50 | mapRequestToAsset: req => new Request(`${new URL(req.url).origin}/404.html`, req), 51 | }) 52 | 53 | return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 }) 54 | } catch (e) { } 55 | } 56 | 57 | return new Response(e.message || e.toString(), { status: 500 }) 58 | } 59 | } 60 | 61 | /** 62 | * Here's one example of how to modify a request to 63 | * remove a specific prefix, in this case `/docs` from 64 | * the url. This can be useful if you are deploying to a 65 | * route on a zone, or if you only want your static content 66 | * to exist at a specific path. 67 | */ 68 | function handlePrefix(prefix) { 69 | return request => { 70 | // compute the default (e.g. / -> index.html) 71 | let defaultAssetKey = mapRequestToAsset(request) 72 | let url = new URL(defaultAssetKey.url) 73 | 74 | // strip the prefix from the path for lookup 75 | url.pathname = url.pathname.replace(prefix, '/') 76 | 77 | // inherit all other props from the default request 78 | return new Request(url.toString(), defaultAssetKey) 79 | } 80 | } -------------------------------------------------------------------------------- /frontend/workers-site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "worker", 4 | "version": "1.0.0", 5 | "description": "A template for kick starting a Cloudflare Workers project", 6 | "main": "index.js", 7 | "author": "Ashley Lewis ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "@cloudflare/kv-asset-handler": "^0.0.5" 11 | } 12 | } 13 | --------------------------------------------------------------------------------