citation_format
Format references in APA 7, MLA 9, Chicago author-date, IEEE or Vancouver (NLM) from CSL-JSON items or a BibTeX string: journal articles, books, chapters, conference papers, reports, theses and web pages, with author lists, et al. rules, page ranges and DOIs. Plain text and Markdown with italics.
Install
/tools install citation_format /tools install citation_format@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 · citation_format 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 citation_format; 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 |
|---|---|---|---|---|
style | string | no | "apa" | one of "apa", "mla", "chicago", "ieee", "vancouver" |
items | array of object | no | CSL-JSON: type, title, author [{family, given}], issued {date-parts}, container-title, volume, issue, page, DOI, URL, publisher, publisher-place, edition, editor | |
bibtex | string | no | One or more BibTeX entries instead of items |
Input schema (JSON)
{
"type": "object",
"additionalProperties": false,
"properties": {
"style": {
"type": "string",
"enum": [
"apa",
"mla",
"chicago",
"ieee",
"vancouver"
],
"default": "apa"
},
"items": {
"type": "array",
"items": {
"type": "object"
},
"description": "CSL-JSON: type, title, author [{family, given}], issued {date-parts}, container-title, volume, issue, page, DOI, URL, publisher, publisher-place, edition, editor"
},
"bibtex": {
"type": "string",
"description": "One or more BibTeX entries instead of items"
}
}
}
Example
Input
{
"style": "vancouver",
"items": [
{
"type": "article-journal",
"author": [
{
"family": "Halpern",
"given": "Scott D"
},
{
"family": "Ubel",
"given": "Peter A"
},
{
"family": "Caplan",
"given": "Arthur L"
}
],
"title": "Solid-organ transplantation in HIV-infected patients",
"container-title": "N Engl J Med",
"issued": {
"date-parts": [
[
2002,
7,
25
]
]
},
"volume": "347",
"issue": "4",
"page": "284-287"
}
]
}
Output (stdout)
{
"references": [
{
"text": "1. Halpern SD, Ubel PA, Caplan AL. Solid-organ transplantation in HIV-infected patients. N Engl J Med. 2002 Jul 25;347(4):284-7."
}
]
}
Files
The command the install card registers is python3 citation_format.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.
citation_format.py · sha256 0aff6e0acd47862623738def1ee876cefc4c4fe21f3184e33803faa7299746ab
#!/usr/bin/env python3
"""Format references in APA 7, MLA 9, Chicago author-date (17th ed.), IEEE or Vancouver from
CSL-JSON items or a BibTeX string. Output per entry: plain text and Markdown (italics as
*...*); the author-date styles are sorted by author, the numbered ones keep input order.
Input (JSON on stdin):
style "apa" | "mla" | "chicago" | "ieee" | "vancouver"
items [CSL-JSON objects] or
bibtex "@article{key, author = {...}, ...}"
"""
import json
import re
import sys
STYLES = ("apa", "mla", "chicago", "ieee", "vancouver")
BIBTEX_TYPES = {"article": "article-journal", "book": "book", "incollection": "chapter", "inbook": "chapter",
"inproceedings": "paper-conference", "conference": "paper-conference", "phdthesis": "thesis",
"mastersthesis": "thesis", "techreport": "report", "misc": "webpage", "online": "webpage"}
BIBTEX_FIELDS = {"title": "title", "journal": "container-title", "booktitle": "container-title", "volume": "volume",
"number": "issue", "pages": "page", "doi": "DOI", "url": "URL", "publisher": "publisher",
"address": "publisher-place", "edition": "edition", "school": "publisher", "institution": "publisher"}
MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def bib_value(text, i):
if text[i] == "{":
depth, j = 0, i
while j < len(text):
depth += {"{": 1, "}": -1}.get(text[j], 0)
if depth == 0:
return text[i + 1:j], j + 1
j += 1
fail("unbalanced braces in bibtex")
if text[i] == '"':
j = text.index('"', i + 1)
return text[i + 1:j], j + 1
m = re.match(r"[A-Za-z0-9_.-]+", text[i:])
if not m:
fail(f"cannot read a bibtex value at: {text[i:i + 30]!r}")
return m.group(0), i + m.end()
def bib_names(text):
names = []
for part in re.split(r"\s+and\s+", text.strip()):
part = part.strip()
if not part:
continue
if part.lower() == "others":
names.append({"literal": "others"})
elif "," in part:
family, given = [p.strip() for p in part.split(",", 1)]
names.append({"family": family, "given": given})
elif " " in part:
given, family = part.rsplit(" ", 1)
names.append({"family": family, "given": given})
else:
names.append({"literal": part})
return names
def parse_bibtex(text):
items = []
for m in re.finditer(r"@(\w+)\s*\{\s*([^,\s]+)\s*,", text):
kind, key, i = m.group(1).lower(), m.group(2), m.end()
if kind in ("comment", "string", "preamble"):
continue
item, fields = {"id": key, "type": BIBTEX_TYPES.get(kind, "article-journal")}, {}
while i < len(text):
fm = re.compile(r"\s*([A-Za-z_-]+)\s*=\s*").match(text, i)
if not fm:
break
value, i = bib_value(text, fm.end())
fields[fm.group(1).lower()] = re.sub(r"\s+", " ", value.replace("{", "").replace("}", "")).strip()
sep = re.compile(r"\s*,?\s*").match(text, i)
i = sep.end()
if i < len(text) and text[i] == "}":
break
for bib, csl in BIBTEX_FIELDS.items():
if bib in fields and csl not in item:
item[csl] = fields[bib]
for role in ("author", "editor"):
if role in fields:
item[role] = bib_names(fields[role])
if "year" in fields:
parts = [int(fields["year"])] if fields["year"].isdigit() else []
month = fields.get("month", "")[:3].lower()
if parts and month in [x[:3].lower() for x in MONTHS]:
parts.append([x[:3].lower() for x in MONTHS].index(month) + 1)
item["issued"] = {"date-parts": [parts]} if parts else {"literal": fields["year"]}
items.append(item)
if not items:
fail("no bibtex entries found")
return items
def initials(given, sep=" ", dots=True):
out = []
for word in given.replace(".", ". ").split():
pieces = word.split("-")
out.append("-".join(p[0] + ("." if dots else "") for p in pieces if p))
return sep.join(out) if dots else "".join(out)
def year(item):
issued = item.get("issued")
if isinstance(issued, dict):
parts = issued.get("date-parts", [[]])
if parts and parts[0]:
return str(parts[0][0])
if issued.get("literal"):
return str(issued["literal"])
if item.get("year"):
return str(item["year"])
return None
def date_parts(item):
issued = item.get("issued")
if isinstance(issued, dict) and issued.get("date-parts") and issued["date-parts"][0]:
return issued["date-parts"][0]
return []
def pages(item, abbreviate=None):
"""The page range with an en dash; abbreviate "mla" keeps at least two digits of the
second number (207–17), "nlm" drops every repeated leading digit and uses a hyphen (284-7)."""
page = str(item.get("page", "")).replace("--", "-").replace("–", "-")
if "-" not in page:
return page
first, last = [p.strip() for p in page.split("-", 1)]
if abbreviate and first.isdigit() and last.isdigit() and len(first) == len(last):
common = 0
while common < len(last) - 1 and first[common] == last[common]:
common += 1
if abbreviate == "nlm":
last = last[common:]
elif int(first) > 99:
last = last[min(common, len(last) - 2):]
return f"{first}{'-' if abbreviate == 'nlm' else '–'}{last}"
def edition(item):
raw = str(item.get("edition", "")).strip()
if not raw:
return ""
if raw.isdigit():
n = int(raw)
suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix} ed."
return raw if raw.endswith(".") else raw + " ed."
def doi_url(item):
if item.get("DOI"):
doi = re.sub(r"^(https?://(dx\.)?doi\.org/|doi:)", "", str(item["DOI"]))
return f"https://doi.org/{doi}"
return item.get("URL")
def person(name, order):
if "literal" in name:
return name["literal"]
family, given = name.get("family", ""), name.get("given", "")
if order == "apa":
return f"{family}, {initials(given)}" if given else family
if order == "inverted":
return f"{family}, {given}" if given else family
if order == "ieee":
return f"{initials(given)} {family}".strip()
if order == "vancouver":
return f"{family} {initials(given, dots=False)}".strip()
return f"{given} {family}".strip()
def join_names(names, last_sep, two_sep=None):
if len(names) == 1:
return names[0]
if len(names) == 2 and two_sep is not None:
return f"{names[0]}{two_sep}{names[1]}"
return ", ".join(names[:-1]) + last_sep + names[-1]
def authors(item, style):
names = list(item.get("author") or [])
if not names:
return ""
others = names[-1].get("literal") == "others" if names else False
if others:
names = names[:-1]
listed = {"apa": [person(n, "apa") for n in names], "ieee": [person(n, "ieee") for n in names],
"vancouver": [person(n, "vancouver") for n in names]}.get(style)
if style in ("mla", "chicago"):
return person(names[0], "inverted") + ", et al"
if style == "vancouver":
return ", ".join(listed[:6]) + ", et al"
return ", ".join(listed) + (", et al." if style == "apa" else " et al.")
if style == "apa":
listed = [person(n, "apa") for n in names]
if len(listed) > 20:
return ", ".join(listed[:19]) + ", . . . " + listed[-1]
return join_names(listed, ", & ", ", & ")
if style == "mla":
if len(names) >= 3:
return person(names[0], "inverted") + ", et al"
return join_names([person(names[0], "inverted")] + [person(n, "direct") for n in names[1:]], ", and ", ", and ")
if style == "chicago":
listed = [person(names[0], "inverted")] + [person(n, "direct") for n in names[1:]]
if len(listed) > 10:
listed = listed[:7] + ["et al"]
return ", ".join(listed)
return join_names(listed, ", and ", " and ")
if style == "ieee":
listed = [person(n, "ieee") for n in names]
if len(listed) > 6:
return listed[0] + " et al."
return join_names(listed, ", and ", " and ")
listed = [person(n, "vancouver") for n in names]
return ", ".join(listed[:6]) + (", et al" if len(listed) > 6 else "")
def end(text, mark="."):
text = text.rstrip()
return text if not text or text[-1] in ".?!" else text + mark
def it(text, md):
return f"*{text}*" if md and text else text
def format_item(item, style, md, number):
kind = item.get("type", "article-journal")
title, container = item.get("title", ""), item.get("container-title", "")
vol, issue, y = item.get("volume"), item.get("issue"), year(item) or "n.d."
publisher, place, link = item.get("publisher"), item.get("publisher-place"), doi_url(item)
who = authors(item, style)
journal = kind in ("article-journal", "article-magazine", "article-newspaper", "article")
chapter = kind in ("chapter", "paper-conference")
editors = item.get("editor") or []
if style == "apa":
head = f"{end(who)} ({y})." if who else f"{it(title, md) if not journal else title}. ({y})."
parts = [head]
if who:
ed = f" ({edition(item)})" if edition(item) and not (journal or chapter) else ""
parts.append(end(it(title, md) + ed if not (journal or chapter) else title))
if journal:
src = it(container, md)
if vol:
src += ", " + it(str(vol), md) + (f"({issue})" if issue else "")
if item.get("page"):
src += ", " + pages(item)
parts.append(end(src))
elif chapter:
ed = ""
if editors:
names = [f"{initials(n.get('given', ''))} {n.get('family', '')}".strip() if "literal" not in n else n["literal"] for n in editors]
ed = join_names(names, ", & ", " & ") + (" (Eds.), " if len(names) > 1 else " (Ed.), ")
pp = f" (pp. {pages(item)})" if item.get("page") else ""
parts.append(end(f"In {ed}{it(container, md)}{pp}"))
if publisher:
parts.append(end(publisher))
else:
if publisher:
parts.append(end(publisher))
elif container:
parts.append(end(container))
text = " ".join(parts) + (f" {link}" if link else "")
elif style == "mla":
parts = [end(who)] if who else []
parts.append(f"“{end(title)}”" if journal or chapter else end(it(title, md)))
bits = []
if container:
bits.append(it(container, md))
if vol:
bits.append(f"vol. {vol}")
if issue:
bits.append(f"no. {issue}")
if edition(item) and not journal:
bits.append(edition(item))
if publisher and not journal:
bits.append(publisher)
bits.append(y)
if item.get("page"):
bits.append(("pp. " if "-" in str(item["page"]) else "p. ") + pages(item, abbreviate="mla"))
parts.append(end(", ".join(bits)))
if link:
parts.append(end(link))
text = " ".join(parts)
elif style == "chicago":
parts = [end(who)] if who else []
parts.append(end(y))
if journal:
parts.append(f"“{end(title)}”")
src = it(container, md) + (f" {vol}" if vol else "") + (f" ({issue})" if issue else "")
src += f": {pages(item)}" if item.get("page") else ""
parts.append(end(src))
elif chapter:
parts.append(f"“{end(title)}”")
parts.append(end(f"In {it(container, md)}" + (f", {pages(item)}" if item.get("page") else "")))
if publisher:
parts.append(end(f"{place}: {publisher}" if place else publisher))
else:
parts.append(end(it(title, md)))
if edition(item):
parts.append(end(edition(item)))
if publisher:
parts.append(end(f"{place}: {publisher}" if place else publisher))
if link:
parts.append(end(link))
text = " ".join(parts)
elif style == "ieee":
prefix = f"[{number}] "
if journal or chapter:
bits = [f"{who}, “{title},”" if who else f"“{title},”"]
src = (("in " + it(container, md)) if chapter else it(container, md))
tail = [src]
if vol:
tail.append(f"vol. {vol}")
if issue:
tail.append(f"no. {issue}")
if item.get("page"):
tail.append(("pp. " if "-" in str(item["page"]) else "p. ") + pages(item))
parts = date_parts(item)
tail.append((MONTHS[parts[1] - 1][:3] + ". " if len(parts) > 1 else "") + y)
text = prefix + " ".join(bits) + " " + ", ".join(tail)
if link and item.get("DOI"):
text += f", doi: {re.sub(r'^https://doi.org/', '', link)}."
else:
text = end(text) + (f" [Online]. Available: {link}" if link else "")
else:
ed = f", {edition(item)}" if edition(item) else ""
where = f" {place}: {publisher}, {y}." if place and publisher else f" {publisher}, {y}." if publisher else f" {y}."
text = prefix + (f"{who}, " if who else "") + it(title, md) + ed + ("" if ed else ".") + where + (f" [Online]. Available: {link}" if link else "")
else:
prefix = f"{number}. "
parts = [end(who)] if who else []
parts.append(end(title))
if journal:
dp = date_parts(item)
when = y + (f" {MONTHS[dp[1] - 1][:3]}" if len(dp) > 1 else "") + (f" {dp[2]}" if len(dp) > 2 else "")
src = f"{container}. {when}" + (f";{vol}" if vol else "") + (f"({issue})" if issue else "") + (f":{pages(item, 'nlm')}" if item.get("page") else "")
parts.append(end(src))
elif chapter:
parts.append(end(f"In: {container}"))
parts.append(end(f"{place}: {publisher}; {y}" if place and publisher else f"{publisher or ''}; {y}".lstrip("; ")))
if item.get("page"):
parts.append(end(f"p. {pages(item, 'nlm')}"))
else:
if edition(item):
parts.append(end(edition(item)))
parts.append(end(f"{place}: {publisher}; {y}" if place and publisher else f"{publisher}; {y}" if publisher else y))
if item.get("DOI"):
parts.append(f"doi:{re.sub(r'^https://doi.org/', '', link)}")
elif link:
parts.append(f"Available from: {link}")
text = prefix + " ".join(parts)
return re.sub(r"\s+", " ", text).replace("..", ".").strip()
def sort_key(item):
names = item.get("author") or []
first = names[0] if names else {}
family = first.get("family") or first.get("literal") or item.get("title", "")
return (family.lower(), year(item) or "", item.get("title", "").lower())
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")
style = request.get("style", "apa")
if style not in STYLES:
fail(f"style must be one of {', '.join(STYLES)}")
if isinstance(request.get("bibtex"), str):
items = parse_bibtex(request["bibtex"])
else:
items = request.get("items")
if not isinstance(items, list) or not items or not all(isinstance(i, dict) for i in items):
fail("give items (CSL-JSON objects) or bibtex")
ordered = sorted(items, key=sort_key) if style in ("apa", "mla", "chicago") else items
out = []
for n, item in enumerate(ordered, 1):
if not item.get("title"):
fail(f"entry {item.get('id', n)} has no title")
out.append({"id": item.get("id"), "text": format_item(item, style, False, n), "markdown": format_item(item, style, True, n)})
print(json.dumps({"style": style, "references": 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/citation_format/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/citation_format/1.0.0/citation_format.py