From fd27b7d42d98a6b28b07a0beef5d14b2641ed128 Mon Sep 17 00:00:00 2001 From: Rowan Cockett Date: Wed, 29 Jan 2025 12:50:37 -0700 Subject: [PATCH] pubmatter:0.2.0 --- packages/preview/pubmatter/0.2.0/LICENSE | 21 + packages/preview/pubmatter/0.2.0/README.md | 180 +++++++ .../preview/pubmatter/0.2.0/pubmatter.typ | 474 ++++++++++++++++++ packages/preview/pubmatter/0.2.0/typst.toml | 9 + .../pubmatter/0.2.0/validate-frontmatter.typ | 418 +++++++++++++++ 5 files changed, 1102 insertions(+) create mode 100644 packages/preview/pubmatter/0.2.0/LICENSE create mode 100644 packages/preview/pubmatter/0.2.0/README.md create mode 100644 packages/preview/pubmatter/0.2.0/pubmatter.typ create mode 100644 packages/preview/pubmatter/0.2.0/typst.toml create mode 100644 packages/preview/pubmatter/0.2.0/validate-frontmatter.typ diff --git a/packages/preview/pubmatter/0.2.0/LICENSE b/packages/preview/pubmatter/0.2.0/LICENSE new file mode 100644 index 0000000000..d3f9569a28 --- /dev/null +++ b/packages/preview/pubmatter/0.2.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Continuous Science Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/pubmatter/0.2.0/README.md b/packages/preview/pubmatter/0.2.0/README.md new file mode 100644 index 0000000000..7431c3a538 --- /dev/null +++ b/packages/preview/pubmatter/0.2.0/README.md @@ -0,0 +1,180 @@ +# pubmatter + +_Beautiful scientific documents with structured metadata for publishers_ + +[![Documentation](https://img.shields.io/badge/typst-docs-orange.svg)](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/continuous-foundation/pubmatter/blob/main/LICENSE) + +Pubmatter is a typst library for parsing, normalizing and showing scientific publication frontmatter. + +Utilities for loading, normalizing and working with authors, affiliations, abstracts, keywords and other frontmatter information common in scientific publications. Our goal is to introduce standardized ways of working with this content to expose metadata to scientific publishers who are interested in using typst in a standardized way. The specification for this `pubmatter` is based on [MyST Markdown](https://mystmd.org) and [Quarto](https://quarto.org), and can load their YAML files directly. + +## Examples + +Pubmatter was used to create these documents, for loading the authors in a standardized way and creating the common elements (authors, affiliations, ORCIDs, DOIs, Open Access Links, copyright statements, etc.) + +![](https://github.com/continuous-foundation/pubmatter/blob/main/images/lapreprint.png?raw=true) + +![](https://github.com/continuous-foundation/pubmatter/blob/main/images/scipy.png?raw=true) + +![](https://github.com/continuous-foundation/pubmatter/blob/main/images/agrogeo.png?raw=true) + +## Documentation + +The full documentation can be found in [docs.pdf](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf). To use `pubmatter` import it: + +```typst +#import "@preview/pubmatter:0.1.0" +``` + +The docs also use `pubmatter`, in a simplified way, you can see the [docs.typ](https://github.com/continuous-foundation/pubmatter/blob/main/docs.typ) to see a simple example of using various components to create a new document. Here is a preview of the docs: + +[![](https://github.com/continuous-foundation/pubmatter/blob/main/images/pubmatter.png?raw=true)](https://github.com/continuous-foundation/pubmatter/blob/main/docs.pdf) + +### Loading Frontmatter + +The frontmatter can contain all information for an article, including title, authors, affiliations, abstracts and keywords. These are then normalized into a standardized format that can be used with a number of `show` functions like `show-authors`. For example, we might have a YAML file that looks like this: + +```yaml +author: Rowan Cockett +date: 2024/01/26 +``` + +You can load that file with `yaml`, and pass it to the `load` function: + +```typst +#let fm = pubmatter.load(yaml("pubmatter.yml")) +``` + +This will give you a normalized data-structure that can be used with the `show` functions for showing various parts of a document. + +You can also use a `dictionary` directly: + +```typst +#let fm = pubmatter.load(( + author: ( + ( + name: "Rowan Cockett", + email: "rowan@curvenote.com", + orcid: "0000-0002-7859-8394", + affiliations: "Curvenote Inc.", + ), + ), + date: datetime(year: 2024, month: 01, day: 26), + doi: "10.1190/tle35080703.1", +)) +#pubmatter.show-author-block(fm) +``` + +![](https://github.com/continuous-foundation/pubmatter/blob/main/images/author-block.png?raw=true) + +### Theming + +The theme including color and font choice can be set using the `THEME` state. +For example, this document has the following theme set: + +```typst +#let theme = (color: red.darken(20%), font: "Noto Sans") +#state("THEME").update(theme) +#set page(header: pubmatter.show-page-header(fm), footer: pubmatter.show-page-footer(fm)) +``` + +Note that for the `header` the theme must be passed in directly. This will hopefully become easier in the future, however, there is a current bug that removes the page header/footer if you set this above the `set page`. See [https://github.com/typst/typst/issues/2987](#2987). + +The `font` option only corresponds to the frontmatter content (abstracts, title, header/footer etc.) allowing the body of your document to have a different font choice. + +### Normalized Frontmatter Object + +The frontmatter object has the following normalized structure: + +```yaml +title: content +subtitle: content +short-title: string # alias: running-title, running-head +# Authors Array +# simple string provided for author is turned into ((name: string),) +authors: # alias: author + - name: string + url: string # alias: website, homepage + email: string + phone: string + fax: string + orcid: string # alias: ORCID + note: string + corresponding: boolean # default: `true` when email set + equal-contributor: boolean # alias: equalContributor, equal_contributor + deceased: boolean + roles: string[] # must be a contributor role + affiliations: # alias: affiliation + - id: string + index: number +# Affiliations Array +affiliations: # alias: affiliation + - string # simple string is turned into (name: string) + - id: string + index: number + name: string + institution: string # use either name or institution +# Other publication metadata +open-access: boolean +license: # Can be set with a SPDX ID for creative commons + id: string + url: string + name: string +doi: string # must be only the ID, not the full URL +date: datetime # validates from 'YYYY-MM-DD' if a string +citation: content +# Abstracts Array +# content is turned into ((title: "Abstract", content: string),) +abstracts: # alias: abstract + - title: content + content: content +``` + +Note that you will usually write the affiliations directly in line, in the following example, we can see that the output is a normalized affiliation object that is linked by the `id` of the affiliation (just the name if it is a string!). + +```typst +#let fm = pubmatter.load(( + authors: ( + ( + name: "Rowan Cockett", + affiliations: "Curvenote Inc.", + ), + ( + name: "Steve Purves", + affiliations: ("Project Jupyter", "Curvenote Inc."), + ), + ), +)) +#raw(lang:"yaml", yaml.encode(fm)) +``` + +![](https://github.com/continuous-foundation/pubmatter/blob/main/images/normalized.png?raw=true) + +### Full List of Functions + +- `load()` - Load a raw frontmatter object +- `doi-link()` - Create a DOI link +- `email-link()` - Create a mailto link with an email icon +- `github-link()` - Create a link to a GitHub profile with the GitHub icon +- `open-access-link()` - Create a link to Wikipedia with an OpenAccess icon +- `orcid-link()` - Create a ORCID link with an ORCID logo +- `show-abstract-block()` - Show abstract-block including all abstracts and keywords +- `show-abstracts()` - Show all abstracts (e.g. abstract, plain language summary) +- `show-affiliations()` - Show affiliations +- `show-author-block()` - Show author block, including author, icon links (e.g. ORCID, email, etc.) and affiliations +- `show-authors()` - Show authors +- `show-citation()` - Create a short citation in APA format, e.g. Cockett _et al._, 2023 +- `show-copyright()` - Show copyright statement based on license +- `show-keywords()` - Show keywords as a list +- `show-license-badge()` - Show the license badges +- `show-page-footer()` - Show the venue, date and page numbers +- `show-page-header()` - Show an open-access badge and the DOI and then the running-title and citation +- `show-spaced-content()` +- `show-title()` - Show title and subtitle +- `show-title-block()` - Show title, authors and affiliations + +## Contributing + +To help with standardization of metadata or improve the show-functions please contribute to this package: \ +https://github.com/continuous-foundation/pubmatter diff --git a/packages/preview/pubmatter/0.2.0/pubmatter.typ b/packages/preview/pubmatter/0.2.0/pubmatter.typ new file mode 100644 index 0000000000..b6f5799e68 --- /dev/null +++ b/packages/preview/pubmatter/0.2.0/pubmatter.typ @@ -0,0 +1,474 @@ +#import "@preview/scienceicons:0.0.6": orcid-icon, email-icon, open-access-icon, github-icon, cc-icon, cc-zero-icon, cc-by-icon, cc-nc-icon, cc-nd-icon, cc-sa-icon, ror-icon +#import "./validate-frontmatter.typ": load, show-citation + +#let THEME = state("THEME", (color: blue.darken(20%), font: "")) + +#let with-theme(func) = context { + let theme = THEME.at(here()) + func(theme) +} + +/// Create a ORCID link with an ORCID logo +/// +/// ```example +/// #pubmatter.orcid-link(orcid: "0000-0002-7859-8394") +/// ``` +/// +/// - orcid (str): Use an ORCID identifier with no URL, e.g. `0000-0000-0000-0000` +/// -> content +#let orcid-link( + orcid: none, +) = { + let orcid-green = rgb("#AECD54") + if (orcid == none) { return orcid-icon(color: orcid-green) } + if (orcid.starts-with("https://")) { return link(orcid, orcid-icon(color: orcid-green)) } + return link("https://orcid.org/" + orcid, orcid-icon(color: orcid-green)) +} + +/// Create a DOI link +/// +/// ```example +/// #pubmatter.doi-link(doi: "10.1190/tle35080703.1") +/// ``` +/// +/// - doi (str): Only include the DOI identifier, not the URL +/// -> content +#let doi-link(doi: none) = { + if (doi == none) { return none } + // Proper practices are to show the whole DOI link in text + if (doi.starts-with("https://")) { return link(doi, doi) }; + return link("https://doi.org/" + doi, "https://doi.org/" + doi) +} + +/// Create a ROR link +/// +/// ```example +/// #pubmatter.ror-link(ror: "02mz0e468") +/// ``` +/// +/// - ror (str): Only include the ROR identifier, not the URL +/// -> content +#let ror-link(ror: none) = { + let ror-black = rgb("#2c2c2c") + if (ror == none) { return none } + if (ror.starts-with("https://")) { return link(ror, ror-icon(color: ror-black)) }; + return link("https://ror.org/" + ror, ror-icon(color: ror-black)) +} + +/// Create a mailto link with an email icon +/// +/// ```example +/// #pubmatter.email-link(email: "rowan@curvenote.com") +/// ``` +/// +/// - email (str): Email as a string +/// -> content +#let email-link(email: none) = { + if (email == none) { return none } + return link("mailto:" + email, email-icon(color: gray)) +} + +/// Create a link to Wikipedia with an OpenAccess icon. +/// +/// ```example +/// #pubmatter.open-access-link() +/// ``` +/// +/// -> content +#let open-access-link() = { + let orange = rgb("#E78935") + return link("https://en.wikipedia.org/wiki/Open_access", open-access-icon(color: orange)) +} + + +/// Create a link to a GitHub profile with the GitHub icon. +/// +/// ```example +/// #pubmatter.github-link(github: "rowanc1") +/// ``` +/// +/// - github (str): GitHub username (no `@`) +/// -> content +#let github-link(github: none) = { + if (github.starts-with("https://")) { return link(github, github-icon()) } + return link("https://github.com/" + github, github-icon()) +} + + +/// Create a spaced content array separated with a `spacer`. +/// +/// The default spacer is ` | `, and undefined elements are removed. +/// +/// ```example +/// #pubmatter.show-spaced-content(("Hello", "There")) +/// ``` +/// +/// - spacer (content): How to join the content +/// - content (array): The various things to going together +/// -> content +#let show-spaced-content(spacer: text(fill: gray)[#h(8pt) | #h(8pt)], content) = { + content.filter(h => h != none and h != "").join(spacer) +} + + +/// Show license badge +/// +/// Works for creative common license and other license. +/// +/// ```example +/// #pubmatter.show-license-badge(pubmatter.load((license: "CC0"))) +/// ``` +/// +/// ```example +/// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-4.0"))) +/// ``` +/// +/// ```example +/// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-NC-4.0"))) +/// ``` +/// +/// ```example +/// #pubmatter.show-license-badge(pubmatter.load((license: "CC-BY-NC-ND-4.0"))) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-license-badge(color: black, fm) = { + let license = if ("license" in fm) { fm.license } + if (license == none) { return none } + if (license.id == "CC0-1.0") { + return link(license.url, [#cc-icon(color: color)#cc-zero-icon(color: color)]) + } + if (license.id == "CC-BY-4.0") { + return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)]) + } + if (license.id == "CC-BY-NC-4.0") { + return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)]) + } + if (license.id == "CC-BY-NC-SA-4.0") { + return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)]) + } + if (license.id == "CC-BY-ND-4.0") { + return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nd-icon(color: color)]) + } + if (license.id == "CC-BY-NC-ND-4.0") { + return link(license.url, [#cc-icon(color: color)#cc-by-icon(color: color)#cc-nc-icon(color: color)#cc-nd-icon(color: color)]) + } +} + +/// Show copyright +/// +/// Function chose a short citation with the copyright year followed by the license text. +/// If the license is a Creative Commons License, additional explainer text is shown. +/// +/// ```example +/// #pubmatter.show-copyright(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-copyright(fm) = { + let year = if (fm.date != none) { fm.date.display("[year]") } + let citation = show-citation(show-year: false, fm) + let license = if ("license" in fm) { fm.license } + if (license == none) { + return [Copyright © #{ year } + #citation#{if (fm.at("open-access", default: none) == true){[. This article is open-access.]}}] + } + return [Copyright © #{ year } + #citation. + This #{if (fm.at("open-access", default: none) == true){[is an open-access article]} else {[article is]}} distributed under the terms of the + #link(license.url, license.name) license#{ + if (license.id == "CC-BY-4.0") { + [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format, so long as attribution is given to the creator] + } else if (license.id == "CC-BY-NC-4.0") { + [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for _noncommercial purposes only_, and only so long as attribution is given to the creator] + } else if (license.id == "CC-BY-NC-SA-4.0") { + [, which enables reusers to distribute, remix, adapt, and build upon the material in any medium or format for noncommercial purposes only, and only so long as attribution is given to the creator. If you remix, adapt, or build upon the material, you must license the modified material under identical terms] + } else if (license.id == "CC-BY-ND-4.0") { + [, which enables reusers to copy and distribute the material in any medium or format in _unadapted form only_, and only so long as attribution is given to the creator] + } else if (license.id == "CC-BY-NC-ND-4.0") { + [, which enables reusers to copy and distribute the material in any medium or format in _unadapted form only_, for _noncommercial purposes only_, and only so long as attribution is given to the creator] + } + }.] +} + +/// Show authors +/// +/// ```example +/// #pubmatter.show-authors(authors) +/// ``` +/// +/// - size (length): Size of the author text +/// - weight (weight): Weight of the author text +/// - show-affiliations (boolean): Show affiliations text +/// - show-orcid (boolean): Show orcid logo +/// - show-email (boolean): Show email logo +/// - show-github (boolean): Show github logo +/// - authors (fm, array): The frontmatter object or authors directly +/// -> content +#let show-authors( + size: 10pt, + weight: "semibold", + show-affiliations: true, + show-orcid: true, + show-email: true, + show-github: true, + authors, +) = { + // Allow to pass frontmatter as well + let authors = if (type(authors) == dictionary and "authors" in authors) {authors.authors} else { authors } + if authors.len() == 0 { return none } + + return box(inset: (top: 10pt, bottom: 5pt), width: 100%, { + with-theme((theme) => { + set text(size, font: theme.font) + authors.map(author => { + text(size, font: theme.font, weight: weight, author.name) + if (show-affiliations and "affiliations" in author) { + text(size: 2.5pt, [~]) // Ensure this is not a linebreak + if (type(author.affiliations) == str) { + super(author.affiliations) + } else if (type(author.affiliations) == array) { + super(author.affiliations.map((affiliation) => str(affiliation.index)).join(",")) + } + } + if (show-orcid and "orcid" in author) { + orcid-link(orcid: author.orcid) + } + if (show-github and "github" in author) { + github-link(github: author.github) + } + if (show-email and "email" in author) { + email-link(email: author.email) + } + }).join(", ", last: ", and ") + }) + }) +} + + +/// Show affiliations +/// +/// ```example +/// #pubmatter.show-affiliations(affiliations) +/// ``` +/// +/// - size (length): Size of the affiliations text +/// - fill (color): Color of of the affiliations text +/// - show-ror (boolean): Show ror logo +/// - affiliations (fm, array): The frontmatter object or affiliations directly +/// -> content +#let show-affiliations(size: 8pt, fill: gray.darken(50%), show-ror: true, affiliations) = { + // Allow to pass frontmatter as well + let affiliations = if (type(affiliations) == dictionary and "affiliations" in affiliations) {affiliations.affiliations} else { affiliations } + if affiliations.len() == 0 { return none } + return box(inset: (bottom: 9pt), width: 100%, { + with-theme((theme) => { + set text(size, font: theme.font, fill: fill) + affiliations.map(affiliation => { + super(str(affiliation.index)) + text(size: 2.5pt, [~]) // Ensure this is not a linebreak + if ("name" in affiliation) { + affiliation.name + } else if ("institution" in affiliation) { + affiliation.institution + } + if ("ror" in affiliation) { + text(size: 8pt, [~]) // Ensure this is not a linebreak + ror-link(ror: affiliation.ror) + } + }).join(", ") + }) + }) +} + + +/// Show author block, including author, icon links (e.g. ORCID, email, etc.) and affiliations +/// +/// ```example +/// #pubmatter.show-author-block(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-author-block(fm) = { + show-authors(fm) + show-affiliations(fm) +} + +/// Show title and subtitle +/// +/// ```example +/// #pubmatter.show-title(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-title(fm) = { + with-theme(theme => { + set text(font: theme.font) + let title = if (type(fm) == dictionary and "title" in fm) {fm.title} else if (type(fm) == str or type(fm) == content) { fm } else { none } + let subtitle = if (type(fm) == dictionary and "subtitle" in fm) {fm.subtitle} else { none } + if (title != none) { + box(inset: (bottom: 2pt), width: 100%, text(17pt, weight: "bold", fill: theme.color, title)) + } + if (subtitle != none) { + parbreak() + box(width: 100%, text(12pt, fill: gray.darken(30%), subtitle)) + } + }) +} + +/// Show title block - title, authors and affiliations +/// +/// ```example +/// #pubmatter.show-title-block(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-title-block(fm) = { + with-theme(theme => { + show-title(fm) + show-author-block(fm) + }) +} + +/// Show page footer +/// +/// Default is the venue, date and page numbers +/// +/// ```example +/// #pubmatter.show-page-footer(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-page-footer(fm) = { + return block( + width: 100%, + stroke: (top: 1pt + gray), + inset: (top: 8pt, right: 2pt), + with-theme((theme) => [ + #set text(font: theme.font) + #grid(columns: (75%, 25%), + align(left, text(size: 9pt, fill: gray.darken(50%), + show-spaced-content(( + if("venue" in fm) {emph(fm.venue)}, + if("date" in fm and fm.date != none) {fm.date.display("[month repr:long] [day], [year]")} + )) + )), + align(right)[ + #text( + size: 9pt, fill: gray.darken(50%) + )[ + #counter(page).display() of #{context {counter(page).final().first()}} + ] + ] + ) + ]) + ) +} + +/// Show page header +/// +/// Default an open-access badge and the DOI and then the running-title and citation +/// +/// ```example +/// #pubmatter.show-page-header(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-page-header(fm) = context { + let loc = here() + if(loc.page() == 1) { + let headers = ( + if ("open-access" in fm) {[#smallcaps[Open Access] #open-access-link()]}, + if ("doi" in fm) { link("https://doi.org/" + fm.doi, "https://doi.org/" + fm.doi)} + ) + // TODO: There is a bug in the first page state update + // https://github.com/typst/typst/issues/2987 + return with-theme((theme) => { + align(left, text(size: 8pt, font: theme.font, fill: gray, show-spaced-content(headers))) + }) + } else { + return with-theme((theme) => {align(right + top, box(inset: (top: 1cm), text(size: 8pt, font: theme.font, fill: gray.darken(50%), + show-spaced-content(( + if ("short-title" in fm) { fm.short-title } else if ("title" in fm) { fm.title }, + if ("citation" in fm) { fm.citation }, + ))) + )) + }) + } +} + +/// Show all abstracts (e.g. abstract, plain language summary) +/// +/// ```example +/// #pubmatter.show-abstracts(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-abstracts(fm) = { + let abstracts + if (type(fm) == "content") { + abstracts = ((title: "Abstract", content: fm),) + } else if (type(fm) == dictionary and "abstracts" in fm) { + abstracts = fm.abstracts + } else { + return + } + + with-theme((theme) => { + abstracts.map(abs => { + set text(font: theme.font) + text(fill: theme.color, weight: "semibold", size: 9pt, abs.title) + parbreak() + set par(justify: true) + text(size: 9pt, abs.content) + }).join(parbreak()) + }) +} + +/// Show keywords +/// +/// ```example +/// #pubmatter.show-keywords(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-keywords(fm) = { + let keywords + if (type(fm) == dictionary and "keywords" in fm) { + keywords = fm.keywords + } else { + return + } + if (keywords.len() > 0) { + with-theme((theme) => { + text(size: 9pt, font: theme.font, { + text(fill: theme.color, weight: "semibold", "Keywords") + h(8pt) + keywords.join(", ") + }) + }) + } +} + +/// Show abstract-block including all abstracts and keywords +/// +/// ```example +/// #pubmatter.show-abstract-block(fm) +/// ``` +/// +/// - fm (fm): The frontmatter object +/// -> content +#let show-abstract-block(fm) = { + box(inset: (top: 16pt, bottom: 16pt), stroke: (top: 0.5pt + gray.lighten(30%), bottom: 0.5pt + gray.lighten(30%)), show-abstracts(fm)) + show-keywords(fm) + v(10pt) +} diff --git a/packages/preview/pubmatter/0.2.0/typst.toml b/packages/preview/pubmatter/0.2.0/typst.toml new file mode 100644 index 0000000000..a8dfa36ac9 --- /dev/null +++ b/packages/preview/pubmatter/0.2.0/typst.toml @@ -0,0 +1,9 @@ +[package] +name = "pubmatter" +version = "0.2.0" +entrypoint = "pubmatter.typ" +authors = ["rowanc1"] +license = "MIT" +description = "Parse, normalize and show publication frontmatter, including authors and affiliations" +repository = "https://github.com/continuous-foundation/pubmatter" +keywords = ["frontmatter", "authors", "affiliations", "abstract", "orcid"] diff --git a/packages/preview/pubmatter/0.2.0/validate-frontmatter.typ b/packages/preview/pubmatter/0.2.0/validate-frontmatter.typ new file mode 100644 index 0000000000..bb9876c485 --- /dev/null +++ b/packages/preview/pubmatter/0.2.0/validate-frontmatter.typ @@ -0,0 +1,418 @@ + +#let validateContent(raw, name, alias: none) = { + if (name in raw) { + assert(type(raw.at(name)) == str or type(raw.at(name)) == content, message: name + " must be a string or content") + return raw.at(name) + } + if (type(alias) != array) { return } + for a in alias { if (a in raw) { validateContent(raw, a) } } +} + +#let validateString(raw, name, alias: none) = { + if (name in raw) { + assert(type(raw.at(name)) == str, message: name + " must be a string") + return raw.at(name) + } + if (type(alias) != array) { return } + for a in alias { if (a in raw) { validateString(raw, a) } } +} + +#let validateBoolean(raw, name, alias: none) = { + if (name in raw) { + assert(type(raw.at(name)) == bool, message: name + " must be a boolean") + return raw.at(name) + } + if (type(alias) != array) { return } + for a in alias { if (a in raw) { validateBoolean(raw, a) } } +} + +#let validateArray(raw, name, alias: none) = { + if (name in raw) { + assert(type(raw.at(name)) == array, message: name + " must be an array") + return raw.at(name) + } + if (type(alias) != array) { return } + for a in alias { if (a in raw) { validateArray(raw, a) } } +} + +#let validateDate(raw, name, alias: none) = { + if (name in raw) { + let rawDate = raw.at(name) + if (type(rawDate) == datetime) { return rawDate } + if (type(rawDate) == int) { + // assume this is the year + assert(rawDate > 1000 and rawDate < 3000, message: "The date is assumed to be a year between 1000 and 3000") + return datetime(year: rawDate, month: 1, day: 1) + } + if (type(rawDate) == str) { + let yearMatch = rawDate.find(regex(`^([1|2])([0-9]{3})$`.text)) + if (yearMatch != none) { + // This isn't awesome, but probably fine + return datetime(year: int(rawDate), month: 1, day: 1) + } + let dateMatch = rawDate.find(regex(`^([1|2])([0-9]{3})([-\/])([0-9]{1,2})([-\/])([0-9]{1,2})$`.text)) + if (dateMatch != none) { + let parts = rawDate.split(regex("[-\/]")) + return datetime( + year: int(parts.at(0)), + month: int(parts.at(1)), + day: int(parts.at(2)), + ) + } + panic("Unknown datetime object from string, try: `2020/03/15` as YYYY/MM/DD, also accepts `2020-03-15`") + } + if (type(rawDate) == dictionary) { + if ("year" in rawDate and "month" in rawDate and "day" in rawDate) { + return return datetime( + year: rawDate.at("year"), + month: rawDate.at("month"), + day: rawDate.at("day"), + ) + } + if ("year" in rawDate and "month" in rawDate) { + return return datetime( + year: rawDate.at("year"), + month: rawDate.at("month"), + day: 1, + ) + } + if ("year" in rawDate) { + return return datetime( + year: rawDate.at("year"), + month: 1, + day: 1, + ) + } + panic("Unknown datetime object from dictionary, try: `(year: 2022, month: 2, day: 3)`") + } + panic("Unknown date of type '" + type(rawDate)+ "' accepts: datetime, str, int, and object") + } + if (type(alias) != array) { return } + for a in alias { if (a in raw) { return validateDate(raw, a) } } +} + +#let validateAffiliation(raw) = { + let out = (:) + if (type(raw) == str) { + out.name = raw; + return out; + } + let id = validateString(raw, "id") + if (id != none) { out.id = id } + let name = validateString(raw, "name") + if (name != none) { out.name = name } + let institution = validateString(raw, "institution") + if (institution != none) { out.institution = institution } + let department = validateString(raw, "department") + if (department != none) { out.department = department } + let doi = validateString(raw, "doi") + if (doi != none) { out.doi = doi } + let ror = validateString(raw, "ror") + if (ror != none) { out.ror = ror } + let address = validateString(raw, "address") + if (address != none) { out.address = address } + let city = validateString(raw, "city") + if (city != none) { out.city = city } + let region = validateString(raw, "region", alias: ("state", "province")) + if (region != none) { out.region = region } + let postal-code = validateString(raw, "postal-code", alias: ("postal_code", "postalCode", "zip_code", "zip-code", "zipcode", "zipCode")) + if (postal-code != none) { out.postal-code = postal-code } + let country = validateString(raw, "country") + if (country != none) { out.country = country } + let phone = validateString(raw, "phone") + if (phone != none) { out.phone = phone } + let fax = validateString(raw, "fax") + if (fax != none) { out.fax = fax } + let email = validateString(raw, "email") + if (email != none) { out.email = email } + let url = validateString(raw, "url") + if (url != none) { out.url = url } + let collaboration = validateBoolean(raw, "collaboration") + if (collaboration != none) { out.collaboration = collaboration } + return out; +} + +#let pickAffiliationsObject(raw) = { + if ("affiliation" in raw and "affiliations" in raw) { + panic("You can only use `affiliation` or `affiliations`, not both") + } + if ("affiliation" in raw) { + raw.affiliations = raw.affiliation + } + if ("affiliations" not in raw) { return; } + if (type(raw.affiliations) == str or type(raw.affiliations) == "dictionary") { + // convert to a list + return (validateAffiliation(raw.affiliations),) + } else if (type(raw.affiliations) == "array") { + // validate each entry + return raw.affiliations.map(validateAffiliation) + } else { + panic("The `affiliation` or `affiliations` must be a array, dictionary or string, got:", type(raw.affiliations)) + } +} + +#let validateAuthor(raw) = { + let out = (:) + if (type(raw) == str) { + out.name = raw; + out.affiliations = () + return out; + } + let name = validateString(raw, "name") + if (name != none) { out.name = name } + let orcid = validateString(raw, "orcid", alias: ("ORCID",)) + if (orcid != none) { out.orcid = orcid } + let email = validateString(raw, "email") + if (email != none) { out.email = email } + let corresponding = validateBoolean(raw, "corresponding") + if (corresponding != none) { out.corresponding = corresponding } + else if (email != none) { out.corresponding = true } + let phone = validateString(raw, "phone") + if (phone != none) { out.phone = phone } + let fax = validateString(raw, "fax") + if (fax != none) { out.fax = fax } + let url = validateString(raw, "url", alias: ("website", "homepage")) + if (url != none) { out.url = url } + let github = validateString(raw, "github") + if (github != none) { out.github = github } + + let deceased = validateBoolean(raw, "deceased") + if (deceased != none and deceased) { out.deceased = deceased } + let equal-contributor = validateBoolean(raw, "equal_contributor", alias: ("equal-contributor", "equalContributor")) + if (equal-contributor != none and equal-contributor) { out.equal-contributor = equal-contributor } + + let note = validateString(raw, "note") + if (note != none) { out.note = note } + + let affiliations = pickAffiliationsObject(raw); + if (affiliations != none) { out.affiliations = affiliations } else { out.affiliations = () } + + return out; +} + +#let consolidateAffiliations(authors, affiliations) = { + let cnt = 0 + for affiliation in affiliations { + if ("id" not in affiliation) { + affiliation.insert("id", "aff-" + str(cnt + 1)) + } + affiliations.at(cnt) = affiliation + cnt += 1 + } + + let authorCnt = 0 + for author in authors { + let affCnt = 0 + for affiliation in author.affiliations { + let pos = affiliations.position(item => { ("id" in item and item.id == affiliation.name) or ("name" in item and item.name == affiliation.name) }) + if (pos != none) { + affiliation.remove("name") + affiliation.id = affiliations.at(pos).id + affiliations.at(pos) = affiliations.at(pos) + affiliation + } else { + affiliation.id = if ("id" in affiliation) { affiliation.id } else { affiliation.name } + affiliations.push(affiliation) + } + author.affiliations.at(affCnt) = (id: affiliation.id) + affCnt += 1 + } + authors.at(authorCnt) = author + authorCnt += 1 + } + + // Now that they are normalized, loop again and update the numbers + let fullAffCnt = 0 + let authorCnt = 0 + for author in authors { + let affCnt = 0 + for affiliation in author.affiliations { + let pos = affiliations.position(item => { item.id == affiliation.id }) + let aff = affiliations.at(pos) + if ("index" not in aff) { + fullAffCnt += 1 + aff.index = fullAffCnt + affiliations.at(pos) = affiliations.at(pos) + (index: fullAffCnt) + } + author.affiliations.at(affCnt) = (id: affiliation.id, index: aff.index) + affCnt += 1 + } + authors.at(authorCnt) = author + authorCnt += 1 + } + return (authors: authors, affiliations: affiliations) +} + +/// Create a short citation in APA format, e.g. Cockett _et al._, 2023 +/// - show-year (boolean): Include the year in the citation +/// - fm (fm): The frontmatter object +/// -> content +#let show-citation(show-year: true, fm) = { + if ("authors" not in fm) {return none} + let authors = fm.authors + let date = fm.date + let year = if (show-year and date != none) { ", " + date.display("[year]") } else { none } + if (authors.len() == 1) { + return authors.at(0).name.split(" ").last() + year + } else if (authors.len() == 2) { + return authors.at(0).name.split(" ").last() + " & " + authors.at(1).name.split(" ").last() + year + } else if (authors.len() > 2) { + return authors.at(0).name.split(" ").last() + " " + emph("et al.") + year + } + return none +} + + +#let validateLicense(raw) = { + if ("license" not in raw) { return none } + let rawLicense = raw.at("license") + if (type(rawLicense) == str) { + if (rawLicense == "CC0" or rawLicense == "CC0-1.0") { + return ( + id: "CC0-1.0", + url: "https://creativecommons.org/licenses/zero/1.0/", + name: "Creative Commons Zero v1.0 Universal", + ) + } else if (rawLicense == "CC-BY" or rawLicense == "CC-BY-4.0") { + return ( + id: "CC-BY-4.0", + url: "https://creativecommons.org/licenses/by/4.0/", + name: "Creative Commons Attribution 4.0 International", + ) + } else if (rawLicense == "CC-BY-NC" or rawLicense == "CC-BY-NC-4.0") { + return ( + id: "CC-BY-NC-4.0", + url: "https://creativecommons.org/licenses/by-nc/4.0/", + name: "Creative Commons Attribution Non Commercial 4.0 International", + ) + } else if (rawLicense == "CC-BY-NC-SA" or rawLicense == "CC-BY-NC-SA-4.0") { + return ( + id: "CC-BY-NC-SA-4.0", + url: "https://creativecommons.org/licenses/by-nc-sa/4.0/", + name: "Creative Commons Attribution Non Commercial Share Alike 4.0 International", + ) + } else if (rawLicense == "CC-BY-ND" or rawLicense == "CC-BY-ND-4.0") { + return ( + id: "CC-BY-ND-4.0", + url: "https://creativecommons.org/licenses/by-nd/4.0/", + name: "Creative Commons Attribution No Derivatives 4.0 International", + ) + } else if (rawLicense == "CC-BY-NC-ND" or rawLicense == "CC-BY-NC-ND-4.0") { + return ( + id: "CC-BY-NC-ND-4.0", + url: "https://creativecommons.org/licenses/by-nc-nd/4.0/", + name: "Creative Commons Attribution Non Commercial No Derivatives 4.0 International", + ) + } + panic("Unknown license string: '" + rawLicense + "'") + } + if (type(rawLicense) == dictionary) { + assert("id" in rawLicense and "url" in rawLicense and "name" in rawLicense, message: "License nust contain fields of 'id' (the SPDX ID), 'url': the URL to the license, and 'name' the human-readable license name") + let id = validateString(rawLicense, "id") + let url = validateString(rawLicense, "url") + let name = validateString(rawLicense, "name") + return (id: id, url: url, name: name) + } + panic("Unknown format for license: '" + type(rawLicense) + "'") +} + +#let load(raw) = { + let out = (:) + let title = validateContent(raw, "title") + if (title != none) { out.title = title } + let subtitle = validateContent(raw, "subtitle") + if (subtitle != none) { out.subtitle = subtitle } + let short-title = validateString(raw, "short-title", alias: ("short_title", "shortTitle", "running-head", "running_head", "runningHead", "runningTitle", "running_title", "running-title")) + if (short-title != none) { out.short-title = short-title } + + // author information + if ("author" in raw and "authors" in raw) { + panic("You can only use `author` or `authors`, not both") + } + if ("author" in raw) { + raw.authors = raw.author + } + if ("authors" in raw) { + if (type(raw.authors) == str or type(raw.authors) == "dictionary") { + // convert to a list + out.authors = (validateAuthor(raw.authors),) + } else if (type(raw.authors) == "array") { + // validate each entry + out.authors = raw.authors.map(validateAuthor) + } else { + panic("The `author` or `authors` must be a array, dictionary or string, got:", type(raw.authors)) + } + } else { + out.authors = () + } + + let affiliations = pickAffiliationsObject(raw); + if (affiliations != none) { out.affiliations = affiliations } else { out.affiliations = () } + + let open-access = validateBoolean(raw, "open-access", alias: ("open_access", "openAccess",)) + if (open-access != none) { out.open-access = open-access } + let venue = validateString(raw, "venue") + if (venue != none) { out.venue = venue } + let subject = validateString(raw, "subject") + if (subject != none) { out.subject = subject } + let license = validateLicense(raw) + if (license != none) { out.license = license } + let doi = validateString(raw, "doi") + if (doi != none) { + assert(not doi.starts-with("http"), message: "DOIs should not include the link, use only the part after `https://doi.org/[]`") + out.doi = doi + } + + if ("date" in raw) { + out.date = validateDate(raw, "date"); + } else { + out.date = datetime.today() + } + let citation = validateString(raw, "citation") + + if (citation != none) { + out.citation = citation; + } else { + out.citation = show-citation(out) + } + + if ("abstract" in raw and "abstracts" in raw) { + panic("You can only use `abstract` or `abstracts`, not both") + } + if ("abstract" in raw) { + raw.abstracts = raw.abstract + } + if ("abstracts" in raw) { + if (type(raw.abstracts) == str or type(raw.abstracts) == content) { + raw.abstracts = (content: raw.abstracts) + } + if (type(raw.abstracts) == dictionary) { + if ("title" not in raw.abstracts) { + raw.abstracts.title = "Abstract" + } + raw.abstracts = (raw.abstracts,) + } + if (type(raw.abstracts) == array) { + // validate each entry + out.abstracts = raw.abstracts.map((abs) => { + if (type(abs) != dictionary or "title" not in abs or "content" not in abs) { + return + } + return (title: abs.at("title"), content: abs.at("content")) + }) + } else { + panic("The `abstract` or `abstracts` must be content, or an array, got:", type(raw.abstracts)) + } + } + + let keywords = validateArray(raw, "keywords") + if (keywords != none) { + out.keywords = keywords.map((k) => validateString((keyword: k), "keyword")) + } + + let consolidated = consolidateAffiliations(out.authors, out.affiliations) + out.authors = consolidated.authors + out.affiliations = consolidated.affiliations + + return out +} +