├── .cirrus.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Changelog.md ├── LICENSE ├── Makefile ├── README.md └── src ├── lib.rs ├── main.rs └── wasm.rs /.cirrus.yml: -------------------------------------------------------------------------------- 1 | env: 2 | PATH: "$HOME/.cargo/bin:$PATH" 3 | RUST_VERSION: '1.70.0' # Needs to be <= FreeBSD version 4 | WASM_VERSION: '1.73.0' 5 | AWS_ACCESS_KEY_ID: ENCRYPTED[5c3f77d4196c6b47340a4f07f6dec015753bf26df6f7323467217282d614988db7568c67785129a46a6de7fe8117c2af] 6 | AWS_SECRET_ACCESS_KEY: ENCRYPTED[4c49da1c017860dd66710621efb3edb855c7da0dcad438a7bf9a3dbf41adfd15905cbe14df679ba2a83cff5a9ec2afdc] 7 | 8 | task: 9 | name: Build (Alpine Linux) 10 | container: 11 | image: alpine:3.21 12 | cpu: 4 13 | environment: 14 | RUST_VERSION: "stable" 15 | cargo_cache: 16 | folder: $HOME/.cargo/registry 17 | fingerprint_script: cat Cargo.toml 18 | install_script: 19 | - apk --update add curl git gcc musl-dev 20 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain ${RUST_VERSION} 21 | test_script: 22 | - cargo test 23 | before_cache_script: rm -rf $HOME/.cargo/registry/index 24 | 25 | task: 26 | name: Build (Debian Linux) 27 | container: 28 | image: debian:12-slim 29 | cpu: 4 30 | cargo_cache: 31 | folder: $HOME/.cargo/registry 32 | fingerprint_script: cat Cargo.lock 33 | install_script: 34 | - apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl gcc libc6-dev musl-tools 35 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain ${RUST_VERSION} 36 | - rustup target add x86_64-unknown-linux-musl 37 | - mkdir ~/bin 38 | - curl -L https://releases.wezm.net/upload-to-s3/0.2.0/upload-to-s3-0.2.0-x86_64-unknown-linux-musl.tar.gz | tar xzf - -C ~/bin 39 | test_script: 40 | - cargo test 41 | publish_script: | 42 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 43 | if [ -n "$tag" ]; then 44 | cargo build --release --locked --target x86_64-unknown-linux-musl 45 | tarball="titlecase-${tag}-x86_64-unknown-linux-musl.tar.gz" 46 | strip target/x86_64-unknown-linux-musl/release/titlecase 47 | tar zcf "$tarball" -C target/x86_64-unknown-linux-musl/release titlecase 48 | ~/bin/upload-to-s3 -b releases.wezm.net "$tarball" "titlecase/$tag/$tarball" 49 | fi 50 | before_cache_script: rm -rf $HOME/.cargo/registry/index 51 | 52 | task: 53 | name: Build (Web Assembly) 54 | container: 55 | image: debian:12-slim 56 | cpu: 4 57 | cargo_cache: 58 | folder: $HOME/.cargo/registry 59 | fingerprint_script: cat Cargo.lock 60 | install_script: 61 | - apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl gcc libc6-dev 62 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain ${WASM_VERSION} 63 | - rustup target add wasm32-unknown-unknown 64 | build_script: 65 | - cargo build --lib --features wasm --target wasm32-unknown-unknown 66 | before_cache_script: rm -rf $HOME/.cargo/registry/index 67 | 68 | task: 69 | name: Build (FreeBSD) 70 | freebsd_instance: 71 | image_family: freebsd-13-4 72 | cpu: 4 73 | cargo_cache: 74 | folder: $HOME/.cargo/registry 75 | fingerprint_script: cat Cargo.lock 76 | install_script: 77 | - pkg install -y git-lite rust ca_root_nss 78 | - fetch -o - https://releases.wezm.net/upload-to-s3/0.2.0/upload-to-s3-0.2.0-amd64-unknown-freebsd.tar.gz | tar xzf - -C /usr/local/bin 79 | test_script: 80 | - cargo test 81 | publish_script: | 82 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 83 | if [ -n "$tag" ]; then 84 | cargo build --release --locked 85 | tarball="titlecase-${tag}-amd64-unknown-freebsd.tar.gz" 86 | strip target/release/titlecase 87 | tar zcf "$tarball" -C target/release titlecase 88 | upload-to-s3 -b releases.wezm.net "$tarball" "titlecase/$tag/$tarball" 89 | fi 90 | before_cache_script: rm -rf $HOME/.cargo/registry/index 91 | 92 | task: 93 | name: Build (Mac OS) 94 | macos_instance: 95 | image: ghcr.io/cirruslabs/macos-monterey-base:latest 96 | env: 97 | PATH: "$HOME/.cargo/bin:$HOME/bin:$PATH" 98 | cargo_cache: 99 | folder: $HOME/.cargo/registry 100 | fingerprint_script: cat Cargo.lock 101 | install_script: 102 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain ${RUST_VERSION} 103 | - mkdir ~/bin 104 | - curl -L https://releases.wezm.net/upload-to-s3/0.2.0/upload-to-s3-0.2.0-universal-apple-darwin.tar.gz | tar xzf - -C ~/bin 105 | - rustup target add x86_64-apple-darwin 106 | test_script: 107 | - cargo test 108 | publish_script: | 109 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 110 | if [ -n "$tag" ]; then 111 | cargo build --release --locked 112 | cargo build --release --locked --target x86_64-apple-darwin 113 | mv target/release/titlecase target/release/titlecase.$CIRRUS_ARCH 114 | lipo target/release/titlecase.$CIRRUS_ARCH target/x86_64-apple-darwin/release/titlecase -create -output target/release/titlecase 115 | lipo -info target/release/titlecase 116 | tarball="titlecase-${tag}-universal-apple-darwin.tar.gz" 117 | strip target/release/titlecase 118 | tar zcf "$tarball" -C target/release titlecase 119 | upload-to-s3 -b releases.wezm.net "$tarball" "titlecase/$tag/$tarball" 120 | fi 121 | before_cache_script: rm -rf $HOME/.cargo/registry/index 122 | 123 | task: 124 | name: Build (Windows) 125 | windows_container: 126 | image: cirrusci/windowsservercore:cmake 127 | cpu: 4 128 | cargo_cache: 129 | folder: $HOME/.cargo/registry 130 | fingerprint_script: cat Cargo.lock 131 | environment: 132 | CIRRUS_SHELL: powershell 133 | install_script: 134 | - Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe 135 | - .\rustup-init -y --profile minimal --default-toolchain $env:RUST_VERSION 136 | - Invoke-WebRequest https://releases.wezm.net/upload-to-s3/0.2.0/upload-to-s3-0.2.0-x86_64-pc-windows-msvc.zip -OutFile upload-to-s3.zip 137 | - Expand-Archive upload-to-s3.zip -DestinationPath . 138 | - git fetch --tags 139 | test_script: | 140 | ~\.cargo\bin\cargo test 141 | if ($LASTEXITCODE) { Throw } 142 | publish_script: | 143 | try { 144 | $tag=$(git describe --exact-match HEAD 2>$null) 145 | if ($LASTEXITCODE) { Throw } 146 | } catch { 147 | $tag="" 148 | } 149 | if ( $tag.Length -gt 0 ) { 150 | ~\.cargo\bin\cargo build --release --locked 151 | if ($LASTEXITCODE) { Throw } 152 | $tarball="titlecase-$tag-x86_64-pc-windows-msvc.zip" 153 | cd target\release 154 | strip titlecase.exe 155 | if ($LASTEXITCODE) { Throw } 156 | Compress-Archive .\titlecase.exe "$tarball" 157 | cd ..\.. 158 | .\upload-to-s3 -b releases.wezm.net "target\release\$tarball" "titlecase/$tag/$tarball" 159 | if ($LASTEXITCODE) { Throw } 160 | } 161 | before_cache_script: Remove-Item -Recurse -Force -Path $HOME/.cargo/registry/index 162 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | /wasm 3 | **/*.rs.bk 4 | -------------------------------------------------------------------------------- /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 = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "bumpalo" 16 | version = "3.16.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 19 | 20 | [[package]] 21 | name = "cfg-if" 22 | version = "1.0.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 25 | 26 | [[package]] 27 | name = "log" 28 | version = "0.4.21" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" 31 | 32 | [[package]] 33 | name = "memchr" 34 | version = "2.7.2" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" 37 | 38 | [[package]] 39 | name = "once_cell" 40 | version = "1.19.0" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 43 | 44 | [[package]] 45 | name = "proc-macro2" 46 | version = "1.0.81" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" 49 | dependencies = [ 50 | "unicode-ident", 51 | ] 52 | 53 | [[package]] 54 | name = "quote" 55 | version = "1.0.36" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 58 | dependencies = [ 59 | "proc-macro2", 60 | ] 61 | 62 | [[package]] 63 | name = "regex" 64 | version = "1.10.4" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" 67 | dependencies = [ 68 | "aho-corasick", 69 | "memchr", 70 | "regex-automata", 71 | "regex-syntax", 72 | ] 73 | 74 | [[package]] 75 | name = "regex-automata" 76 | version = "0.4.6" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" 79 | dependencies = [ 80 | "aho-corasick", 81 | "memchr", 82 | "regex-syntax", 83 | ] 84 | 85 | [[package]] 86 | name = "regex-syntax" 87 | version = "0.8.3" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" 90 | 91 | [[package]] 92 | name = "syn" 93 | version = "2.0.60" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" 96 | dependencies = [ 97 | "proc-macro2", 98 | "quote", 99 | "unicode-ident", 100 | ] 101 | 102 | [[package]] 103 | name = "titlecase" 104 | version = "3.4.0" 105 | dependencies = [ 106 | "regex", 107 | "wasm-bindgen", 108 | ] 109 | 110 | [[package]] 111 | name = "unicode-ident" 112 | version = "1.0.12" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 115 | 116 | [[package]] 117 | name = "wasm-bindgen" 118 | version = "0.2.92" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" 121 | dependencies = [ 122 | "cfg-if", 123 | "wasm-bindgen-macro", 124 | ] 125 | 126 | [[package]] 127 | name = "wasm-bindgen-backend" 128 | version = "0.2.92" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" 131 | dependencies = [ 132 | "bumpalo", 133 | "log", 134 | "once_cell", 135 | "proc-macro2", 136 | "quote", 137 | "syn", 138 | "wasm-bindgen-shared", 139 | ] 140 | 141 | [[package]] 142 | name = "wasm-bindgen-macro" 143 | version = "0.2.92" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" 146 | dependencies = [ 147 | "quote", 148 | "wasm-bindgen-macro-support", 149 | ] 150 | 151 | [[package]] 152 | name = "wasm-bindgen-macro-support" 153 | version = "0.2.92" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" 156 | dependencies = [ 157 | "proc-macro2", 158 | "quote", 159 | "syn", 160 | "wasm-bindgen-backend", 161 | "wasm-bindgen-shared", 162 | ] 163 | 164 | [[package]] 165 | name = "wasm-bindgen-shared" 166 | version = "0.2.92" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" 169 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "titlecase" 3 | description = "Capitalize text according to a style defined by John Gruber for Daring Fireball." 4 | version = "3.4.0" 5 | edition = "2021" 6 | authors = ["Wesley Moore "] 7 | rust-version = "1.70.0" 8 | 9 | documentation = "https://docs.rs/titlecase" 10 | repository = "https://github.com/wezm/titlecase" 11 | 12 | readme = "README.md" 13 | license = "MIT" 14 | 15 | keywords = ["title", "case", "capitalization", "capitalisation", "wasm"] 16 | categories = ["text-processing"] 17 | 18 | [lib] 19 | # cdylib is for WASM 20 | crate-type = ["cdylib", "rlib"] 21 | 22 | [dependencies] 23 | regex = { version = "1.10", default-features = false, features = ["std", "unicode-perl"]} 24 | wasm-bindgen = { version = "0.2.92", optional = true } 25 | 26 | [features] 27 | default = ["perf"] 28 | perf = ["regex/perf"] 29 | wasm = ["dep:wasm-bindgen"] 30 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## [3.4.0](https://github.com/wezm/titlecase/releases/tag/v3.4.0) 5 | 6 | - Add `Titlecase` trait and implementation to allow calling `.titlecase()` on 7 | `AsRef` types [#31](https://github.com/wezm/titlecase/pull/31). Thanks 8 | @carlocorradini 9 | 10 | ## [3.3.0](https://github.com/wezm/titlecase/releases/tag/v3.3.0) 11 | 12 | - Introduce `wasm` cargo feature to enable wasm functionality 13 | to address [#26](https://github.com/wezm/titlecase/issues/26). 14 | 15 | ## [3.2.0](https://github.com/wezm/titlecase/releases/tag/v3.2.0) 16 | 17 | - Introduce `perf` cargo feature tied to the feature of the same 18 | name in the regex crate. 19 | - This allows building for wasm with this feature disabled, 20 | producing a smaller wasm module. 21 | 22 | ## [3.1.1](https://github.com/wezm/titlecase/releases/tag/v3.1.1) 23 | 24 | - Tweak Cargo metadata to make crates.io accept it 25 | 26 | ## [3.1.0](https://github.com/wezm/titlecase/releases/tag/v3.1.0) 27 | 28 | - Add wasm build [#23](https://github.com/wezm/titlecase/pull/23). 29 | 30 | ## [3.0.0](https://github.com/wezm/titlecase/releases/tag/v3.0.0) 31 | 32 | - Update regex dependency [#20](https://github.com/wezm/titlecase/pull/20). 33 | Slim down `regex` crate default features. 34 | - Remove joinery dependency [#19](https://github.com/wezm/titlecase/pull/19) 35 | - Use OnceLock instead of LazyStatic [#18](https://github.com/wezm/titlecase/pull/18) 36 | - Minimum Supported Rust Version is now 1.70.0 [#17](https://github.com/wezm/titlecase/pull/17) 37 | 38 | ## [2.2.0](https://github.com/wezm/titlecase/releases/tag/v2.2.0) 39 | 40 | - Further reduce allocations and optimise regex use 41 | 42 | ## [2.1.0](https://github.com/wezm/titlecase/releases/tag/v2.1.0) 43 | 44 | - Lowercase small words that are uppercase #7 45 | - Clean up and reduce intermediate allocations #8 46 | 47 | ## [2.0.0](https://github.com/wezm/titlecase/releases/tag/v2.0.0) 48 | 49 | - Update dependencies 50 | - Minimum Supported Rust Version is now 1.40.0 51 | 52 | ## [1.1.0](https://github.com/wezm/titlecase/releases/tag/v1.1.0) 53 | 54 | - Update dependencies 55 | - Add help and version flags to CLI 56 | 57 | ## [0.10.0](https://github.com/wezm/titlecase/releases/tag/v0.10.0) 58 | 59 | - Improve documentation 60 | - Make use of regular expressions more efficient 61 | - Errors encountered by the titlecase tool are now written to stderr 62 | 63 | ## [0.9.2](https://github.com/wezm/titlecase/releases/tag/v0.9.2) 64 | 65 | Fix typos in Cargo.toml 66 | 67 | ## [0.9.1](https://github.com/wezm/titlecase/releases/tag/0.9.1) 68 | 69 | Initial release 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is Copyright (c) 2015-2019 by John Gruber, 2 | Aristotle Pagaltzis, David Gouch, Wesley Moore. 3 | 4 | This is free software, licensed under: 5 | 6 | The MIT (X11) License 7 | 8 | The MIT License 9 | 10 | Permission is hereby granted, free of charge, to any person 11 | obtaining a copy of this software and associated documentation 12 | files (the "Software"), to deal in the Software without 13 | restriction, including without limitation the rights to use, 14 | copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the 16 | Software is furnished to do so, subject to the following 17 | conditions: 18 | 19 | The above copyright notice and this permission notice shall be 20 | included in all copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 24 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 25 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 26 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 27 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 28 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 29 | OTHER DEALINGS IN THE SOFTWARE. 30 | 31 | 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: wasm/titlecase.js 2 | 3 | wasm/titlecase.js: target/wasm32-unknown-unknown/release/titlecase.wasm 4 | wasm-bindgen target/wasm32-unknown-unknown/release/titlecase.wasm --target web --out-dir wasm 5 | 6 | target/wasm32-unknown-unknown/release/titlecase.wasm: 7 | cargo build --release --lib \ 8 | --no-default-features \ 9 | --features wasm \ 10 | --target wasm32-unknown-unknown \ 11 | --config "profile.release.debug=true" \ 12 | --config "profile.release.lto=true" 13 | 14 | .PHONY: target/wasm32-unknown-unknown/release/titlecase.wasm 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Title Case (titlecase) 2 | 3 | `titlecase` is a small tool and library (crate) that capitalizes English text 4 | [according to a style][style] defined by John Gruber for post titles on his 5 | website [Daring Fireball]. `titlecase` should run on all platforms supported 6 | by Rust including Linux, macOS, FreeBSD, NetBSD, OpenBSD, and Windows. 7 | 8 | [![Build Status](https://api.cirrus-ci.com/github/wezm/titlecase.svg)](https://cirrus-ci.com/github/wezm/titlecase) 9 | [![crates.io](https://img.shields.io/crates/v/titlecase.svg)](https://crates.io/crates/titlecase) 10 | [![Documentation](https://docs.rs/titlecase/badge.svg)][crate-docs] 11 | [![License](https://img.shields.io/crates/l/titlecase.svg)][MIT] 12 | 13 | ## Try Online 14 | 15 | 16 | 17 | ## Command Line Usage 18 | 19 | `titlecase` reads lines of text from **stdin** and prints title cased versions 20 | to **stdout**. 21 | 22 | ### Examples 23 | 24 | ``` 25 | % echo 'Being productive on linux' | titlecase 26 | Being Productive on Linux 27 | 28 | % echo 'Finding an alternative to Mac OS X — part 2' | titlecase 29 | Finding an Alternative to Mac OS X — Part 2 30 | 31 | % echo 'an example with small words and sub-phrases: "the example"' | titlecase 32 | An Example With Small Words and Sub-Phrases: "The Example" 33 | ``` 34 | 35 | ## Install 36 | 37 | ### Pre-compiled binaries 38 | 39 | Pre-compiled binaries are available for some platforms, check the 40 | [latest release](https://github.com/wezm/titlecase/releases/latest). 41 | 42 | ### From Source 43 | 44 | If you have a stable [Rust compiler toolchain][rustup] installed you can 45 | install the most recently released `titlecase` with cargo: 46 | 47 | cargo install titlecase 48 | 49 | ## Usage as a Rust Crate 50 | 51 | **Minimum Supported Rust Version:** 1.70.0 52 | 53 | See the [crate documentation][crate-docs]. 54 | 55 | ## Building for WebAssembly 56 | 57 | ### Pre-requisites 58 | 59 | - Rust 1.73.0+ 60 | - Rust `wasm32-unknown-unknown` target 61 | (`rustup target add wasm32-unknown-unknown` or `rust-wasm` package on Chimera Linux) 62 | - [wasm-bindgen] 63 | (`wasm-bindgen` package on Arch, or `cargo install wasm-bindgen-cli --version 0.2.92`) 64 | - `make` (GNU or BSD should work) 65 | 66 | ### Building 67 | 68 | There is a `Makefile` that automates building for WebAssembly. 69 | 70 | make 71 | 72 | The output is put into a `wasm` directory. See 73 | for an 74 | example that uses the wasm build. 75 | 76 | ## Style 77 | 78 | Instead of simply capitalizing each word `titlecase` does the following 79 | ([amongst other things][style]): 80 | 81 | * Lower case small words like an, of, or in. 82 | * Don't capitalize words like iPhone. 83 | * Don't interfere with file paths, URLs, domains, and email addresses. 84 | * Always capitalize the first and last words, even if they are small words 85 | or surrounded by quotes. 86 | * Don't interfere with terms like "Q&A", or "AT&T". 87 | * Don't interfere with acronyms like "(BBC)" or "(DVD)". 88 | * Capitalize small words after a colon. 89 | 90 | ## Credits 91 | 92 | This tool makes use of prior work by [John Gruber][style], [Aristotle 93 | Pagaltzis], and [David Gouch]. 94 | 95 | [Aristotle Pagaltzis]: http://plasmasturm.org/code/titlecase/ 96 | [crate-docs]: https://docs.rs/titlecase 97 | [Daring Fireball]: https://daringfireball.net/ 98 | [David Gouch]: http://individed.com/code/to-title-case/ 99 | [MIT]: https://github.com/wezm/titlecase/blob/master/LICENSE 100 | [rustup]: https://www.rust-lang.org/tools/install 101 | [style]: https://daringfireball.net/2008/05/title_case 102 | [wasm-bindgen]: https://github.com/rustwasm/wasm-bindgen 103 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `titlecase` capitlizes English text according to [a style][style] defined by John 2 | //! Gruber for post titles on his website [Daring Fireball]. 3 | //! 4 | //! [Daring Fireball]: https://daringfireball.net/ 5 | //! [style]: https://daringfireball.net/2008/05/title_case 6 | //! 7 | //! ## Example 8 | //! 9 | //! ```rust 10 | //! use titlecase::{titlecase, Titlecase}; 11 | //! 12 | //! let text = "a sample title to capitalize: an example"; 13 | //! assert_eq!(text.titlecase(), "A Sample Title to Capitalize: An Example"); 14 | //! assert_eq!(titlecase(text), "A Sample Title to Capitalize: An Example"); 15 | //! ``` 16 | //! 17 | //! ## Style 18 | //! 19 | //! Instead of simply capitalizing each word it does the following ([amongst other 20 | //! things][style]): 21 | //! 22 | //! * Lower case small words like an, of, or in. 23 | //! * Don't capitalize words like iPhone. 24 | //! * Don't interfere with file paths, URLs, domains, and email addresses. 25 | //! * Always capitalize the first and last words, even if they are small words 26 | //! or surrounded by quotes. 27 | //! * Don't interfere with terms like "Q&A", or "AT&T". 28 | //! * Capitalize small words after a colon. 29 | 30 | use std::borrow::Cow; 31 | use std::sync::OnceLock; 32 | 33 | use regex::{Captures, Regex}; 34 | 35 | #[cfg(feature = "wasm")] 36 | mod wasm; 37 | 38 | #[rustfmt::skip] 39 | const SMALL_WORDS: &[&str] = &[ 40 | "a", 41 | "an", 42 | "and", 43 | "as", 44 | "at", 45 | "but", 46 | "by", 47 | "en", 48 | "for", 49 | "if", 50 | "in", 51 | "of", 52 | "on", 53 | "or", 54 | "the", 55 | "to", 56 | "v[.]?", 57 | "via", 58 | "vs[.]?", 59 | ]; 60 | 61 | fn small_words_alternation() -> &'static String { 62 | static SMALL_WORDS_PIPE: OnceLock = OnceLock::new(); 63 | SMALL_WORDS_PIPE.get_or_init(|| SMALL_WORDS.join("|")) 64 | } 65 | 66 | #[inline] 67 | fn words_regex() -> &'static Regex { 68 | static WORDS: OnceLock = OnceLock::new(); 69 | WORDS.get_or_init(|| { 70 | Regex::new( 71 | r"(?x) 72 | (_*) 73 | ([\w'’.:/@\[\]/()&]+) 74 | (_*)", 75 | ) 76 | .expect("unable to compile words regex") 77 | }) 78 | } 79 | 80 | /// A trait describing an item that can be converted to title case. 81 | /// 82 | /// # Examples 83 | /// 84 | /// ```rust 85 | /// use titlecase::Titlecase; 86 | /// 87 | /// assert_eq!("hello world!".titlecase(), "Hello World!"); 88 | /// ``` 89 | pub trait Titlecase { 90 | /// Convert `self` to title case. 91 | fn titlecase(&self) -> String; 92 | } 93 | 94 | impl> Titlecase for T { 95 | fn titlecase(&self) -> String { 96 | titlecase(self.as_ref()) 97 | } 98 | } 99 | 100 | /// Returns `input` in title case. 101 | /// 102 | /// ### Example 103 | /// 104 | /// ``` 105 | /// use titlecase::titlecase; 106 | /// 107 | /// let text = "a sample title to capitalize: an example"; 108 | /// assert_eq!(titlecase(text), "A Sample Title to Capitalize: An Example"); 109 | /// ``` 110 | pub fn titlecase(input: &str) -> String { 111 | titlecase_internal(input, false) 112 | } 113 | 114 | fn titlecase_internal(input: &str, skip_to_lowercase: bool) -> String { 115 | // Remove leading and trailing whitespace 116 | let trimmed_input = input.trim(); 117 | // If input is yelling (all uppercase) make lowercase 118 | let trimmed_input = if skip_to_lowercase || trimmed_input.chars().any(|ch| ch.is_lowercase()) { 119 | Cow::from(trimmed_input) 120 | } else { 121 | Cow::from(trimmed_input.to_lowercase()) 122 | }; 123 | 124 | let result = words_regex().replace_all(&trimmed_input, |captures: &Captures| { 125 | let mut result = captures.get(1).map_or("", |cap| cap.as_str()).to_owned(); 126 | let word = &captures[2]; 127 | result.push_str(&process_word(word)); 128 | result.push_str(captures.get(3).map_or("", |cap| cap.as_str())); 129 | result 130 | }); 131 | 132 | // Now deal with small words at the start and end of the text 133 | fix_small_word_at_end(&fix_small_word_at_start(&result)).into_owned() 134 | } 135 | 136 | #[inline] 137 | fn small_words_regex() -> &'static Regex { 138 | static SMALL_RE: OnceLock = OnceLock::new(); 139 | SMALL_RE.get_or_init(|| { 140 | Regex::new(&format!(r"\A(?:{})\z", *small_words_alternation())) 141 | .expect("unable to compile small words regex") 142 | }) 143 | } 144 | 145 | fn process_word(word: &str) -> Cow<'_, str> { 146 | if is_digital_resource(word) { 147 | // Pass through 148 | return Cow::from(word); 149 | } 150 | 151 | let lower_word = word.to_lowercase(); 152 | if small_words_regex().is_match(&lower_word) { 153 | Cow::from(lower_word) 154 | } else if has_internal_slashes(word) { 155 | Cow::from( 156 | word.split('/') 157 | .map(|word| titlecase_internal(word, true)) 158 | // TODO: Awaiting rust iter.intersperse('/'); 159 | .collect::>() 160 | .join("/"), 161 | ) 162 | } else if is_acronym(word) { 163 | // Preserve caps like (BBC) or (DVD) 164 | Cow::from(word) 165 | } else if starts_with_bracket(word) { 166 | let rest = titlecase(&word[1..]); 167 | Cow::from(format!("({}", rest)) 168 | } else if has_internal_caps(word) { 169 | // Preserve internal caps like iPhone or DuBois 170 | Cow::from(word) 171 | } else { 172 | Cow::from(ucfirst(word)) 173 | } 174 | } 175 | 176 | // https://stackoverflow.com/a/38406885 177 | fn ucfirst(input: &str) -> String { 178 | let mut chars = input.chars(); 179 | match chars.next() { 180 | None => String::new(), 181 | Some(f) => f.to_uppercase().chain(chars).collect(), 182 | } 183 | } 184 | 185 | #[inline] 186 | fn is_digital_resource_regex() -> &'static Regex { 187 | static RE: OnceLock = OnceLock::new(); 188 | RE.get_or_init(|| { 189 | Regex::new( 190 | r"(?x) 191 | \A 192 | (?: [/\\] [[:alpha:]]+ [-_[:alpha:]/\\]+ | # file path or 193 | [-_[:alpha:]]+ [@.:] [-_[:alpha:]@.:/]+ ) # URL, domain, or email", 194 | ) 195 | .expect("unable to compile file/url regex") 196 | }) 197 | } 198 | 199 | fn is_digital_resource(word: &str) -> bool { 200 | is_digital_resource_regex().is_match(word) 201 | } 202 | 203 | #[inline] 204 | fn is_acronym_regex() -> &'static Regex { 205 | static RE: OnceLock = OnceLock::new(); 206 | RE.get_or_init(|| { 207 | Regex::new( 208 | r"(?x) 209 | \A 210 | \(+[A-Z0-9]+\)+ 211 | \z", 212 | ) 213 | .expect("") 214 | }) 215 | } 216 | 217 | // E.g. (BBC) or (DVD) 218 | fn is_acronym(word: &str) -> bool { 219 | // Check if the number of open and closed braces is equal 220 | word.chars().fold(0, |acc, char| match char { 221 | '(' => acc + 1, 222 | ')' => acc - 1, 223 | _ => acc, 224 | }) == 0 225 | && is_acronym_regex().is_match(word) 226 | } 227 | 228 | // E.g. iPhone or DuBois 229 | fn has_internal_caps(word: &str) -> bool { 230 | word.chars().skip(1).any(|chr| chr.is_uppercase()) 231 | } 232 | 233 | fn has_internal_slashes(word: &str) -> bool { 234 | !word.is_empty() && word.chars().skip(1).any(|chr| chr == '/') 235 | } 236 | 237 | fn starts_with_bracket(word: &str) -> bool { 238 | word.starts_with('(') 239 | } 240 | 241 | #[inline] 242 | fn fix_small_word_at_start_regex() -> &'static Regex { 243 | static RE: OnceLock = OnceLock::new(); 244 | RE.get_or_init(|| { 245 | Regex::new(&format!( 246 | r#"(?x) 247 | ( \A [[:punct:]]* # start of title... 248 | | [:.;?!]\x20+ # or of subsentence... 249 | | \x20['"“‘(\[]\x20* ) # or of inserted subphrase... 250 | ( {small_re} ) \b # ... followed by small word 251 | "#, 252 | small_re = *small_words_alternation() 253 | )) 254 | .expect("unable to compile fix_small_word_at_start regex") 255 | }) 256 | } 257 | 258 | fn fix_small_word_at_start(text: &str) -> Cow<'_, str> { 259 | fix_small_word_at_start_regex().replace_all(text, |captures: &Captures| { 260 | let mut result = captures[1].to_owned(); 261 | result.push_str(&ucfirst(&captures[2])); 262 | result 263 | }) 264 | } 265 | 266 | #[inline] 267 | fn fix_small_word_at_end_regex() -> &'static Regex { 268 | static RE: OnceLock = OnceLock::new(); 269 | RE.get_or_init(|| { 270 | Regex::new(&format!( 271 | r#"(?x) 272 | \b ( {small_re} ) # small word... 273 | ( [[:punct:]]* \z # ... at the end of the title... 274 | | ['"’”)\]] \x20 ) # ... or of an inserted subphrase? 275 | "#, 276 | small_re = *small_words_alternation() 277 | )) 278 | .expect("unable to compile fix_small_word_at_end regex") 279 | }) 280 | } 281 | 282 | fn fix_small_word_at_end(text: &str) -> Cow<'_, str> { 283 | fix_small_word_at_end_regex().replace_all(text, |captures: &Captures| { 284 | let mut result = ucfirst(&captures[1]); 285 | result.push_str(&captures[2]); 286 | result 287 | }) 288 | } 289 | 290 | #[cfg(test)] 291 | mod tests { 292 | use super::{titlecase, Titlecase}; 293 | 294 | macro_rules! testcase { 295 | ($name:ident, $input:expr, $expected:expr) => { 296 | #[test] 297 | fn $name() { 298 | assert_eq!(titlecase($input), $expected); 299 | assert_eq!($input.titlecase(), $expected); 300 | } 301 | }; 302 | } 303 | 304 | testcase!( 305 | email, 306 | "For step-by-step directions email someone@gmail.com", 307 | "For Step-by-Step Directions Email someone@gmail.com" 308 | ); 309 | 310 | testcase!( 311 | subphrase_in_single_quotes, 312 | "2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'", 313 | "2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'" 314 | ); 315 | 316 | testcase!( 317 | subphrase_in_double_quotes, 318 | r#"2lmc Spool: "Gruber on OmniFocus and Vapo(u)rware""#, 319 | r#"2lmc Spool: "Gruber on OmniFocus and Vapo(u)rware""# 320 | ); 321 | 322 | testcase!( 323 | curly_double_quotes, 324 | "Have you read “the lottery”?", 325 | "Have You Read “The Lottery”?" 326 | ); 327 | 328 | testcase!( 329 | brackets, 330 | "your hair[cut] looks (nice)", 331 | "Your Hair[cut] Looks (Nice)" 332 | ); 333 | 334 | testcase!( 335 | multiple_brackets, 336 | "your hair[cut] looks ((Very Nice))", 337 | "Your Hair[cut] Looks ((Very Nice))" 338 | ); 339 | 340 | testcase!( 341 | url, 342 | "People probably won't put http://foo.com/bar/ in titles", 343 | "People Probably Won't Put http://foo.com/bar/ in Titles" 344 | ); 345 | 346 | testcase!( 347 | name_url, 348 | "Scott Moritz and TheStreet.com’s million iPhone la‑la land", 349 | "Scott Moritz and TheStreet.com’s Million iPhone La‑La Land" 350 | ); 351 | 352 | testcase!( 353 | acronym, 354 | "(ABC) ((ABC)) (ABC ABC) ((ABC) (ABC)) (Abc) (abc) (aBC) (aBc) (ABC)/(ABC) (ABC)/abc ABC", 355 | "(ABC) ((ABC)) (Abc ABC) ((Abc) (Abc)) (Abc) (Abc) (aBC) (aBc) (ABC)/(ABC) (ABC)/Abc ABC" 356 | ); 357 | 358 | testcase!(iphone, "BlackBerry vs. iPhone", "BlackBerry vs. iPhone"); 359 | 360 | testcase!( 361 | curly_single_quotes, 362 | "Notes and observations regarding Apple’s announcements from ‘The Beat Goes On’ special event", 363 | "Notes and Observations Regarding Apple’s Announcements From ‘The Beat Goes On’ Special Event" 364 | ); 365 | 366 | testcase!( 367 | markdown, 368 | "Read markdown_rules.txt to find out how _underscores around words_ will be interpreted", 369 | "Read markdown_rules.txt to Find Out How _Underscores Around Words_ Will Be Interpreted" 370 | ); 371 | 372 | testcase!( 373 | q_and_a, 374 | "Q&A with Steve Jobs: 'That's what happens in technology'", 375 | "Q&A With Steve Jobs: 'That's What Happens in Technology'" 376 | ); 377 | 378 | testcase!( 379 | at_and_t, 380 | "What is AT&T's problem?", 381 | "What Is AT&T's Problem?" 382 | ); 383 | 384 | testcase!( 385 | at_and_t2, 386 | "Apple deal with AT&T falls through", 387 | "Apple Deal With AT&T Falls Through" 388 | ); 389 | 390 | testcase!(thisvthat, "this v that", "This v That"); 391 | 392 | testcase!(thisvthat2, "this vs that", "This vs That"); 393 | 394 | testcase!(thisvthat3, "this v. that", "This v. That"); 395 | 396 | testcase!(thisvthat4, "this vs. that", "This vs. That"); 397 | 398 | testcase!( 399 | sec, 400 | "The SEC's Apple probe: what you need to know", 401 | "The SEC's Apple Probe: What You Need to Know" 402 | ); 403 | 404 | testcase!( 405 | small_word_at_start_in_quotes, 406 | "'by the way, small word at the start but within quotes.'", 407 | "'By the Way, Small Word at the Start but Within Quotes.'" 408 | ); 409 | 410 | testcase!( 411 | small_word_at_end, 412 | "Small word at end is nothing to be afraid of", 413 | "Small Word at End Is Nothing to Be Afraid Of" 414 | ); 415 | 416 | testcase!( 417 | subphrase_starting_with_small_word, 418 | "Starting sub-phrase with a small word: a trick, perhaps?", 419 | "Starting Sub-Phrase With a Small Word: A Trick, Perhaps?" 420 | ); 421 | 422 | testcase!( 423 | subphrase_with_small_word_in_single_quotes, 424 | "Sub-phrase with a small word in quotes: 'a trick, perhaps?'", 425 | "Sub-Phrase With a Small Word in Quotes: 'A Trick, Perhaps?'" 426 | ); 427 | 428 | testcase!( 429 | a_subphrase_with_small_word_in_single_quotes, 430 | "a Sub-phrase with a small word in quotes: 'a trick, perhaps?'", 431 | "A Sub-Phrase With a Small Word in Quotes: 'A Trick, Perhaps?'" 432 | ); 433 | 434 | testcase!( 435 | subphrase_with_small_word_in_double_quotes, 436 | "Sub-phrase with a small word in quotes: \"a trick, perhaps?\"", 437 | "Sub-Phrase With a Small Word in Quotes: \"A Trick, Perhaps?\"" 438 | ); 439 | 440 | testcase!( 441 | all_in_double_quotes, 442 | "\"Nothing to Be Afraid of?\"", 443 | "\"Nothing to Be Afraid Of?\"" 444 | ); 445 | 446 | testcase!(a_thing, "a thing", "A Thing"); 447 | 448 | testcase!( 449 | dr_strangelove, 450 | "Dr. Strangelove (or: how I Learned to Stop Worrying and Love the Bomb)", 451 | "Dr. Strangelove (Or: How I Learned to Stop Worrying and Love the Bomb)" 452 | ); 453 | 454 | testcase!(trimming, " this is trimming", "This Is Trimming"); 455 | 456 | testcase!(trimming2, "this is trimming ", "This Is Trimming"); 457 | 458 | testcase!(trimming3, " this is trimming ", "This Is Trimming"); 459 | 460 | testcase!( 461 | yelling, 462 | "IF IT’S ALL CAPS, FIX IT", 463 | "If It’s All Caps, Fix It" 464 | ); 465 | 466 | testcase!( 467 | slashes, 468 | "What could/should be done about slashes?", 469 | "What Could/Should Be Done About Slashes?" 470 | ); 471 | 472 | testcase!( 473 | paths, 474 | "Never touch paths like /var/run before/after /boot", 475 | "Never Touch Paths Like /var/run Before/After /boot" 476 | ); 477 | 478 | // TODO: Implement these 479 | // testcase!( 480 | // in_flight, 481 | // "The in-flight entertainment was excellent", 482 | // "The In-Flight Entertainment Was Excellent" 483 | // ); 484 | 485 | // testcase!( 486 | // stand_in, 487 | // "The Stand-in teacher gave us homework", 488 | // "The Stand-In Teacher Gave Us Homework" 489 | // ); 490 | 491 | testcase!( 492 | man_in_the_middle, 493 | "They executed a man-in-the-middle attack", 494 | "They Executed a Man-in-the-Middle Attack" 495 | ); 496 | 497 | testcase!( 498 | man_in_the_machine, 499 | "Jonathan Kim on Alex Gibney’s ‘Steve Jobs: The man in the machine’", 500 | "Jonathan Kim on Alex Gibney’s ‘Steve Jobs: The Man in the Machine’" 501 | ); 502 | 503 | testcase!( 504 | lower_small_words, 505 | "Way Of The Dragon makes Of In An A lowercase", 506 | "Way of the Dragon Makes of in an a Lowercase" 507 | ); 508 | 509 | testcase!(small_greek_letters, "μ", "Μ"); 510 | 511 | testcase!( 512 | japanese, 513 | "発売時の名称は「apple macintosh」であったが、後に拡張版のMacintosh 512Kが発売された段階で「macintosh 128K」と再命名された。test", 514 | "発売時の名称は「Apple Macintosh」であったが、後に拡張版のMacintosh 512Kが発売された段階で「Macintosh 128K」と再命名された。Test" 515 | ); 516 | } 517 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::io::{self, BufRead}; 3 | use titlecase::titlecase; 4 | 5 | fn main() { 6 | match env::args().nth(1).as_deref() { 7 | Some("-h") | Some("--help") => return help(), 8 | Some("-v") | Some("--version") => return version(), 9 | Some(option) => return eprintln!("unknown option {}", option), 10 | _ => (), 11 | } 12 | 13 | let stdin = io::stdin(); 14 | for line in stdin.lock().lines() { 15 | match line { 16 | Ok(line) => println!("{}", titlecase(&line)), 17 | Err(error) => { 18 | eprintln!("{}", error); 19 | } 20 | } 21 | } 22 | } 23 | 24 | fn help() { 25 | println!( 26 | "\ 27 | Usage: titlecase [OPTIONS] 28 | 29 | titlecase reads lines from stdin and applies title casing rules to each line, 30 | outputting the result on stdout. 31 | 32 | Optional arguments: 33 | -h, --help print help message 34 | -v, --version print the version" 35 | ); 36 | } 37 | 38 | fn version() { 39 | println!("titlecase {}", env!("CARGO_PKG_VERSION")); 40 | } 41 | -------------------------------------------------------------------------------- /src/wasm.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | #[wasm_bindgen] 4 | pub fn titlecase(text: &str) -> String { 5 | crate::titlecase(text) 6 | } 7 | --------------------------------------------------------------------------------