From 025eca47eab6925666ead2da986127bdd32f4e0c Mon Sep 17 00:00:00 2001 From: Charlotte Habre Date: Wed, 29 Jul 2026 15:27:37 +0200 Subject: [PATCH] feat: :sparkles: scope query loops to Tag/Category term archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve Tag and Category nodes as term archives that render their FSE archive template, and scope the archive's `core/query` loop to the current term at request time. - WP: expose `fseTemplate` on Tag and Category (tag/category → archive template hierarchy). - Next: resolve Tag/Category nodes (fragments + template data + node types), thread the current term through the block-data context, and filter the `contentNodes` query by `categoryIn`/`tagIn`. Builds on the request-time block-data context from #126. Co-Authored-By: Claude Opus 4.8 --- next/src/components/core/Query/data.ts | 12 +++++ .../src/components/templates/Category/data.ts | 18 +++++++ next/src/components/templates/Tag/data.ts | 18 +++++++ next/src/components/templates/data.ts | 4 +- next/src/lib/format-blocks-json.ts | 1 + .../lib/get-block-final-component-props.ts | 4 ++ next/src/lib/get-node-by-uri.ts | 54 +++++++++++++++++-- next/src/typings.d.ts | 10 ++++ .../graphql/register-fse-templates.php | 32 +++++++++++ 9 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 next/src/components/templates/Category/data.ts create mode 100644 next/src/components/templates/Tag/data.ts diff --git a/next/src/components/core/Query/data.ts b/next/src/components/core/Query/data.ts index 741adc2..dcff550 100644 --- a/next/src/components/core/Query/data.ts +++ b/next/src/components/core/Query/data.ts @@ -52,6 +52,8 @@ const queryContentNodes = gql` $notIn: [ID] $search: String $contentTypes: [ContentTypeEnum] + $categoryIn: [ID] + $tagIn: [ID] ${configs.isMultilang ? '$language: LanguageCodeFilterEnum' : ''} ) { contentNodes( @@ -63,6 +65,8 @@ const queryContentNodes = gql` search: $search stati: PUBLISH contentTypes: $contentTypes + categoryIn: $categoryIn + tagIn: $tagIn ${configs.isMultilang ? 'language: $language' : ''} } ) { @@ -224,6 +228,12 @@ export const getData = async ( const postType = attrs?.query?.postType; const contentTypes = postType ? [postType.toUpperCase()] : null; + // Scope the loop to the current term archive (Tag/Category page) when one is + // in context, so the query returns only that term's posts. + const term = context?.term ?? null; + const categoryIn = term?.taxonomy === 'category' ? [term.databaseId] : null; + const tagIn = term?.taxonomy === 'tag' ? [term.databaseId] : null; + const variables = { first: perPage, offset, @@ -232,6 +242,8 @@ export const getData = async ( notIn, search, contentTypes, + categoryIn, + tagIn, ...(configs.isMultilang ? { language: lang ? lang.toUpperCase() : 'ALL' } : {}), diff --git a/next/src/components/templates/Category/data.ts b/next/src/components/templates/Category/data.ts new file mode 100644 index 0000000..0477d2f --- /dev/null +++ b/next/src/components/templates/Category/data.ts @@ -0,0 +1,18 @@ +import { gql } from '@/utils'; + +import { languageFields, translationsFields } from '@/lib/fragments'; + +export const slug = 'category'; + +export const fragment = gql` + fragment categoryFragment on Category { + id: databaseId + title: name + uri + fseTemplate { + slug + } + ${languageFields} + ${translationsFields} + } +`; diff --git a/next/src/components/templates/Tag/data.ts b/next/src/components/templates/Tag/data.ts new file mode 100644 index 0000000..e5ffc92 --- /dev/null +++ b/next/src/components/templates/Tag/data.ts @@ -0,0 +1,18 @@ +import { gql } from '@/utils'; + +import { languageFields, translationsFields } from '@/lib/fragments'; + +export const slug = 'tag'; + +export const fragment = gql` + fragment tagFragment on Tag { + id: databaseId + title: name + uri + fseTemplate { + slug + } + ${languageFields} + ${translationsFields} + } +`; diff --git a/next/src/components/templates/data.ts b/next/src/components/templates/data.ts index 5056dbc..1aca106 100644 --- a/next/src/components/templates/data.ts +++ b/next/src/components/templates/data.ts @@ -1,3 +1,5 @@ export * as archiveData from './Archive/data'; +export * as categoryData from './Category/data'; export * as singlePageData from './SinglePage/data'; -export * as singlePostData from './SinglePost/data'; \ No newline at end of file +export * as singlePostData from './SinglePost/data'; +export * as tagData from './Tag/data'; \ No newline at end of file diff --git a/next/src/lib/format-blocks-json.ts b/next/src/lib/format-blocks-json.ts index bf1810e..ffac453 100644 --- a/next/src/lib/format-blocks-json.ts +++ b/next/src/lib/format-blocks-json.ts @@ -9,6 +9,7 @@ export default async function formatBlocksJSON( lang?: string | null; page?: number; baseUri?: string; + term?: BlockDataContext['term']; } ) { /** diff --git a/next/src/lib/get-block-final-component-props.ts b/next/src/lib/get-block-final-component-props.ts index f07c107..8088905 100644 --- a/next/src/lib/get-block-final-component-props.ts +++ b/next/src/lib/get-block-final-component-props.ts @@ -43,6 +43,7 @@ export default function getBlockFinalComponentProps( lang?: string | null; page?: number; baseUri?: string; + term?: BlockDataContext['term']; } ): Promise { return new Promise(async (res) => { @@ -105,6 +106,7 @@ const getAttributes = ( lang?: string | null; page?: number; baseUri?: string; + term?: BlockDataContext['term']; } ) => new Promise(async (res) => { @@ -135,6 +137,7 @@ const getAttributes = ( getData(fetchAPI, attributes, options?.lang ?? null, { page: options?.page, baseUri: options?.baseUri, + term: options?.term, innerBlocks, }).then( (data = {}) => { @@ -157,6 +160,7 @@ const getInnerBlocks = ( lang?: string | null; page?: number; baseUri?: string; + term?: BlockDataContext['term']; } ) => new Promise((res, rej) => { diff --git a/next/src/lib/get-node-by-uri.ts b/next/src/lib/get-node-by-uri.ts index d903afd..c0b671c 100644 --- a/next/src/lib/get-node-by-uri.ts +++ b/next/src/lib/get-node-by-uri.ts @@ -9,7 +9,8 @@ import fseTemplatesData from '@/lib/fse/fse-templates-and-parts.json'; const templatesData: any = _templatesData; -const { archiveData, singlePageData, singlePostData } = templatesData; +const { archiveData, categoryData, singlePageData, singlePostData, tagData } = + templatesData; /** * NOTE: in preview, the `uri` could be in fact the ID (i.e. a draft doesn't have a slug/uri yet) @@ -109,6 +110,10 @@ export default async function getNodeByURI( node.fullUri = uri; + // On a term archive (Tag/Category), expose the current term so query loops + // inside the archive template scope their posts to it at request time. + const term = getTermContext(node); + if (blockEnrichment) { const { blocksJSON, templateData, templateBlocks } = await Promise.allSettled([ @@ -116,14 +121,15 @@ export default async function getNodeByURI( previewDraft ? (node.preview?.node?.blocksJSON ?? '') : (node?.blocksJSON ?? ''), - { lang, page: routePage, baseUri: uri } + { lang, page: routePage, baseUri: uri, term } ), getTemplateData(node), enrichTemplateBlocks( getTemplateBlocks(node?.fseTemplate?.slug), lang, routePage, - uri + uri, + term ), ]) .then(([bProm, tProm, tbProm]) => ({ @@ -206,6 +212,18 @@ const types = [ fields: 'singlePageFragment', translatable: true, }, + { + type: 'Category', + fragment: categoryData.fragment, + fields: 'categoryFragment', + translatable: true, + }, + { + type: 'Tag', + fragment: tagData.fragment, + fields: 'tagFragment', + translatable: true, + }, { type: 'Post', fragment: singlePostData.fragment, @@ -277,6 +295,26 @@ for (const key in templatesData) { } } +// Maps a resolved node's `__typename` to its WPGraphQL taxonomy handle. Only +// term archives (Tag/Category) qualify — single posts/pages and ContentType +// (post-type) archives have no current term to scope a query loop by. +const TERM_TAXONOMIES: Record = { + Tag: 'tag', + Category: 'category', +}; + +const getTermContext = (node: any): BlockDataContext['term'] | undefined => { + const taxonomy = TERM_TAXONOMIES[node?.__typename]; + if (!taxonomy) return undefined; + + // The term fragments alias `id: databaseId`, so `node.id` is the WP DB id. + const databaseId = + typeof node?.id === 'number' ? node.id : Number.parseInt(node?.id, 10); + if (!Number.isFinite(databaseId)) return undefined; + + return { taxonomy, databaseId }; +}; + const getTemplateData = async (node: any) => { const type = `single-${node.__typename}`.toLowerCase(); @@ -340,13 +378,19 @@ const enrichTemplateBlocks = ( blocks: BlockPropsType[], lang: string | null = null, page = 1, - baseUri: string | undefined = undefined + baseUri: string | undefined = undefined, + term: BlockDataContext['term'] | undefined = undefined ): Promise => blocks.length === 0 ? Promise.resolve([]) : Promise.allSettled( blocks.map((block) => - getBlockFinalComponentProps(block, { lang, page, baseUri }) + getBlockFinalComponentProps(block, { + lang, + page, + baseUri, + term, + }) ) ).then( (results) => diff --git a/next/src/typings.d.ts b/next/src/typings.d.ts index dfba9ec..c166ef0 100644 --- a/next/src/typings.d.ts +++ b/next/src/typings.d.ts @@ -37,6 +37,16 @@ type BlockDataContext = { baseUri?: string; /** The block's own `innerBlocks`, so `getData` can enrich/override children. */ innerBlocks?: Array; + /** + * The taxonomy term being viewed on a term archive (Tag/Category), so query + * loops can scope their posts to the current term without a rebuild. + */ + term?: { + /** WPGraphQL taxonomy handle, e.g. "tag" | "category". */ + taxonomy: string; + /** The term's WordPress database ID. */ + databaseId: number; + }; }; type FseTemplateEntry = { diff --git a/wordpress/theme/includes/graphql/register-fse-templates.php b/wordpress/theme/includes/graphql/register-fse-templates.php index 224a1e4..a9d41cd 100644 --- a/wordpress/theme/includes/graphql/register-fse-templates.php +++ b/wordpress/theme/includes/graphql/register-fse-templates.php @@ -131,6 +131,38 @@ function register_resolved_template_field() { return ['slug' => $resolved->slug]; }, ]); + + register_graphql_field('Tag', 'fseTemplate', [ + 'type' => 'FseTemplateInfo', + 'description' => _x('The FSE template used for this tag archive.', 'GraphQL field desc', 'supt'), + 'resolve' => function ($source) { + $term = get_term($source->databaseId, 'post_tag'); + if (! $term || is_wp_error($term)) return null; + + $hierarchy = ["tag-{$term->slug}", "tag-{$term->term_id}", 'tag', 'archive']; + $resolved = resolve_block_template('tag', $hierarchy, ''); + + if (! $resolved) return null; + + return ['slug' => $resolved->slug]; + }, + ]); + + register_graphql_field('Category', 'fseTemplate', [ + 'type' => 'FseTemplateInfo', + 'description' => _x('The FSE template used for this category archive.', 'GraphQL field desc', 'supt'), + 'resolve' => function ($source) { + $term = get_term($source->databaseId, 'category'); + if (! $term || is_wp_error($term)) return null; + + $hierarchy = ["category-{$term->slug}", "category-{$term->term_id}", 'category', 'archive']; + $resolved = resolve_block_template('category', $hierarchy, ''); + + if (! $resolved) return null; + + return ['slug' => $resolved->slug]; + }, + ]); } /**