Sunday, September 20, 2026

Citation.js Version 0.9: TypeScript types

The release of version 0.9.0 of Citation.js comes shortly after the release of version 0.8.0. This is in part because v0.8.0 was released shortly before Node.js v20 went End Of Life, which led to a lot of (developmental) dependencies to dropping support for it. An added benefit though is that v22.12.0 of Node.js finally added support for CommonJS modules (require() syntax) to import most ESM modules (import/export syntax). This allowed me to stop using the @larsgw/wikibase-sdk fork of wikibase-sdk.

Hoverfly on willow blossom against deep blue sky
Melangyna pavlovskyi, 2026.iii.22, Zeddam, The Netherlands

In short:

  • The minimum Node.js version is now v22.12.0.
  • Use of node-fetch was replaced with the built-in version of the Fetch API (based on undici); similarly sync-fetch was replaced with sync-fetch-undici.
  • Use of the fork @larsgw/wikibase-sdk was replaced with the upstream wikibase-sdk.
  • Babel was updated to v8, which does mean a fork of Babelify had to be used for the browser bundles in the citation-js package, until Babelify is updated upstream.
  • The repository documentation now explicitly directs contributors to not include spurious Co-Authored-By tags in their commit messages for advertising purposes (i.e. LLMs).

Another major change is the addition of TypeScript types, which people have been asking for, for some time. I have attempted to create type definitions that are actually useful, which proved difficult given the convoluted plugin system. In the process, I learnt some interesting features of TypeScript, like module augmentation and some pretty nice generic functions.

Module augmentation

The way to register a plugin with Citation.js is to import or require() it. The first step was to employ module augmentation to add these overloads, like below. (Note that some formats have multiple return types, meaning the overload also depends on the second argument; this will come back later!)

// node_modules/@citation-js/core/index.d.ts
export class Cite {
  // ...

	format (format: 'data', options?: { format?: 'text', version: string }): string
	format (format: 'data', options: { format: 'object', version: string }): Array<CSL>
	format (format: 'label'): Record<string, string>
}

// node_modules/@citation-js/plugin-csl/index.d.ts
import '@citation-js/core'

declare module '@citation-js/core' {
  class Cite {
    format (format: 'bibliography', options?: { /* ... */ }): string
    // ...
  }
}

// index.ts
import { Cite } from '@citation-js/core'
import from '@citation-js/plugin-csl'

const a = (new Cite()).format('bibliography')
// typeof a === string

This is for plugins with output formatting features, for input parsing features and plugin configuration something similar is possible. However, formatting features not only affect the possible overloads of the Cite#format() function, but also the plugins.output.format() function. This would mean repeating all the overloads.

Additionally, if the typing of a call to Cite#format() is wrong, the error is actually not very helpful. TypeScript will only show how the last overload of a function does not match. There is another way, though.

Generic functions

With generic functions for Cite#format() and plugins.output.format() we can reuse the same output formatter signatures for both functions. This was achieved by exporting an interface type with the signatures.

interface Formats {
  data:
    | ((options?: { format?: 'text', version?: string }) => string)
    | ((options: { format: 'object', version?: string }) => Array<CSL>)
  label: () => Record<string, string>
}

This interface can be extended by plugins as well, so adding signatures is simple. The generic function is not so simple; I started with the following:

export class Cite {
  format<
    Format extends keyof Formats,
    Options extends Parameters<Formats[Format]>
  > (format: Format, ...options: Options): ReturnType<Formats[Format]>
}

However, because the 'data' output format can have multiple return types depending on the second argument, the return type would be string | Array<CSL>. I instead ended up with

export class Cite {
  format<
    Format extends keyof Formats,
    Options extends Parameters<Formats[Format]>,
    Result extends ReturnType<Extract<Formats[Format], (...args: Options) => any>>
  > (format: Format, ...options: Options): Result
}

Because of Extract<T> the correct return type is selected and the overloads work as expected. This works wonders with autocomplete, and gives appropriate errors. And, although the resulting code looks pretty simple, it took a while to get there in practice.

Type definitions for plugins

In total, the index.d.ts type definitions for a typical plugin looks like this:

import type { CSL } from '@citation-js/core'

interface Entry {
  title: string
  // ...
}

interface Config {
  stringConstants: Record<string, string>
}

declare module '@citation-js/core' {
  namespace plugins {
    namespace input {
      interface Formats {
        '@foo/file': (input: string) => Array<Entry>
        '@foo/record': (input: Entry) => CSL
      }
    }

    namespace output {
      interface Formats {
        foo:
          | ((options: { format: 'object', spec?: number }) => Array<Entry>)
          | ((options?: { format?: 'text', spec?: number }) => string)
      }
    }

    namespace config {
      function get ('@foo'): Config
    }
  }
}

This is implemented for all plugins included in citation-js (though that package does not have TypeScript types itself), and I will slowly roll this out for other plugins. Any feedback is welcome!

Thursday, September 3, 2026

Using GBIF RDF to find applicable taxonomic literature

Recently, after the wonderful work of Andra Waagmeester, Jerven Bolleman, and Hannah Bast, an RDF version of the data on the Global Biodiversity Information Facility (GBIF) was made available through a SPARQL endpoint on QLever.

Roderic Page writes about it in iPhylo:

What I like most about this work is that it may help catalyse further exploration of linked data for biodiversity. There have been lots of discussions of the years, and lots of small-scale (typically short-lived) demos, but nothing on the scale of having billions of records available to query. Given how central GBIF is to biodiversity informatics, there’s now an incentive to explore links to other datasets […].

Now, it just so happens that the Library of Identification Resources (LoIR) is available as an RDF dataset. In fact, the project has a great use case for querying GBIF occurrences. The search engine ranks the taxonomic literature in LoIR based on its applicability on an observation. For example, if I have seen a shield bug in the Netherlands (Fig. 1) and want to identify which species it is, it will generate a checklist of shield bugs in the Netherlands from GBIF occurrences, and find the identification keys with the highest coverage of the Dutch checklist.


Figure 1: Pentatomidae, 2.ix.2021, Eindhoven, the Netherlands (link)

This is currently implemented in JavaScript, but with the SPARQL endpoint for GBIF data, it can also be implemented in SPARQL. The taxonomic scope of shields bugs (Pentatomidae) is represented by gbifsp:9650, the geographic scope of Netherlands by "NL". First, the checklist is generated:

SELECT ?taxon ?scientificName (COUNT(?occurrence) AS ?amount) WHERE {
  # Species in gbifsp:9650 and their scientific name
  ?taxon skos:broader* gbifsp:9650 ;
         gbifterms:rank gbifrank:species ;
         dwc:scientificName ?scientificName .
  # Occurrences of those species in NL
  ?occurrence dwc:countryCode "NL" ;
              dwciri:toTaxon ?taxon .
} GROUP BY ?taxon ?scientificName

This part of the query has to then be repeated (as far as I can tell) to get the number of species and the total amount of occurrences. We can combine this query with LoIR using a SERVICE <https://qlever.dev/api/gbif> statement. Following, resources in the LoIR with those species are selected, and the number of species (and their occurrences) are calculated as a proportion of the respective totals.

SELECT
  ?resource
  (COUNT(?taxon)/?totalCount AS ?pTax)
  (SUM(?amount)/?totalAmount AS ?pObs)
WHERE {
  SERVICE <https://qlever.dev/api/gbif> {
    # ...
  }

  # Match with resource checklists
  ?resource dcterms:subject/dwc:taxonID ?taxon .
} GROUP BY ?resource ?totalCount ?totalAmount

Finally, some additional metadata gets queried, resulting in the full query:

PREFIX dcterms: <http://purl.org/dc/terms/>
PREFIX dwc: <http://rs.tdwg.org/dwc/terms/>
PREFIX dwciri: <http://rs.tdwg.org/dwc/iri/>
PREFIX gbifrank: <https://rs.gbif.org/terms/rank/>
PREFIX gbifsp: <https://www.gbif.org/species/>
PREFIX gbifterms: <https://rs.gbif.org/terms/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>

SELECT ?resource ?title (YEAR(?date) AS ?year) ?pTax ?pObs WHERE {
  {
    SELECT
      ?resource
      (COUNT(?taxon)/?totalCount AS ?pTax)
      (SUM(?amount)/?totalAmount AS ?pObs)
    WHERE {
      SERVICE <https://qlever.dev/api/gbif> {
        # Get species of Pentatomidae (gbifsp:9650) occuring in the Netherlands ("NL") and their count
        {
          SELECT ?taxon ?scientificName (COUNT(?occurrence) AS ?amount) WHERE {
            ?taxon skos:broader* gbifsp:9650 ;
                  gbifterms:rank gbifrank:species ;
                  dwc:scientificName ?scientificName .
            ?occurrence dwc:countryCode "NL" ;
                        dwciri:toTaxon ?taxon .
          } GROUP BY ?taxon ?scientificName
        }
        # Repeat to get the count of species and total amount of occurrences
        {
          SELECT (COUNT(?taxon) AS ?totalCount) (SUM(?amount) AS ?totalAmount) WHERE {
            {
              SELECT ?taxon (COUNT(?occurrence) AS ?amount) WHERE {
                ?taxon skos:broader* gbifsp:9650 ;
                       gbifterms:rank gbifrank:species ;
                       dwc:scientificName ?scientificName .
                ?occurrence dwc:countryCode "NL" ;
                            dwciri:toTaxon ?taxon .
              } GROUP BY ?taxon
            }
          }
        }
      }

      # Match with resource checklists
      ?resource dcterms:subject/dwc:taxonID ?taxon .
    } GROUP BY ?resource ?totalCount ?totalAmount
  }

  # Get additional metadata
  ?work dcterms:hasPart ?resource ;
        dcterms:title ?title ;
        dcterms:issued ?date .
} ORDER BY DESC(?pTax)

This returns the following table, which is pretty similar to the results obtained with the JavaScript-based algorithm.

Resource Title Year Prop. species Prop. occurrences
B1288:1 Les Punaises Pentatomoidea de France 2015 0.87 0.96
B2473:1 Veldgids Wantsen. Pentatomorpha 2026 0.83 0.96
B2473:2 Veldgids Wantsen. Pentatomorpha 2026 0.83 0.96
B887:1 Veldgids wantsen. Deel 1 2016 0.70 0.83
B937:1 Soortzoeker Schildwantsen van Nederland 2018 0.70 0.83
B2569:1 Щитники Средней Азии (Hemiptera, Pentatomoidea) 1965 0.68 0.83
B3094:1 Tabelle per la determinazione dei piu comuni eterotteri italinai (Heteroptera) 1989 0.62 0.79
B1376:1 Щитники 1961 0.60 0.80
B2890:1 Revisión de los pentatómidos ibéricos (Hemiptera): Parte II. Tribus Aeliini Stål, 1872, Stagonomini nov. nom. (= Eysarcorini auct…) y Carpocorini Distant, 1902 1974 0.60 0.99

It doesn’t account for synonyms yet, nor for the additional factors affecting the ranking of the results in the original implementation. It also cannot find resources without checklists by comparing taxonomic and geographic scopes yet. But this is all possible in SPARQL, and without enormously complicated queries! Overall, it seems like a very viable use case for the GBIF RDF.

Update: A SPARQL endpoint for the LoIR data is now available at https://sparql.loir.u.science.ru.nl/query!

References