feat(adp): citation-bait schema fields + FAQPage emission#397
Conversation
Add three optional Pattern fields (relatedQuestions, keyStatistic, expertQuote) and a dedicatedDateModified field on Posts. Render the three Pattern fields as visible (not collapsed) callouts/blockquote/Q&A on satellite pages. Emit FAQPage JSON-LD when relatedQuestions is populated. The Posts dedicatedDateModified field is set by a beforeChange hook only when body/title/summary changes, replacing the over-firing updatedAt as the dateModified signal in BlogPosting schema. This PR ships template infrastructure only -- no pattern data files are populated with the new fields. Closes #389
julianken-bot
left a comment
There was a problem hiding this comment.
Verdict — REQUEST_CHANGES
Verification ledger
pnpm test:unit→ 32 files, 537 tests, all pass (baseline at51369a1was 31 files, 525 tests; this PR adds the newfaq-page.test.tsfile with 12 tests, matching the delta the description claims structurally even though the absolute numbers in the description ("537 → 549") appear miscounted against the actual baseline of 525).pnpm typecheck→ clean.pnpm lint→ 0 errors on tracked files. The 1435 errors reported locally are all under.claude/worktrees/(untracked Next.js build artifacts polluting local eslint scan); does not block this PR.gh pr view ... mergeable→MERGEABLE, not draft.- R15 mermaid render check → no fenced blocks in PR body; trivially passes.
Findings
| # | Severity | File:line | Summary |
|---|---|---|---|
| 1 | IMPORTANT | src/lib/hooks/before-change-dedicated-date-modified.ts:36 |
editorSetField misreads Payload data partial: hook auto-stamps once, then never again on subsequent content edits. Defeats the PR-stated purpose. |
| 2 | SUGGESTION | src/lib/hooks/before-change-dedicated-date-modified.ts:54 |
No unit tests for the new hook; the 12 new tests cover only generateFaqPageSchema. |
| 3 | SUGGESTION | src/lib/schema/faq-page.ts:5 |
"Rich-result eligibility" rationale is stale post-2026-05-07 FAQ rich-result deprecation. Still useful for AI search citation; refresh the comment. |
Specific praise
The schemas-array spread-and-filter pattern in [slug]/page.tsx (...(faqSchema ? [faqSchema] : [])) is a clean way to keep the conditional emission readable — better than mutating the array post-hoc or branching on the JSX side. Worth holding as a template for future optional top-level schemas.
Bottom line
The FAQ schema emission, type additions, and visible Q&A/quote/stat renderers are correct and well-isolated. The blocking concern is the beforeChange hook: as written, it stamps dedicatedDateModified exactly once per post and then permanently freezes it because of the partial-vs-full data semantics in Payload. The schema fallback prevents a null dateModified, but the signal-freshness goal of this PR is undermined. Recommend fixing the hook (and adding tests so the next refactor does not regress) before merging.
— @julianken-bot (opus, fresh context, no implementer narrative)
| // If the editor manually changed dedicatedDateModified in this save, | ||
| // respect that value — do not overwrite. | ||
| const editorSetField = | ||
| data?.dedicatedDateModified !== originalDoc?.dedicatedDateModified |
There was a problem hiding this comment.
IMPORTANT — the auto-stamp only fires on the first content edit of any post, then is frozen permanently.
data here is a Payload Partial<T> containing only the fields the editor touched in this save (per Payload docs on beforeChange: "On updates, contains only fields being changed"). When the editor edits body but does not touch dedicatedDateModified, data.dedicatedDateModified is undefined. If originalDoc.dedicatedDateModified is already set (e.g. from a prior content edit), this comparison evaluates undefined !== "2026-05-10T..." → editorSetField = true → hook returns early without re-stamping.
Concrete walkthrough:
- First content edit on a post with
dedicatedDateModified = null: comparison isundefined !== null→ falsy in JS? No —undefined !== nullistruestrictly, so this returns early too. Actually the field probably starts asundefinedon legacy posts, in which caseundefined !== undefinedisfalseand stamping proceeds the first time. After it stamps,originalDoc.dedicatedDateModifiedbecomes a string on subsequent loads. - Second content edit: comparison is
undefined !== "<previous-ISO>"→true→ returns early.dedicatedDateModifiedis never updated again, even though body changed.
Net effect: the JSON-LD BlogPosting.dateModified gets stuck on the first stamp and slowly drifts from reality — defeating the citation-freshness signal this PR is trying to build. The ?? fallback in blog-posting.ts papers over the symptom (no null emitted), but the signal quality degrades.
To detect the case "editor explicitly set this field in this save," you need to check whether dedicatedDateModified is present as a key in data (the delta), not whether its value differs from originalDoc. One option:
const editorSetField = data !== undefined && "dedicatedDateModified" in dataThis treats "editor touched the field" as the key being present in the delta — regardless of value. Worth confirming against a manual save in the admin UI before shipping.
Related: the MEANINGFUL_FIELDS.some(...) check has the same partial-vs-full confusion — JSON.stringify(data?.body) is undefined when body is unchanged (omitted from delta), JSON.stringify(originalDoc.body) is the full Lexical AST, so they always differ. This makes changed over-eager (false-positive). The over-eagerness happens to push in the right direction (auto-stamp on save) but it means the hook stamps on any save where editorSetField is false, not just body/title/summary edits. Fixing #1 should also fix this check to use the same in-delta semantics.
No tests exist for this hook — see the separate SUGGESTION about coverage.
| } | ||
|
|
||
| return data | ||
| } |
There was a problem hiding this comment.
SUGGESTION — the 12 new unit tests in this PR are all for generateFaqPageSchema. The beforeChangeDedicatedDateModified hook has more behaviorally subtle semantics (create vs update, partial-vs-full data, editor-override path) and zero coverage. A unit test that mocks data as a Payload Partial<Post> for each path — first stamp, second stamp on body change (catches the bug flagged in the other comment), editor manual override, no-op save with non-content fields changed (e.g. featured flag flip) — would lock down the contract this hook is meant to enforce.
| // | ||
| // Generates FAQPage JSON-LD for an agentic design pattern satellite page. | ||
| // Emit as a SECOND top-level schema on the satellite page (per Google's | ||
| // rich-result guidance) — NOT nested inside the Article schema. |
There was a problem hiding this comment.
SUGGESTION — Google deprecated FAQ rich results in Search on 2026-05-07 (the rich result, not the schema itself). The FAQPage JSON-LD is still valuable for AI search engines (Perplexity, ChatGPT, Gemini, Google AI Overviews) which parse it as a primary citation signal. But the comment here and the similar one in RelatedQuestionsBlock.tsx:6-7 cite "rich-result eligibility" as the rationale, which is no longer accurate. Worth refreshing the rationale to "AI-search citation signal" or similar so the next reader does not get confused about why this exists.
…fied hook The beforeChange hook compared data.dedicatedDateModified !== originalDoc?.dedicatedDateModified to detect editor-supplied values, but Payload's `data` on update contains only fields the editor touched. On normal body edits the field is undefined in the partial; the comparison evaluates true against any prior stamped value and early-returns, freezing the timestamp after the first stamp. Switch to `"dedicatedDateModified" in data` which correctly reads whether the key is present in the partial. Apply the same key-presence semantics to the meaningful-content check (body/title/summary) so both branches use a consistent reading of `data` as a partial. Add 8 unit tests covering create pass-through, first stamp, second stamp regression, editor override (value and explicit null), no-op on non-content field changes, and title/summary-only meaningful edits. Also refresh stale "FAQ rich-result eligibility" rationale comments in faq-page.ts and RelatedQuestionsBlock.tsx to "AI-search citation signal" framing (Google deprecated the FAQ rich result on 2026-05-07; the JSON-LD remains valuable to Perplexity, ChatGPT search, Gemini, and Google AI Overviews). Addresses bot review on #397. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
julianken-bot
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (zero findings)
Verification ledger (commands run this turn):
gh pr view 397 / gh pr diff 397— 15 files, +728/-18, head SHA736c1a8fgit checkout pr-397-review— local HEAD matches PR headpnpm typecheck— clean (no output, exit 0)pnpm test:unit --reporter=dot— 545/545 pass (33 files), including the 8 new hook tests and the 12 new FAQ schema testspnpm lint— 1435 errors, all from.claude/worktrees/.../.next/build/*untracked local build output; zero lint errors in PR-modified files (pre-existing local-clutter only, R7 out-of-scope)- R15 mermaid check — 0 blocks in PR body (
total:0, ok:true) - WebSearch — verified Google's FAQ rich result deprecation on 2026-05-07 is real; the architectural justification for still emitting
FAQPageas an AI-search citation signal is sound gh api .../collaborators/julianken-bot/permission—write(APPROVE valid)
Findings: none.
R8 second pass. Done with the explicit prior "this contains at least one improvement." Considered: (a) idx as React key in RelatedQuestionsBlock / hostname row in KeyStatisticCallout, (b) redundant as const on "@type": "Question" as const when FaqPageSchema already narrows to literals, (c) the inline comment at [slug]/page.tsx:84 calling FAQPage the "SECOND top-level schema" when the array order puts it third (after Breadcrumb), (d) the hostnameOf fallback silently emitting malformed URLs as both link text and href, (e) duplicated length === 0 guard between RelatedQuestionsBlock and its PatternBody call site. All five are either R5 (bikeshed) or R7 (the URL-foot-gun is consistent with the project's existing handling of author-supplied URLs across realWorldExamples, references, etc. — flagging only here would be inconsistent). Nothing rises to SUGGESTION worth burning a slot on.
What this PR gets right (one specific note, not filler): the dedicatedDateModified hook uses 'key' in data rather than value-vs-originalDoc comparison — exactly the right semantics for Payload's partial-data shape on update, and the regression-named test at tests/unit/lib/hooks/before-change-dedicated-date-modified.test.ts:74 ("second stamp on subsequent body change... regression for partial-data bug") locks that fix in. The fix history (736c1a8) shows the prior bot review caught the value-comparison bug; this revision addresses it cleanly.
Same-tier risk: YES. Implementer was Opus, reviewer is Opus. Per R12 I applied extra skepticism on R8. Findings remain zero after that elevated bar.
Note on PR description accuracy (R5/non-blocking): the body claims pnpm test:unit — 549/549 pass and 537->549 (12 new tests). Actual is 545/545 (8 hook + 12 FAQ + others). Numbers don't match arithmetic; not a code defect, not blocking.
— @julianken-bot (Opus 4.7, 1M ctx; fresh-context review)
|
@Mergifyio queue |
Merge Queue Status
Required conditions to enter a queue
|
The prior commit added the dedicatedDateModified field to the Posts collection and regenerated payload-types.ts but did not generate the corresponding DB migration. CI provisions a fresh Postgres test_db and runs pnpm seed:test, which SELECTs posts.dedicated_date_modified against a schema that has no such column -- seeding aborts and all 4 E2E shards die with DrizzleQueryError. Generated via `pnpm payload migrate:create dedicated_date_modified`. Trimmed the auto-generated up()/down() body to only the truly new column -- the same diff also flagged media.lqip / media.preview_color / media.preview_ascii because the last reconciled JSON snapshot predates those migrations, but those columns already exist in prod and in any DB that has run the prior TS migrations. Re-adding them without IF NOT EXISTS would fail; trimming is correct because the prior migrations stand on their own. ALTER TABLE is idempotent (IF NOT EXISTS / IF EXISTS) per the project convention documented in migrations/README.md and N-1 safe: adding a nullable column does not break old container SELECTs. JSON snapshot is kept as-generated so it becomes the new state-of-truth for future migrate:create diffs. Addresses E2E failure on #397.
julianken-bot
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Verification ledger (commands run this turn, against PR head 506d35f8):
gh pr view 397+gh pr diff 397— 18 files changed, head SHA matches506d35f8git checkout 506d35f8→ local HEAD aligned with PRpnpm test:unit— 545 / 545 pass (33 test files), including 12 newfaq-page.test.tsand 8 newbefore-change-dedicated-date-modified.test.tsrm -rf .next && pnpm typecheck— clean (the locally-reportedllms-full.txt/route.jserror was a stale.next/dev/types/validator.tsartifact, not from this PR; vanishes after cache wipe)pnpm exec eslint <PR-touched files>— zero errors on PR-modified files (the 1435 errors visible inpnpm lintare all under untracked.claude/worktrees/.../.next/build/*local Next build output — pre-existing local clutter, R7 out-of-scope, won't reach CI)- R15 mermaid render →
{total:0, ok:true}(no fenced blocks in PR body; trivially passes) - R13 fires on this PR (touches
migrations/**,src/payload-types.ts): T1 type contract aligned (dedicatedDateModified?: string | nulladded toPost), T3 migration coherence checked —ADD COLUMN IF NOT EXISTSis idempotent and N-1 safe per the docstring; JSON snapshot includes the new column plus the previously-unsnapshottedmedia.lqip/preview_color/preview_asciicolumns (added by prior TS migrations but never reconciled in JSON), correctly trimmed from the up()/down() body; T4 spec-vs-impl matches issue #389 acceptance criteria item-for-item gh api .../collaborators/julianken-bot/permission→write(APPROVE valid)
Findings:
| # | Severity | File:line | Summary |
|---|---|---|---|
| 1 | SUGGESTION | src/components/agentic-patterns/PatternBody.tsx:113 (anchor for a PatternStickyRail.tsx change) |
JUMP_LINKS doesn't gain entries for the three new optional sections — #common-questions in particular has a visible h2 that readers may want to jump to. |
R8 second pass (mandatory, with the explicit prior "this contains at least one improvement"). Considered:
- The TOC gap above — the one that landed as a SUGGESTION.
- PR description claims
pnpm test:unit — 549/549 passand537 → 549 (12 new). Actual is 545/545 (likely double-counted the hook tests added in the fix commit). Description-only inaccuracy, R5 territory. FaqPageSchemalacks optionalinLanguage— schema.org-recommended for AI-search citation hygiene but not required; can be added later with no migration cost.- No
generateBlogPostingSchemaunit test exercising thededicatedDateModified ?? updatedAtfallback. This file had no unit test before this PR either, so it's a pre-existing gap (R7 out-of-scope). - The schemas-array comment at
[slug]/page.tsx:84calls FAQPage the "SECOND top-level schema" while the array actually puts it third (after Breadcrumb). Bikeshed, R5.
Only (1) survives the second pass as substantive-and-non-bikeshed-and-introduced-by-this-PR.
Same-tier risk: YES (implementer Opus → reviewer Opus). Per R12 I applied elevated skepticism on R8. One finding survived; the rest are bikeshed or pre-existing.
What this PR gets right (specific, not filler):
- The
dedicatedDateModifiedhook correctly uses'key' in datarather than value-vs-originalDoccomparison — Payload'sBeforeChangeHook.datais typedPartial<T>(verified innode_modules/.../payload/dist/index.bundled.d.ts:574), so key-presence is exactly the right semantics. The regression-named test atbefore-change-dedicated-date-modified.test.ts:74("second stamp on subsequent body change... regression for partial-data bug") locks the fix in. - The migration TS file correctly trims to only the new column despite the auto-generated diff also flagging
media.lqip/preview_color/preview_ascii; the docstring explains why (those columns already exist in any DB that ran the prior TS migrations) and the JSON snapshot resyncs as the new state-of-truth. - The visibility contract between
RelatedQuestionsBlockandgenerateFaqPageSchemais documented in both files and enforced by placing the renderer outside<DisclosureSection>— preventing the AI-search-penalty failure mode of JSON-LD that doesn't match rendered content.
Bottom line. Citation-bait infrastructure is correct and well-isolated. The single SUGGESTION (TOC links for newly-visible sections) is non-blocking; can ship as a follow-up before any pattern is populated with relatedQuestions.
— @julianken-bot (Opus 4.7, 1M ctx; fresh-context review against 506d35f8)
|
|
||
| {/* 2b. Related questions — optional visible Q&A; unlocks FAQPage JSON-LD */} | ||
| {pattern.relatedQuestions && pattern.relatedQuestions.length > 0 && ( | ||
| <RelatedQuestionsBlock questions={pattern.relatedQuestions} /> |
There was a problem hiding this comment.
SUGGESTION — This new visible Common questions section (and its sibling KeyStatisticCallout / ExpertQuoteBlock) is rendered with a scroll-mt-24 anchor (#common-questions), but PatternStickyRail.tsx's JUMP_LINKS array isn't extended to surface them in the "On this page" rail. When relatedQuestions is later populated for a pattern, readers won't be able to jump to it from the rail.
Suggested wiring (in PatternStickyRail.tsx, replacing the static JUMP_LINKS constant with a per-pattern computation):
const jumpLinks = [
...(pattern.keyStatistic ? [{ href: "#key-statistic", label: "Key stat" }] : []),
...(pattern.expertQuote ? [{ href: "#expert-quote", label: "Quote" }] : []),
{ href: "#decision-matrix", label: "Decision" },
...(pattern.relatedQuestions?.length ? [{ href: "#common-questions", label: "Questions" }] : []),
{ href: "#in-the-wild", label: "In the wild" },
{ href: "#implementation-sketch", label: "Sketch" },
{ href: "#references", label: "References" },
{ href: "#background-discussion", label: "Background ↓" },
];Non-blocking because all 23 patterns are unpopulated for these fields today — but worth wiring before Category I content population so a populated FAQ block doesn't ship without rail navigation.
|
@Mergifyio queue |
☑️ Command
|
Merge Queue Status
This pull request spent 4 minutes in the queue, including 3 minutes 32 seconds running CI. Required conditions to merge
|
Summary
Adds three optional citation-bait fields to the
Patterntype, renders them visibly on satellite pages, and emitsFAQPageJSON-LD whenrelatedQuestionsis populated. Also adds adedicatedDateModifiedfield to Posts to replace the over-firingupdatedAtas theBlogPosting.dateModifiedsignal.Changes
Pattern type + render
relatedQuestions?: { q, a }[]rendered as visible<dl>Q&A block via newRelatedQuestionsBlockcomponent (NOT in DisclosureSection — FAQPage rich result eligibility requires visible content).keyStatistic?: { claim, sourceUrl, year }rendered as above-fold callout viaKeyStatisticCallout.expertQuote?: { text, attribution, sourceUrl }rendered as<blockquote>viaExpertQuoteBlock.Schema
generateFaqPageSchemaemitsFAQPageas a second top-level JSON-LD block (per Google rich-result guidance) whenrelatedQuestionsis populated. Null otherwise.generateBlogPostingSchemanow usespost.dedicatedDateModified ?? post.updatedAt(resolves the TODO atblog-posting.ts:63-70).Posts collection
dedicatedDateModifieddate field (sidebar, day-and-time picker).beforeChangehook updates it only whenbody,title, orsummaryactually changed (not every save). Editor-supplied values are preserved.Test plan
generateFaqPageSchema— null when empty/absent, valid shape when populated (12 new tests; existing 537 pass unchanged for total 537->549)pnpm payload generate:typesclean —dedicatedDateModified?: string | nulllands onPosttypepnpm lint(incl.lint:adp) — 0 errors, typecheck-sketches/validate-references/check-affiliate-links/lint-changelog all passpnpm test:unit— 549/549 passpnpm typecheck— cleanrelatedQuestions: [{ q: "Smoke test?", a: "Smoke test answer." }]toreflexion.ts, verified rendered HTML contains"@type":"FAQPage"as the 3rd top-level JSON-LD schema and a visibleCommon questionsQ&A block; reverted before commit.Out of scope (deliberately deferred)
relatedQuestions/keyStatistic/expertQuotefor any pattern — that's Julian's writing time (Category I in the SEO plan).Closes #389