# A reading list from a Zotero BibTeX export

> A seminar reading list whose references are a Zotero BibTeX export pasted as is, cited in Chicago author-date, with nocite for works the text never cites.

- HTML version: https://postext.dev/en/cookbook/zotero-bibtex-nocite
- Recipe Nº 097 · Book structure · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Textbooks, Reports
- Requires postext ≥ 1.12.1 · tested with 1.12.1 on 2026-10-01
- Pages: [1](https://postext.dev/cookbook/zotero-bibtex-nocite/en/p01.webp?v=40a01388), [2](https://postext.dev/cookbook/zotero-bibtex-nocite/en/p02.webp?v=40a01388)
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=zotero-bibtex-nocite&lang=en (.postext: https://postext.dev/cookbook/zotero-bibtex-nocite/en/zotero-bibtex-nocite.postext)
- Last updated: 2026-10-01
- Other languages: [es](https://postext.dev/es/cookbook/zotero-bibtex-nocite.md), [zh](https://postext.dev/zh/cookbook/zotero-bibtex-nocite.md)

## In short

A university seminar's reading list. The teacher pastes the file Zotero exports; Postext prints the citations and a list that also holds books the text does not mention.

## What you'll build

The reading list of a six-week seminar on the history and sociology of science, set on a 210 × 280 mm page in two columns. A deep green field takes the top half of the first page, with the course title in Spectral Light at 50 pt; the text below is the convenor's guide to each week. The references are the seminar's Zotero collection, exported as BibTeX and pasted into the document without touching a line: Zotero's citation keys, its braces around capitals, its `{\"o}` accents, `month = dec`, the `file`, `keywords` and `urldate` fields. The text cites eight of the works. The other five, background reading the seminar does not discuss, reach the list through `nocite`.

**This recipe answers:**

- How do I use a Zotero BibTeX export and list works the text never cites?
- How do I cite works and build the bibliography in APA, IEEE or another citation style?

## The short answer

Chicago author-date, and a list that holds more than the text cites.

```js
// script.js, lines 28–40
// The works come from a :::references{format=bibtex} block: the Zotero export pasted as it
// is. The text cites some of them; `nocite` in the front matter adds the rest by key, and
// `nocite: "@*"` would add the whole export. citeproc-js writes both in the style named.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
  link: true, // a citation jumps to its entry
  bibliography: {
    // includeUncited: true is the same as nocite: "@*", set from the design side.
    fontSize: em(0.94), lineHeight: pt(12.4), hangingIndent: mm(5), entrySpacing: pt(3.5),
    doi: 'link', // Zotero's doi field prints as https://doi.org/…; its url field is dropped
  },
};
```

## Ingredients

**Teaches**

- [Bibliography from the references](https://postext.dev/en/docs/document-format.md#citations-and-bibliography): The list of works cited, built from the document's references (front matter or a BibTeX block) where :::bibliography stands or after the last chapter.
- [Citations in a citation style](https://postext.dev/en/docs/document-format.md#citations-and-bibliography): Works cited as [@key, p. 33] and formatted in a CSL style (APA, Chicago, MLA, IEEE, Vancouver, ISO 690, GB/T 7714…) chosen in the settings, linked to their entries.

**Also uses**

- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [One or two columns](https://postext.dev/en/docs/configuration.md#layout-types)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`citations`](https://postext.dev/en/docs/configuration.md#citations), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles)

**APIs**

- [`LOCALES`](https://postext.dev/en/docs/document-format.md#citations-and-bibliography), [`STYLES`](https://postext.dev/en/docs/document-format.md#citations-and-bibliography), [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`createCiteprocEngine`](https://postext.dev/en/docs/document-format.md#citations-and-bibliography), [`registerCitationEngine`](https://postext.dev/en/docs/document-format.md#citations-and-bibliography), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Spectral (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)

## Method

### 1 · Name the style once

The code is [the short answer](#the-short-answer) above. The citation engine is registered before the first build and the style is one setting: Chicago author-date writes "(Shapin 1984)" for `[@shapin_pump_1984]` and "Shapin and Schaffer (1985)" for `@shapin_leviathan_1985` in a sentence. The keys are the ones Zotero built from the first author, the first significant word of the title and the year, so `gieryn_boundary-work_1983` keeps its hyphen; Postext reads a hyphen or an underscore inside a key as part of it.

### 2 · Paste the export as Zotero writes it

The last block of the document holds the export, one entry per work, tab-indented as Zotero writes it:

```bibtex
@article{gieryn_boundary-work_1983,
	title = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}: …},
	url = {https://www.jstor.org/stable/2095325},
	doi = {10.2307/2095325},
	urldate = {2026-08-21},
	journal = {American Sociological Review},
	author = {Gieryn, Thomas F.},
	month = dec,
	year = {1983},
	keywords = {demarcation, week 4},
	pages = {781--795},
	file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/2HJ8M6PA/Gieryn - 1983 - ….pdf:application/pdf},
}
```

The reader drops the braces that protect capitals, composes `{\"o}` and `{\'e}` into ö and é, turns `--` into an en dash and reads `month = dec` as a bare month name. `urldate` becomes the access date and `language` the item's language; `file`, `keywords`, `abstract` and `shorttitle` are read past, so nothing from your hard disk reaches the page.

### 3 · Bring in the works the text does not cite

The front matter lists them by key:

```yaml
---
title: "Science as a Social Practice"
nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, @rheinberger_toward_1997, @lopez_pinero_ciencia_1979"
---
```

The list holds every cited work plus these five, sorted together by author as Chicago asks; nothing marks an entry as uncited. `nocite: "@*"` lists the whole export, and `bibliography.includeUncited: true` does the same from the configuration, for a design that always prints the whole library.

### 4 · A green field for the card

```js
// script.js, lines 44–68
const BAND = 132; // mm from the trim's top to the band's foot
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const opener = {
  enabled: true,
  minHeight: mm(BAND - TOP + 9), // the columns start 9 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
      fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(1.7), textTransform: 'uppercase',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(14) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
      fontSize: pt(50), fontWeight: 300, lineHeight: 1.02,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(7) },
        size: { width: mm(150), height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('tint'),
      placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(7) },
        size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
      italic: true, fontSize: pt(13), lineHeight: 1.3, color: col('tint'),
      placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(128), height: 'auto' } } },
  ] },
};
```

The band is a box anchored to the trim, 132 mm deep, and the two columns start 9 mm under it. The title, the rule and the standfirst are chained with `below` anchors, so a longer course title pushes the standfirst down instead of colliding with it.

```js
// script.js, lines 15–21
const palette = {
  ink: '#1c211f', accent: '#2d5b48', tint: '#dfe9e2', rule: '#c3cdc6', muted: '#5c6862',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/zotero-bibtex-nocite

### script.js

```js
// ═══ Postext Cookbook · Nº 097 · A reading list from a Zotero BibTeX export ═══════
// https://postext.dev/en/cookbook/zotero-bibtex-nocite
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Spectral, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.12.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
} from 'https://esm.sh/postext';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'zotero-bibtex-nocite';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a department green, one tint, everything else in ink
const palette = {
  ink: '#1c211f', accent: '#2d5b48', tint: '#dfe9e2', rule: '#c3cdc6', muted: '#5c6862',
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [TEXT, LABEL] = ['Spectral', 'IBM Plex Sans Condensed'];
const LEAD = 13.4; // pt
const [TOP, OUTER] = [24, 18]; // mm

// #region answer: Chicago author-date, and a list that holds more than the text cites
// The works come from a :::references{format=bibtex} block: the Zotero export pasted as it
// is. The text cites some of them; `nocite` in the front matter adds the rest by key, and
// `nocite: "@*"` would add the whole export. citeproc-js writes both in the style named.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
  link: true, // a citation jumps to its entry
  bibliography: {
    // includeUncited: true is the same as nocite: "@*", set from the design side.
    fontSize: em(0.94), lineHeight: pt(12.4), hangingIndent: mm(5), entrySpacing: pt(3.5),
    doi: 'link', // Zotero's doi field prints as https://doi.org/…; its url field is dropped
  },
};
// #endregion

// #region opener: a green field over the top half, the course in white on it
const BAND = 132; // mm from the trim's top to the band's foot
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const opener = {
  enabled: true,
  minHeight: mm(BAND - TOP + 9), // the columns start 9 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
      fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(1.7), textTransform: 'uppercase',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(14) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
      fontSize: pt(50), fontWeight: 300, lineHeight: 1.02,
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(7) },
        size: { width: mm(150), height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('tint'),
      placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(7) },
        size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
      italic: true, fontSize: pt(13), lineHeight: 1.3, color: col('tint'),
      placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(128), height: 'auto' } } },
  ] },
};
// #endregion

const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 600, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra,
});
const folio = { color: col('accent') };
const listName = t({ en: 'HPS 214 · Reading list', es: 'HPS 214 · Lecturas' });

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette,
  citations,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: TEXT, color: col('ink'), fontWeight: 600,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 600,
        color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0),
      },
    ],
  },
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
    head('recto-title', listName, 'odd', 'top-right', -(OUTER + 8)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Science as a Social Practice"
nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, @rheinberger_toward_1997, @lopez_pinero_ciencia_1979"
---

# Science as a Social Practice {kicker="HPS 214 · Seminar reading list · Autumn term 2026" lead="Six weeks on how scientific knowledge is made, tested and trusted, from the paradigm to the laboratory notebook."}

This seminar reads the history and sociology of science as one subject. Its question is how a claim about nature becomes a fact that people who never saw the experiment are willing to rely on. The answers we read come from historians of the seventeenth century, from sociologists who sat in laboratories for two years, and from philosophers who looked at what scientists actually do rather than at what their textbooks say they do.

Each week has one set reading, which everyone reads before the seminar, and one or two companion texts for the person introducing the discussion. The set readings are available in the course reader; the others are in the library’s reserve collection.

## Week 1 · Paradigms and normal science

We start with the book that made the subject fashionable. Read the first five sections and the Postscript of @kuhn_structure_1970, which Kuhn added for the second edition to answer the charge that “paradigm” meant too many things at once. His preface credits a monograph by Ludwik Fleck, written in 1935 and nearly forgotten until Kuhn mentioned it; it is in the list at the end, and the student introducing the week should read its second chapter.

## Week 2 · Witnessing an experiment

The set reading is the first two chapters of @shapin_leviathan_1985, on Robert Boyle’s air-pump and the dispute with Thomas Hobbes over whether an experiment could settle anything. The companion text is the shorter article in which Shapin first described Boyle’s “literary technology”, the way a report written in plain, circumstantial prose turned its readers into witnesses [@shapin_pump_1984].

## Week 3 · Inside the laboratory

Bruno Latour spent two years at the Salk Institute as an observer of a neuroendocrinology laboratory. Read the second and third chapters of @latour_laboratory_1979 and follow how a statement loses its qualifications (“it seems that”, “X has claimed”) as it travels from one paper to the next until it is printed without any.

## Week 4 · Where science ends

The demarcation problem asks what separates science from what is not science. Philosophers looked for a criterion; @gieryn_boundary-work_1983 looked at the people who draw the line in practice, and at what they gain by drawing it where they do. Bring an example from the news of the last month.

## Week 5 · Objectivity has a history

Read the article first [@daston_image_1992], then the first chapter and the chapter on “trained judgment” in the book it grew into [@daston_objectivity_2007]. The argument is that objectivity, as a virtue of the scientist, appeared in the nineteenth century, and that atlases of skeletons, snowflakes and clouds show it arriving.

## Week 6 · Whom to believe

The last week returns to the seventeenth century with a broader question: how did gentlemen-philosophers decide whose reports to accept? Read the first chapter of @shapin_social_1994 and come ready to compare its answer with how you decide whom to believe when you read a paper outside your field.

## Further reading

The list below is the seminar’s Zotero collection, exported as BibTeX and set as it came out of Zotero. It includes every work cited above and five that the seminar does not discuss but that you will meet in the essays you read: Fleck’s monograph, Merton’s collected essays on the norms of science, Latour’s later handbook, Rheinberger’s study of experimental systems, and a history of Spanish science in the sixteenth and seventeenth centuries for those who read Spanish.

:::bibliography{title=""}

:::paragraphs{style="colophon"}
Set in Spectral and IBM Plex Sans Condensed (SIL OFL) · Reading list and notes: original, CC BY 4.0 · The works are real; the Zotero file paths and access dates are illustrative.
:::

:::references{format=bibtex}

@book{kuhn_structure_1970,
	address = {Chicago},
	edition = {2},
	title = {The {Structure} of {Scientific} {Revolutions}},
	abstract = {Normal science, anomaly and crisis, and the paradigm changes that end them, with the Postscript of 1969.},
	language = {en},
	publisher = {University of Chicago Press},
	author = {Kuhn, Thomas S.},
	year = {1970},
	keywords = {paradigms, week 1},
	file = {Kuhn - 1970 - The Structure of Scientific Revolutions.pdf:/Users/hps214/Zotero/storage/7QW2KD9N/Kuhn - 1970 - The Structure of Scientific Revolutions.pdf:application/pdf},
}

@book{fleck_genesis_1979,
	address = {Chicago},
	title = {Genesis and {Development} of a {Scientific} {Fact}},
	language = {en},
	publisher = {University of Chicago Press},
	author = {Fleck, Ludwik},
	editor = {Trenn, Thaddeus J. and Merton, Robert K.},
	translator = {Bradley, Fred and Trenn, Thaddeus J.},
	year = {1979},
	keywords = {thought collectives, week 1},
}

@book{shapin_leviathan_1985,
	address = {Princeton, NJ},
	title = {Leviathan and the {Air}-{Pump}: {Hobbes}, {Boyle}, and the {Experimental} {Life}},
	shorttitle = {Leviathan and the {Air}-{Pump}},
	language = {en},
	publisher = {Princeton University Press},
	author = {Shapin, Steven and Schaffer, Simon},
	year = {1985},
	keywords = {experiment, week 2},
}

@article{shapin_pump_1984,
	title = {Pump and {Circumstance}: {Robert} {Boyle}'s {Literary} {Technology}},
	volume = {14},
	shorttitle = {Pump and {Circumstance}},
	url = {https://doi.org/10.1177/030631284014004001},
	doi = {10.1177/030631284014004001},
	language = {en},
	number = {4},
	urldate = {2026-08-21},
	journal = {Social Studies of Science},
	author = {Shapin, Steven},
	month = nov,
	year = {1984},
	keywords = {literary technology, week 2},
	pages = {481--520},
	file = {Shapin - 1984 - Pump and Circumstance.pdf:/Users/hps214/Zotero/storage/V3NC8RTE/Shapin - 1984 - Pump and Circumstance.pdf:application/pdf},
}

@book{latour_laboratory_1979,
	address = {Beverly Hills},
	title = {Laboratory {Life}: {The} {Social} {Construction} of {Scientific} {Facts}},
	shorttitle = {Laboratory {Life}},
	language = {en},
	publisher = {Sage},
	author = {Latour, Bruno and Woolgar, Steve},
	year = {1979},
	keywords = {ethnography, week 3},
}

@book{latour_science_1987,
	address = {Cambridge, MA},
	title = {Science in {Action}: {How} to {Follow} {Scientists} and {Engineers} through {Society}},
	shorttitle = {Science in {Action}},
	language = {en},
	publisher = {Harvard University Press},
	author = {Latour, Bruno},
	year = {1987},
}

@book{rheinberger_toward_1997,
	address = {Stanford, CA},
	title = {Toward a {History} of {Epistemic} {Things}: {Synthesizing} {Proteins} in the {Test} {Tube}},
	language = {en},
	publisher = {Stanford University Press},
	author = {Rheinberger, Hans-J{\"o}rg},
	year = {1997},
	keywords = {experimental systems},
}

@article{gieryn_boundary-work_1983,
	title = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}: {Strains} and {Interests} in {Professional} {Ideologies} of {Scientists}},
	volume = {48},
	shorttitle = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}},
	url = {https://www.jstor.org/stable/2095325},
	doi = {10.2307/2095325},
	language = {en},
	number = {6},
	urldate = {2026-08-21},
	journal = {American Sociological Review},
	author = {Gieryn, Thomas F.},
	month = dec,
	year = {1983},
	keywords = {demarcation, week 4},
	pages = {781--795},
	file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/2HJ8M6PA/Gieryn - 1983 - Boundary-Work and the Demarcation of Science from Non-Science.pdf:application/pdf},
}

@article{daston_image_1992,
	title = {The {Image} of {Objectivity}},
	url = {https://www.jstor.org/stable/2928741},
	doi = {10.2307/2928741},
	language = {en},
	number = {40},
	urldate = {2026-08-22},
	journal = {Representations},
	author = {Daston, Lorraine and Galison, Peter},
	year = {1992},
	keywords = {objectivity, week 5},
	pages = {81--128},
	file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/F9XK4C2B/Daston and Galison - 1992 - The Image of Objectivity.pdf:application/pdf},
}

@book{daston_objectivity_2007,
	address = {New York},
	title = {Objectivity},
	language = {en},
	publisher = {Zone Books},
	author = {Daston, Lorraine and Galison, Peter},
	year = {2007},
	keywords = {objectivity, week 5},
}

@book{shapin_social_1994,
	address = {Chicago},
	series = {Science and {Its} {Conceptual} {Foundations}},
	title = {A {Social} {History} of {Truth}: {Civility} and {Science} in {Seventeenth}-{Century} {England}},
	language = {en},
	publisher = {University of Chicago Press},
	author = {Shapin, Steven},
	year = {1994},
	keywords = {trust, week 6},
}

@book{merton_sociology_1973,
	address = {Chicago},
	title = {The {Sociology} of {Science}: {Theoretical} and {Empirical} {Investigations}},
	language = {en},
	publisher = {University of Chicago Press},
	author = {Merton, Robert K.},
	editor = {Storer, Norman W.},
	year = {1973},
}

@book{lopez_pinero_ciencia_1979,
	address = {Barcelona},
	title = {Ciencia y t{\'e}cnica en la sociedad espa{\~n}ola de los siglos {XVI} y {XVII}},
	language = {es},
	publisher = {Labor},
	author = {L{\'o}pez Pi{\~n}ero, Jos{\'e} Mar{\'i}a},
	year = {1979},
}
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Spectral: ['300', '300i', '400', '400i', '600', '600i'],
  'IBM Plex Sans Condensed': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'A reading list from Zotero', es: 'Lecturas desde Zotero' }) });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variations

### List the whole Zotero collection

When the export is the list, skip the keys: every entry in the block prints, cited or not.

```diff
-nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, …"
+nocite: "@*"
```

### Change the style

The same keys give a Harvard list (Cite Them Right) with no other change.

```diff
-  style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
+  style: 'harvard-cite-them-right',
```

## Pitfalls

- **A nocite key that matches nothing is dropped silently.** A cited key with no reference raises unknownCitationKey, but a key in nocite that matches no entry is skipped without a warning. Copy the keys from the export and count the entries in the list.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().

- A key in `nocite` that matches no entry is dropped without a warning. Copy the keys from the export rather than typing them, and count the entries on the page.
- Zotero builds the keys from the data: correct an author's name or a title in Zotero and the next export may change the key, which breaks every citation of it. Pin the keys before the text is written: Zotero reads a line `Citation Key: …` in an item's Extra field.
- Zotero exports UTF-8 by default, with the accents as they are. The block here was exported with the Western encoding option, which writes `{\"o}`; Postext reads both.
- Chicago's 18th edition leaves out the place of publication, so the `address` field does not print, and an article with a DOI prints the DOI and not the JSTOR `url`.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Spectral (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 087 · A thesis chapter cited in APA 7](https://postext.dev/en/cookbook/apa-thesis-with-bibtex.md): A doctoral chapter whose [@key, p. 33] citations become APA 7 through citeproc-js, with the reference list built from a BibTeX block. · Level 2 (Intermediate) · Papers & academic, Reports
- [Nº 093 · One text in four citation styles](https://postext.dev/en/cookbook/one-text-four-citation-styles.md): One short essay with six citations, built in APA 7, Chicago author-date, IEEE and ISO 690: the style setting is the only thing that changes. · Level 2 (Intermediate) · Papers & academic, Reports
- [Nº 073 · An index of names and an index of subjects](https://postext.dev/en/cookbook/names-and-subjects-indexes.md): Marks with index="names" feed an index of people and the rest an index of subjects; :::index prints each in two columns, and the build settles the pages. · Level 2 (Intermediate) · Textbooks
