slug_title_case
Text casing and typography: URL slugs (Unicode transliterated to ASCII through NFKD, a chosen separator, cut at a word boundary), title case by style guide (Chicago 18th and 17th editions, AP, APA: minor words, hyphenated compounds, acronyms kept), sentence case keeping listed proper nouns, and smart quotes, apostrophes and dashes in English or Spanish.
Install
/tools install slug_title_case /tools install slug_title_case@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · slug_title_case 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool slug_title_case; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
operation | string | yes | one of "slug", "title_case", "sentence_case", "smart_quotes" | |
text | string | yes | ||
style | string | no | "chicago" | title_case; chicago is the 18th edition (2024). one of "chicago", "chicago17", "ap", "apa" |
separator | string | no | "-" | one of "-", "_", "." |
max_length | integer | no | 80 | ≥ 1; ≤ 2000 |
keep | array of string | no | sentence_case: proper nouns to keep | |
language | string | no | "en" | smart_quotes. one of "en", "es" |
Input schema (JSON)
{
"type": "object",
"required": [
"operation",
"text"
],
"additionalProperties": false,
"properties": {
"operation": {
"type": "string",
"enum": [
"slug",
"title_case",
"sentence_case",
"smart_quotes"
]
},
"text": {
"type": "string"
},
"style": {
"type": "string",
"enum": [
"chicago",
"chicago17",
"ap",
"apa"
],
"default": "chicago",
"description": "title_case; chicago is the 18th edition (2024)"
},
"separator": {
"type": "string",
"enum": [
"-",
"_",
"."
],
"default": "-"
},
"max_length": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"default": 80
},
"keep": {
"type": "array",
"items": {
"type": "string"
},
"description": "sentence_case: proper nouns to keep"
},
"language": {
"type": "string",
"enum": [
"en",
"es"
],
"default": "en",
"description": "smart_quotes"
}
}
}
Example
Input
{
"operation": "title_case",
"style": "chicago",
"text": "the gospel according to matthew"
}
Output (stdout)
{
"result": "The Gospel According to Matthew"
}
Files
The command the install card registers is python3 slug_title_case.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
slug_title_case.py · sha256 cad5c59fee9b1c64d0c0fe737daaeda793c546292c6dd78ba33081568ce7397e
#!/usr/bin/env python3
"""URL slugs, title case by style guide (Chicago 18th and 17th editions, AP, APA), sentence
case, and smart quotes and dashes.
Input (JSON on stdin):
operation "slug" | "title_case" | "sentence_case" | "smart_quotes"
text the text (required)
style title_case: "chicago" (18th ed., default) | "chicago17" | "ap" | "apa"
separator slug: "-" (default), "_" or "."
max_length slug: at most this many characters, cut at a separator (default 80)
keep sentence_case: words to keep as written (proper nouns)
language smart_quotes: "en" (default: “ ” ‘ ’) | "es" (« » outside, “ ” inside)
"""
import json
import re
import sys
import unicodedata
EXTRA = {"ß": "ss", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE", "ø": "o", "Ø": "O", "đ": "d", "Đ": "D",
"ł": "l", "Ł": "L", "þ": "th", "Þ": "TH", "ð": "d", "Ð": "D", "ı": "i", "&": " and "}
ARTICLES = {"a", "an", "the"}
COORD = {"and", "but", "for", "or", "nor"}
PREPOSITIONS = set("""
aboard about above across after against along amid among around as at atop before behind below beneath
beside besides between beyond by concerning despite down during except for from in inside into like near
of off on onto opposite out outside over past per regarding round since through throughout till to toward
towards under underneath unlike until unto up upon versus via with within without according
""".split())
SHORT_CONJ = {"and", "as", "but", "for", "if", "nor", "or", "so", "yet"}
PREFIXES = set("""anti co counter de dis extra infra inter intra macro micro mid mini mis multi neo non over
post pre pro pseudo re semi sub super supra trans tri ultra un""".split())
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def minor(word, style, position_in_compound=False):
"""Is this (lowercase) word kept lowercase inside a title?"""
if word in ARTICLES:
return True
if style in ("chicago", "chicago17"):
if word in COORD or word in ("to", "as"):
return True
if word in PREPOSITIONS:
return style == "chicago17" or len(word) <= 4
return False
if style == "ap":
return len(word) <= 3 and (word in PREPOSITIONS or word in SHORT_CONJ)
return len(word) <= 3 and (word in PREPOSITIONS or word in SHORT_CONJ) # apa
def keep_as_is(word):
"""Acronyms (NASA) and words with inner capitals (iPhone, McDonald) are left alone."""
letters = [c for c in word if c.isalpha()]
return len(letters) > 1 and (all(c.isupper() for c in letters) or any(c.isupper() for c in letters[1:]))
def cap(word):
for i, c in enumerate(word):
if c.isalpha():
return word[:i] + c.upper() + word[i + 1:]
return word
def core(word):
return re.sub(r"^[^\w]+|[^\w']+$", "", word).lower()
def title_case(text, style):
if not any(c.islower() for c in text):
text = text.lower() # a title typed in capitals: nothing to keep as an acronym
tokens = re.split(r"(\s+)", text.strip())
words = [i for i, t in enumerate(tokens) if t and not t.isspace()]
out = list(tokens)
after_break = True
for n, i in enumerate(words):
token = tokens[i]
first, last = n == 0 or after_break, n == len(words) - 1
after_break = bool(re.search(r"[:?!—]$", token))
if keep_as_is(token.replace("-", "")) and "-" not in token:
continue
parts = token.split("-")
new = []
for k, part in enumerate(parts):
c = core(part)
if keep_as_is(part):
new.append(part)
continue
lower = part.lower()
if k == 0:
edge = first or (last and len(parts) == 1)
new.append(cap(lower) if edge or not minor(c, style) else lower)
continue
if minor(c, style) and not (last and k == len(parts) - 1 and style != "chicago17"):
new.append(lower)
elif style == "chicago17" and k == 1 and core(parts[0]) in PREFIXES:
new.append(part if part[:1].isupper() else lower) # 17th ed.: anti-intellectual, pre-Columbian
else:
new.append(cap(lower))
out[i] = "-".join(new)
return "".join(out)
def sentence_case(text, keep):
keep_lower = {k.lower(): k for k in keep}
if not any(c.islower() for c in text):
text = text.lower()
tokens = re.split(r"(\s+)", text.strip())
out, start = [], True
for token in tokens:
if not token or token.isspace():
out.append(token)
continue
c = core(token)
if c in keep_lower:
new = token.lower().replace(c, keep_lower[c], 1)
elif keep_as_is(token.replace("-", "")):
new = token
elif c == "i" or c.startswith("i'"):
new = cap(token.lower())
else:
new = token.lower()
if start:
new = cap(new)
out.append(new)
start = bool(re.search(r"[:.?!]$", token))
return "".join(out)
def slug(text, separator, max_length):
text = "".join(EXTRA.get(c, c) for c in text)
text = unicodedata.normalize("NFKD", text)
text = "".join(c for c in text if not unicodedata.combining(c))
text = text.encode("ascii", "ignore").decode("ascii").lower()
words = re.findall(r"[a-z0-9]+", text)
out = ""
for w in words:
candidate = w if not out else out + separator + w
if len(candidate) > max_length:
if not out:
out = w[:max_length]
break
out = candidate
return out, words
def smart_quotes(text, language):
dq_open, dq_close = ("«", "»") if language == "es" else ("“", "”")
text = text.replace("---", "—").replace("--", "–").replace("...", "…")
out = []
depth = 0
for i, c in enumerate(text):
prev = text[i - 1] if i else ""
nxt = text[i + 1] if i + 1 < len(text) else ""
opening = not prev or prev.isspace() or prev in "([{—–-/«“"
if c == '"':
if language == "es":
if opening:
out.append(dq_open if depth == 0 else "“")
depth += 1
else:
depth = max(depth - 1, 0)
out.append(dq_close if depth == 0 else "”")
else:
out.append(dq_open if opening else dq_close)
elif c == "'":
if prev.isalnum() and nxt.isalpha():
out.append("’") # don't, l'eau
elif opening and re.match(r"\d\d(s|\b)", text[i + 1:i + 4]):
out.append("’") # '90s
else:
out.append("‘" if opening else "’")
else:
out.append(c)
return "".join(out)
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
text = request.get("text")
if not isinstance(text, str) or not text.strip():
fail("text must be a non-empty string")
if len(text) > 100000:
fail("text is longer than 100000 characters")
operation = request.get("operation")
if operation == "slug":
separator = request.get("separator", "-")
if separator not in ("-", "_", "."):
fail("separator must be -, _ or .")
max_length = request.get("max_length", 80)
if not isinstance(max_length, int) or isinstance(max_length, bool) or not 1 <= max_length <= 2000:
fail("max_length must be an integer from 1 to 2000")
result, words = slug(text, separator, max_length)
out = {"operation": "slug", "slug": result, "length": len(result), "truncated": len(separator.join(words)) > len(result)}
elif operation == "title_case":
style = request.get("style", "chicago")
if style not in ("chicago", "chicago17", "ap", "apa"):
fail("style must be chicago, chicago17, ap or apa")
out = {"operation": "title_case", "style": style, "result": title_case(text, style),
"note": "part of speech is guessed from word lists: a preposition used as an adverb (Look Up) or a word kept lowercase by sense may need a manual fix"}
elif operation == "sentence_case":
keep = request.get("keep", [])
if not isinstance(keep, list) or not all(isinstance(k, str) and k.strip() for k in keep):
fail("keep must be a list of words")
out = {"operation": "sentence_case", "result": sentence_case(text, keep),
"note": "proper nouns are only kept when listed in keep, all in capitals, or with an inner capital"}
elif operation == "smart_quotes":
language = request.get("language", "en")
if language not in ("en", "es"):
fail("language must be en or es")
out = {"operation": "smart_quotes", "language": language, "result": smart_quotes(text, language)}
else:
fail("operation must be slug, title_case, sentence_case or smart_quotes")
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/slug_title_case/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/slug_title_case/1.0.0/slug_title_case.py