Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 28 additions & 6 deletions bibliography-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
}
}
}
}
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions docs/current-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
196 changes: 167 additions & 29 deletions includes/save-markup.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
}

Expand Down Expand Up @@ -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 ) {
Expand All @@ -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' ) );
}
Expand Down Expand Up @@ -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' ) ) );

Expand Down Expand Up @@ -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(
Expand All @@ -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 ) );

Expand Down Expand Up @@ -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();

Expand All @@ -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];
Expand Down
19 changes: 19 additions & 0 deletions src/deprecated.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading