diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d64c82f..cb5cdf65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,15 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change ## [Unreleased] +### Added + +- Formatted references are now a generated asset like any other, built when needed and regenerated when the bibliography, the citations, or the chosen style changes. `pretext generate references` is no longer a debugging-only step. +- A `citations` optional dependency (also part of `all`) installs `citeproc-py-styles`, which supplies the Citation Style Language (CSL) styles that journals use: `pip install pretext[citations]`. + ### Changed - Template publication files use the new `html/knowl/@exercise-reading` name (formerly `@exercise-readingquestion`), and their comments now list current defaults and values, including `read-aloud`, GeoGebra `play-button`, `slide-numbering`, and Beamer options. +- Naming a supported `journal` in the publication file now selects that journal's CSL style, so PreTeXt formats references and citations in every output format instead of BibTeX. These styles need `citeproc-py-styles` (see above); without it, references fall back to default formatting. Choose a different style with `common/citation-stylesheet-language/@style`. ### Fixed diff --git a/pretext/__init__.py b/pretext/__init__.py index 7ab983f5..a0b3d0cf 100644 --- a/pretext/__init__.py +++ b/pretext/__init__.py @@ -18,7 +18,7 @@ VERSION = get_version("pretext", Path(__file__).parent.parent) -CORE_COMMIT = "cf64e2a6ed2d70d2c118306fbe8ab25e8522680f" +CORE_COMMIT = "ee0a7e135e3346f0b0c6192822d6942424cb4af0" def activate() -> None: diff --git a/pretext/constants.py b/pretext/constants.py index 0181db28..ef662320 100644 --- a/pretext/constants.py +++ b/pretext/constants.py @@ -29,6 +29,7 @@ "datafile", "myopenmath", "dynamic-subs", + "references", "qrcode", "gdscript", ], @@ -45,6 +46,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "latex": [ "webwork", @@ -59,6 +61,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "epub": [ "webwork", @@ -73,6 +76,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "epub_nozip": [ "webwork", @@ -87,6 +91,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "kindle": [ "webwork", @@ -101,6 +106,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "braille": [ "webwork", @@ -114,9 +120,11 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "revealjs": [ "webwork", + "references", "latex-image", "sageplot", "asymptote", @@ -139,6 +147,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", ], "webwork": [ "webwork", @@ -157,6 +166,7 @@ "mermaid", "myopenmath", "dynamic-subs", + "references", "gdscript", ], } diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 2190e35c..d2fb10fc 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -465,7 +465,6 @@ def original_source_element(self) -> ET._Element: source_doc = ET.parse(self.source_abspath()) for _ in range(25): source_doc.xinclude() - print("Type of source_doc: ", type(source_doc)) return source_doc.getroot() def source_element(self) -> ET._Element: @@ -520,6 +519,66 @@ def source_element_with_ids(self) -> ET._Element: def publication_abspath(self) -> Path: return self._project.publication_abspath() / self.publication + def publication_csl_settings(self) -> t.Tuple[t.Optional[str], t.Optional[str]]: + """ + The journal name and the CSL style named in the publication file, + either of which may be absent. Between them they decide whether + references and citations are rendered by CSL at all, and which + style does it. + + Read from the publication file directly: answering from there + saves a publisher variable report -- a full XSL pass over the + source -- on every build that could not possibly need one. + """ + try: + publication = ET.parse(self.publication_abspath()) + except Exception: + # a malformed publication file is reported, loudly, elsewhere + return (None, None) + + def first(xpath: str) -> t.Optional[str]: + values = publication.xpath(xpath) + assert isinstance(values, t.List) + value = str(values[0]).strip() if values else "" + return value or None + + return ( + first("/publication/common/journal/@name"), + first("/publication/common/citation-stylesheet-language/@style"), + ) + + def journal_csl_style(self, journal: str) -> t.Optional[str]: + """ + The CSL style a journal supplies, from core's list of supported + journals, or None for a journal without one (or an unknown one). + Journal codes are matched regardless of case, as core does. + """ + journals_xml = ( + resources.resource_base_path() / "core" / "journals" / "journals.xml" + ) + try: + journals = ET.parse(journals_xml) + except Exception as e: + log.debug(f"Unable to read the list of journals: {e}", exc_info=True) + return None + styles = journals.xpath( + "/ptx-journals/journal[code = $code]/citation-stylesheet-language/@style", + code=journal.lower(), + ) + assert isinstance(styles, t.List) + return str(styles[0]) if styles else None + + def publication_uses_csl(self) -> bool: + """ + Whether the publication file opts into CSL styles for references + and citations, by naming a style outright or a journal that + supplies one. + """ + journal, csl_style = self.publication_csl_settings() + if csl_style is not None: + return True + return journal is not None and self.journal_csl_style(journal) is not None + def output_dir_abspath(self) -> Path: if self.is_standalone() and self.output_dir is None: if self.format == Format.PDF or self.compression == Compression.SCORM: @@ -708,6 +767,16 @@ def generate_asset_table(self) -> pt.AssetTable: for node in setups: assert isinstance(node, ET._Element) hash.update(ET.tostring(node)) + # For references, the style is as much an input as the + # bibliography itself: changing journals must regenerate. Both + # publication file entries are hashed, since either can pick the + # style. (A style that changes inside core, for a journal whose + # name stays put, is caught instead by the assembly-time check + # against the style stamped on the generated file.) + if asset == "references": + for setting in self.publication_csl_settings(): + if setting is not None: + hash.update(setting.encode("utf-8")) # Finally, we store the hash as a string in the dictionary. asset_hash_dict[asset] = hash.hexdigest() return asset_hash_dict @@ -1239,21 +1308,14 @@ def generate_assets( # without a build. A no-op when a build already ran the check. self.check_schema() - # To help with debugging, we are temporarily adding a reference generation step here. The only way this will be called is if `pretext generate references` is called explicitly. - if requested_asset_types == ("references",): - try: - core.references( - xml_source=self.source_abspath(), - pub_file=self.publication_abspath().as_posix(), - stringparams=self.stringparams.copy(), - xmlid_root=xmlid, - dest_dir=self.generated_dir_abspath() / "references", - ) - except Exception as e: - log.error(f"Unable to generate some references:\n {e}") - log.debug(e, exc_info=True) - finally: - return + # Whether references were asked for by name, which changes how a + # project that has not opted into CSL styles is answered below. + references_requested = ( + requested_asset_types is not None + and "references" in requested_asset_types + and "ALL" not in requested_asset_types + ) + # To help with debugging, we are temporarily adding a stack generation step here. The only way this will be called is if `pretext generate stack` is called explicitly. if requested_asset_types == ("stack",): try: @@ -1303,6 +1365,18 @@ def generate_assets( log.debug( f"Based on format {self.format}, assets to be generated are: {requested_asset_types}." ) + # Rendering references and citations with CSL is opt-in: without a + # journal or a style named in the publication file there is nothing + # to render them with, and core would rightly object. + if "references" in requested_asset_types and not self.publication_uses_csl(): + if references_requested: + log.warning( + "References are rendered with a Citation Style Language (CSL) style, " + "which this project has not asked for. Name a style as " + "`citation-stylesheet-language/@style`, or a journal that has one as `journal/@name`, " + "in the publication file's `common` element. No references will be generated." + ) + requested_asset_types.remove("references") # We always build the asset hash table, even if only_changed=True: this tells us which assets need to be built, and how to update the saved asset hash table at the end of the method. # utils.clean_asset_table purges any asset types from the loaded table that are no longer in the target. source_asset_table = self.generate_asset_table() @@ -1593,20 +1667,19 @@ def generate_assets( except Exception as e: log.error(f"Unable to generate some datafiles:\n {e}") log.debug(e, exc_info=True) - # The following code will eventually be needed, but for now, we leave as a placeholder. - # if "references" in assets_to_generate and debug_references: - # try: - # core.references( - # xml_source=self.source_abspath(), - # pub_file=self.publication_abspath().as_posix(), - # stringparams=stringparams_copy, - # xmlid_root=xmlid, - # dest_dir=self.generated_dir_abspath() / "references", - # ) - # successful_assets.append("references") - # except Exception as e: - # log.error(f"Unable to generate some references:\n {e}") - # log.debug(e, exc_info=True) + if "references" in assets_to_generate: + try: + core.references( + xml_source=self.source_abspath(), + pub_file=self.publication_abspath().as_posix(), + stringparams=stringparams_copy, + xmlid_root=xmlid, + dest_dir=self.generated_dir_abspath() / "references", + ) + successful_assets.append("references") + except Exception as e: + log.error(f"Unable to generate some references:\n {e}") + log.debug(e, exc_info=True) # Delete temporary directories left behind by core: try: core.release_temporary_directories(any_log_level=clean_tmp_dirs) diff --git a/pyproject.toml b/pyproject.toml index 884c4f1a..ca0d96c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,12 @@ pretext = "pretext.cli:main" [project.optional-dependencies] homepage = ["pelican[markdown]>=4.10,<5"] prefigure = ["prefig[text]>=0.7.4,<0.8"] -all = ["pelican[markdown]>=4.10,<5", "prefig[text]>=0.7.4,<0.8"] +citations = ["citeproc-py-styles>=0.1,<0.2"] +all = [ + "pelican[markdown]>=4.10,<5", + "prefig[text]>=0.7.4,<0.8", + "citeproc-py-styles>=0.1,<0.2", +] # Development dependencies # ------------------------ diff --git a/tests/examples/projects/journal-bibliography/project.ptx b/tests/examples/projects/journal-bibliography/project.ptx new file mode 100644 index 00000000..c67a9c45 --- /dev/null +++ b/tests/examples/projects/journal-bibliography/project.ptx @@ -0,0 +1,22 @@ + + + + + html + source/main.ptx + publication.xml + output/web + + + + latex + pdflatex + xelatex + asy + sage + pdftops + pdf-crop-margins + pageres + node + + diff --git a/tests/examples/projects/journal-bibliography/publication.xml b/tests/examples/projects/journal-bibliography/publication.xml new file mode 100644 index 00000000..1ea81491 --- /dev/null +++ b/tests/examples/projects/journal-bibliography/publication.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/examples/projects/journal-bibliography/source/main.ptx b/tests/examples/projects/journal-bibliography/source/main.ptx new file mode 100644 index 00000000..c44f8444 --- /dev/null +++ b/tests/examples/projects/journal-bibliography/source/main.ptx @@ -0,0 +1,23 @@ + + +
+ An Article For A Journal +
+ One +

A result appears in and also in .

+
+ + + References + + ThomasJudson + Abstract Algebra: Theory and Applications + + + David C.Lay + Subspaces and Echelon Forms + + + +
+
diff --git a/tests/test_project.py b/tests/test_project.py index 97eee449..238e046f 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -15,6 +15,7 @@ The CLI equivalents of these behaviors are tested in ``test_cli.py``. """ +import importlib.util import time from pathlib import Path from typing import Any @@ -979,3 +980,165 @@ def test_stage(tmp_path: Path) -> None: assert "foobar" in f.read() assert (project.stage_abspath() / "web2" / "article-id.html").exists() shutil.rmtree(project.stage_abspath()) + + +# Formatting references needs the journals' Citation Style Language styles, +# which come from the optional "citeproc-py-styles" package. +requires_csl_styles = pytest.mark.skipif( + importlib.util.find_spec("citeproc_styles") is None, + reason="citeproc-py-styles is not installed", +) + + +def test_publication_csl_settings(tmp_path: Path) -> None: + """A publication file's journal name and CSL style are read back, and + either one on its own counts as opting into CSL styles.""" + prj_path = tmp_path / "journal-bibliography" + shutil.copytree(EXAMPLES_DIR / "projects" / "journal-bibliography", prj_path) + publication = prj_path / "publication.xml" + with utils.working_directory(prj_path): + target = pr.Project.parse().get_target("web") + assert target.publication_csl_settings() == ("bull-amer-math-soc", None) + assert target.publication_uses_csl() + + # a style of the publisher's own, and no journal + publication.write_text( + publication.read_text().replace( + '', + '', + ) + ) + target = pr.Project.parse().get_target("web") + assert target.publication_csl_settings() == (None, "harvard1") + assert target.publication_uses_csl() + + # a journal without a style of its own does not opt in + publication.write_text( + publication.read_text().replace( + '', + '', + ) + ) + target = pr.Project.parse().get_target("web") + assert target.publication_csl_settings() == ("electron-j-combin", None) + assert not target.publication_uses_csl() + + # neither: the feature stays out of the way + publication.write_text( + publication.read_text().replace('', "") + ) + target = pr.Project.parse().get_target("web") + assert target.publication_csl_settings() == (None, None) + assert not target.publication_uses_csl() + + +def test_references_asset_hash(tmp_path: Path) -> None: + """The references hash covers the bibliography, the citations, and the + style that formats them: each changes it, and nothing else does.""" + prj_path = tmp_path / "journal-bibliography" + shutil.copytree(EXAMPLES_DIR / "projects" / "journal-bibliography", prj_path) + source = prj_path / "source" / "main.ptx" + publication = prj_path / "publication.xml" + source_text, publication_text = source.read_text(), publication.read_text() + + def references_hash() -> Any: + with utils.working_directory(prj_path): + return ( + pr.Project.parse() + .get_target("web") + .generate_asset_table()["references"] + ) + + original = references_hash() + assert references_hash() == original + + # an edited bibliography entry + source.write_text( + source_text.replace("Judson", "Judsen") + ) + assert references_hash() != original + source.write_text(source_text) + + # an added citation, with every "biblio" untouched + source.write_text( + source_text.replace( + "References", + 'References

', + ) + ) + assert references_hash() != original + source.write_text(source_text) + + # a different journal, with the source untouched + publication.write_text( + publication_text.replace("bull-amer-math-soc", "ann-pure-appl-logic") + ) + assert references_hash() != original + publication.write_text(publication_text) + + assert references_hash() == original + + +def test_references_need_opting_in(tmp_path: Path) -> None: + """Without a journal or a style in the publication file, references are + left alone: nothing is generated and the build is undisturbed.""" + prj_path = tmp_path / "journal-bibliography" + shutil.copytree(EXAMPLES_DIR / "projects" / "journal-bibliography", prj_path) + publication = prj_path / "publication.xml" + publication.write_text( + publication.read_text().replace('', "") + ) + with utils.working_directory(prj_path): + target = pr.Project.parse().get_target("web") + target.generate_assets(requested_asset_types=["references"]) + assert not (target.generated_dir_abspath() / "references").exists() + + +@requires_csl_styles +def test_journal_selects_bibliography_style(tmp_path: Path) -> None: + """Naming a journal formats the bibliography in that journal's style, + with no other setting: the generated file records the style, and the + built HTML carries the formatted entries and citations.""" + prj_path = tmp_path / "journal-bibliography" + shutil.copytree(EXAMPLES_DIR / "projects" / "journal-bibliography", prj_path) + with utils.working_directory(prj_path): + target = pr.Project.parse().get_target("web") + target.build() + + generated = ( + target.generated_dir_abspath() / "references" / "csl-bibliography.xml" + ) + assert 'csl-style-file="american-mathematical-society-numeric"' in ( + generated.read_text() + ) + + # numeric citations, in a style that orders entries by citation + references = ( + target.output_dir_abspath() / "references-backmatter.html" + ).read_text() + assert "Lay, David C." in references + section = (target.output_dir_abspath() / "sec-one.html").read_text() + assert "[1]" in section + + +@requires_csl_styles +def test_publisher_style_outranks_journal(tmp_path: Path) -> None: + """A style named in the publication file is used in place of the one the + journal would supply.""" + prj_path = tmp_path / "journal-bibliography" + shutil.copytree(EXAMPLES_DIR / "projects" / "journal-bibliography", prj_path) + publication = prj_path / "publication.xml" + publication.write_text( + publication.read_text().replace( + '', + '' + '', + ) + ) + with utils.working_directory(prj_path): + target = pr.Project.parse().get_target("web") + target.build() + generated = ( + target.generated_dir_abspath() / "references" / "csl-bibliography.xml" + ) + assert 'csl-style-file="harvard1"' in generated.read_text() diff --git a/uv.lock b/uv.lock index a71f6df1..549df129 100644 --- a/uv.lock +++ b/uv.lock @@ -193,6 +193,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/80/bacafa527858774f53271224ca83e5fc22e4b116c1bfe7e4d4f9dfb3e305/citeproc_py-0.10.1-py3-none-any.whl", hash = "sha256:9058b231603fdd71566c85cb5b9d30c507d0bbdbca833acfcdbd972d1030f889", size = 356183, upload-time = "2026-07-08T22:06:08.558Z" }, ] +[[package]] +name = "citeproc-py-styles" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/c9/88cdec9fccaea98f97a23b0c8826b3824f8c878ba84dfd4a17dd667d7541/citeproc_py_styles-0.1.6.tar.gz", hash = "sha256:6499914e1425f2d7275f15d8c8417982314b5b79c1b63a9669719fdaeb833169", size = 6348333, upload-time = "2026-07-07T22:20:37.855Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/c58e3bf20e37fadaae939d9f98fe90ca37f219ec6a1765aacd4af19396dc/citeproc_py_styles-0.1.6-py2.py3-none-any.whl", hash = "sha256:f1720fe9004c88ea9117a7312e9f44e5753cf5436a995b56f77297aaee03590a", size = 15543542, upload-time = "2026-07-07T22:20:34.996Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -1379,10 +1392,14 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "citeproc-py-styles" }, { name = "pelican", version = "4.11.0.post0", source = { registry = "https://pypi.org/simple" }, extra = ["markdown"], marker = "python_full_version < '3.11'" }, { name = "pelican", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["markdown"], marker = "python_full_version >= '3.11'" }, { name = "prefig", extra = ["text"] }, ] +citations = [ + { name = "citeproc-py-styles" }, +] homepage = [ { name = "pelican", version = "4.11.0.post0", source = { registry = "https://pypi.org/simple" }, extra = ["markdown"], marker = "python_full_version < '3.11'" }, { name = "pelican", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["markdown"], marker = "python_full_version >= '3.11'" }, @@ -1410,6 +1427,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "citeproc-py", specifier = ">=0,<1" }, + { name = "citeproc-py-styles", marker = "extra == 'all'", specifier = ">=0.1,<0.2" }, + { name = "citeproc-py-styles", marker = "extra == 'citations'", specifier = ">=0.1,<0.2" }, { name = "click", specifier = ">=8,<9" }, { name = "click-log", specifier = ">=0.4,<0.5" }, { name = "coloraide", specifier = ">=4,<5" }, @@ -1431,7 +1450,7 @@ requires-dist = [ { name = "requests", specifier = ">=2,<3" }, { name = "single-version", specifier = ">=1,<2" }, ] -provides-extras = ["homepage", "prefigure", "all"] +provides-extras = ["homepage", "prefigure", "citations", "all"] [package.metadata.requires-dev] dev = [