From 7aa36ec70e479c7e7761ca602d34484fdcdbae3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 22:06:44 +0000 Subject: [PATCH] Sort names with particles per each style's demote setting (#142) A surname opening with a lowercase particle (van, de la, von, d') sorted under the particle in every style. Chicago and Vancouver demote the particle, so they now sort by the surname proper, and the formatter splits the particle out for display-and-sort styles so citeproc-php renders "Duijnhoven, Elza van". Other styles keep the particle in front. Names are stored as entered; the particle is inferred at sort and format time. save() order changes, so a new deprecation freezes the pre-1.12.0 shape, and the PHP save port, parity fixtures, and sort-coordination cases are updated to match. Closes #142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KbVuMPN7YTcV27vwsG3LZ2 --- CHANGELOG.md | 4 + bibliography-builder.php | 34 ++- docs/current-metrics.md | 6 +- includes/save-markup.php | 196 +++++++++++++++--- src/deprecated.js | 19 ++ src/deprecated.test.js | 88 +++++--- src/deprecation-fixtures.test.js | 36 ++++ .../sorter.snapshot.test.js.snap | 8 +- src/lib/formatting/style-registry.js | 9 + src/lib/name-particles.js | 135 ++++++++++++ src/lib/name-particles.test.js | 112 ++++++++++ src/lib/normalize-author-names.js | 30 +-- src/lib/sorter.conformance.test.js | 6 - src/lib/sorter.js | 94 ++++++--- src/save-markup.js | 3 +- src/save.js | 1 + .../fixtures/deprecations/v13-deprecated.html | 3 + tests/fixtures/save-parity/collation-mix.html | 2 +- .../save-parity/mla-repeated-authors.html | 2 +- tests/fixtures/sort-coordination/cases.json | 56 +++++ tests/phpunit/FormatterAdaptationTest.php | 71 +++++++ tests/phpunit/SortCoordinationTest.php | 8 +- 22 files changed, 794 insertions(+), 129 deletions(-) create mode 100644 src/lib/name-particles.js create mode 100644 src/lib/name-particles.test.js create mode 100644 tests/fixtures/deprecations/v13-deprecated.html diff --git a/CHANGELOG.md b/CHANGELOG.md index bba0a62..f418c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Names with particles sort where each style puts them.** A surname that opens with a lowercase particle, such as "van Duijnhoven", "de la Cruz", "von Neumann", or "d'Alembert", was sorted under the particle in every style. Chicago (both styles) and Vancouver sort by the surname proper, so Chicago lists "Duijnhoven, Elza van" under D, and the formatter now renders it that way. APA, MLA, Harvard, and the other styles keep the particle in front and still sort under V. A capitalized form ("Van Buren") is part of the surname and always sorts under V. Names are stored as entered, so exports and Edit fields are unchanged. Bibliographies saved earlier stay valid in the editor and take the new order the next time they are saved. ([#142](https://github.com/dknauss/Borges/issues/142)) + ## [1.11.0] - 2026-10-14 ### Fixed diff --git a/bibliography-builder.php b/bibliography-builder.php index 391f986..b5fb147 100644 --- a/bibliography-builder.php +++ b/bibliography-builder.php @@ -1099,7 +1099,7 @@ function bibliography_builder_get_formatter_style_hash( $template ) { function bibliography_builder_get_formatter_cache_key( $csl_items, $style_key, $style ) { $template = isset( $style['template'] ) ? (string) $style['template'] : ''; $payload = array( - 'version' => 2, + 'version' => 3, 'style' => sanitize_key( $style_key ), 'template' => $template, // The bundled styles change between releases (new manual editions), so @@ -1239,11 +1239,18 @@ function bibliography_builder_extract_citeproc_entries( $html, $style ) { * inverted or initialized. * - `page-first`: citeproc-php does not derive it from `page`, and OSCOLA * cites a journal article by its first page. - * - * @param array $item CSL-JSON item. + * - Name particles: for a style that demotes them in display (Chicago: + * `demote-non-dropping-particle="display-and-sort"`), a lowercase particle + * still inside `family` ("van Duijnhoven") moves to `non-dropping-particle`, + * so citeproc-php prints "Duijnhoven, Elza van". Other styles print the name + * as stored, so their output is unchanged; splitting there would also make + * citeproc-php print an elided particle with a space ("d’ Alembert"). + * + * @param array $item CSL-JSON item. + * @param bool $demote_particle_display Whether the style demotes particles in display. * @return array */ -function bibliography_builder_prepare_csl_for_formatter( $item ) { +function bibliography_builder_prepare_csl_for_formatter( $item, $demote_particle_display = false ) { $name_variables = array( 'author', 'collection-editor', @@ -1272,6 +1279,17 @@ function bibliography_builder_prepare_csl_for_formatter( $item ) { if ( '' !== $literal ) { $item[ $variable ][ $position ] = array( 'family' => $literal ); + continue; + } + + if ( $demote_particle_display && is_array( $name ) && empty( $name['non-dropping-particle'] ) ) { + $split = bibliography_builder_split_name_particle( isset( $name['family'] ) ? $name['family'] : null ); + + if ( null !== $split ) { + $name['family'] = $split['family']; + $name['non-dropping-particle'] = $split['particle']; + $item[ $variable ][ $position ] = $name; + } } } } @@ -1331,10 +1349,14 @@ function bibliography_builder_format_csl_items( $csl_items, $style_key ) { ); } - $prepared_items = array(); + $prepared_items = array(); + $demote_particle = 1 === preg_match( '/demote-non-dropping-particle="display-and-sort"/', $style_xml ); foreach ( array_values( $csl_items ) as $index => $item ) { - $item_array = bibliography_builder_prepare_csl_for_formatter( is_array( $item ) ? $item : array() ); + $item_array = bibliography_builder_prepare_csl_for_formatter( + is_array( $item ) ? $item : array(), + $demote_particle + ); $item_array['id'] = 'bibliography-builder-format-' . $index; $prepared_items[] = $item_array; } diff --git a/docs/current-metrics.md b/docs/current-metrics.md index 7532046..119d06a 100644 --- a/docs/current-metrics.md +++ b/docs/current-metrics.md @@ -11,9 +11,9 @@ Source and LOC figures last verified: **2026-10-06** against the heading-level b | Metric | Value | Re-derivation command | |---|---|---| -| Main plugin file (`bibliography-builder.php`) | **2,185** | `wc -l bibliography-builder.php` | -| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **9,537** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | -| JS source (`src/`, excl. `*.test.js`) | **11,198** | `find ./src -name '*.js' -not -name '*.test.js' -print0 \| xargs -0 wc -l \| tail -1` | +| Main plugin file (`bibliography-builder.php`) | **2,207** | `wc -l bibliography-builder.php` | +| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **9,697** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | +| JS source (`src/`, excl. `*.test.js`) | **11,381** | `find ./src -name '*.js' -not -name '*.test.js' -print0 \| xargs -0 wc -l \| tail -1` | | Shipped frontend runtime (`build/view.js`, minified) | **1,449 bytes** | `npm run build` then `wc -c < build/view.js` | The only PHP that executes at runtime on a visitor request path is `bibliography-builder.php` diff --git a/includes/save-markup.php b/includes/save-markup.php index 9bfb24b..8df0766 100644 --- a/includes/save-markup.php +++ b/includes/save-markup.php @@ -334,31 +334,32 @@ function bibliography_builder_save_attributes( $attributes ) { /** * Front-end style definitions the save markup depends on. * - * Mirrors the `listType`, `family`, and `locale` fields of `STYLE_DEFINITIONS` - * in `src/lib/formatting/style-registry.js`. + * Mirrors the `listType`, `family`, `locale`, and `demoteNonDroppingParticle` + * fields of `STYLE_DEFINITIONS` in `src/lib/formatting/style-registry.js`. * * @param string $style_key Style key. - * @return array{key: string, listType: string, family: string, locale: string} + * @return array{key: string, listType: string, family: string, locale: string, demoteNonDroppingParticle: string} */ function bibliography_builder_get_save_style_definition( $style_key ) { $styles = array( - 'chicago-notes-bibliography' => array( 'ul', 'notes', 'en-US' ), - 'chicago-author-date' => array( 'ul', 'author-date', 'en-US' ), - 'apa-7' => array( 'ul', 'author-date', 'en-US' ), - 'mla-9' => array( 'ul', 'author-date', 'en-US' ), - 'harvard' => array( 'ul', 'author-date', 'en-US' ), - 'ieee' => array( 'ol', 'numeric', 'en-US' ), - 'vancouver' => array( 'ol', 'numeric', 'en-US' ), - 'oscola' => array( 'ul', 'notes', 'en-GB' ), - 'abnt' => array( 'ul', 'author-date', 'pt-BR' ), + 'chicago-notes-bibliography' => array( 'ul', 'notes', 'en-US', 'display-and-sort' ), + 'chicago-author-date' => array( 'ul', 'author-date', 'en-US', 'display-and-sort' ), + 'apa-7' => array( 'ul', 'author-date', 'en-US', 'never' ), + 'mla-9' => array( 'ul', 'author-date', 'en-US', 'never' ), + 'harvard' => array( 'ul', 'author-date', 'en-US', 'never' ), + 'ieee' => array( 'ol', 'numeric', 'en-US', 'never' ), + 'vancouver' => array( 'ol', 'numeric', 'en-US', 'sort-only' ), + 'oscola' => array( 'ul', 'notes', 'en-GB', 'never' ), + 'abnt' => array( 'ul', 'author-date', 'pt-BR', 'never' ), ); $key = is_string( $style_key ) && isset( $styles[ $style_key ] ) ? $style_key : 'chicago-notes-bibliography'; return array( - 'key' => $key, - 'listType' => $styles[ $key ][0], - 'family' => $styles[ $key ][1], - 'locale' => $styles[ $key ][2], + 'key' => $key, + 'listType' => $styles[ $key ][0], + 'family' => $styles[ $key ][1], + 'locale' => $styles[ $key ][2], + 'demoteNonDroppingParticle' => $styles[ $key ][3], ); } @@ -479,12 +480,143 @@ function bibliography_builder_name_part( $name, $part ) { } /** - * `getAuthorSort()`. + * JavaScript `trim()` over the whitespace the port recognizes. * - * @param array $csl CSL-JSON item. + * @param string $value Value. * @return string */ -function bibliography_builder_sort_author_key( $csl ) { +function bibliography_builder_js_trim( $value ) { + return (string) preg_replace( + '/^[' . BIBLIOGRAPHY_BUILDER_JS_WHITESPACE . ']+|[' . BIBLIOGRAPHY_BUILDER_JS_WHITESPACE . ']+$/u', + '', + $value + ); +} + +/** + * `NAME_PARTICLES` from `src/lib/name-particles.js`. + * + * @return string[] + */ +function bibliography_builder_name_particles() { + return array( + 'van', + 'von', + 'vom', + 'zu', + 'zur', + 'der', + 'den', + 'de', + 'del', + 'della', + 'di', + 'da', + 'das', + 'dos', + 'du', + 'la', + 'le', + 'el', + 'al', + 'bin', + 'ibn', + 'ter', + 'ten', + 'op', + ); +} + +/** + * `splitNameParticle()`: the lowercase particles that open a family name + * ("van der Waals", "de la Cruz", "d'Alembert"), or null. A capitalized form + * is part of the surname ("Van Buren"). + * + * @param mixed $family Family name. + * @return array{particle: string, family: string}|null + */ +function bibliography_builder_split_name_particle( $family ) { + if ( ! is_string( $family ) ) { + return null; + } + + if ( 1 === preg_match( "/^([dl]['\x{2019}])(?=\p{Lu})/u", $family, $elided ) ) { + return array( + 'particle' => $elided[1], + 'family' => substr( $family, strlen( $elided[1] ) ), + ); + } + + $words = array(); + $rest = bibliography_builder_js_trim( $family ); + $particles = bibliography_builder_name_particles(); + + while ( 1 === preg_match( '/^(\S+)\s+(?=\S)/u', $rest, $match ) && in_array( $match[1], $particles, true ) ) { + $words[] = $match[1]; + $rest = substr( $rest, strlen( $match[0] ) ); + } + + if ( array() === $words || 1 !== preg_match( '/^\p{Lu}/u', $rest ) ) { + return null; + } + + return array( + 'particle' => implode( ' ', $words ), + 'family' => $rest, + ); +} + +/** + * `getSortFamily()`: the surname proper when the style demotes particles, + * else the particle and surname together. + * + * @param mixed $name CSL name. + * @param bool $demote Whether the style demotes the particle for sorting. + * @return string + */ +function bibliography_builder_sort_family( $name, $demote ) { + $family = is_array( $name ) && isset( $name['family'] ) && is_string( $name['family'] ) ? $name['family'] : ''; + $particle = is_array( $name ) && isset( $name['non-dropping-particle'] ) ? $name['non-dropping-particle'] : ''; + $particle = is_string( $particle ) ? bibliography_builder_js_trim( $particle ) : ''; + + if ( '' === $family ) { + return ''; + } + + if ( '' === $particle ) { + $split = bibliography_builder_split_name_particle( $family ); + + if ( null !== $split ) { + $particle = $split['particle']; + $family = $split['family']; + } + } + + if ( '' === $particle || $demote ) { + return $family; + } + + return 1 === preg_match( "/['\x{2019}]$/u", $particle ) ? $particle . $family : $particle . ' ' . $family; +} + +/** + * `demotesParticleForSort()`. + * + * @param string $demote Style's demote-non-dropping-particle option. + * @return bool + */ +function bibliography_builder_demotes_particle_for_sort( $demote ) { + return 'sort-only' === $demote || 'display-and-sort' === $demote; +} + +/** + * `getAuthorSort()` with particle-aware sorting. + * + * @param array $csl CSL-JSON item. + * @param bool $demote Whether the style demotes particles for sorting. + * @return string + */ +function bibliography_builder_sort_author_key( $csl, $demote = false ) { $contributors = bibliography_builder_sort_primary_contributors( $csl ); if ( null === $contributors ) { @@ -496,7 +628,7 @@ function bibliography_builder_sort_author_key( $csl ) { } $first = $contributors[0]; - $name = bibliography_builder_name_part( $first, 'family' ); + $name = bibliography_builder_sort_family( $first, $demote ); return bibliography_builder_js_lower( '' !== $name ? $name : bibliography_builder_name_part( $first, 'literal' ) ); } @@ -560,13 +692,14 @@ function bibliography_builder_sort_compare_years( $a, $b ) { } /** - * `normalizeContributorKey()`. + * `normalizeContributorKey()` with particle-aware sorting. * * @param mixed $contributor Name object. + * @param bool $demote Whether the style demotes particles for sorting. * @return string */ -function bibliography_builder_sort_contributor_key( $contributor ) { - $family = bibliography_builder_js_lower( trim( bibliography_builder_name_part( $contributor, 'family' ) ) ); +function bibliography_builder_sort_contributor_key( $contributor, $demote = false ) { + $family = bibliography_builder_js_lower( trim( bibliography_builder_sort_family( $contributor, $demote ) ) ); $given = bibliography_builder_js_lower( trim( bibliography_builder_name_part( $contributor, 'given' ) ) ); $literal = bibliography_builder_js_lower( trim( bibliography_builder_name_part( $contributor, 'literal' ) ) ); @@ -625,13 +758,14 @@ function bibliography_builder_sort_compare_chains( $collator, $chain_a, $chain_b * @param Collator $collator Collator. * @param array $a CSL-JSON item. * @param array $b CSL-JSON item. + * @param bool $demote Whether the style demotes particles for sorting. * @return int */ -function bibliography_builder_sort_compare( $family, $collator, $a, $b ) { +function bibliography_builder_sort_compare( $family, $collator, $a, $b, $demote = false ) { $authors = bibliography_builder_locale_compare( $collator, - bibliography_builder_sort_author_key( $a ), - bibliography_builder_sort_author_key( $b ) + bibliography_builder_sort_author_key( $a, $demote ), + bibliography_builder_sort_author_key( $b, $demote ) ); $titles = static function () use ( $collator, $a, $b ) { return bibliography_builder_locale_compare( @@ -651,7 +785,9 @@ function bibliography_builder_sort_compare( $family, $collator, $a, $b ) { return 0 !== $title_comparison ? $title_comparison : bibliography_builder_sort_compare_years( $a, $b ); } - $key = 'bibliography_builder_sort_contributor_key'; + $key = static function ( $contributor ) use ( $demote ) { + return bibliography_builder_sort_contributor_key( $contributor, $demote ); + }; $chain_a = array_map( $key, (array) bibliography_builder_sort_primary_contributors( $a ) ); $chain_b = array_map( $key, (array) bibliography_builder_sort_primary_contributors( $b ) ); @@ -689,6 +825,7 @@ function bibliography_builder_sort_citations_for_save( $citations, $style_key ) $collator = new Collator( str_replace( '-', '_', $style['locale'] ) ); $collator->setStrength( Collator::PRIMARY ); + $demote = bibliography_builder_demotes_particle_for_sort( $style['demoteNonDroppingParticle'] ); $indexed = array(); @@ -698,12 +835,13 @@ function bibliography_builder_sort_citations_for_save( $citations, $style_key ) usort( $indexed, - static function ( $left, $right ) use ( $style, $collator ) { + static function ( $left, $right ) use ( $style, $collator, $demote ) { $comparison = bibliography_builder_sort_compare( $style['family'], $collator, bibliography_builder_citation_csl( $left[1] ), - bibliography_builder_citation_csl( $right[1] ) + bibliography_builder_citation_csl( $right[1] ), + $demote ); return 0 !== $comparison ? $comparison : $left[0] - $right[0]; diff --git a/src/deprecated.js b/src/deprecated.js index 71e9384..bf1fc8b 100644 --- a/src/deprecated.js +++ b/src/deprecated.js @@ -122,6 +122,25 @@ function migrateSortedAttributes(attributes) { } export const deprecated = [ + { + // Freezes the save() shape from before 1.12.0: entries sorted by the + // family name as stored, so a name particle filed "van Duijnhoven" + // under V even in Chicago, which sorts it under D. + attributes: deprecatedAttributes, + save: ({ attributes }) => + renderBibliographySave(attributes, { + sortEntries: true, + headingTag: getHeadingTag(attributes.headingLevel), + entryTag: 'cite', + includeCiteExport: attributes.outputCiteExport ?? false, + repeatedAuthors: true, + inlineMarkup: true, + quoteAwareItalics: true, + urlAwareItalics: true, + canonicalDoiUrl: true, + labels: LOCALE_INDEPENDENT_SAVE_LABELS, + }), + }, { // Freezes the save() shape from before 1.11.0: JSON-LD was a top-level // array of the cited works, so a consumer that reads the first WebPage diff --git a/src/deprecated.test.js b/src/deprecated.test.js index f901b15..077bae8 100644 --- a/src/deprecated.test.js +++ b/src/deprecated.test.js @@ -37,7 +37,7 @@ describe('deprecated block versions', () => { it('freezes the current pre-Phase-4 save shape:
  • with no
    ', () => { const markup = renderToStaticMarkup( - deprecated[6].save({ + deprecated[7].save({ attributes: { citationStyle: 'chicago-notes-bibliography', headingText: 'References', @@ -58,7 +58,7 @@ describe('deprecated block versions', () => { it('supports the immediate prior save markup with deprecated entry roles', () => { const markup = renderToStaticMarkup( - deprecated[7].save({ + deprecated[8].save({ attributes: { citationStyle: 'chicago-notes-bibliography', headingText: 'References', @@ -93,7 +93,7 @@ describe('deprecated block versions', () => { it('supports the prior save markup with linked URLs and static aria-label', () => { const markup = renderToStaticMarkup( - deprecated[8].save({ + deprecated[9].save({ attributes: { citationStyle: 'chicago-notes-bibliography', headingText: 'References', @@ -126,7 +126,7 @@ describe('deprecated block versions', () => { it('supports the prior save markup variant without linked visible URLs', () => { const markup = renderToStaticMarkup( - deprecated[9].save({ + deprecated[10].save({ attributes: { citationStyle: 'chicago-notes-bibliography', citations: [ @@ -155,7 +155,7 @@ describe('deprecated block versions', () => { }); it('migrate re-sorts citations into style order', () => { - const migrated = deprecated[10].migrate({ + const migrated = deprecated[11].migrate({ citationStyle: 'chicago-author-date', citations: [ createCitation({ id: 'z', family: 'Zulu', title: 'Zeta Book' }), @@ -172,14 +172,14 @@ describe('deprecated block versions', () => { }); it('migrate handles missing citations attribute with empty array fallback', () => { - const migrated = deprecated[10].migrate({ citationStyle: 'apa-7' }); + const migrated = deprecated[11].migrate({ citationStyle: 'apa-7' }); expect(migrated.citations).toEqual([]); }); it('supports the prior unsorted save markup variant', () => { const markup = renderToStaticMarkup( - deprecated[10].save({ + deprecated[11].save({ attributes: { citationStyle: 'chicago-notes-bibliography', citations: [ @@ -204,7 +204,7 @@ describe('deprecated block versions', () => { }); }); -describe('locale-independence deprecation (deprecated[5])', () => { +describe('locale-independence deprecation (deprecated[6])', () => { const untitled = { id: 'untitled', csl: { type: 'webpage', author: [{ family: 'Beta' }] }, @@ -213,7 +213,7 @@ describe('locale-independence deprecation (deprecated[5])', () => { it('falls back to the current translations when the markup gave no labels', () => { const markup = renderToStaticMarkup( - deprecated[5].save({ + deprecated[6].save({ attributes: { citationStyle: 'chicago-notes-bibliography', citations: [untitled], @@ -230,7 +230,7 @@ describe('locale-independence deprecation (deprecated[5])', () => { it('reads the fallback label from a matching link and skips one that does not match', () => { const markup = renderToStaticMarkup( - deprecated[5].save({ + deprecated[6].save({ attributes: { citationStyle: 'chicago-notes-bibliography', citations: [untitled], @@ -257,14 +257,14 @@ describe('locale-independence deprecation (deprecated[5])', () => { it('renders nothing when the block has no citations', () => { expect( - deprecated[5].save({ + deprecated[6].save({ attributes: { citationStyle: 'chicago-notes-bibliography' }, }) ).toBeNull(); }); }); -describe('pre-repeated-author deprecation (deprecated[4])', () => { +describe('pre-repeated-author deprecation (deprecated[5])', () => { const borges = (id, title) => ({ id, csl: { @@ -281,7 +281,7 @@ describe('pre-repeated-author deprecation (deprecated[4])', () => { }; it('writes repeated MLA authors in full, as save() did before', () => { - const old = renderToStaticMarkup(deprecated[4].save({ attributes })); + const old = renderToStaticMarkup(deprecated[5].save({ attributes })); const current = renderToStaticMarkup(save({ attributes })); expect(old).not.toContain('bibliography-builder-repeated-author'); @@ -291,7 +291,7 @@ describe('pre-repeated-author deprecation (deprecated[4])', () => { }); }); -describe('pre-inline-markup deprecation (deprecated[3])', () => { +describe('pre-inline-markup deprecation (deprecated[4])', () => { const attributes = { citationStyle: 'chicago-notes-bibliography', citations: [ @@ -306,7 +306,7 @@ describe('pre-inline-markup deprecation (deprecated[3])', () => { }; it('showed tags in stored text as text; save() now reads them', () => { - const old = renderToStaticMarkup(deprecated[3].save({ attributes })); + const old = renderToStaticMarkup(deprecated[4].save({ attributes })); const current = renderToStaticMarkup(save({ attributes })); expect(old).toContain( @@ -330,15 +330,15 @@ describe('pre-inline-markup deprecation (deprecated[3])', () => { ], }; - // Compared with save() as of 1.10.x (deprecated[0]): 1.11.0 changed only + // Compared with save() as of 1.10.x (deprecated[1]): 1.11.0 changed only // the JSON-LD shape, which this deprecation predates. expect( - renderToStaticMarkup(deprecated[3].save({ attributes: plain })) - ).toBe(renderToStaticMarkup(deprecated[0].save({ attributes: plain }))); + renderToStaticMarkup(deprecated[4].save({ attributes: plain })) + ).toBe(renderToStaticMarkup(deprecated[1].save({ attributes: plain }))); }); }); -describe('pre-1.9.1 deprecation (deprecated[2])', () => { +describe('pre-1.9.1 deprecation (deprecated[3])', () => { const attributes = { citationStyle: 'chicago-notes-bibliography', citations: [ @@ -366,7 +366,7 @@ describe('pre-1.9.1 deprecation (deprecated[2])', () => { }; it('italicized a quoted title and encoded the DOI slash; save() does neither', () => { - const old = renderToStaticMarkup(deprecated[2].save({ attributes })); + const old = renderToStaticMarkup(deprecated[3].save({ attributes })); const current = renderToStaticMarkup(save({ attributes })); expect(old).toContain('“Usage Statistics for Drupal Core.”'); @@ -377,7 +377,7 @@ describe('pre-1.9.1 deprecation (deprecated[2])', () => { }); }); -describe('pre-1.10.0 deprecation (deprecated[1])', () => { +describe('pre-1.10.0 deprecation (deprecated[2])', () => { const attributes = { citationStyle: 'chicago-notes-bibliography', citations: [ @@ -397,7 +397,7 @@ describe('pre-1.10.0 deprecation (deprecated[1])', () => { }; it('split the eLife DOI link with an italic journal name; save() keeps it whole', () => { - const old = renderToStaticMarkup(deprecated[1].save({ attributes })); + const old = renderToStaticMarkup(deprecated[2].save({ attributes })); const current = renderToStaticMarkup(save({ attributes })); expect(old).toContain('href="https://doi.org/10.7554/"'); @@ -408,7 +408,7 @@ describe('pre-1.10.0 deprecation (deprecated[1])', () => { }); }); -describe('pre-1.11.0 deprecation (deprecated[0])', () => { +describe('pre-1.11.0 deprecation (deprecated[1])', () => { const attributes = { citationStyle: 'chicago-notes-bibliography', outputJsonLd: true, @@ -436,7 +436,7 @@ describe('pre-1.11.0 deprecation (deprecated[0])', () => { } it('wrote cited works as top-level JSON-LD; save() nests them in an ItemList', () => { - const old = renderToStaticMarkup(deprecated[0].save({ attributes })); + const old = renderToStaticMarkup(deprecated[1].save({ attributes })); const current = renderToStaticMarkup(save({ attributes })); expect(jsonLd(old)).toEqual([ @@ -467,3 +467,43 @@ describe('pre-1.11.0 deprecation (deprecated[0])', () => { expect(strip(old)).toBe(strip(current)); }); }); + +describe('pre-1.12.0 deprecation (deprecated[0])', () => { + const attributes = { + citationStyle: 'chicago-notes-bibliography', + citations: [ + { + id: 'edwards', + csl: { + type: 'book', + title: 'A Later Book', + author: [{ family: 'Edwards', given: 'Ann' }], + }, + formattedText: 'Edwards, Ann. A Later Book.', + displayOverride: null, + }, + { + id: 'duijnhoven', + csl: { + type: 'book', + title: 'An Earlier Book', + author: [{ family: 'van Duijnhoven', given: 'Elza' }], + }, + formattedText: 'Duijnhoven, Elza van. An Earlier Book.', + displayOverride: null, + }, + ], + }; + + it('filed a particle name under the particle; save() files it under the surname in Chicago', () => { + const old = renderToStaticMarkup(deprecated[0].save({ attributes })); + const current = renderToStaticMarkup(save({ attributes })); + + expect(old.indexOf('Edwards, Ann')).toBeLessThan( + old.indexOf('Duijnhoven, Elza van') + ); + expect(current.indexOf('Duijnhoven, Elza van')).toBeLessThan( + current.indexOf('Edwards, Ann') + ); + }); +}); diff --git a/src/deprecation-fixtures.test.js b/src/deprecation-fixtures.test.js index 4d271ff..1fe7c77 100644 --- a/src/deprecation-fixtures.test.js +++ b/src/deprecation-fixtures.test.js @@ -280,6 +280,42 @@ const FIXTURE_ATTRIBUTES_BY_VERSION = { }, ], }, + // v13 froze save() from before 1.12.0, when entries sorted by the family + // name as stored, so "van Duijnhoven" filed under V even in Chicago, which + // files it under D. FIXTURE_ATTRIBUTES have no particle names. + 13: { + ...FIXTURE_ATTRIBUTES, + citations: [ + { + id: 'edwards2019', + csl: { + id: 'edwards2019', + type: 'book', + title: 'Edwards: a work that sorts after the particle name', + publisher: 'Test University Press', + author: [{ family: 'Edwards', given: 'Ann' }], + issued: { 'date-parts': [[2019]] }, + }, + formattedText: + 'Edwards, Ann. Edwards: a work that sorts after the particle name. Test University Press, 2019.', + displayOverride: null, + }, + { + id: 'duijnhoven2025', + csl: { + id: 'duijnhoven2025', + type: 'book', + title: 'Specialized Orthotic Care', + publisher: 'Test University Press', + author: [{ family: 'van Duijnhoven', given: 'Elza' }], + issued: { 'date-parts': [[2025]] }, + }, + formattedText: + 'Duijnhoven, Elza van. Specialized Orthotic Care. Test University Press, 2025.', + displayOverride: null, + }, + ], + }, }; function fixtureAttributes(version) { diff --git a/src/lib/__snapshots__/sorter.snapshot.test.js.snap b/src/lib/__snapshots__/sorter.snapshot.test.js.snap index b2bdb79..9b6eb16 100644 --- a/src/lib/__snapshots__/sorter.snapshot.test.js.snap +++ b/src/lib/__snapshots__/sorter.snapshot.test.js.snap @@ -77,9 +77,10 @@ exports[`sortCitations snapshot baseline records current ordering for chicago-au "angstrom-beta-2024", "title-only-assistant-handbook", "title-only-bare-alpha", + "de-beauvoir-beta-2024", + "van-der-berg-alpha-2023", "binder-zeta-2025", "brown-two-authors-2021", - "de-beauvoir-beta-2024", "garcia-alpha-2020", "jones-alpha-2018", "jones-aardvark-2020", @@ -97,7 +98,6 @@ exports[`sortCitations snapshot baseline records current ordering for chicago-au "editor-owens-companion-2022", "smith-zeta-2024", "unesco-cultural-2020", - "van-der-berg-alpha-2023", "who-alpha-report-2021", "title-only-zebra-handbook", "zimmer-omega-2017", @@ -112,9 +112,10 @@ exports[`sortCitations snapshot baseline records current ordering for chicago-no "angstrom-beta-2024", "title-only-assistant-handbook", "title-only-bare-alpha", + "de-beauvoir-beta-2024", + "van-der-berg-alpha-2023", "binder-zeta-2025", "brown-two-authors-2021", - "de-beauvoir-beta-2024", "garcia-alpha-2020", "jones-aardvark-2020", "jones-alpha-2018", @@ -132,7 +133,6 @@ exports[`sortCitations snapshot baseline records current ordering for chicago-no "editor-owens-companion-2022", "smith-zeta-2024", "unesco-cultural-2020", - "van-der-berg-alpha-2023", "who-alpha-report-2021", "title-only-zebra-handbook", "zimmer-omega-2017", diff --git a/src/lib/formatting/style-registry.js b/src/lib/formatting/style-registry.js index 557fa66..59a3fa2 100644 --- a/src/lib/formatting/style-registry.js +++ b/src/lib/formatting/style-registry.js @@ -7,6 +7,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'chicago-notes-bibliography', + demoteNonDroppingParticle: 'display-and-sort', listType: 'ul', family: 'notes', locale: 'en-US', @@ -19,6 +20,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'chicago-author-date', + demoteNonDroppingParticle: 'display-and-sort', listType: 'ul', family: 'author-date', locale: 'en-US', @@ -31,6 +33,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'apa', + demoteNonDroppingParticle: 'never', listType: 'ul', family: 'author-date', locale: 'en-US', @@ -43,6 +46,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'modern-language-association', + demoteNonDroppingParticle: 'never', listType: 'ul', family: 'author-date', locale: 'en-US', @@ -55,6 +59,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'harvard1', + demoteNonDroppingParticle: 'never', listType: 'ul', family: 'author-date', locale: 'en-US', @@ -67,6 +72,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'ieee', + demoteNonDroppingParticle: 'never', listType: 'ol', family: 'numeric', locale: 'en-US', @@ -79,6 +85,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'vancouver', + demoteNonDroppingParticle: 'sort-only', listType: 'ol', family: 'numeric', locale: 'en-US', @@ -94,6 +101,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'oscola', + demoteNonDroppingParticle: 'never', listType: 'ul', family: 'notes', locale: 'en-GB', @@ -106,6 +114,7 @@ export const STYLE_DEFINITIONS = { enabled: true, backend: 'csl', cslTemplate: 'abnt', + demoteNonDroppingParticle: 'never', listType: 'ul', family: 'author-date', locale: 'pt-BR', diff --git a/src/lib/name-particles.js b/src/lib/name-particles.js new file mode 100644 index 0000000..8246961 --- /dev/null +++ b/src/lib/name-particles.js @@ -0,0 +1,135 @@ +/** + * Name particles ("van", "de la", "von", "d'") and how a style sorts them. + * + * CSL keeps a particle that is part of the surname in `non-dropping-particle`, + * and each style's `demote-non-dropping-particle` option says whether it moves + * behind the given name ("Duijnhoven, Elza van", Chicago) or stays in front + * ("van Duijnhoven, E.", APA). Imported names usually arrive with the particle + * still inside `family`, so a lowercase particle at the start of `family` is + * read the same way. A capitalized form is part of the surname as written and + * never split ("Van Buren"). + * + * The PHP save port mirrors this in includes/save-markup.php, and the formatter + * splits the same particles before citeproc-php renders a demoting style. + */ + +// Lowercase particles kept lowercase inside a name and treated as +// non-dropping particles when they open a family name. +export const NAME_PARTICLES = new Set([ + 'van', + 'von', + 'vom', + 'zu', + 'zur', + 'der', + 'den', + 'de', + 'del', + 'della', + 'di', + 'da', + 'das', + 'dos', + 'du', + 'la', + 'le', + 'el', + 'al', + 'bin', + 'ibn', + 'ter', + 'ten', + 'op', +]); + +// An elided particle joined to the surname: d'Alembert, l'Hôpital. +const ELIDED_PARTICLE = /^([dl]['’])(?=\p{Lu})/u; + +/** + * Split the particles that open a family name. + * + * @param {string} family Family name. + * @return {{particle: string, family: string}|null} The particle and the rest, + * or null when the name opens with no lowercase particle. + */ +export function splitNameParticle(family) { + if (typeof family !== 'string') { + return null; + } + + const elided = family.match(ELIDED_PARTICLE); + + if (elided) { + return { + particle: elided[1], + family: family.slice(elided[1].length), + }; + } + + const words = []; + let rest = family.trim(); + let match = rest.match(/^(\S+)\s+(?=\S)/u); + + while (match && NAME_PARTICLES.has(match[1])) { + words.push(match[1]); + rest = rest.slice(match[0].length); + match = rest.match(/^(\S+)\s+(?=\S)/u); + } + + // The surname proper starts with a capital; "van der" alone, or a run of + // lowercase words, is not a particle plus a surname. + if (!words.length || !/^\p{Lu}/u.test(rest)) { + return null; + } + + return { particle: words.join(' '), family: rest }; +} + +/** + * Whether a style's `demote-non-dropping-particle` option moves the particle + * out of the sort key. + * + * @param {string} demote `never`, `sort-only`, or `display-and-sort`. + * @return {boolean} True when the style sorts by the surname proper. + */ +export function demotesParticleForSort(demote) { + return demote === 'sort-only' || demote === 'display-and-sort'; +} + +/** + * The part of a personal name a style sorts by: the surname proper when the + * style demotes the particle, the particle and surname together when it does + * not. A dropping particle never takes part in sorting. + * + * @param {Object} name CSL name. + * @param {boolean} demoteParticle Whether the style demotes the particle. + * @return {string} Sort family, or '' when the name has no family. + */ +export function getSortFamily(name = {}, demoteParticle = false) { + let family = typeof name.family === 'string' ? name.family : ''; + let particle = + typeof name['non-dropping-particle'] === 'string' + ? name['non-dropping-particle'].trim() + : ''; + + if (!family) { + return ''; + } + + if (!particle) { + const split = splitNameParticle(family); + + if (split) { + particle = split.particle; + family = split.family; + } + } + + if (!particle || demoteParticle) { + return family; + } + + return /['’]$/u.test(particle) + ? `${particle}${family}` + : `${particle} ${family}`; +} diff --git a/src/lib/name-particles.test.js b/src/lib/name-particles.test.js new file mode 100644 index 0000000..f3daef5 --- /dev/null +++ b/src/lib/name-particles.test.js @@ -0,0 +1,112 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { STYLE_DEFINITIONS } from './formatting/style-registry'; +import { + demotesParticleForSort, + getSortFamily, + splitNameParticle, +} from './name-particles'; + +describe('splitNameParticle', () => { + it.each([ + ['van Duijnhoven', 'van', 'Duijnhoven'], + ['van der Waals', 'van der', 'Waals'], + ['de la Cruz', 'de la', 'Cruz'], + ['von Neumann', 'von', 'Neumann'], + ["d'Alembert", "d'", 'Alembert'], + ['d’Alembert', 'd’', 'Alembert'], + [' van Gogh', 'van', 'Gogh'], + ])('splits %p', (family, particle, rest) => { + expect(splitNameParticle(family)).toEqual({ particle, family: rest }); + }); + + it.each([ + ['Van Buren', 'a capitalized particle is part of the surname'], + ['De Witt', 'a capitalized particle is part of the surname'], + ['van der', 'particles with no surname after them'], + ['van gogh', 'a lowercase surname after the particle'], + ['al-Khwarizmi', 'a hyphenated prefix'], + ['Duijnhoven', 'no particle'], + ["O'Brien", 'a capitalized elision'], + [42, 'a non-string family'], + ])('leaves %p alone (%s)', (family) => { + expect(splitNameParticle(family)).toBeNull(); + }); +}); + +describe('getSortFamily', () => { + it('sorts by the surname when the style demotes the particle', () => { + expect(getSortFamily({ family: 'van Duijnhoven' }, true)).toBe( + 'Duijnhoven' + ); + expect( + getSortFamily( + { family: 'Neumann', 'non-dropping-particle': 'von' }, + true + ) + ).toBe('Neumann'); + expect(getSortFamily({ family: "d'Alembert" }, true)).toBe('Alembert'); + }); + + it('keeps the particle in front when the style does not demote it', () => { + expect(getSortFamily({ family: 'van Duijnhoven' }, false)).toBe( + 'van Duijnhoven' + ); + expect( + getSortFamily( + { family: 'Neumann', 'non-dropping-particle': 'von' }, + false + ) + ).toBe('von Neumann'); + expect( + getSortFamily( + { family: 'Alembert', 'non-dropping-particle': 'd’' }, + false + ) + ).toBe('d’Alembert'); + }); + + it('ignores a dropping particle and returns empty for a nameless entry', () => { + expect( + getSortFamily( + { family: 'Beethoven', 'dropping-particle': 'van' }, + false + ) + ).toBe('Beethoven'); + expect(getSortFamily({ literal: 'UNESCO' }, false)).toBe(''); + }); +}); + +describe('demotesParticleForSort', () => { + it('demotes for display-and-sort and sort-only, not for never', () => { + expect(demotesParticleForSort('display-and-sort')).toBe(true); + expect(demotesParticleForSort('sort-only')).toBe(true); + expect(demotesParticleForSort('never')).toBe(false); + expect(demotesParticleForSort(undefined)).toBe(false); + }); +}); + +describe('style registry particle settings', () => { + const STYLE_DIR = path.resolve( + __dirname, + '../../packages/citation-style-language-styles' + ); + + it.each(Object.values(STYLE_DEFINITIONS))( + '$key matches its bundled CSL style', + ({ cslTemplate, demoteNonDroppingParticle }) => { + const xml = fs.readFileSync( + path.join(STYLE_DIR, `${cslTemplate}.csl`), + 'utf8' + ); + const declared = xml.match( + /demote-non-dropping-particle="([^"]+)"/ + ); + + expect(demoteNonDroppingParticle).toBe( + declared ? declared[1] : 'display-and-sort' + ); + } + ); +}); diff --git a/src/lib/normalize-author-names.js b/src/lib/normalize-author-names.js index 9d6e4dc..29647c8 100644 --- a/src/lib/normalize-author-names.js +++ b/src/lib/normalize-author-names.js @@ -12,35 +12,9 @@ * to manual entry — see `normalizeResolvedCsl` in parser.js. */ -const NAME_LIST_FIELDS = ['author', 'editor', 'reviewed-author']; +import { NAME_PARTICLES } from './name-particles'; -// Lowercase nobiliary particles kept lowercase when they appear inside a name. -const NAME_PARTICLES = new Set([ - 'van', - 'von', - 'vom', - 'zu', - 'zur', - 'der', - 'den', - 'de', - 'del', - 'della', - 'di', - 'da', - 'das', - 'dos', - 'du', - 'la', - 'le', - 'el', - 'al', - 'bin', - 'ibn', - 'ter', - 'ten', - 'op', -]); +const NAME_LIST_FIELDS = ['author', 'editor', 'reviewed-author']; function titleCaseWord(word) { const lower = word.toLowerCase(); diff --git a/src/lib/sorter.conformance.test.js b/src/lib/sorter.conformance.test.js index f2ba0ad..fd635fd 100644 --- a/src/lib/sorter.conformance.test.js +++ b/src/lib/sorter.conformance.test.js @@ -20,10 +20,6 @@ const CONFORMANCE_STYLE_KEYS = Object.values(STYLE_DEFINITIONS) .filter((styleKey) => !(styleKey in EXCLUDED_STYLE_KEYS)); const EXCLUDED_FIXTURE_IDS = { - 'de-beauvoir-beta-2024': - 'Particle surname handling (de/van) remains outside current sorter parity scope.', - 'van-der-berg-alpha-2023': - 'Particle surname handling (de/van) remains outside current sorter parity scope.', 'jones-aardvark-2020': 'Leading-article title sort semantics differ from citeproc in current sorter implementation.', }; @@ -56,9 +52,7 @@ describe('sort conformance against citeproc-js', () => { it('documents fixture exclusions that currently diverge from citeproc sort semantics', () => { expect(Object.keys(EXCLUDED_FIXTURE_IDS).sort()).toEqual([ - 'de-beauvoir-beta-2024', 'jones-aardvark-2020', - 'van-der-berg-alpha-2023', ]); }); diff --git a/src/lib/sorter.js b/src/lib/sorter.js index 9d62d8e..39b86c6 100644 --- a/src/lib/sorter.js +++ b/src/lib/sorter.js @@ -1,4 +1,5 @@ import { getStyleDefinition } from './formatting'; +import { demotesParticleForSort, getSortFamily } from './name-particles'; const LEADING_ARTICLES = /^(a|an|the)\s+/i; @@ -20,13 +21,30 @@ function stripArticles(title) { return (title || '').replace(LEADING_ARTICLES, ''); } +/** + * The family name a contributor sorts by. With `particles` (current save) a + * name particle follows the style's demote-non-dropping-particle option; with + * `particles: null`, `family` is used as stored, the order save() produced + * before particle-aware sorting. + * + * @param {Object} contributor CSL name. + * @param {Object|null} particles `{ demote }`, or null for the legacy key. + * @return {string} Family sort string, or '' when the name has none. + */ +function getContributorFamily(contributor = {}, particles = null) { + return particles + ? getSortFamily(contributor, particles.demote) + : contributor.family || ''; +} + /** * Get the sortable author string from a CSL-JSON object. * - * @param {Object} csl CSL-JSON object. + * @param {Object} csl CSL-JSON object. + * @param {Object|null} particles Particle sort rule (see getContributorFamily). * @return {string} Lowercase author string for sorting. */ -function getAuthorSort(csl = {}) { +function getAuthorSort(csl = {}, particles = null) { const primaryContributors = getPrimaryContributors(csl); if (!primaryContributors) { @@ -34,7 +52,11 @@ function getAuthorSort(csl = {}) { } const first = primaryContributors[0]; - return (first.family || first.literal || '').toLowerCase(); + return ( + getContributorFamily(first, particles) || + first.literal || + '' + ).toLowerCase(); } /** @@ -67,9 +89,9 @@ function getTitleSort(csl = {}) { return stripArticles(csl.title || '').toLowerCase(); } -function compareAuthors(a, b, locale) { - const authorA = getAuthorSort(a.csl); - const authorB = getAuthorSort(b.csl); +function compareAuthors(a, b, locale, particles) { + const authorA = getAuthorSort(a.csl, particles); + const authorB = getAuthorSort(b.csl, particles); return authorA.localeCompare(authorB, locale, { sensitivity: 'base', @@ -89,17 +111,21 @@ function compareYears(a, b) { return getSortableYear(a.csl) - getSortableYear(b.csl); } -function normalizeContributorKey(contributor = {}) { - const family = (contributor.family || '').trim().toLowerCase(); +function normalizeContributorKey(contributor = {}, particles = null) { + const family = getContributorFamily(contributor, particles) + .trim() + .toLowerCase(); const given = (contributor.given || '').trim().toLowerCase(); const literal = (contributor.literal || '').trim().toLowerCase(); return family ? `${family}|${given}` : literal; } -function getAuthorChain(csl = {}) { +function getAuthorChain(csl = {}, particles = null) { const primaryContributors = getPrimaryContributors(csl) || []; - return primaryContributors.map(normalizeContributorKey); + return primaryContributors.map((contributor) => + normalizeContributorKey(contributor, particles) + ); } function compareAuthorChains(chainA, chainB, locale) { @@ -139,8 +165,8 @@ function compareAuthorChains(chainA, chainB, locale) { return chainA.length - chainB.length; } -function compareNotes(a, b, locale) { - const authorCmp = compareAuthors(a, b, locale); +function compareNotes(a, b, locale, particles) { + const authorCmp = compareAuthors(a, b, locale, particles); if (authorCmp !== 0) { return authorCmp; } @@ -153,9 +179,9 @@ function compareNotes(a, b, locale) { return compareYears(a, b); } -function compareAuthorDate(a, b, locale) { - const chainA = getAuthorChain(a.csl); - const chainB = getAuthorChain(b.csl); +function compareAuthorDate(a, b, locale, particles) { + const chainA = getAuthorChain(a.csl, particles); + const chainB = getAuthorChain(b.csl, particles); if (chainA.length && chainB.length) { const chainCmp = compareAuthorChains(chainA, chainB, locale); @@ -163,13 +189,13 @@ function compareAuthorDate(a, b, locale) { return chainCmp; } } else { - const authorCmp = compareAuthors(a, b, locale); + const authorCmp = compareAuthors(a, b, locale, particles); if (authorCmp !== 0) { return authorCmp; } } - const authorCmp = compareAuthors(a, b, locale); + const authorCmp = compareAuthors(a, b, locale, particles); if (authorCmp !== 0) { return authorCmp; } @@ -182,33 +208,51 @@ function compareAuthorDate(a, b, locale) { return compareTitles(a, b, locale); } -function getComparatorForFamily(family, locale) { +function getComparatorForFamily(family, locale, particles) { switch (family) { case 'notes': - return (a, b) => compareNotes(a, b, locale); + return (a, b) => compareNotes(a, b, locale, particles); case 'author-date': - return (a, b) => compareAuthorDate(a, b, locale); + return (a, b) => compareAuthorDate(a, b, locale, particles); case 'numeric': return null; default: - return (a, b) => compareAuthorDate(a, b, locale); + return (a, b) => compareAuthorDate(a, b, locale, particles); } } /** * Sort an array of citation objects by style family rules. * - * @param {Array} citations Array of citation objects. - * @param {string} styleKey Citation style key. + * Name particles ("van Duijnhoven", "de la Cruz") sort as the style's + * demote-non-dropping-particle option says: under the surname for Chicago, + * under the particle for APA and MLA. `particleAwareSort: false` sorts by the + * stored family as written, the order save() produced before 1.12.0. + * + * @param {Array} citations Array of citation objects. + * @param {string} styleKey Citation style key. + * @param {Object} [options] Options. + * @param {boolean} [options.particleAwareSort] Follow the style's particle + * rule (default true). * @return {Array} Sorted citation array. * * @since 0.1.0 */ -export function sortCitations(citations, styleKey) { +export function sortCitations( + citations, + styleKey, + { particleAwareSort = true } = {} +) { const style = getStyleDefinition(styleKey); + const particles = particleAwareSort + ? { + demote: demotesParticleForSort(style.demoteNonDroppingParticle), + } + : null; const comparator = getComparatorForFamily( style.family, - style.locale || 'en-US' + style.locale || 'en-US', + particles ); if (comparator === null) { diff --git a/src/save-markup.js b/src/save-markup.js index 14fe655..703caa2 100644 --- a/src/save-markup.js +++ b/src/save-markup.js @@ -65,6 +65,7 @@ export function renderBibliographySave( inlineMarkup = false, quoteAwareItalics = false, urlAwareItalics = false, + particleAwareSort = false, canonicalDoiUrl = false, jsonLdItemList = false, labels: labelOverrides = null, @@ -86,7 +87,7 @@ export function renderBibliographySave( const labels = labelOverrides || getLocalizedSaveLabels(); const blockProps = useBlockProps.save(); const renderedCitations = sortEntries - ? sortCitations(citations, citationStyle) + ? sortCitations(citations, citationStyle, { particleAwareSort }) : citations; const cslArray = renderedCitations.map((c) => c.csl); const repeatedAuthorPrefixes = repeatedAuthors diff --git a/src/save.js b/src/save.js index 4fd2390..53a2919 100644 --- a/src/save.js +++ b/src/save.js @@ -24,6 +24,7 @@ export default function save({ attributes }) { inlineMarkup: true, quoteAwareItalics: true, urlAwareItalics: true, + particleAwareSort: true, canonicalDoiUrl: true, jsonLdItemList: true, labels: LOCALE_INDEPENDENT_SAVE_LABELS, diff --git a/tests/fixtures/deprecations/v13-deprecated.html b/tests/fixtures/deprecations/v13-deprecated.html new file mode 100644 index 0000000..f764315 --- /dev/null +++ b/tests/fixtures/deprecations/v13-deprecated.html @@ -0,0 +1,3 @@ + +

    References

    • Edwards, Ann. Edwards: a work that sorts after the particle name. Test University Press, 2019.
      Cite / Export
    • Duijnhoven, Elza van. Specialized Orthotic Care. Test University Press, 2025.
      Cite / Export
    + diff --git a/tests/fixtures/save-parity/collation-mix.html b/tests/fixtures/save-parity/collation-mix.html index 93c7963..8e2ae66 100644 --- a/tests/fixtures/save-parity/collation-mix.html +++ b/tests/fixtures/save-parity/collation-mix.html @@ -1 +1 @@ -
    • . T23.
    • Padded . T22.
    • 123 Group. T19.
    • Ångström. T3.
    • Angstrom. T4.
    • d'Alembert. T12.
    • de la Cruz. T10.
    • De Witt. T11.
    • émile. T17.
    • Emile. T18.
    • Mc-Donald. T15.
    • McDonald. T16.
    • O'Brien. T13.
    • Obrien. T14.
    • Oster. T2.
    • Østergaard. T5.
    • Ostergard. T6.
    • Öztürk. T1.
    • Straße. T7.
    • Strasse. T8.
    • Zimmermann. T0.
    • Σωκράτης. T20.
    • Иванов. T9.
    • 李. T21.
    +
    • . T23.
    • Padded . T22.
    • 123 Group. T19.
    • d'Alembert. T12.
    • Ångström. T3.
    • Angstrom. T4.
    • de la Cruz. T10.
    • De Witt. T11.
    • émile. T17.
    • Emile. T18.
    • Mc-Donald. T15.
    • McDonald. T16.
    • O'Brien. T13.
    • Obrien. T14.
    • Oster. T2.
    • Østergaard. T5.
    • Ostergard. T6.
    • Öztürk. T1.
    • Straße. T7.
    • Strasse. T8.
    • Zimmermann. T0.
    • Σωκράτης. T20.
    • Иванов. T9.
    • 李. T21.
    diff --git a/tests/fixtures/save-parity/mla-repeated-authors.html b/tests/fixtures/save-parity/mla-repeated-authors.html index 87074be..a719a6e 100644 --- a/tests/fixtures/save-parity/mla-repeated-authors.html +++ b/tests/fixtures/save-parity/mla-repeated-authors.html @@ -1 +1 @@ -

    Works Cited

    • Becker, Thomas J., and Yuki Nakamura. Reading Machines. New Directions, 2021.
    • Becker, Thomas J., and Yuki Nakamura. Writing Machines. New Directions, 2023.
    • Borges, Jorge Luis. Ficciones. New Directions, 1944.
    • Borges, Jorge Luis. The Aleph and Other Stories. Manual text.
    • Borges, Jorge Luis. Labyrinths. New Directions, 1962.
    • Borges, Norah. Sketches. New Directions, 1950.
    • van Gogh, Vincent. Letters. New Directions, 1990.
    • van Gogh, Vincent. Sketches. New Directions, 1991.
    • Kim, Ji-woo, et al. A Shared Corpus. New Directions, 2023.
    • Kim, Ji-woo, et al. Another Corpus. New Directions, 2024.
    • About Lee, Kim. Part One.
    • About Lee, Kim. Part Two.
    • Lee, Kim. Part One
    • Lee, Kim. Part Two
    • Lee, Kim. Part Three
    • Lee, Kim. Part Four
    • Ng, Ada. Tides. 2020.
    • Ng, Ada. Currents. 2021.
    • Okafor, Chidi, editor. Essays One. 2018.
    • Okafor, Chidi, editor. Essays Two. 2019.
    • Smith, J. R. First Light. New Directions, 2001.
    • Smith, J. R. Second Light. New Directions, 2002.
    • Smith, John, Jr., and James Doe Jr. Junior One. New Directions, 2014.
    • Smith, John, Jr., and James Doe Jr. Junior Two. New Directions, 2015.
    • Smith, John, and Jane Smith. Shared Names. New Directions, 2010.
    • Smith, John, and Jane Smith. Shared Again. New Directions, 2012.
    +

    Works Cited

    • Becker, Thomas J., and Yuki Nakamura. Reading Machines. New Directions, 2021.
    • Becker, Thomas J., and Yuki Nakamura. Writing Machines. New Directions, 2023.
    • Borges, Jorge Luis. Ficciones. New Directions, 1944.
    • Borges, Jorge Luis. The Aleph and Other Stories. Manual text.
    • Borges, Jorge Luis. Labyrinths. New Directions, 1962.
    • Borges, Norah. Sketches. New Directions, 1950.
    • Kim, Ji-woo, et al. A Shared Corpus. New Directions, 2023.
    • Kim, Ji-woo, et al. Another Corpus. New Directions, 2024.
    • About Lee, Kim. Part One.
    • About Lee, Kim. Part Two.
    • Lee, Kim. Part One
    • Lee, Kim. Part Two
    • Lee, Kim. Part Three
    • Lee, Kim. Part Four
    • Ng, Ada. Tides. 2020.
    • Ng, Ada. Currents. 2021.
    • Okafor, Chidi, editor. Essays One. 2018.
    • Okafor, Chidi, editor. Essays Two. 2019.
    • Smith, J. R. First Light. New Directions, 2001.
    • Smith, J. R. Second Light. New Directions, 2002.
    • Smith, John, Jr., and James Doe Jr. Junior One. New Directions, 2014.
    • Smith, John, Jr., and James Doe Jr. Junior Two. New Directions, 2015.
    • Smith, John, and Jane Smith. Shared Names. New Directions, 2010.
    • Smith, John, and Jane Smith. Shared Again. New Directions, 2012.
    • van Gogh, Vincent. Letters. New Directions, 1990.
    • van Gogh, Vincent. Sketches. New Directions, 1991.
    diff --git a/tests/fixtures/sort-coordination/cases.json b/tests/fixtures/sort-coordination/cases.json index a213b9b..ab18333 100644 --- a/tests/fixtures/sort-coordination/cases.json +++ b/tests/fixtures/sort-coordination/cases.json @@ -130,5 +130,61 @@ { "id": "king-a", "csl": { "type": "book", "title": "A Beta Collection", "author": [{ "family": "King", "given": "Kim" }], "issued": { "date-parts": [[2019]] } } } ], "expectedOrder": ["king-an", "king-a", "king-the"] + }, + { + "id": "case-14-particles-chicago-notes", + "style": "chicago-notes-bibliography", + "citations": [ + { "id": "vangogh", "csl": { "type": "book", "title": "Letters", "author": [{ "family": "van Gogh", "given": "Vincent" }], "issued": { "date-parts": [[1914]] }}}, + { "id": "duijnhoven", "csl": { "type": "article-journal", "title": "Specialized Orthotic Care", "author": [{ "family": "van Duijnhoven", "given": "Elza" }], "issued": { "date-parts": [[2025]] }}}, + { "id": "delacruz", "csl": { "type": "book", "title": "Poems", "author": [{ "family": "de la Cruz", "given": "Juana Inés" }], "issued": { "date-parts": [[1689]] }}}, + { "id": "vonneumann", "csl": { "type": "book", "title": "Theory of Games", "author": [{ "family": "Neumann", "non-dropping-particle": "von", "given": "John" }], "issued": { "date-parts": [[1944]] }}}, + { "id": "vanburen", "csl": { "type": "book", "title": "Autobiography", "author": [{ "family": "Van Buren", "given": "Martin" }], "issued": { "date-parts": [[1920]] }}}, + { "id": "dalembert", "csl": { "type": "book", "title": "Discours préliminaire", "author": [{ "family": "d'Alembert", "given": "Jean" }], "issued": { "date-parts": [[1751]] }}}, + { "id": "edwards", "csl": { "type": "book", "title": "Sermons", "author": [{ "family": "Edwards", "given": "Jonathan" }], "issued": { "date-parts": [[1741]] }}} + ], + "expectedOrder": ["dalembert", "delacruz", "duijnhoven", "edwards", "vangogh", "vonneumann", "vanburen"] + }, + { + "id": "case-15-particles-chicago-author-date", + "style": "chicago-author-date", + "citations": [ + { "id": "vangogh", "csl": { "type": "book", "title": "Letters", "author": [{ "family": "van Gogh", "given": "Vincent" }], "issued": { "date-parts": [[1914]] }}}, + { "id": "duijnhoven", "csl": { "type": "article-journal", "title": "Specialized Orthotic Care", "author": [{ "family": "van Duijnhoven", "given": "Elza" }], "issued": { "date-parts": [[2025]] }}}, + { "id": "delacruz", "csl": { "type": "book", "title": "Poems", "author": [{ "family": "de la Cruz", "given": "Juana Inés" }], "issued": { "date-parts": [[1689]] }}}, + { "id": "vonneumann", "csl": { "type": "book", "title": "Theory of Games", "author": [{ "family": "Neumann", "non-dropping-particle": "von", "given": "John" }], "issued": { "date-parts": [[1944]] }}}, + { "id": "vanburen", "csl": { "type": "book", "title": "Autobiography", "author": [{ "family": "Van Buren", "given": "Martin" }], "issued": { "date-parts": [[1920]] }}}, + { "id": "dalembert", "csl": { "type": "book", "title": "Discours préliminaire", "author": [{ "family": "d'Alembert", "given": "Jean" }], "issued": { "date-parts": [[1751]] }}}, + { "id": "edwards", "csl": { "type": "book", "title": "Sermons", "author": [{ "family": "Edwards", "given": "Jonathan" }], "issued": { "date-parts": [[1741]] }}} + ], + "expectedOrder": ["dalembert", "delacruz", "duijnhoven", "edwards", "vangogh", "vonneumann", "vanburen"] + }, + { + "id": "case-16-particles-apa", + "style": "apa-7", + "citations": [ + { "id": "vangogh", "csl": { "type": "book", "title": "Letters", "author": [{ "family": "van Gogh", "given": "Vincent" }], "issued": { "date-parts": [[1914]] }}}, + { "id": "duijnhoven", "csl": { "type": "article-journal", "title": "Specialized Orthotic Care", "author": [{ "family": "van Duijnhoven", "given": "Elza" }], "issued": { "date-parts": [[2025]] }}}, + { "id": "delacruz", "csl": { "type": "book", "title": "Poems", "author": [{ "family": "de la Cruz", "given": "Juana Inés" }], "issued": { "date-parts": [[1689]] }}}, + { "id": "vonneumann", "csl": { "type": "book", "title": "Theory of Games", "author": [{ "family": "Neumann", "non-dropping-particle": "von", "given": "John" }], "issued": { "date-parts": [[1944]] }}}, + { "id": "vanburen", "csl": { "type": "book", "title": "Autobiography", "author": [{ "family": "Van Buren", "given": "Martin" }], "issued": { "date-parts": [[1920]] }}}, + { "id": "dalembert", "csl": { "type": "book", "title": "Discours préliminaire", "author": [{ "family": "d'Alembert", "given": "Jean" }], "issued": { "date-parts": [[1751]] }}}, + { "id": "edwards", "csl": { "type": "book", "title": "Sermons", "author": [{ "family": "Edwards", "given": "Jonathan" }], "issued": { "date-parts": [[1741]] }}} + ], + "expectedOrder": ["dalembert", "delacruz", "edwards", "vanburen", "duijnhoven", "vangogh", "vonneumann"] + }, + { + "id": "case-17-particles-mla", + "style": "mla-9", + "citations": [ + { "id": "vangogh", "csl": { "type": "book", "title": "Letters", "author": [{ "family": "van Gogh", "given": "Vincent" }], "issued": { "date-parts": [[1914]] }}}, + { "id": "duijnhoven", "csl": { "type": "article-journal", "title": "Specialized Orthotic Care", "author": [{ "family": "van Duijnhoven", "given": "Elza" }], "issued": { "date-parts": [[2025]] }}}, + { "id": "delacruz", "csl": { "type": "book", "title": "Poems", "author": [{ "family": "de la Cruz", "given": "Juana Inés" }], "issued": { "date-parts": [[1689]] }}}, + { "id": "vonneumann", "csl": { "type": "book", "title": "Theory of Games", "author": [{ "family": "Neumann", "non-dropping-particle": "von", "given": "John" }], "issued": { "date-parts": [[1944]] }}}, + { "id": "vanburen", "csl": { "type": "book", "title": "Autobiography", "author": [{ "family": "Van Buren", "given": "Martin" }], "issued": { "date-parts": [[1920]] }}}, + { "id": "dalembert", "csl": { "type": "book", "title": "Discours préliminaire", "author": [{ "family": "d'Alembert", "given": "Jean" }], "issued": { "date-parts": [[1751]] }}}, + { "id": "edwards", "csl": { "type": "book", "title": "Sermons", "author": [{ "family": "Edwards", "given": "Jonathan" }], "issued": { "date-parts": [[1741]] }}} + ], + "expectedOrder": ["dalembert", "delacruz", "edwards", "vanburen", "duijnhoven", "vangogh", "vonneumann"] } ] diff --git a/tests/phpunit/FormatterAdaptationTest.php b/tests/phpunit/FormatterAdaptationTest.php index c4866de..8c7f6d4 100644 --- a/tests/phpunit/FormatterAdaptationTest.php +++ b/tests/phpunit/FormatterAdaptationTest.php @@ -36,6 +36,77 @@ public function test_literal_names_become_family_only_names() { $this->assertSame( array( 'family' => 'Board' ), $item['editor'][0] ); } + public function test_particles_split_only_for_a_display_demoting_style() { + $item = array( + 'author' => array( + array( + 'family' => 'van Duijnhoven', + 'given' => 'Elza', + ), + array( + 'family' => 'Van Buren', + 'given' => 'Martin', + ), + array( + 'family' => 'Neumann', + 'non-dropping-particle' => 'von', + ), + ), + ); + + $this->assertSame( $item, bibliography_builder_prepare_csl_for_formatter( $item ) ); + + $prepared = bibliography_builder_prepare_csl_for_formatter( $item, true ); + + $this->assertSame( + array( + 'family' => 'Duijnhoven', + 'given' => 'Elza', + 'non-dropping-particle' => 'van', + ), + $prepared['author'][0] + ); + $this->assertSame( $item['author'][1], $prepared['author'][1] ); + $this->assertSame( $item['author'][2], $prepared['author'][2] ); + } + + public function test_split_name_particle_matches_the_js_rules() { + $this->assertSame( + array( + 'particle' => 'van der', + 'family' => 'Waals', + ), + bibliography_builder_split_name_particle( 'van der Waals' ) + ); + $this->assertSame( + array( + 'particle' => 'd’', + 'family' => 'Alembert', + ), + bibliography_builder_split_name_particle( 'd’Alembert' ) + ); + + foreach ( array( 'Van Buren', 'van der', 'van gogh', "O'Brien", 'Duijnhoven', 42 ) as $family ) { + $this->assertNull( bibliography_builder_split_name_particle( $family ), (string) $family ); + } + } + + public function test_sort_family_follows_the_style_demote_setting() { + $this->assertSame( 'Duijnhoven', bibliography_builder_sort_family( array( 'family' => 'van Duijnhoven' ), true ) ); + $this->assertSame( 'van Duijnhoven', bibliography_builder_sort_family( array( 'family' => 'van Duijnhoven' ), false ) ); + $this->assertSame( "d'Alembert", bibliography_builder_sort_family( array( 'family' => "d'Alembert" ), false ) ); + $this->assertSame( + 'von Neumann', + bibliography_builder_sort_family( + array( + 'family' => 'Neumann', + 'non-dropping-particle' => 'von', + ), + false + ) + ); + } + public function test_blank_literal_names_are_left_alone() { $item = bibliography_builder_prepare_csl_for_formatter( array( 'author' => array( array( 'literal' => ' ' ) ) ) diff --git a/tests/phpunit/SortCoordinationTest.php b/tests/phpunit/SortCoordinationTest.php index 25314fa..1b1c706 100644 --- a/tests/phpunit/SortCoordinationTest.php +++ b/tests/phpunit/SortCoordinationTest.php @@ -82,9 +82,15 @@ private function render_order_for_style( array $citations, string $style_key ): $style_path = BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'vendor/citation-style-language/styles/' . $style_file_name; $style_xml = file_get_contents( $style_path ); $prepared_items = array(); + // Prepare items as the formatter does, so a style that demotes name + // particles sees them split out of `family` (see #142). + $demote_particle = 1 === preg_match( '/demote-non-dropping-particle="display-and-sort"/', $style_xml ); foreach ( array_values( $citations ) as $index => $citation ) { - $csl = is_array( $citation['csl'] ?? null ) ? $citation['csl'] : array(); + $csl = bibliography_builder_prepare_csl_for_formatter( + is_array( $citation['csl'] ?? null ) ? $citation['csl'] : array(), + $demote_particle + ); $source_id = isset( $citation['id'] ) ? (string) $citation['id'] : 'citation-' . $index; $csl['id'] = 'coord-' . $source_id; $prepared_items[] = $csl;