bib2wiki is a perl script that writes the template citing a scientific reference on this very website straight from its $\mathrm{B{\scriptstyle{IB}}\TeX}$ entry—since v2.0 into the wiki itself, one Template:<Key> page per reference, rather than to the screen.
This is, for instance, an old paper of ours: Electrostatic control of quantum dot entanglement induced by coupling to external reservoirs. E. del Valle, F. P. Laussy and C. Tejedor in Europhys. Lett. 80:57001 (2007).
The rationale is explained in this blog post and examples of its use are in this other blog post. Undefined references can be fixed with keys2wiki.
It supports Bash autocompletion, as is declared in showme.
It is based on BibTeX::Parser.
# Usage: # To print one key: # bib2wiki laussy04a # # To print several keys: # bib2wiki -keys=laussy04a,laussy05a,laussy06a # # To print everything: # bib2wiki -keys=all
bib2wiki delvalle12a # write Template:Delvalle12a (one page)
bib2wiki -keys=a12b,c13d # write several pages (used by keys2wiki)
bib2wiki -keys=all # write every entry in the .bib files
bib2wiki -dry delvalle12a # show what *would* be written, write nothing
bib2wiki -force delvalle12a # overwrite even if the page already exists
bib2wiki -uo # rebuild [[Scientific articles]] online, then exit
bib2wiki -h # usage; -v the version
-uo (update online) exists because the online list—some 5000 transcluded templates, ~6 MB of HTML—takes about a minute to parse, which is longer than a web request may run on the shared host. So the rebuild happens over ssh: purge, drain the job queue, then a CLI parse that stores straight into the parser cache and answers CACHE-STORED. Visitors only ever read the stored copy, in a couple of seconds. The daily lw-warm cron does the same on its own when the bibliography changed.
It reads ~/bib/sci.bib, ~/bib/arXiv.bib and ~/bib/Books.bib, and authenticates with the bot-password credentials of ~/.syncy. Without -force an existing template is never touched.
An article becomes the house one-liner—<u>[[key|Title]]</u>. Authors in [[Journal]] [doi '''vol''':pages] ([[year]]).—the title anchored on the key, which is the canonical url of the paper's gloss if it has one. A book, a chapter or a thesis follows the format of Referencing instead, with 🕮, the publisher as a wiki link and the ISBN through {{ISBN}}. Every template closes with a <noinclude>[[Category:Articles]]</noinclude>—Books for a whole volume, ArXiv for a preprint—which catalogues the template page without dragging the pages that cite it into the category, and which is what keeps Scientific articles alive.
No TeX ever reaches the wiki (v3.0). Accents, the standalone letters, the cedilla/macron/caron/breve/overdot/double-acute/ogonek families and the dotless \i are converted to their character; ``x and `x' become real quotation marks. And since a whitelist silently passes whatever it has not met, a guard refuses to write any page whose text still holds a backslash outside $…$, naming the macro instead—a skipped page is a bug report, a written one is a corrupted page nobody notices.
s/, and/ and/g ran over the whole citation and ate the serial comma out of titles too.s/\\^A/Â/g reads ^ as an anchor, not a caret, so those rows had matched nothing since the day they were written.* {{key}} bullet then carried a blank line, shattering author-page lists into one-item fragments.``Comment on `Optical...' ''); and the quote rule stopped eating wiki bold: a title opening with `` but closing with typographic ’’ sent the search past its own field, where it swallowed two apostrophes of the volume's —one unclosed that ran through the 3283 following entries of Scientific articles.#!/usr/bin/perl -X -s
# _ _ _ ____ _ _ _
# | |__ (_) |__ |___ \__ _(_) | _(_)
# | '_ \| | '_ \ __) \ \ / / | |/ / |
# | |_) | | |_) |/ __/ \ V /| | <| |
# |_.__/|_|_.__/_____| \_/ |_|_|\_\_|
# F.P. Laussy - [email protected]
# v0°5 Sun 16 Jul 2023
# v1.6 Mon Sep 22 2025 - all diacritics Grok can think of
# v2.0 Mon Jun 08 2026 - writes into the local MediaWiki (one Template:<Key>
# page per reference) instead of echoing.
# v2.2 Tue Jun 09 2026 - MediaWiki API write, authenticating with the same
# bot-password credentials as syncy: env SYNCY_USER /
# SYNCY_PASS, or ~/.syncy (chmod 600):
# user = Fabrice@syncy
# pass = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# (optionally api = http://localhost/laussywiki/api.php)
# --createonly means an existing template is never
# changed. The bot password needs the grants
# "Edit existing pages" AND "Create, edit, and move
# pages" (new template pages are *created*).
# v2.3 Thu Aug 06 2026 - separate the two .bib files with a newline when
# concatenating them: sci.bib has no trailing newline
# (doi2bib writes it that way), so its last "}" glued
# itself onto the "@Article{" of the *first* entry of
# arXiv.bib and that entry was invisible to the parser
# -- which is precisely where arXiv2bib -w puts the
# entry it has just created.
# v2.4 Thu Aug 06 2026 - read Books.bib too (third file), so that the new
# isbn2bib -a can push a book the way doi2bib -a pushes
# an article, and render books, chapters and theses:
# publisher (or school) instead of journal, no
# volume:pages, and editor-only entries -- an edited
# volume has no author at all and used to be skipped.
# v2.5 Fri Aug 07 2026 - books now follow the house format documented on the
# [[Books]] page and set by Template:Barrett_book12a:
# {{book}}<u>[[key|Title]]</u>. [[A]] and [[B]].
# [[Publisher]], [[Year]]. {{ISBN|...}}
# i.e. the 🕮 of {{book}}, the publisher as a wiki link
# (not an italic external one) and the ISBN through
# {{ISBN}}, which points at WorldCat. The url is used
# only when it adds something: a doi becomes {{doi}},
# an openlibrary link is dropped as the ISBN already
# says it. Book templates also get a
# <noinclude>[[Category:Books]]</noinclude>, which
# catalogues the template page itself without tagging
# any of the pages that cite it.
# v2.6 Fri Aug 07 2026 - the category goes on whole volumes only (@Book,
# @Proceedings). A chapter (@InBook, @InCollection) and
# a thesis are still *formatted* the book way, but they
# are not books: dropped into [[:Category:Books]] they
# would show up in the rendered list on the [[Books]]
# page as article-shaped entries -- "…L. C. Andreani in
# 🕮Confined Electrons and Photons…" -- which is not
# what that list is for. The wiki already holds some
# sixteen such chapters, uncategorised.
# v4.0 Sun Aug 09 2026 - the command line grew up: -h/-help and -v/-version,
# and -uo (update online), which rebuilds
# [[Scientific articles]] on laussy.org over ssh.
# WHY OVER SSH: that page expands ~5000 templates and
# takes ~a minute to parse on the shared host, past what
# a web request there may run for. refreshLinks re-parses
# the page AND writes the parser cache, and the CLI has
# no time limit -- so the render happens where it can
# finish and no visitor ever waits for one. Use it before
# sending someone the link; the daily cron covers the
# rest. Numbered 4.0, not the 3.0 asked for: 3.6 shipped
# this morning, so 3.0 would have gone backwards.
# v3.7 Sun Aug 09 2026 - the TeX-quote rule stopped eating wiki bold. ``x'' with
# a typographic closer sent the search past the field and
# it swallowed the ''' of the volume, leaving an unclosed
# <b> that ran through 3283 entries of the article list.
# v3.6 Sun Aug 09 2026 - nested TeX quotation: ``Comment on `Optical
# coherence: A convenient fiction''' put its closing
# quotes in the wrong order, the outer '' being taken
# from the inner apostrophe. The three-apostrophe
# idiom is now matched whole, before the general rules.
# v3.5 Sun Aug 09 2026 - the END hook regenerating [[Scientific article]] is
# GONE: the page is a live DPL again (with the ⦃key⦄
# Keytag tail) and the hook would have overwritten it
# with the retired bibtex2html format on the next
# template write. bib2listing is thereby orphaned.
# v3.4 Sat Aug 09 2026 - the category tag glues directly to the citation: a
# newline before <noinclude> is part of the transcluded
# text, and every "* {{key}}" bullet then carried a
# trailing blank line -- author pages' lists (Roy
# Glauber, J. Javanainen) shattered into one-item
# fragments, one <ul> per paper.
# v3.3 Sat Aug 08 2026 - after any real write (not -dry), the [[Scientific
# article]] bibliography is regenerated by calling
# bib2listing: the listing can never go stale, because
# this script is the only door a template is born
# through. (Fabrice caught the gap by creating
# aaronson11a while the first, static listing was
# being replaced.)
# v3.2 Sat Aug 08 2026 - an arXiv entry is catalogued in Category:ArXiv, not
# Category:Articles: the preprints have their own shelf.
# v3.1 Sat Aug 08 2026 - every non-book template gets
# <noinclude>[[Category:Articles]]</noinclude>, the
# articles' twin of the Books line of v2.5/v2.6.
# v3.0 Sat Aug 08 2026 - no TeX ever reaches the wiki. The diacritics table was
# a whitelist, so every macro it did not know leaked a
# backslash into the HTML -- "A. Ma\^\itre", "J. M\ork",
# and ``Hidden'' for a pair of quotation marks. Three
# changes: TeX quoting becomes real quotation marks;
# the standalone letters, the cedilla/macron/caron/
# breve/overdot/double-acute families and the dotless
# \i are converted; and -- the point -- a GUARD now
# refuses to write any page whose text still contains a
# backslash outside $...$, naming the macro instead. A
# skipped page is a bug report; a written one is a
# corrupted page nobody notices. Fabrice, 2026-08-08:
# "not every TeX/bibTeX formatting has to be brought to
# the wiki, on the opposite".
# v2.9 Sat Aug 08 2026 - two things a dry run over all 1339 existing templates
# showed, both of which a --force rewrite would have
# baked in. (1) The brace strip ate the grouping braces
# of a subscript: "Ga$_{1-x}$Al$_x$As" became
# "Ga$_1-x$Al$_x$As". Maths is now put aside before the
# strip and given back after. (2) An accent macro
# separated from its letter -- "S\'anchez Mu{\~ n}oz" --
# survived the strip as "Mu\~ noz" and no diacritic
# matched, so the backslash reached the wiki. The gap is
# now closed before the table runs.
# v2.8 Sat Aug 08 2026 - a citation with no volume and/or no pages (a paper at
# a conference, a Wolfram Demonstration) used to print
# the punctuation of the fields it did not have,
# "[[Wolfram Demonstrations Project]] [ '''''':]"; each
# piece now takes its own separators with it, and an
# escaped underscore loses its backslash.
# v2.7 Fri Aug 07 2026 - two things the Conti entry showed up. (1) The author
# list is now joined, not glued-and-repaired: the old
# "s/, and/ and/g" ran over the whole citation and ate
# the serial comma out of titles too -- "Fundamentals,
# spin glasses, and optical computing" lost one.
# (2) A town already named in the publisher is dropped,
# so "Cambridge University Press, Cambridge, 2026"
# becomes "Cambridge University Press, 2026"; a real
# place still shows ("Plenum, New York").
#
# Usage:
# bib2wiki delvalle12a # write Template:Delvalle12a (one page)
# bib2wiki -keys=a12b,c13d # write several pages (used by keys2wiki)
# bib2wiki -keys=all # write every entry in the .bib files
# bib2wiki -dry delvalle12a # show what *would* be written, write nothing
# bib2wiki -force delvalle12a # overwrite even if the page already exists
my $ERR;
BEGIN {
# Keep the real STDERR for our own diagnostics, then silence both
# compile-time module chatter ('experimental' notices) and the
# run-time warnings BibTeX::Parser likes to print.
open($ERR, '>&', \*STDERR);
open(STDERR, '>', '/dev/null');
}
use BibTeX::Parser;
use IO::File;
use LWP::UserAgent;
use HTTP::Cookies;
use JSON::PP;
use URI;
use Unicode::Normalize;
use Encode qw(encode_utf8);
my $summary = 'bib2wiki: auto-generated reference';
sub bail { print {$ERR} "\033[1;31m$_[0]\033[0m\n"; exit 1; }
# ------------------------------------------------------------------ v4.0 ---
# The command line. Switch parsing is perl's own -s (see the shebang): every
# -flag lands in the matching $flag, so -h, -v and -uo need no plumbing.
our $VERSION = '4.0';
sub usage {
print <<"END";
bib2wiki $VERSION -- turn BibTeX entries into Template:<Key> pages on the wiki.
Usage:
bib2wiki KEY write Template:Key (one page)
bib2wiki -keys=a12b,c13d write several
bib2wiki -keys=all write every entry in the .bib files
bib2wiki -dry KEY show what would be written, write nothing
bib2wiki -force KEY overwrite a template that already exists
bib2wiki -uo rebuild [[Scientific articles]] ONLINE, then exit
bib2wiki -keys=all -uo ... or do it once the run has finished
bib2wiki -h, -help this message
bib2wiki -v, -version the version
WITHOUT -force an existing template is never touched: the run only creates what
is missing. That is deliberate -- a regeneration drops any {{pdf|File:...}} link
the page carries, because nothing in the .bib records which papers have their
PDF uploaded.
WHAT -uo IS FOR. [[Scientific articles]] expands ~5000 templates and takes about
a minute to parse on the shared host -- longer than a web request there is
allowed to run. refreshLinks re-parses the page AND stores the result in the
parser cache, and it runs under the CLI where there is no time limit. So -uo
does the render over ssh and no visitor ever waits for one. Use it when you are
about to send someone the link; otherwise the daily cron is enough.
Credentials: ~/.syncy (same bot password as syncy). The ssh target is the one
pushcode uses.
END
exit 0;
}
usage() if $h || $help;
if ($v || $version) { print "bib2wiki $VERSION\n"; exit 0; }
# ---- -uo: rebuild the heavy page on the ONLINE wiki, over ssh --------------
my $SSH_HOST = '[email protected]'; # the target pushcode uses
my $SSH_PORT = 2222;
my $SSH_KEY = "$ENV{HOME}/.ssh/laussy_imh";
my $REMOTE = 'public_html';
my $HEAVY = 'Scientific_articles';
sub update_online {
print "Rebuilding [[" . ($HEAVY =~ s/_/ /gr) . "]] online over ssh; "
. "this takes about a minute.\n";
# The page id is resolved ON THE SERVER, so this needs no online API call
# and no WAF header. refreshLinks then re-parses that one page and writes
# the parser cache, with no execution-time limit to run into.
# Drain the job queue FIRST. A template edit queues an htmlCacheUpdate
# that bumps page_touched when the server cron runs it -- and a touch
# AFTER our render invalidates it, sending the next visitor into the
# 30-second death the render existed to prevent. Happened 2026-08-09:
# llw2lw pushed 15 template fixes, -uo rendered at 18:37, the cron
# touched the page at 19:12, and the page was back to HTTP 500.
# ...then parse the page and STORE it, which is the part refreshLinks
# turned out never to do: the "working" render of 18:37 on 2026-08-09 had
# been written by a diagnostic probe, not by refreshLinks, and every -uo
# since had been re-rendering into the void. ~/.warm-sci.code is a
# one-line ParserOutputAccess::getParserOutput() call -- the exact code
# path a page view takes, minus the 30-second web execution limit. It
# prints CACHE-STORED on success, which we require below.
# Purge FIRST: a brand-new paper joins the category without touching this
# page (DPL leaves no trace in the link tables), so its cached render is
# stale yet still VALID -- and getParserOutput would hand the stale copy
# back and honestly print CACHE-STORED without parsing anything. The purge
# bumps page_touched, which is what turns "-uo" into an order rather than
# a suggestion.
my $remote = "cd \$HOME/$REMOTE && "
. "echo $HEAVY | php maintenance/run.php purgePage >/dev/null 2>&1; "
. "php maintenance/run.php runJobs --maxtime=120 --memory-limit=512M "
. ">/dev/null 2>&1; "
. "php maintenance/run.php eval < \$HOME/.warm-sci.code";
my $out = qx(ssh -o BatchMode=yes -p $SSH_PORT -i $SSH_KEY $SSH_HOST '$remote' 2>&1);
my $rc = $out =~ /CACHE-STORED/ ? 0 : 1;
if ($rc == 0) {
print "\033[1;32mdone\033[0m: the online page is rendered and cached.\n";
} else {
print {$ERR} "\033[1;31mthe online rebuild failed\033[0m -- no "
. "CACHE-STORED came back. Output was:\n$out\n";
}
return $rc == 0;
}
# Paths to your bib files
my $file1 = "/home/laussy/bib/sci.bib";
my $file2 = "/home/laussy/bib/arXiv.bib";
my $file3 = "/home/laussy/bib/Books.bib";
# Concatenate them into memory
my $content = '';
for my $file ($file1, $file2, $file3) {
my $bfh = IO::File->new($file, 'r') or bail("Cannot open $file: $!");
$content .= $_ while <$bfh>;
$bfh->close;
$content .= "\n" unless $content =~ /\n\z/; # sci.bib ends on a bare "}"
}
my $fh = IO::File->new(\$content, 'r')
or bail("Cannot create in-memory filehandle: $!");
# Build list of keys to process
my @mykeys = split(/,/, $keys // '');
push(@mykeys, $ARGV[0]) if $ARGV[0];
@mykeys = grep { $_ && $_ !~ /^\s*$/ } @mykeys;
# Lazily-initialised MediaWiki session (login once, reuse for all pages)
my ($UA, $CSRF, $API);
# -uo on its own is a rebuild order, not a generation run
if ($uo && !@mykeys) { exit( update_online() ? 0 : 1 ); }
if ($keys && lc($keys) eq "all") {
# Process ALL entries in original file order
my $iterator = BibTeX::Parser->new($fh);
while (my $entry = $iterator->next) {
next unless $entry->parse_ok;
handle_entry($entry);
}
}
else {
# Process specific keys in the order requested
foreach my $key (@mykeys) {
$fh->seek(0, 0); # rewind
my $iterator = BibTeX::Parser->new($fh);
my $found = 0;
while (my $entry = $iterator->next) {
if ($entry->parse_ok && $entry->key eq $key) {
handle_entry($entry);
$found = 1;
last;
}
}
print "\033[1;33mWarning: Key '$key' not found in bibliography.\033[0m\n"
unless $found;
}
}
# ...and if asked, push the render online now that the pages exist
update_online() if $uo;
# ------------------------------------------------------------------
sub handle_entry {
my ($entry) = @_;
my ($page, $text) = build_entry($entry);
return unless defined $text; # author-less entries are skipped
if ($dry) {
print "\033[1;31m$page\033[0m\n$text\n\n";
} else {
write_to_wiki($page, $text);
}
}
# ------------------------------------------------------------------
# Credentials, the syncy way: env first, then ~/.syncy (user=/pass=, and
# optionally api=/url=/wiki= for the endpoint).
sub load_creds {
my $u = $ENV{SYNCY_USER};
my $p = $ENV{SYNCY_PASS};
my $a = $ENV{SYNCY_API} || $ENV{BIB2WIKI_API};
my $rc = ($ENV{HOME} // '') . "/.syncy";
if (-f $rc) {
open(my $f, '<', $rc) or bail("Cannot read $rc: $!");
while (<$f>) {
next if /^\s*#/ || /^\s*$/;
next unless /^\s*(\w+)\s*=\s*(.+?)\s*$/;
my ($k, $v) = (lc $1, $2);
$u ||= $v if $k eq 'user';
$p ||= $v if $k eq 'pass';
$a ||= $v if $k =~ /^(api|url|wiki)$/;
}
close $f;
}
bail("No MediaWiki credentials: set SYNCY_USER/SYNCY_PASS or put\n"
. " user = Fabrice\@syncy\n pass = ...\nin ~/.syncy (chmod 600).")
unless $u && $p;
$a ||= 'http://localhost/laussywiki/api.php';
$a =~ s{/+$}{};
$a .= '/api.php' if $a !~ /\.php(\?|$)/; # accept a bare wiki base URL
return ($u, $p, $a);
}
# ------------------------------------------------------------------
# MediaWiki API helpers
sub from_json_safe {
my ($txt) = @_;
my $data = eval { decode_json($txt) };
bail("The wiki did not return JSON (check the API URL / \$wgSecureLogin).\n"
. "First bytes were: " . substr($txt, 0, 160)) if $@ || !$data;
return $data;
}
sub mw_get {
my (%f) = @_;
$f{format} = 'json'; $f{formatversion} = 2;
my $uri = URI->new($API);
$uri->query_form(%f);
my $res = $UA->get($uri);
bail("HTTP error talking to $API: " . $res->status_line) unless $res->is_success;
return from_json_safe($res->decoded_content);
}
sub mw_post {
my (%f) = @_;
$f{format} = 'json'; $f{formatversion} = 2;
my $res = $UA->post($API, \%f);
bail("HTTP error talking to $API: " . $res->status_line) unless $res->is_success;
return from_json_safe($res->decoded_content);
}
sub mw_login {
my ($user, $pass, $api) = load_creds();
$API = $api;
$UA = LWP::UserAgent->new(
agent => 'bib2wiki/2.2',
cookie_jar => HTTP::Cookies->new,
ssl_opts => { verify_hostname => 0, SSL_verify_mode => 0 }, # localhost cert
);
push @{ $UA->requests_redirectable }, 'POST'; # tolerate $wgSecureLogin redirects
my $t = mw_get(action => 'query', meta => 'tokens', type => 'login');
my $lgtoken = $t->{query}{tokens}{logintoken}
or bail("Could not obtain a login token from $API");
my $r = mw_post(action => 'login',
lgname => $user, lgpassword => $pass, lgtoken => $lgtoken);
bail("MediaWiki login failed: " . encode_json($r->{login} // $r))
unless $r->{login} && $r->{login}{result} eq 'Success';
my $c = mw_get(action => 'query', meta => 'tokens');
$CSRF = $c->{query}{tokens}{csrftoken}
or bail("Could not obtain a CSRF (edit) token");
}
sub write_to_wiki {
my ($page, $text) = @_;
mw_login() unless $UA && $CSRF;
# A {{pdf|File:...}} link lives ONLY on the wiki: nothing in the .bib
# records which of the ~4600 pdf= fields has its file actually uploaded
# (38 do, all Laussy-group papers). A -force regeneration silently dropped
# it -- that is how Delvalle12a lost its link on 2026-08-09 -- and
# doi2bib -a calls -force on every re-push. So: before overwriting, read
# the live page and splice its pdf fragment into the new text. An external
# URL in {{pdf|http...}} is NOT rescued; the citation already carries it.
if ($force && $text !~ /\{\{pdf\|/i) {
my $cur = eval { mw_get(action => 'query', prop => 'revisions',
rvprop => 'content', rvslots => 'main',
formatversion => 2, titles => $page) };
my $old = eval { $cur->{query}{pages}[0]{revisions}[0]{slots}{main}{content} } // '';
if ($old =~ /((?:\s| )*\{\{pdf\|\s*(?:File|Media)\s*:[^{}]*\}\}\.?)/i) {
my $frag = $1;
# $old came out of decode_json as WIDE characters; splicing a wide
# fragment into byte-string $text upgrades ALL of it, and URI's
# form encoder then UTF-8-encodes the already-UTF-8 bytes --
# "Gonz\xc3\xa1lez" became "GonzA~A!lez" on this guard's very
# first live write. Downgrade to bytes before touching $text.
utf8::encode($frag) if utf8::is_utf8($frag);
$text =~ s/<noinclude>/$frag<noinclude>/ or $text .= $frag;
print " \033[36mkept\033[0m the {{pdf}} link already on $page\n";
}
}
my %f = (
action => 'edit',
title => $page,
text => $text,
summary => $summary,
token => $CSRF,
bot => 1,
assert => 'user', # fail loudly if the session was dropped
);
$f{createonly} = 1 unless $force; # never modify an existing template
my $r = mw_post(%f);
if ($r->{edit} && ($r->{edit}{result} // '') eq 'Success') {
my $what = $r->{edit}{new} ? 'created' : 'updated';
print " \033[1;32m$what\033[0m $page\n";
}
elsif ($r->{error} && ($r->{error}{code} // '') eq 'articleexists') {
print " \033[1;34mexists \033[0m $page (left unchanged)\n";
}
else {
print " \033[1;31mERROR \033[0m $page: "
. ($r->{error}{info} // encode_json($r)) . "\n";
}
}
# ------------------------------------------------------------------
# Build the wiki text for one entry.
# Returns ($pagetitle, $pagetext), or () if the entry has no authors.
sub build_entry {
my ($entry) = @_;
my $bibkey = $entry->key;
my $title = $entry->field("title") // "";
my $url = $entry->field("url") // "";
my $journal= $entry->field("journal")// "";
my $volume = $entry->field("volume") // "";
my $pages = $entry->field("pages") // "";
my $year = $entry->field("year") // "";
my $page = "Template:" . ucfirst($bibkey); # delvalle12a -> Template:Delvalle12a
# Books.bib holds books, chapters and theses as well as articles: they have
# a publisher (or a school) where an article has a journal, no volume and
# no pages, and an edited volume has no author at all -- only editors.
my $type = uc($entry->type // '');
my $is_book = $type =~ /^(BOOK|INBOOK|INCOLLECTION|PROCEEDINGS|PHDTHESIS|MASTERSTHESIS)$/;
my $output = "";
my @authors = $entry->author;
my $eds_only = 0;
unless (@authors) { # an edited volume
@authors = $entry->editor;
$eds_only = 1 if @authors;
}
return unless @authors; # safety
my @notlast = @authors;
pop @notlast;
# Build the links first and join them, rather than gluing on ", " and then
# repairing ", and" afterwards: that repair was a global substitution and
# it also ate the serial comma out of *titles* -- Conti's "Fundamentals,
# spin glasses, and optical computing" came out one comma short.
my @links;
foreach my $author (@notlast) {
my $initials = $author->first // "";
# A TeX tie ("J.~C.") is a space; the tilde of an ACCENT is not. The
# parser puts "Galv{\~a}o" in the first-name field, so an unguarded
# s/~/ / turned it into "{\ a}" and Teodoro10a was refused for it.
$initials =~ s/(?<!\\)~/ /g;
push @links, "[[" . $initials . " " . ($author->von // "") . " " . $author->last . "]]";
}
my $last_initials = $authors[-1]->first // "";
$last_initials =~ s/(?<!\\)~//g; # ... and here too
my $last_link = "[[" . $last_initials . " " . ($authors[-1]->von // "") . " " . $authors[-1]->last . "]]";
$output .= @links ? join(", ", @links) . " and " . $last_link : $last_link;
$output .= " (Ed" . (@authors > 1 ? "s" : "") . ".)" if $eds_only;
# A book stops the author list with a full stop -- "…and [[Peter Byrne]].
# [[Princeton University Press]]" -- except after "(Eds.)", which is
# punctuation enough.
$output .= $is_book ? ($eds_only ? " " : ". ") : " in ";
# arXiv detection and ID extraction
my $arxiv_id = "";
if ($journal =~ /arXiv/i || $url =~ /arXiv/i) {
if ($url =~ /arXiv[.:](\d{4}\.\d{4,5})/i) {
$arxiv_id = $1;
} elsif ($journal =~ /arXiv[.:]?(\d{4}\.\d{4,5})/i) {
$arxiv_id = $1;
}
if ($arxiv_id) {
$output = "<wz tip=\"arXiv:$arxiv_id\">[[File:arXXXiv.png|14px|link=]]</wz><u>[["
. $bibkey . "|" . $title . "]]</u>. " . $output
. "{{arXiv|$arxiv_id}} ([[" . $year . "]]).\n\n";
}
}
if (!$arxiv_id && $is_book) {
# Book, chapter or thesis, in the house format of Template:Barrett_book12a
# (see the [[Books]] page): the publisher -- or the school -- stands
# where the journal would and is a *wiki* link, the year is a wiki link
# of its own, and the identifiers close the entry as templates.
my $where = $entry->field("publisher") // $entry->field("school") // "";
my $address = $entry->field("address") // "";
my $chapter = $entry->field("chapter") // "";
my $edition = $entry->field("edition") // "";
my $isbn = $entry->field("isbn") // "";
my $vol = $volume;
# A doi is worth a {{doi}}; an openlibrary link says nothing the ISBN
# does not already say, so it is dropped rather than shown twice.
my $doi = "";
$doi = $1 if $url =~ m{^\s*(?:doi:|https?://(?:dx\.)?doi\.org/)(\S+)\s*$}i;
my $link = ($doi || !$url || $url =~ m{openlibrary\.org}i) ? "" : $url;
# @InBook names the book in "title" and the piece in "chapter";
# @InCollection does it the other way round, the piece being the title
# and the volume the "booktitle". Either way both are named.
my $booktitle = $entry->field("booktitle") // "";
my $head = "<u>[[" . $bibkey . "|" . $title . "]]</u>";
$head .= ", chapter \"" . $chapter . "\"" if $chapter;
$head .= ", in ''" . $booktitle . "''" if $booktitle;
$head .= ", p. " . $pages if $pages && ($chapter || $booktitle);
my $tail = "";
$tail .= "PhD thesis, " if $type eq 'PHDTHESIS';
$tail .= "MSc thesis, " if $type eq 'MASTERSTHESIS';
$tail .= "vol. " . $vol . ", " if $vol;
if ($where) {
# "cup" expands to "Cambridge University Press, Cambridge": only the
# press is a page on the wiki, the town trails it as plain text.
# Old entries write the town the German way, "Leipzig: G. Teubner".
$where = "$2, $1" if $where =~ /^([^:]+):\s*(.+)$/;
my ($press, $town) = split(/\s*,\s*/, $where, 2);
# "Cambridge University Press, Cambridge" already says Cambridge:
# printing the town again only adds a comma before the year.
undef $town if $town && $press =~ /\b\Q$town\E\b/i;
$tail .= "[[" . $press . "]]";
$tail .= ", " . $town if $town;
$tail .= ", " . $address if $address && !$town;
$tail .= ", " . ordinal($edition) . " ed." if $edition;
$tail .= ", ";
}
$tail .= "[[" . $year . "]].";
$tail .= " {{ISBN|" . $isbn . "}}" if $isbn;
$tail .= " {{doi|" . $doi . "}}" if $doi;
$tail .= " [" . $link . "]" if $link;
$output = "{{book}}" . $head . ". " . $output . $tail . "\n";
}
elsif (!$arxiv_id) {
# Non-arXiv article. Volume, pages and url are each optional: a
# conference paper has a paper number where the pages would be and no
# volume at all, a Wolfram Demonstration has neither. Every missing
# piece must take its punctuation away with it, or the citation reads
# "... in [[Wolfram Demonstrations Project]] [ '''''':] ([[2011]])".
$url =~ s/^doi:/http:\/\/dx.doi.org\//;
my $where = "";
$where .= "'''" . $volume . "'''" if $volume;
$where .= ":" if $volume && $pages;
$where .= $pages if $pages;
my $ref = "";
if ($url) { $ref = $where ? "[" . $url . " " . $where . "]"
: " [" . $url . "]"; }
else { $ref = $where; }
$output = "<u>[[" . $bibkey . "|" . $title . "]]</u>. " . $output
. "[[" . $journal . "]]"
. ($ref ? ($ref =~ /^ / ? $ref : " " . $ref) : "")
. " ([[" . $year . "]]).\n\n";
}
# Protect wiki templates
# "{\'{\i}}" ends in "}}", which the template guard just below would take
# for a template close and hand back untouched at the very end -- which is
# how Mart{\'{\i}}nez reached the refusal check as "Mart\'\i}}nez".
# Collapse the dotless i into its accent before the guard can see it.
$output =~ s/\\([`'"^~=.uv])\s*\{\s*\\i\s*\}/\\$1i/g;
$output =~ s/{{/XXXopenXXX/g;
$output =~ s/}}/XXXcloseXXX/g;
# Protect maths. The brace strip below is meant for BibTeX's capitalisation
# braces, but it cannot tell them from the ones that group a subscript:
# "Ga$_{1-x}$Al$_x$As" came out as "Ga$_1-x$Al$_x$As", which is a different
# formula. Put every $...$ span aside and give it back untouched.
my @maths;
$output =~ s/(\$[^\$]*\$)/push(@maths, $1); "XXXmathXXX" . $#maths . "XXXdnamXXX"/ge;
# TeX quoting is a typesetting convention, not content: ``x'' and `x' are
# the source's way of asking for real quotation marks. The wiki wants the
# marks themselves -- a backtick reaching HTML is simply wrong. Both forms
# need a backtick to fire, so wiki italics ('' '') are never touched.
# Inner pair first: a nested quotation -- ``Comment on `Optical
# coherence: A convenient fiction''' -- otherwise the inner closing
# apostrophe eats the outer '' and the outer `` is left on the page.
# A nested quotation closes with three apostrophes -- ``Comment on
# `Optical coherence: A convenient fiction''' -- which the general rules
# below would split in the wrong place, so it is matched whole first.
# Every rule needs a backtick to fire, which is what keeps them off the
# ''' of a bold volume.
$output =~ s/(?<!\\)``([^`]*)`([^`']*)'''/“$1‘$2’”/g;
# Two guards, both learnt from greenberger80a. (1) The closer may already
# be the typographic ’’ -- a title that mixes ``...’’ used to send this
# search past the end of the field. (2) '' that is part of ''' is WIKI BOLD,
# not a TeX quote: without (?!') this ate the opening ''' of the volume,
# leaving "”'48'''" and an unclosed <b> that ran through the remaining
# 3283 entries of [[Scientific articles]].
$output =~ s/(?<!\\)``((?:(?!``).)*?)(?:''(?!')|’’)/“$1”/gs;
$output =~ s/(?<!\\)`([^`']*?)'/‘$1’/g;
# BibTeX can emit an accent as a math/text pair, and only the text branch
# can reach the wiki: keep it and drop the scaffolding. Without this,
# "\ifmmode \check{C}\else \v{C}\fi{}ern\'y" reached the refusal check
# with \ifmmode still in it and Cerny93a was skipped.
$output =~ s/\\ifmmode\s*.*?\\else\s*(.*?)\\fi\s*(?:\{\})?/$1/gs;
# The same accents spelled as named macros: \dot{Z} is \.Z, \check{C} is
# \vC. Rewrite them into the symbol form the tables below already know.
my %namedaccent = ('acute'=>"'", 'grave'=>'`', 'hat'=>'^', 'ddot'=>'"',
'tilde'=>'~', 'bar'=>'=', 'dot'=>'.', 'check'=>'v',
'breve'=>'u');
$output =~ s/\\(acute|grave|hat|ddot|tilde|bar|dot|check|breve)\s*\{\s*(\\?[a-zA-Z])\s*\}/"\\" . $namedaccent{$1} . $2/ge;
# \i and \j are dotless letters, and exist only to carry an accent that
# would otherwise collide with the dot: "Ma{\^\i}tre", "Burshte\={\i}n".
# Turn them back into their ordinary letter and let the accent apply.
$output =~ s/(\\[`'"^~=.uv])\s*\{\s*\\i\s*\}/$1i/g; # \'{\i} -> \'i
$output =~ s/(\\[`'"^~=.])\\i/$1i/g; # "Ram\'\irez" -> "Ram\'irez"
$output =~ s/\{\\i\}|\\i(?![a-zA-Z])/i/g;
$output =~ s/\{\\j\}|\\j(?![a-zA-Z])/j/g;
# Font-switching commands carry no meaning the wiki can use: keep the
# argument, drop the command. "[Phys. Rev. Lett.\textbf{109}, 183601]".
$output =~ s/\\(?:textbf|textit|emph|textrm|texttt|textsc|mathrm|mbox|text)\s*(?=\{)//g;
# An accent macro may be separated from its letter, either by a space or by
# the braces that used to group them: "Mu{\~ n}oz", "S{\'a}nchez". Close
# the gap here, while the braces are still there to be seen, so that the
# diacritics table below has the "\~n" it is looking for.
$output =~ s/\\([`'"^~vuHck.=])\s*\{?\s*([a-zA-Z])\}?/\\$1$2/g;
# Cleanups
$output =~ s/ +/ /g;
$output =~ s/--/-/g;
$output =~ s/[{}]//g;
$output =~ s/\\_/_/g; # "IG\_3\_1": BibTeX has to escape an underscore,
# the wiki must not carry the backslash over.
# Diacritics (same comprehensive list as before)
$output =~ s/\\"A/Ä/g; $output =~ s/\\"E/Ë/g; $output =~ s/\\"I/Ï/g; $output =~ s/\\"O/Ö/g;
$output =~ s/\\"U/Ü/g; $output =~ s/\\"Y/Ÿ/g; $output =~ s/\\"a/ä/g; $output =~ s/\\"e/ë/g;
$output =~ s/\\"i/ï/g; $output =~ s/\\"o/ö/g; $output =~ s/\\"u/ü/g; $output =~ s/\\"y/ÿ/g;
$output =~ s/\\'A/Á/g; $output =~ s/\\'E/É/g; $output =~ s/\\'I/Í/g; $output =~ s/\\'O/Ó/g;
$output =~ s/\\'U/Ú/g; $output =~ s/\\'Y/Ý/g; $output =~ s/\\'a/á/g; $output =~ s/\\'c/ć/g;
$output =~ s/\\'e/é/g; $output =~ s/\\'i/í/g; $output =~ s/\\'l/ĺ/g; $output =~ s/\\'n/ń/g;
$output =~ s/\\'o/ó/g; $output =~ s/\\'r/ŕ/g; $output =~ s/\\'s/ś/g; $output =~ s/\\'u/ú/g;
$output =~ s/\\'y/ý/g; $output =~ s/\\'z/ź/g;
$output =~ s/\\`A/À/g; $output =~ s/\\`E/È/g; $output =~ s/\\`I/Ì/g; $output =~ s/\\`O/Ò/g;
$output =~ s/\\`U/Ù/g; $output =~ s/\\`a/à/g; $output =~ s/\\`e/è/g; $output =~ s/\\`i/ì/g;
$output =~ s/\\`o/ò/g; $output =~ s/\\`u/ù/g;
$output =~ s/\\~A/Ã/g; $output =~ s/\\~N/Ñ/g; $output =~ s/\\~O/Õ/g;
$output =~ s/\\~a/ã/g; $output =~ s/\\~n/ñ/g; $output =~ s/\\~o/õ/g;
$output =~ s/\\\^A/Â/g; $output =~ s/\\\^E/Ê/g; $output =~ s/\\\^I/Î/g; $output =~ s/\\\^O/Ô/g;
$output =~ s/\\\^U/Û/g; $output =~ s/\\\^Y/Ŷ/g; $output =~ s/\\\^a/â/g; $output =~ s/\\\^e/ê/g;
$output =~ s/\\\^i/î/g; $output =~ s/\\\^o/ô/g; $output =~ s/\\\^u/û/g; $output =~ s/\\\^y/ŷ/g;
$output =~ s/\\=A/Ā/g; $output =~ s/\\=E/Ē/g; $output =~ s/\\=I/Ī/g; $output =~ s/\\=O/Ō/g;
$output =~ s/\\=U/Ū/g; $output =~ s/\\=a/ā/g; $output =~ s/\\=e/ē/g; $output =~ s/\\=i/ī/g;
$output =~ s/\\=o/ō/g; $output =~ s/\\=u/ū/g;
$output =~ s/\\vA/Ǎ/g; $output =~ s/\\vC/Č/g; $output =~ s/\\vD/Ď/g; $output =~ s/\\vE/Ě/g;
$output =~ s/\\vL/Ľ/g; $output =~ s/\\vN/Ň/g; $output =~ s/\\vR/Ř/g; $output =~ s/\\vS/Š/g;
$output =~ s/\\vT/Ť/g; $output =~ s/\\vU/Ů/g; $output =~ s/\\vZ/Ž/g;
$output =~ s/\\va/ǎ/g; $output =~ s/\\vc/č/g; $output =~ s/\\vd/ď/g; $output =~ s/\\ve/ě/g;
$output =~ s/\\vl/ľ/g; $output =~ s/\\vn/ň/g; $output =~ s/\\vr/ř/g; $output =~ s/\\vs/š/g;
$output =~ s/\\vt/ť/g; $output =~ s/\\vu/ů/g; $output =~ s/\\vz/ž/g;
$output =~ s/\\cC/Ç/g; $output =~ s/\\cc/ç/g;
$output =~ s/\\ss/ß/g; $output =~ s/\\AA/Å/g; $output =~ s/\\AE/Æ/g; $output =~ s/\\O/Ø/g;
$output =~ s/\\OE/Œ/g; $output =~ s/\\L/Ł/g; $output =~ s/\\l/ł/g;
# Special names
$output =~ s/\\Imamoglu/İmamoğlu/g;
$output =~ s/\\Vuckovic/Vučković/g;
# Letters that are a macro in their own right rather than an accent over
# one. Longest first: \oe before \o, \AE before \A.
$output =~ s/\\oe/œ/g; $output =~ s/\\OE/Œ/g;
$output =~ s/\\ae/æ/g; $output =~ s/\\AE/Æ/g;
$output =~ s/\\aa/å/g; $output =~ s/\\AA/Å/g;
$output =~ s/\\ss/ß/g; $output =~ s/\\SS/ẞ/g;
$output =~ s/\\o/ø/g; $output =~ s/\\O/Ø/g;
$output =~ s/\\l/ł/g; $output =~ s/\\L/Ł/g;
# Cedilla, macron, overdot, breve, caron and double acute, which the
# table above only ever covered for a handful of letters.
my %cedilla = (C=>"Ç", c=>"ç", S=>"Ş", s=>"ş",
G=>"Ğ", g=>"ğ", T=>"Ţ", t=>"ţ");
$output =~ s/\\c([A-Za-z])/$cedilla{$1} \/\/ "\\c$1"/ge;
my %macron = (a=>"ā", e=>"ē", i=>"ī", o=>"ō",
u=>"ū", A=>"Ā", E=>"Ē", I=>"Ī",
O=>"Ō", U=>"Ū");
$output =~ s/\\=([A-Za-z])/$macron{$1} \/\/ "\\=$1"/ge;
my %caron = (c=>"č", C=>"Č", s=>"š", S=>"Š",
z=>"ž", Z=>"Ž", r=>"ř", R=>"Ř",
e=>"ě", E=>"Ě", n=>"ň", N=>"Ň",
d=>"ď", D=>"Ď", t=>"ť", T=>"Ť");
$output =~ s/\\v([A-Za-z])/$caron{$1} \/\/ "\\v$1"/ge;
my %breve = (a=>"ă", A=>"Ă", g=>"ğ", G=>"Ğ",
u=>"ŭ", U=>"Ŭ");
$output =~ s/\\u([A-Za-z])/$breve{$1} \/\/ "\\u$1"/ge;
my %dot = (z=>"ż", Z=>"Ż", c=>"ċ", C=>"Ċ",
e=>"ė", E=>"Ė", g=>"ġ", G=>"Ġ");
$output =~ s/\\\.([A-Za-z])/$dot{$1} \/\/ "\\.$1"/ge;
my %dacute = (o=>"ő", O=>"Ő", u=>"ű", U=>"Ű");
$output =~ s/\\H([A-Za-z])/$dacute{$1} \/\/ "\\H$1"/ge;
my %ogonek = (a=>"ą", A=>"Ą", e=>"ę", E=>"Ę", i=>"į", I=>"Į",
o=>"ǫ", O=>"Ǫ", u=>"ų", U=>"Ų");
$output =~ s/\\k([A-Za-z])/$ogonek{$1} \/\/ "\\k$1"/ge;
# Whatever those per-letter tables still missed: attach the accent as a
# COMBINING mark and let Unicode compose the character. One rule instead of
# a new row every time a name turns up with an accent nobody listed --
# \'S (Świerczewski) and \=g (Imamo\=glu) both arrived that way.
# Deliberately limited to the accents already handled above: widening it to
# \r, \d, \b would start eating real macros like \ref and \dots.
my %combining = ("'" => "\x{0301}", '`' => "\x{0300}", '^' => "\x{0302}",
'"' => "\x{0308}", '~' => "\x{0303}", '=' => "\x{0304}",
'.' => "\x{0307}", 'u' => "\x{0306}", 'v' => "\x{030C}",
'c' => "\x{0327}", 'k' => "\x{0328}", 'H' => "\x{030B}");
# encode_utf8 is not decoration: NFC returns a CHARACTER string, and letting
# one into $output upgrades the whole thing, so the UTF-8 BYTES the tables
# above already wrote get encoded a second time -- "Cotle\c{t}" came out as
# "Cotleţ". Everything in this sub is bytes; the accent must join as bytes.
$output =~ s/\\(['`^"~=.uvckH])([A-Za-z])/encode_utf8(NFC($2 . $combining{$1}))/ge;
# Characters BibTeX has to escape and the wiki does not.
$output =~ s/\\([&%\$#])/$1/g;
# Restore templates
$output =~ s/XXXmathXXX(\d+)XXXdnamXXX/$maths[$1]/g;
$output =~ s/XXXopenXXX/{{/g;
$output =~ s/XXXcloseXXX/}}/g;
# No trailing newline before a <noinclude>: it would be INSIDE the
# transclusion, and a "* {{key}}" bullet expanding with a trailing blank
# line shatters the list into one-item fragments (v3.4, the Roy Glauber
# lesson). The categories below stick directly to the citation.
$output =~ s/\s+\z//;
# Catalogue the book templates without touching the pages that cite them:
# <noinclude> keeps the category on the template page itself. Only whole
# volumes are catalogued -- a chapter is not a book, and a thesis is not
# one either.
$output .= "<noinclude>[[Category:Books]]</noinclude>\n"
if $type =~ /^(BOOK|PROCEEDINGS)$/;
# Everything else -- articles, and the article-shaped chapters and theses
# -- is catalogued the same way in Category:Articles (v3.1, matching the
# 2026-08-08 silent pass that tagged the 1320 existing templates); an
# arXiv entry goes to the preprints' own Category:ArXiv instead (v3.2).
# An entry is an arXiv preprint if it carries an arXiv id -- OR if it
# simply lives in arXiv.bib, whose keys are all arXiv_-prefixed. The id
# test alone missed 15 preprints whose journal reads "Unpublished" and
# they were shelved with the published articles (found 2026-08-09, when
# they surfaced in [[Scientific articles]]).
my $is_arxiv = $arxiv_id || $entry->key =~ /^arxiv_/i;
unless ( $type =~ /^(BOOK|PROCEEDINGS)$/ ) {
$output .= $is_arxiv
? "<noinclude>[[Category:ArXiv]]</noinclude>\n"
: "<noinclude>[[Category:Articles]]</noinclude>\n";
}
# The guard. Everything above is a whitelist, and a whitelist is a promise
# that the next unfamiliar macro will be silently written into the page --
# which is how "Ma\^\itre" and "M\ork" got onto the wiki. So: if a
# backslash survives outside of $...$ (where TeX is the point, and MathJax
# reads it), this entry is not fit to publish. Refuse it, name it, and
# leave whatever is on the wiki alone; a skipped page is a bug report, a
# written one is a corrupted page nobody notices.
my $prose = $output;
$prose =~ s/\$[^\$]*\$//g;
if ($prose =~ /(.{0,30})(\\[a-zA-Z]+|\\.)(.{0,30})/s) {
print {$ERR} "\033[1;31mrefusing $page\033[0m: unconverted TeX "
. "\033[1m$2\033[0m in \"...$1$2$3...\"\n";
return;
}
return ($page, $output);
}
# ------------------------------------------------------------------
# "2" -> "2nd", "11" -> "11th", "5th" -> "5th" (Books.bib writes both).
sub ordinal {
my ($n) = @_;
return $n unless $n =~ /^\s*(\d+)\s*$/;
$n = $1;
return $n . "th" if $n % 100 >= 11 && $n % 100 <= 13;
return $n . (qw(th st nd rd th th th th th th))[$n % 10];
}