When normalizing user input like titles or descriptions, it’s common to lowercase everything and capitalize the first word. But what if you want to preserve specific acronyms or names like API, CSS, or Elixir?

In this post, we’ll build a small Elixir utility to:

  • Lowercase and normalize input
  • Capitalize the first word of each sentence
  • Preserve casing for special words like API, HTML, MCP, etc.

Example

input = "i built this using elixir, html and css. then deployed the api to phoenix."
TitleCaser.smart_case(input)
# => "I built this using Elixir, HTML and CSS. Then deployed the API to Phoenix."

Implementation

defmodule TitleCaser do
  @special_words ~w(
    I CSS MCP API HTTP HTML XML Elixir Phoenix Go Golang JavaScript TypeScript JSON
  )

  def smart_case(text) do
    text
    |> split_sentences()
    |> Enum.map(&process_sentence/1)
    |> Enum.join(" ")
  end

  defp split_sentences(text) do
    Regex.split(~r/(?<=[.!?])\s+/, text)
  end

  defp process_sentence(sentence) do
    sentence
    |> String.downcase()
    |> String.trim()
    |> String.split(~r/\s+/, trim: true)
    |> case do
      [] -> ""
      [first | rest] ->
        [String.capitalize(first) | rest]
        |> Enum.map(&restore_special_word/1)
        |> Enum.join(" ")
    end
  end

  defp restore_special_word(word) do
    {base, punctuation} = split_word_and_punctuation(word)

    restored =
      Enum.find(@special_words, fn w ->
        String.downcase(w) == String.downcase(base)
      end) || base

    restored <> punctuation
  end

  defp split_word_and_punctuation(word) do
    case Regex.run(~r/^(.+?)([.,!?;:]*)$/, word, capture: :all_but_first) do
      [base, punctuation] -> {base, punctuation}
      _ -> {word, ""}
    end
  end
end

Tests

Use ExUnit to verify correct behavior:

test "preserves casing for acronyms" do
  input = "mcp and json are used in the api. i wrote it in go."
  expected = "MCP and JSON are used in the API. I wrote it in Go."
  assert TitleCaser.smart_case(input) == expected
end

Tip: make special words configurable

To make this reusable, consider passing @special_words as a config or module attribute override.