Skip to content

seo: report real content dates as sitemap lastmod - #2684

Closed
0xkkonrad wants to merge 1 commit into
devfrom
seo/sitemap-lastmod
Closed

seo: report real content dates as sitemap lastmod#2684
0xkkonrad wants to merge 1 commit into
devfrom
seo/sitemap-lastmod

Conversation

@0xkkonrad

@0xkkonrad 0xkkonrad commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Defect (T5)

src/app/sitemap.ts set lastModified to BUILD_DATE for every URL:

const BUILD_DATE = new Date()
...
lastModified: page.lastModified ?? BUILD_DATE   // no call site ever passed lastModified

SitemapEntry.lastModified existed but was dead — nothing populated it. So every deploy
republished all 709 URLs claiming they had just changed. A lastmod that always equals the
deploy timestamp is noise: crawlers learn nothing from it, and the pages that genuinely did
change lose the signal they should have had.

Fix

Content-backed URLs now report the generated_at of the exact file that serves them.

  • contentGeneratedAt() in src/lib/content.ts — coerces a content file's generated_at
    frontmatter to a Date, returning undefined when missing or unparseable.
  • Three one-line lookups in sitemap.ts (pageDate / corridorDate / singletonDate) pair
    the right reader with that coercion. The readers were not all imported before; this adds
    readPageContent, readCorridorContent, readSingletonContent.
  • 15 of the 20 pages.push() call sites now pass a real date. Hand-built pages (homepage,
    /lp/card, /careers, /exchange, legal) and index pages with no single backing file keep
    BUILD_DATE via the existing ?? BUILD_DATE fallback — untouched.

The lookups read through the same cache the has*Content() guards already populate, so they
add no extra file reads.

One subtlety worth reviewing

ContentFrontmatter declares generated_at?: string, but that is wrong at runtime:
gray-matter runs js-yaml, which parses unquoted YAML timestamps into JS Date objects. Proven
against the real submodule:

src/content/content/blog/earn-with-peanut-3min-setup/en.md
  YAML source : generated_at: 2026-03-27
  typeof      : object   instanceof Date: true

src/content/content/send-to/argentina/from/colombia/en.md
  YAML source : generated_at: 2026-02-21T15:00:00Z
  typeof      : object   instanceof Date: true

A naive new Date(frontmatter.generated_at) would still work, but the helper handles Date,
quoted string, missing and unparseable inputs explicitly so this cannot regress silently.

I deliberately did not correct the declared type: generated_at is consumed as JSON-LD
datePublished in 12 page files that expect a string, so retyping it belongs in its own PR.
Flagging for a follow-up.

Verification

Local next build is impossible on this box (earlyoom SIGTERMs it under memory contention from
other agents), so this is unit-level proof plus the Deploy Preview below.

1. The real generateSitemap() run against the pinned content submodule (2a1c5937), via tsx:

total sitemap URLs      : 709
real content dates      : 684
BUILD_DATE fallback     : 25  (today = 2026-08-12)
distinct lastmod values : 21

--- lastmod distribution (top 8) ---
  2026-03-27  163
  2026-03-26  118
  2026-02-23  92
  2026-07-05  80
  2026-07-06  62
  2026-03-10  28
  2026-03-13  28
  2026-08-12  25   <- BUILD_DATE fallback

The 25 fallbacks are exactly the intended set: 6 static entries + 3 non-default-locale landings

  • 4 locales x 4 index pages (/help, /stories, /content, /blog).

2. Sitemap lastmod cross-checked against generated_at on disk:

PASS  /en/help/delete-account
      sitemap lastmod=2026-07-05  frontmatter generated_at=2026-07-05
PASS  /en/send-money-to/brazil
      sitemap lastmod=2026-02-23  frontmatter generated_at=2026-02-23
PASS  /en/pricing
      sitemap lastmod=2026-07-05  frontmatter generated_at=2026-07-05
PASS  /en/supported-networks
      sitemap lastmod=2026-07-05  frontmatter generated_at=2026-07-05
PASS  /en/send-money-from/brazil/to/argentina
      sitemap lastmod=2026-02-20  frontmatter generated_at=2026-02-20

5/5 cross-checks passed

Static pages keep BUILD_DATE as intended:

/careers -> 2026-08-12   /exchange -> 2026-08-12   /lp/card -> 2026-08-12
/en/privacy -> 2026-08-12   /en/terms -> 2026-08-12

3. Gates

jest src/lib/content.test.ts   9 passed, 9 total   (5 pre-existing + 4 new)
tsc --noEmit (scoped to sitemap.ts, content.ts, content.test.ts + transitive deps)   0 errors
eslint src/app/sitemap.ts src/lib/content.ts src/lib/content.test.ts   clean
prettier --check   All matched files use Prettier code style!

4. Deploy Preview — confirmed on the built artifact. Full output in the
evidence comment.
The build stamped BUILD_DATE at 2026-08-12T14:26:32.623Z; all 6 sampled content URLs report a
different, correct date:

PASS  /en/help/delete-account                    lastmod 2026-07-05  = generated_at 2026-07-05
PASS  /en/send-money-to/brazil                   lastmod 2026-02-23  = generated_at 2026-02-23
PASS  /en/pricing                                lastmod 2026-07-05  = generated_at 2026-07-05
PASS  /en/supported-networks                     lastmod 2026-07-05  = generated_at 2026-07-05
PASS  /en/send-money-from/brazil/to/argentina    lastmod 2026-02-20  = generated_at 2026-02-20
PASS  /en/blog/earn-with-peanut-3min-setup       lastmod 2026-03-27  = generated_at 2026-03-27
6/6 matched   (709 URLs total, 21 distinct lastmod values)

/careers /exchange /lp/card /en/privacy /en/terms  -> 2026-08-12T14:26:32.623Z (BUILD_DATE)

CI is green across typecheck, eslint, format, unit, e2e, analyze, report and
Deploy-Preview.

Caveats

  • Full-project tsc --noEmit could not complete locally — earlyoom killed it (exit 143,
    mem avail: 855 of 11960 MiB) across repeated attempts while other agents held the box. The
    scoped typecheck above covers both changed files and everything they import, and CI's
    full typecheck passed
    (1m3s), so this gap is closed.
  • lastmod is only as honest as generated_at. A content edit that does not refresh that field
    will now show a stale date rather than a falsely fresh one — the better failure direction, but
    it does move the burden onto the content pipeline.
  • Branched from origin/dev @ 6e14a490d, which is 7 commits past the SHA in the plan
    (ad5b61b6). That drift is entirely transaction-details and i18n test work; it does not touch
    sitemap.ts, lib/content.ts, or the content submodule pin.
  • Touches no files under src/content (separate pipeline).

🤖 Generated with Claude Code

Every sitemap URL carried lastModified: BUILD_DATE, so each deploy told
crawlers all 709 pages had just changed. That is noise, and it costs the
signal on pages that genuinely did change.

Content-backed URLs now report the generated_at of the exact file that
serves them. 684 of 709 URLs get a real date across 21 distinct values;
the remaining 25 (hand-built pages and index pages with no single backing
file) keep BUILD_DATE as the fallback.

contentGeneratedAt() coerces the frontmatter value to a Date. Note the
type/runtime mismatch it guards: ContentFrontmatter declares generated_at
as a string, but gray-matter runs js-yaml, which parses unquoted YAML
timestamps into Date objects — so both shapes have to work.

The lookups read through the cache the has*Content() guards already
populate, so they add no file reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: da04068a-e7b1-4c52-ba2b-76d1d83d3a7d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 12, 2026 2:27pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7158.83 → 7156.73 (-2.1)
Findings: 0 net (+5 new, -5 resolved)

🆕 New findings (5)

  • critical complexity — src/lib/content.ts — CC 64, MI 61.45, SLOC 252
  • high complexity — src/app/sitemap.ts — CC 26, MI 43.05, SLOC 219
  • medium high-mdd — src/app/sitemap.ts:49 — generateSitemap: MDD 80.0 (uses across many lines from declarations)
  • medium method-complexity — src/app/sitemap.ts:49 — generateSitemap CC 21 SLOC 197
  • low high-mdd — src/lib/content.ts:368 — listAllContent: MDD 11.3 (uses across many lines from declarations)

✅ Resolved (5)

  • src/lib/content.ts — CC 58, MI 61.8, SLOC 239
  • src/app/sitemap.ts — CC 23, MI 37.57, SLOC 174
  • src/app/sitemap.ts:27 — generateSitemap: MDD 73.8 (uses across many lines from declarations)
  • src/app/sitemap.ts:27 — generateSitemap CC 21 SLOC 167
  • src/lib/content.ts:347 — listAllContent: MDD 11.3 (uses across many lines from declarations)

📈 Painscore deltas (top movers)

File Before After Δ
src/app/sitemap.ts 14.3 11.8 -2.5

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2969 ran, 0 failed, 0 skipped, 50.6s

📊 Coverage (unit)

metric %
statements 66.5%
branches 51.7%
functions 56.5%
lines 67.3%
⏱ 10 slowest test cases
time test
3.5s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.2s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in validateInviteCode body
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Deploy Preview evidence — real lastmod confirmed in the built artifact

Preview: https://peanut-wallet-git-seo-sitemap-lastmod-squirrellabs.vercel.app/sitemap.xml
Fetched 2026-08-12 14:28:04 GMT, 709 <url> entries.

The build stamped BUILD_DATE at 2026-08-12T14:26:32.623Z — visible on the static pages
below. Every content URL carries a date that is not that timestamp, so this is the deployed
artifact behaving, not a local simulation.

Content URLs — sitemap lastmod vs generated_at in the pinned submodule (2a1c5937):

PASS  /en/help/delete-account
      sitemap lastmod = 2026-07-05T00:00:00.000Z    generated_at = 2026-07-05
PASS  /en/send-money-to/brazil
      sitemap lastmod = 2026-02-23T00:00:00.000Z    generated_at = 2026-02-23
PASS  /en/pricing
      sitemap lastmod = 2026-07-05T00:00:00.000Z    generated_at = 2026-07-05
PASS  /en/supported-networks
      sitemap lastmod = 2026-07-05T00:00:00.000Z    generated_at = 2026-07-05
PASS  /en/send-money-from/brazil/to/argentina
      sitemap lastmod = 2026-02-20T00:00:00.000Z    generated_at = 2026-02-20
PASS  /en/blog/earn-with-peanut-3min-setup
      sitemap lastmod = 2026-03-27T00:00:00.000Z    generated_at = 2026-03-27

6/6 matched

Those span singleton (/pricing, /supported-networks), page (help, send-to, blog) and
corridor (send-money-from/.../to/...) readers — one per code path added.

Static pages still carry the build clock, exactly as intended:

/careers      2026-08-12T14:26:32.623Z
/exchange     2026-08-12T14:26:32.623Z
/lp/card      2026-08-12T14:26:32.623Z
/en/privacy   2026-08-12T14:26:32.623Z
/en/terms     2026-08-12T14:26:32.623Z

Distribution across all 709 URLs — 21 distinct lastmod values instead of 1:

163  2026-03-27
118  2026-03-26
 92  2026-02-23
 80  2026-07-05
 62  2026-07-06
 28  2026-03-13
 28  2026-03-10
 25  2026-08-12   <- BUILD_DATE fallback (the 25 non-content URLs)
 17  2026-03-16
 16  2026-08-11
 14  2026-05-22
 13  2026-02-28

684 of 709 URLs now report a real authored date.

CI: typecheck, eslint, format, unit, e2e, analyze and report all pass — the
full-project typecheck that earlyoom kept killing on my box is green here.

🤖 Generated with Claude Code

@0xkkonrad

Copy link
Copy Markdown
Contributor Author

Consolidated into #2685 (one commit per fix, review catches included) at the CTO's request — this PR's evidence and review thread remain the reference for its slice. Branch kept until #2685 merges.

@0xkkonrad 0xkkonrad closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant