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 BibTeX export from Zotero 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
// 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
},
};
Chicago author-date, and a list that holds more than the text cites
Ingredients
- Features
- Bibliography from the referencesCitations in a citation styleDesigned openersHeading attributesOne or two columnsRunning heads and foliosParagraph stylesSemantic colour palette
- Type
- Spectral, IBM Plex Sans Condensed (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Name the style once
The code is 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:
@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:
---
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
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.
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
// ═══ 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`---Markdown sample · 204 lines · content.en.md
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 · core, fonts, viewer: the same in every recipe · 235 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#List the whole Zotero collection
When the export is the list, skip the keys: every entry in the block prints, cited or not.
-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.
- style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
+ style: 'harvard-cite-them-right',Pitfalls
Pitfall
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. Bibliography from the references →
Pitfall
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. Chapters that open on a recto →
Pitfall
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. Fonts before layout →
Pitfall
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". Document metadata →
Pitfall
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(). Pages on a canvas →
- A key in
nocitethat 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
addressfield does not print, and an article with a DOI prints the DOI and not the JSTORurl.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Spectral (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


