Skip to content

Search 3.0: honor the site's date_format option in result cards#48763

Open
kangzj wants to merge 3 commits into
trunkfrom
atlas/rsm-269-honor-date-format
Open

Search 3.0: honor the site's date_format option in result cards#48763
kangzj wants to merge 3 commits into
trunkfrom
atlas/rsm-269-honor-date-format

Conversation

@kangzj
Copy link
Copy Markdown
Contributor

@kangzj kangzj commented May 13, 2026

Fixes RSM-269

Why

Site authors set their preferred date format under Settings → General (F j, Y, Y-m-d, d/m/Y, …) expecting every date on the site to match. Today, the Search 3.0 result card always renders dates with a fixed Intl.DateTimeFormat shape (Apr 20, 2026), so the chosen layout is silently ignored — the search results look out of place next to the rest of the theme. This PR teaches the result card to honour the site's date_format setting so search results pick up the same layout authors already see everywhere else.

Proposed changes

  • Seed get_option( 'date_format' ) into the Interactivity API state as dateFormat alongside the existing locale seed.
  • Add projects/packages/search/src/search-blocks/store/wp-date-format.js — a minimal PHP date()-style token formatter (covers d D j l N S w F m M n Y y L o H h G g i s a A plus the \ literal-escape). The Interactivity API view bundle can't import @wordpress/i18n / @wordpress/date, so the parser is self-contained.
  • Thread dateFormat through normalizeResult()formatDate(); when supplied, the new parser renders dates token-by-token using the seeded blog locale for month / day names via Intl.DateTimeFormat. When empty (legacy callers / older tests), formatDate() falls back to the original toLocaleDateString short-form output unchanged.
  • Add unit coverage for the new parser (16 cases — common WP defaults, ordinal-suffix edge cases, ISO-year boundary, backslash escape, time tokens, midnight / noon 12-hour boundary) and extend formatDate / normalizeResult tests to cover the new fourth argument. PHP seed test pins the new state key.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No. date_format is an existing site setting; nothing new is collected or transmitted. The value is exposed only to the same Interactivity API state seed that already carries locale.

Testing instructions

  1. Bring up a docker WP env via jp docker up -d and visit any page that hosts a Search 3.0 results region (e.g. a page containing <!-- wp:jetpack-search/search-results --> with a results-list).
  2. Go to Settings → GeneralDate Format.
  3. Verify each of the following layouts renders correctly on the result card's date row after triggering a search:
    • Default F j, YNovember 5, 2024
    • Y-m-d2024-11-05
    • d/m/Y05/11/2024
    • Custom l, F jS YTuesday, November 5th 2024
    • Custom \Y\e\a\r: YYear: 2024 (\-escape passes literals through)
  4. Switch the site language to a non-English locale (e.g. Settings → General → Site Language → Français), set Date Format to F j, Y, and confirm month names localize (mai 4, 2026) while the layout order stays F j, Y.
  5. Set Date Format to a value containing no recognised tokens (e.g. plain text) and confirm the card still renders without throwing — unknown letters pass through verbatim.

Screenshots

Settings → General → Date Format set to Y-m-d, ?q=hello against a docker env. Trunk renders the legacy toLocaleDateString shape regardless of the WP setting; this branch renders the configured ISO format.

Before (trunk) After (this PR)
before after

@kangzj kangzj added [Status] Needs Review This PR is ready for review. [Plugin] Search A plugin to add an instant search modal to your site to help visitors find content faster. [Package] Search Contains core Search functionality for Jetpack and Search plugins labels May 13, 2026
@kangzj kangzj self-assigned this May 13, 2026
@kangzj kangzj added [Status] Needs Review This PR is ready for review. [Plugin] Search A plugin to add an instant search modal to your site to help visitors find content faster. [Package] Search Contains core Search functionality for Jetpack and Search plugins [Status] In Progress and removed [Status] Needs Review This PR is ready for review. labels May 13, 2026
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 13, 2026

@claude please review this PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the atlas/rsm-269-honor-date-format branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack atlas/rsm-269-honor-date-format

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 13, 2026

Claude finished @kangzj's task in 2m 46s —— View job


PR Review

  • Gather context and understand the request
  • Read changed files
  • Review PHP changes (class-search-blocks.php, Search_Blocks_Test.php)
  • Review JS store changes (index.js, result-utils.js)
  • Review the new wp-date-format.js parser
  • Review test coverage

Overall this is a well-designed feature. The architecture (seed on PHP, self-contained JS parser, fallback for legacy callers) is exactly right for the IAPI constraint. A few things worth noting:

wp-date-format.js — missing tokens

Several PHP date() tokens aren't implemented and will pass through as literal characters:

Token Meaning Risk
W ISO week number (0153) Low — rarely used in date_format
U Unix timestamp Low — rarely used in date_format
z Day of year (0365) Low
t Number of days in the month Low
c / r ISO 8601 / RFC 2822 compound formats Low

The default: return token pass-through is the right choice — it makes the gap visible rather than silently dropping characters. The file-level JSDoc already enumerates supported tokens, so the contract is documented. Just worth confirming this is an accepted limitation rather than an oversight.

w token (0=Sunday, 6=Saturday) vs N token (1=Monday, 7=Sunday) — subtle but correct

N is (( getUTCDay() + 6 ) % 7 ) + 1 — Sunday (getUTCDay()=0) → (0+6)%7+1 = 7 ✓. This is easy to get wrong; it's correct here.

Untested edge case in 12-hour clock tokens

h and g use % 12 || 12 which correctly maps midnight (0) and noon (12) to 12. The tests cover 10:30 and 22:30 but not the midnight/noon boundary. Not a bug — the logic is clearly right — but a 00:00 test would close the loop.

// Example missing test
const midnight = new Date( Date.UTC( 2026, 3, 20, 0, 0, 0 ) );
expect( formatWpDate( midnight, 'h:i', 'en-US' ) ).toBe( '12:00' ); // not '00:00'
const noon = new Date( Date.UTC( 2026, 3, 20, 12, 0, 0 ) );
expect( formatWpDate( noon, 'g:i', 'en-US' ) ).toBe( '12:00' );

Fix this →

getIntlFormatter key composition

const key = `${ locale }|${ Object.entries( options ).flat().join( ',' ) }`;

Object.entries order is insertion-order for string keys in all modern engines, and all options objects here are single-key literals — so in practice this is stable. It would silently produce a wrong cache hit if someone passed { month: 'short', timeZone: 'UTC' } vs { timeZone: 'UTC', month: 'short' }, but since timeZone is always injected via the spread inside getIntlFormatter itself and not passed in options, this is fine as-is.

D / M tokens and locale-specific abbreviation punctuation

Intl.DateTimeFormat with { month: 'short' } returns locale-specific short names, which in some locales include trailing periods (e.g. German "Apr." vs PHP's "Apr"). This is actually the correct behavior — WP's own dateI18n similarly defers to locale translation tables for short month names. Worth documenting in the JSDoc if not already: the output for D/M follows the browser's ICU data, not PHP's own translation strings.

PHP: get_option default vs WordPress built-in default

'dateFormat' => function_exists( 'get_option' )
    ? (string) get_option( 'date_format', '' )
    : '',

WordPress's built-in default for date_format is 'F j, Y' (from wp-includes/option.php). Using '' as the fallback here is intentional — it tells the JS side to use legacy toLocaleDateString rather than a format the user hasn't explicitly configured. However, if date_format is somehow absent from the options table on a fresh install (before WordPress sets its defaults), the JS falls back to the legacy shape rather than the WP default F j, Y. This is a reasonable tradeoff since the option should always be present, and the legacy shape is not broken — just not matching the setting.

PHP test: cleanup pattern is correct

try {
    update_option( 'date_format', 'Y-m-d' );
    $state = Search_Blocks::build_initial_state();
    $this->assertSame( 'Y-m-d', $state['dateFormat'] );
} finally {
    if ( false === $original ) {
        delete_option( 'date_format' );
    } else {
        update_option( 'date_format', $original );
    }
}

This is the right cleanup pattern — finally guarantees restoration even on assertion failure. ✓

index.js — threading looks correct

Both normalizeResult call sites pass state.dateFormat as the fourth argument:

  • Line 751: normalizeResult( r, state.locale, state.searchQuery, state.dateFormat )
  • Line 822: same

Both are consistent with the updated signature. ✓

Overall

The implementation is solid — UTC-consistent date reading, correct ordinal suffix (including the 11th/12th/13th exception), correct ISO week year boundary, clean fallback for legacy callers, and good test coverage for the key cases. The suggestions above are minor; none are blockers.

@github-actions
Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!


Search plugin:

No scheduled milestone found for this plugin.

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

kangzj added 2 commits May 13, 2026 15:23
Result cards rendered post dates through `toLocaleDateString` with a fixed
`{ year, month, day }` shape, so "April 20, 2026" (the WP default
`F j, Y`) came back as "Apr 20, 2026" and other configured formats
(`Y-m-d`, `d/m/Y`, `l, F jS Y`, …) were ignored entirely.

Seed `get_option( 'date_format' )` into the Interactivity state and
parse it client-side in a new `wp-date-format.js` module covering every
PHP `date()` token a WP `date_format` setting is likely to use plus the
`\` literal-escape. Month and day names continue to use the seeded
blog locale via `Intl.DateTimeFormat` since the Interactivity API view
bundle still can't import `@wordpress/i18n` / `@wordpress/date`.

Fixes RSM-269.
…Doc (comment IC_kwDOAOho7M8AAAABCHRcnw)

Adds explicit coverage that `h` / `g` 12-hour tokens render midnight and
noon as `12`, not `00`, matching PHP `date()` semantics. Clarifies in the
module JSDoc that `F` / `M` / `l` / `D` tokens follow the browser's ICU
data — so locale-specific abbreviation conventions (German `"Apr."` vs
`"Apr"`) apply, mirroring `dateI18n()`.
@kangzj kangzj force-pushed the atlas/rsm-269-honor-date-format branch from 697f1d1 to 8bda38d Compare May 13, 2026 03:23
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 13, 2026

Addressing claude[bot]'s review (comment #4436810911) in 8bda38dd96:

  • Added explicit midnight / noon coverage to wp-date-format.test.js so the h % 12 || 12 short-circuit is pinned for both h and g tokens.
  • Extended the module JSDoc to call out that F / M / l / D follow the browser's ICU data — locale-specific abbreviation punctuation (e.g. German "Apr.") is therefore preserved, mirroring dateI18n().

Missing-tokens (W, U, z, t, c, r), the getIntlFormatter key composition, and the PHP get_option fallback are kept as-is per the review's "accepted limitation" / "reasonable tradeoff" framing.

@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 13, 2026

@claude please re-review.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 13, 2026

Claude finished @kangzj's task in 1m 17s —— View job


Re-Review

  • Gather context and understand the request
  • Read updated files (wp-date-format.js, wp-date-format.test.js, result-utils.js, result-utils.test.js)
  • Verify midnight/noon test coverage added in 8bda38d
  • Verify JSDoc update for ICU-based tokens
  • Check for any new issues

The commit 8bda38dd addresses both actionable items from the initial review. Here's a targeted check:

Midnight/noon tests — ✓ correct

The new test at wp-date-format.test.js:70-80 covers all four cases I flagged:

it( 'maps midnight and noon onto the 12-hour clock as 12, not 00', () => {
    const midnight = new Date( Date.UTC( 2026, 3, 20, 0, 0, 0 ) );
    expect( formatWpDate( midnight, 'h:i A', 'en-US' ) ).toBe( '12:00 AM' );  // h ✓
    expect( formatWpDate( midnight, 'g:i a', 'en-US' ) ).toBe( '12:00 am' );  // g ✓
    const noon = new Date( Date.UTC( 2026, 3, 20, 12, 0, 0 ) );
    expect( formatWpDate( noon, 'h:i A', 'en-US' ) ).toBe( '12:00 PM' );      // h noon ✓
    expect( formatWpDate( noon, 'g:i a', 'en-US' ) ).toBe( '12:00 pm' );      // g noon ✓
} );

This also implicitly verifies a/A: midnight (getUTCHours() = 0, 0 < 12 → true) → am/AM, noon (getUTCHours() = 12, 12 < 12 → false) → pm/PM. Both are correct PHP semantics.

JSDoc update for ICU-based tokens — ✓ correct

wp-date-format.js:14-21 now reads:

Tokens that resolve to a month or weekday name (F, M, l, D) are sourced from Intl.DateTimeFormat for the seeded blog locale, so the output follows the browser's ICU data rather than WordPress's own translation tables — this is the closest faithful approximation reachable from inside the IAPI runtime. Note that locale-specific abbreviation conventions still apply: e.g. German short months render as "Apr." rather than the PHP-default "Apr", matching the behavior of dateI18n() on the same site.

This is accurate and covers the concern about locale-specific punctuation (e.g. German "Apr.").

Items kept as-is — confirmed appropriate

The three items flagged as accepted limitations/tradeoffs in the first review remain unchanged, per the author's note. All are documented or low-risk:

  • Missing tokens (W, U, z, t, c, r): the default: return token pass-through makes any gap visible
  • getIntlFormatter key composition: single-key option objects only, insertion-order stable
  • PHP get_option fallback to '': date_format is always present post-install; legacy shape is a safe fallback

No new issues

The full data flow (state.dateFormatnormalizeResultformatDateformatWpDate) is consistent at both call sites in index.js (lines 751 and 822). The PHP seed at class-search-blocks.php and its test in Search_Blocks_Test.php are unchanged from the first review pass and remain correct.

This PR is ready to merge.

@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented May 13, 2026

Code Coverage Summary

Coverage changed in 3 files.

File Coverage Δ% Δ Uncovered
projects/packages/search/src/search-blocks/class-search-blocks.php 364/501 (72.65%) 0.16% 0 💚
projects/packages/search/src/search-blocks/store/index.js 340/380 (89.47%) 0.03% 0 💚
projects/packages/search/src/search-blocks/store/result-utils.js 121/121 (100.00%) 0.00% 0 💚

1 file is newly checked for coverage.

File Coverage
projects/packages/search/src/search-blocks/store/wp-date-format.js 54/62 (87.10%) 💚

Full summary · PHP report · JS report

`state.dateFormat` is a one-shot value seeded by PHP at hydration and never
changes for the page lifetime, so threading it through every
`normalizeResult` call was unnecessary noise. Capture it once via
`setSeededDateFormat()` in `result-utils.js` during `initialize()`, and have
`formatDate()` read it from module scope by default. `normalizeResult()`'s
signature reverts to its trunk shape (raw, locale, searchQuery); call sites
in `store/index.js` no longer need to know about the format string.

Tests gain a `beforeEach` reset so the module-scoped state can't leak
between cases, plus coverage that the seed flows through `formatDate()` by
default and that an explicit `dateFormat` argument still overrides it.
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 13, 2026

@claude please re-review — refactored to remove dateFormat threading per owner feedback (commit d6882ef). dateFormat now lives in a module-scoped seed set once at initialize(), and normalizeResult reverts to the trunk signature.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 13, 2026

Claude finished @kangzj's task in 1m 25s —— View job


Re-Review (post-refactor — d6882ef0)

  • Gather context and understand the request
  • Read index.js — verify module-scoped seed pattern
  • Read result-utils.js — verify normalizeResult reverted to trunk signature
  • Read tests — verify coverage still holds
  • Check for any new issues introduced by the refactor

The refactor is clean. Here's a targeted review of what changed:

normalizeResult signature — ✓ reverted to trunk

Both call sites in index.js (lines 751 and 822) are back to the 3-arg form:

normalizeResult( r, state.locale, state.searchQuery )

The normalizeResult export in result-utils.js (line 465) now matches: (raw, locale = 'en-US', searchQuery = '') — no dateFormat arg. ✓

Module-scoped seed pattern — ✓ correct

setSeededDateFormat( state.dateFormat ) is called once inside initialize(), which is guarded by the initialized flag. Since search results (normalizeResult call sites) only arrive after the store has been initialized, seededDateFormat is always set before it's first needed. ✓

formatDate reads the seed via a default parameter:

export function formatDate( iso, locale = 'en-US', dateFormat = seededDateFormat )

JS default parameters are evaluated at call time, not definition time — so this correctly picks up whatever setSeededDateFormat wrote. ✓

Graceful handling of missing seed — ✓

If state.dateFormat is undefined (e.g. an older PHP seed without the new key):

setSeededDateFormat( state.dateFormat ); // undefined
// inside: typeof undefined === 'string' → false → seededDateFormat = ''

The legacy toLocaleDateString fallback applies. ✓

Test isolation via beforeEach — ✓ correct

beforeEach( () => {
    setSeededDateFormat( '' );
} );

This prevents seededDateFormat from leaking across test cases — the right pattern for module-scoped mutable state. The explicit-override tests ('lets an explicit dateFormat argument override the seed') also verify that passing '' explicitly bypasses the seed, not just the reset. ✓

New setSeededDateFormat test — ✓ correct

it( 'ignores non-string input and resets to empty', () => {
    setSeededDateFormat( 'Y-m-d' );
    setSeededDateFormat( null );
    expect( formatDate( '2026-04-20T10:00:00Z', 'en-US' ) ).toBe( 'Apr 20, 2026' );
} );

Confirms the typeof format === 'string' ? format : '' guard against null/undefined input. ✓

Items unchanged from prior review passes — still hold

wp-date-format.js, the PHP seed (class-search-blocks.php), the PHP test (Search_Blocks_Test.php), and the wp-date-format.test.js additions (including midnight/noon coverage and ICU JSDoc update) are all unchanged and were approved in the previous passes.

No new issues

The refactor eliminates the per-call threading without introducing any timing gaps, test-isolation problems, or API surface changes visible to callers of normalizeResult. The dateFormat override on formatDate is retained purely for test ergonomics and is clearly documented.

This PR is ready to merge.

@kangzj kangzj requested a review from adamwoodnz May 13, 2026 03:52
@kangzj
Copy link
Copy Markdown
Contributor Author

kangzj commented May 13, 2026

Don't know if this is worth the hassle...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Search Contains core Search functionality for Jetpack and Search plugins [Plugin] Search A plugin to add an instant search modal to your site to help visitors find content faster. [Status] In Progress [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant