From e5b61e2bcebce67a4b066c9c2fcd36673e31c8ef Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 19 Oct 2026 17:29:42 +0200 Subject: [PATCH 1/3] Split page ranges for Google Scholar reference tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `citation_reference` tags for bibliography entries never carried `citation_firstpage` or `citation_lastpage`. The Scholar writer reads only CSL `page-first`/`page-last`, and Pandoc's CSL JSON for a bibliography entry has only `page` (BibTeX `97--111` arrives as `97-111`). The split of `page` into first and last page existed only for the document's own citation. That split also mishandled values the document citation can receive: an en-dash range (`97–111`, as written in CSL JSON or YAML) became the first page, a page list (`1-3, 7-9`) gave the last page `3, 7`, and spaced ranges kept their spaces. The parsing moves to `cslPageRange` in `core/csl.ts`, used for both the document citation and bibliography entries. It accepts hyphen, en-dash and em-dash separators, trims, and for a page list writes only the first page rather than inventing an extent. --- src/core/csl.ts | 10 +++ src/format/html/format-html-meta.ts | 13 ++-- src/quarto-core/attribution/document.ts | 26 +++----- .../19/scholar-reference-pages/references.bib | 7 ++ .../scholar-reference-pages.qmd | 14 ++++ tests/unit/scholar-metadata.test.ts | 64 ++++++++++++++++++- 6 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 tests/docs/smoke-all/2026/10/19/scholar-reference-pages/references.bib create mode 100644 tests/docs/smoke-all/2026/10/19/scholar-reference-pages/scholar-reference-pages.qmd diff --git a/src/core/csl.ts b/src/core/csl.ts index e0d4619a9ab..3d6e7d69fe4 100644 --- a/src/core/csl.ts +++ b/src/core/csl.ts @@ -142,6 +142,16 @@ export function cslTextToPlain(value: string): string { return value.replace(cslRichTextTags, ""); } +// Splits a CSL `page` value ("97-111", "97–111", "42") into its first and +// last page. A page list ("1-3, 7-9") yields only the first page. +export function cslPageRange(page: string) { + const [range, ...others] = page.split(/[,&]/); + const match = range.match(/^\s*(.*?)\s*[-–—]+\s*(.*?)\s*$/); + const first = (match ? match[1] : range.trim()) || undefined; + const last = (match && others.length === 0 ? match[2] : "") || undefined; + return { first, last }; +} + export function suggestId(author: CSLName[], date?: CSLDate) { // Try to get the last name let citeIdLeading = ""; diff --git a/src/format/html/format-html-meta.ts b/src/format/html/format-html-meta.ts index b45d3ff3f16..507f05e4fa4 100644 --- a/src/format/html/format-html-meta.ts +++ b/src/format/html/format-html-meta.ts @@ -16,6 +16,7 @@ import { CSL, cslDateToEDTFDate, CSLExtras, + cslPageRange, cslTextToPlain, kAbstractUrl, kEIssn, @@ -205,12 +206,16 @@ function googleScholarMeta( write("citation_language", csl.language); } - if (csl["page-first"]) { - write("citation_firstpage", csl["page-first"]); + // Bibliography entries from Pandoc carry only `page`. + const pageRange = cslPageRange(`${csl.page ?? ""}`); + const firstPage = csl["page-first"] || pageRange.first; + if (firstPage) { + write("citation_firstpage", firstPage); } - if (csl["page-last"]) { - write("citation_lastpage", csl["page-last"]); + const lastPage = csl["page-last"] || pageRange.last; + if (lastPage) { + write("citation_lastpage", lastPage); } const type = csl.type; diff --git a/src/quarto-core/attribution/document.ts b/src/quarto-core/attribution/document.ts index f2f1d52ed7d..974c2be20bc 100644 --- a/src/quarto-core/attribution/document.ts +++ b/src/quarto-core/attribution/document.ts @@ -27,6 +27,7 @@ import { cslDate, CSLExtras, cslNames, + cslPageRange, CSLType, cslType, kAbstractUrl, @@ -485,23 +486,14 @@ function pages(citationMetadata: Metadata): PageRange { let pages = citationMetadata[kPage] ? `${citationMetadata[kPage] as string}` // Force pages to string in case user writes `page: 7` : undefined; - if (pages && pages.includes("-")) { - const pagesSplit = pages.split("-"); - if (!firstPage) { - firstPage = pagesSplit[0]; - } - - if (!lastPage) { - lastPage = pagesSplit[1]; - } - } else if (pages && !firstPage) { - firstPage = pages; - } else if (!pages) { - if (firstPage && lastPage) { - pages = `${firstPage} - ${lastPage}`; - } else if (firstPage) { - pages = `${firstPage}`; - } + if (pages) { + const range = cslPageRange(pages); + firstPage = firstPage || range.first; + lastPage = lastPage || range.last; + } else if (firstPage && lastPage) { + pages = `${firstPage} - ${lastPage}`; + } else if (firstPage) { + pages = `${firstPage}`; } return { firstPage: firstPage as string, diff --git a/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/references.bib b/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/references.bib new file mode 100644 index 00000000000..e5e5d6f2c33 --- /dev/null +++ b/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/references.bib @@ -0,0 +1,7 @@ +@article{ranged, + author = {Doe, Jane}, + title = {Ranged Article}, + journal = {Journal of Tests}, + year = {2020}, + pages = {97--111} +} diff --git a/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/scholar-reference-pages.qmd b/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/scholar-reference-pages.qmd new file mode 100644 index 00000000000..3749784e1b8 --- /dev/null +++ b/tests/docs/smoke-all/2026/10/19/scholar-reference-pages/scholar-reference-pages.qmd @@ -0,0 +1,14 @@ +--- +title: Scholar reference metadata splits a page range +format: html +google-scholar: true +bibliography: references.bib +_quarto: + tests: + html: + ensureHtmlElements: + - ['meta[name="citation_reference"][content*="citation_firstpage=97;"]', 'meta[name="citation_reference"][content*="citation_lastpage=111;"]'] + - [] +--- + +This article cites a work with a page range [@ranged]. diff --git a/tests/unit/scholar-metadata.test.ts b/tests/unit/scholar-metadata.test.ts index cbf2bccc5c0..2901e1dad83 100644 --- a/tests/unit/scholar-metadata.test.ts +++ b/tests/unit/scholar-metadata.test.ts @@ -7,7 +7,7 @@ import { assertEquals, assertStringIncludes } from "testing/asserts"; import { Metadata } from "../../src/config/types.ts"; import { bibliographyCslJson } from "../../src/core/bibliography.ts"; -import { parseHtml } from "../../src/core/deno-dom.ts"; +import { Element, parseHtml } from "../../src/core/deno-dom.ts"; import { join } from "../../src/deno_ral/path.ts"; import { metadataPostProcessor } from "../../src/format/html/format-html-meta.ts"; import { unitTest } from "../test.ts"; @@ -196,3 +196,65 @@ unitTest( }); }, ); + +function metaContent( + doc: Awaited>, + name: string, +) { + return doc.querySelector(`meta[name="${name}"]`)?.getAttribute("content"); +} + +unitTest("Scholar metadata - split the citation page range", async () => { + for ( + const [page, firstPage, lastPage] of [ + ["97-111", "97", "111"], + ["97–111", "97", "111"], + ["97 - 111", "97", "111"], + ["1-3, 7-9", "1", undefined], + ["42", "42", undefined], + ] + ) { + const doc = await scholarMetadata({ + title: "An article", + citation: { page }, + }); + assertEquals(metaContent(doc, "citation_firstpage"), firstPage, page); + assertEquals(metaContent(doc, "citation_lastpage"), lastPage, page); + } +}); + +unitTest("Scholar metadata - split reference page ranges", async () => { + await withTempDir(async (dir) => { + Deno.writeTextFileSync( + join(dir, "references.yaml"), + `--- +references: +- {id: endash, type: article-journal, title: En dash, page: "97–111"} +- {id: single, type: article-journal, title: Single, page: "42"} +- {id: list, type: article-journal, title: List, page: "1-3, 7-9"} +--- +`, + ); + const doc = await scholarMetadata( + { title: "An article", bibliography: "references.yaml" }, + join(dir, "test.qmd"), + '', + ); + const references = Object.fromEntries( + [...doc.querySelectorAll('meta[name="citation_reference"]')].map( + (element) => { + const content = (element as Element).getAttribute("content")!; + return [content.match(/citation_title=([^;]+);/)![1], content]; + }, + ), + ); + assertStringIncludes( + references["En dash"], + "citation_firstpage=97; citation_lastpage=111;", + ); + assertStringIncludes(references["Single"], "citation_firstpage=42;"); + assertEquals(references["Single"].includes("citation_lastpage"), false); + assertStringIncludes(references["List"], "citation_firstpage=1;"); + assertEquals(references["List"].includes("citation_lastpage"), false); + }); +}); From 231e31a93ccf4a3bd627f23a76ee7d6b5f0922ff Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 19 Oct 2026 17:54:40 +0200 Subject: [PATCH 2/3] Add changelog entry for Scholar reference page split --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e5a1c1f3db8..ae46a3d4147 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -61,6 +61,7 @@ All changes included in 1.11: - ([#15051](https://github.com/quarto-dev/quarto-cli/pull/15051)): Fix a callout's `title` option also rendering as an HTML `title` attribute in html, revealjs and epub output, which showed the title as a tooltip over the whole callout. - ([#15108](https://github.com/quarto-dev/quarto-cli/pull/15108)): Fix Google Scholar metadata showing formatting markup such as `` in `citation_reference` tags for bibliography entries, and `undefined` as the book title of a chapter without one. - ([#15115](https://github.com/quarto-dev/quarto-cli/pull/15115)): Fix Google Scholar writing two `citation_reference` tags for a reference defined in two bibliography files. The tag now describes the entry Pandoc renders. +- ([#15118](https://github.com/quarto-dev/quarto-cli/pull/15118)): Fix Google Scholar `citation_reference` tags for bibliography entries missing `citation_firstpage` and `citation_lastpage`, and a document citation `page` written with an en-dash or as a page list giving wrong first and last pages. ### `pdf` From dd229bfe7f5fe4abd22971a39488c47bb973b892 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 19 Oct 2026 17:55:52 +0200 Subject: [PATCH 3/3] fixup! Add changelog entry for Scholar reference page split --- news/changelog-1.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index ae46a3d4147..644bc75da96 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -61,7 +61,7 @@ All changes included in 1.11: - ([#15051](https://github.com/quarto-dev/quarto-cli/pull/15051)): Fix a callout's `title` option also rendering as an HTML `title` attribute in html, revealjs and epub output, which showed the title as a tooltip over the whole callout. - ([#15108](https://github.com/quarto-dev/quarto-cli/pull/15108)): Fix Google Scholar metadata showing formatting markup such as `` in `citation_reference` tags for bibliography entries, and `undefined` as the book title of a chapter without one. - ([#15115](https://github.com/quarto-dev/quarto-cli/pull/15115)): Fix Google Scholar writing two `citation_reference` tags for a reference defined in two bibliography files. The tag now describes the entry Pandoc renders. -- ([#15118](https://github.com/quarto-dev/quarto-cli/pull/15118)): Fix Google Scholar `citation_reference` tags for bibliography entries missing `citation_firstpage` and `citation_lastpage`, and a document citation `page` written with an en-dash or as a page list giving wrong first and last pages. +- ([#15118](https://github.com/quarto-dev/quarto-cli/pull/15118)): Fix Google Scholar metadata missing the first and last page of bibliography entries, and giving wrong pages for a citation `page` written with an en-dash or as a page list. ### `pdf`